mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-01-06 05:47:50 -05:00
Compare commits
63 Commits
v4.4.2-ose
...
debug-buil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46e8c4522b | ||
|
|
d00353ba9c | ||
|
|
dc0d4f371a | ||
|
|
3d198f5454 | ||
|
|
1802740a2d | ||
|
|
138e517d23 | ||
|
|
166b2ac220 | ||
|
|
450a418994 | ||
|
|
d4e9e2a8f7 | ||
|
|
ecc59dda99 | ||
|
|
9c2afbab09 | ||
|
|
cebf2d9dfd | ||
|
|
5f49c675c8 | ||
|
|
62c46e123d | ||
|
|
5f1215801d | ||
|
|
930977c44b | ||
|
|
a0d152a66f | ||
|
|
a8883427bc | ||
|
|
7a8dbef80b | ||
|
|
4a40bb3d6f | ||
|
|
c805e549ff | ||
|
|
26a670c181 | ||
|
|
5b54c9dff0 | ||
|
|
1ca73b67a4 | ||
|
|
194c587476 | ||
|
|
1193027e5f | ||
|
|
7de7980860 | ||
|
|
fc7f42c6fa | ||
|
|
196bfb3aea | ||
|
|
cb5798833d | ||
|
|
a1148613e9 | ||
|
|
12529fa9bd | ||
|
|
d743d19a3d | ||
|
|
4dcee27e22 | ||
|
|
b6ceaa7efc | ||
|
|
5c6f712d32 | ||
|
|
5180b99af2 | ||
|
|
dcb7e315b9 | ||
|
|
111481cd00 | ||
|
|
4dc7df7c53 | ||
|
|
cf609288e1 | ||
|
|
0b9d4cd3b3 | ||
|
|
0581417bba | ||
|
|
f8fb016a27 | ||
|
|
8c3d1cdeae | ||
|
|
4a4dc24cdf | ||
|
|
49a51ef384 | ||
|
|
fc698040aa | ||
|
|
6cbd71ab50 | ||
|
|
47f078dcd7 | ||
|
|
be6ab8728c | ||
|
|
2908bba298 | ||
|
|
b962b68631 | ||
|
|
fca7c09105 | ||
|
|
60c6aba2d2 | ||
|
|
70f6f2603e | ||
|
|
5d4c9c8d94 | ||
|
|
4378bee042 | ||
|
|
3776b50bbc | ||
|
|
a9c7e1929f | ||
|
|
318b9be77e | ||
|
|
26cb845950 | ||
|
|
eae6d0c578 |
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
|
||||
- name: Prepare keystore
|
||||
|
||||
20
.github/workflows/test-dev.yml
vendored
20
.github/workflows/test-dev.yml
vendored
@@ -10,35 +10,35 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
compile:
|
||||
name: Compile and cache
|
||||
name: Compile for build cache
|
||||
if: ${{ github.ref == 'refs/heads/main-ose' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
|
||||
# See https://community.gradle.org/github-actions/docs/setup-gradle/ for more information
|
||||
- uses: gradle/actions/setup-gradle@v3 # creates build cache when on main branch
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
cache-write-only: ${{ github.ref == 'refs/heads/main-ose' }}
|
||||
dependency-graph: generate-and-submit # submit Github Dependency Graph info
|
||||
dependency-graph-continue-on-failure: false
|
||||
|
||||
- run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:assembleDebug
|
||||
- run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:compileOseDebugSource
|
||||
|
||||
test:
|
||||
needs: compile
|
||||
name: Tests without emulator
|
||||
if: ${{ always() }} # even if compile didn't run (because not on main branch)
|
||||
name: Lint and unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
@@ -49,17 +49,17 @@ jobs:
|
||||
- name: Run unit tests
|
||||
run: ./gradlew --build-cache --configuration-cache --configuration-cache-problems=warn app:testOseDebugUnitTest
|
||||
|
||||
# generates the build caches because it uses more gradle dependencies
|
||||
test_on_emulator:
|
||||
needs: compile
|
||||
name: Tests with emulator
|
||||
if: ${{ always() }} # even if compile didn't run (because not on main branch)
|
||||
name: Instrumented tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 21
|
||||
- uses: gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
cache-encryption-key: ${{ secrets.gradle_encryption_key }}
|
||||
|
||||
@@ -13,27 +13,25 @@ plugins {
|
||||
|
||||
// Android configuration
|
||||
android {
|
||||
compileSdk = 34
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "at.bitfire.davdroid"
|
||||
|
||||
versionCode = 404020004
|
||||
versionName = "4.4.2"
|
||||
|
||||
buildConfigField("long", "buildTime", "${System.currentTimeMillis()}L")
|
||||
versionCode = 404030200
|
||||
versionName = "4.4.3.2"
|
||||
|
||||
setProperty("archivesBaseName", "davx5-ose-$versionName")
|
||||
|
||||
minSdk = 24 // Android 7.0
|
||||
targetSdk = 34 // Android 14
|
||||
targetSdk = 35 // Android 15
|
||||
|
||||
testInstrumentationRunner = "at.bitfire.davdroid.HiltTestRunner"
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +82,9 @@ android {
|
||||
|
||||
signingConfig = signingConfigs.findByName("bitfire")
|
||||
}
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
@@ -118,6 +119,10 @@ ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
excludeFields = arrayOf("generated")
|
||||
}
|
||||
|
||||
configurations {
|
||||
configureEach {
|
||||
// exclude modules which are in conflict with system libraries
|
||||
@@ -211,4 +216,4 @@ dependencies {
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,18 +26,15 @@ import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* JUnit ClassRule which initializes the AOSP CalendarProvider.
|
||||
* Needed for some "flaky" tests which would otherwise only succeed on second run.
|
||||
*
|
||||
* Currently tested on development machine (Ryzen) with Android 12 images (with/without Google Play).
|
||||
* Calendar provider behaves quite randomly, so it may or may not work. If you (the reader
|
||||
* if this comment) can find out on how to initialize the calendar provider so that the
|
||||
* tests are reliably run after `adb shell pm clear com.android.providers.calendar`,
|
||||
* please let us know!
|
||||
* It seems that the calendar provider unfortunately forgets the very first requests when it is used the very first time,
|
||||
* maybe by some wrongly synchronized database initialization. So things like querying the instances
|
||||
* fails in this case.
|
||||
*
|
||||
* If you run tests manually, just make sure to ignore the first run after the calendar
|
||||
* provider has been accessed the first time.
|
||||
* So this rule is needed to allow tests which need the calendar provider to succeed even when the calendar provider
|
||||
* is used the very first time (especially in CI tests / a fresh emulator).
|
||||
*
|
||||
* See [at.bitfire.davdroid.resource.LocalCalendarTest] for how to use this rule.
|
||||
* See [at.bitfire.davdroid.resource.LocalCalendarTest] for an example of how to use this rule.
|
||||
*/
|
||||
class InitCalendarProviderRule private constructor(): ExternalResource() {
|
||||
|
||||
@@ -71,13 +68,18 @@ class InitCalendarProviderRule private constructor(): ExternalResource() {
|
||||
|
||||
private fun initCalendarProvider(provider: ContentProviderClient) {
|
||||
val account = Account("LocalCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
val uri = AndroidCalendar.create(account, provider, ContentValues())
|
||||
val calendar = AndroidCalendar.findByID(
|
||||
account,
|
||||
provider,
|
||||
LocalCalendar.Factory,
|
||||
ContentUris.parseId(uri)
|
||||
)
|
||||
|
||||
// Sometimes, the calendar provider returns an ID for the created calendar, but then fails to find it.
|
||||
var calendarOrNull: LocalCalendar? = null
|
||||
for (i in 0..50) {
|
||||
calendarOrNull = createAndVerifyCalendar(account, provider)
|
||||
if (calendarOrNull != null)
|
||||
break
|
||||
else
|
||||
Thread.sleep(100)
|
||||
}
|
||||
val calendar = calendarOrNull ?: throw IllegalStateException("Couldn't create calendar")
|
||||
|
||||
try {
|
||||
// single event init
|
||||
val normalEvent = Event().apply {
|
||||
@@ -102,4 +104,20 @@ class InitCalendarProviderRule private constructor(): ExternalResource() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAndVerifyCalendar(account: Account, provider: ContentProviderClient): LocalCalendar? {
|
||||
val uri = AndroidCalendar.create(account, provider, ContentValues())
|
||||
|
||||
return try {
|
||||
AndroidCalendar.findByID(
|
||||
account,
|
||||
provider,
|
||||
LocalCalendar.Factory,
|
||||
ContentUris.parseId(uri)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warning("Couldn't find calendar after creation: $e")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,23 +4,33 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import android.provider.ContactsContract
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import at.bitfire.vcard4android.LabeledProperty
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import ezvcard.property.Telephone
|
||||
import java.util.LinkedList
|
||||
import javax.inject.Inject
|
||||
import org.junit.After
|
||||
import org.junit.AfterClass
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.ClassRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalAddressBookTest {
|
||||
@@ -28,54 +38,114 @@ class LocalAddressBookTest {
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
lateinit var mainAccount: Account
|
||||
lateinit var addressBook: LocalTestAddressBook
|
||||
|
||||
private val addressBookAccountType by lazy { context.getString(R.string.account_type_address_book) }
|
||||
private val addressBookAccount by lazy { Account("sub", addressBookAccountType) }
|
||||
|
||||
private val accountManager by lazy { AccountManager.get(context) }
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
mainAccount = TestAccountAuthenticator.create()
|
||||
addressBook = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
LocalTestAddressBook.createAccount(context)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
TestAccountAuthenticator.remove(mainAccount)
|
||||
// remove address book
|
||||
addressBook.deleteCollection()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tests whether contacts are moved (and not lost) when an address book is renamed.
|
||||
*/
|
||||
@Test
|
||||
fun testMainAccount_AddressBookAccount_WithMainAccount() {
|
||||
// create address book account
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle(2).apply {
|
||||
putString(LocalAddressBook.USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
|
||||
putString(LocalAddressBook.USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
|
||||
}))
|
||||
fun test_renameAccount_retainsContacts() {
|
||||
// insert contact with data row
|
||||
val uid = "12345"
|
||||
val contact = Contact(
|
||||
uid = uid,
|
||||
displayName = "Test Contact",
|
||||
phoneNumbers = LinkedList(listOf(LabeledProperty(Telephone("1234567890"))))
|
||||
)
|
||||
val uri = LocalContact(addressBook, contact, null, null, 0).add()
|
||||
val id = ContentUris.parseId(uri)
|
||||
val localContact = addressBook.findContactById(id)
|
||||
localContact.resetDirty()
|
||||
assertFalse("Contact is dirty before moving", addressBook.isContactDirty(id))
|
||||
|
||||
// check mainAccount()
|
||||
assertEquals(mainAccount, LocalAddressBook.mainAccount(context, addressBookAccount))
|
||||
// rename address book
|
||||
val newName = "New Name"
|
||||
addressBook.renameAccount(newName)
|
||||
assertEquals(Account(newName, LocalTestAddressBook.ACCOUNT.type), addressBook.addressBookAccount)
|
||||
|
||||
// check whether contact is still here (including data rows) and not dirty
|
||||
val result = addressBook.findContactById(id)
|
||||
assertFalse("Contact is dirty after moving", addressBook.isContactDirty(id))
|
||||
|
||||
val contact2 = result.getContact()
|
||||
assertEquals(uid, contact2.uid)
|
||||
assertEquals("Test Contact", contact2.displayName)
|
||||
assertEquals("1234567890", contact2.phoneNumbers.first().component1().text)
|
||||
}
|
||||
|
||||
fun testMainAccount_AddressBookAccount_WithoutMainAccount() {
|
||||
// create address book account
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, Bundle.EMPTY))
|
||||
/**
|
||||
* Tests whether groups are moved (and not lost) when an address book is renamed.
|
||||
*/
|
||||
@Test
|
||||
fun test_renameAccount_retainsGroups() {
|
||||
// insert group
|
||||
val localGroup = LocalGroup(addressBook, Contact(displayName = "Test Group"), null, null, 0)
|
||||
val uri = localGroup.add()
|
||||
val id = ContentUris.parseId(uri)
|
||||
|
||||
// check mainAccount(); should fail because there's no main account
|
||||
assertNull(LocalAddressBook.mainAccount(context, addressBookAccount))
|
||||
// make sure it's not dirty
|
||||
localGroup.clearDirty(null, null, null)
|
||||
assertFalse("Group is dirty before moving", addressBook.isGroupDirty(id))
|
||||
|
||||
// rename address book
|
||||
val newName = "New Name"
|
||||
addressBook.renameAccount(newName)
|
||||
assertEquals(Account(newName, LocalTestAddressBook.ACCOUNT.type), addressBook.addressBookAccount)
|
||||
|
||||
// check whether group is still here and not dirty
|
||||
val result = addressBook.findGroupById(id)
|
||||
assertFalse("Group is dirty after moving", addressBook.isGroupDirty(id))
|
||||
|
||||
val group = result.getContact()
|
||||
assertEquals("Test Group", group.displayName)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun testMainAccount_OtherAccount() {
|
||||
LocalAddressBook.mainAccount(context, Account("Other Account", "com.example"))
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
@ClassRule
|
||||
val permissionRule = GrantPermissionRule.grant(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS)!!
|
||||
|
||||
private lateinit var provider: ContentProviderClient
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun connect() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
provider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)!!
|
||||
assertNotNull(provider)
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
@JvmStatic
|
||||
fun disconnect() {
|
||||
provider.close()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import android.content.ContentValues
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
import android.provider.CalendarContract.Events
|
||||
import android.util.Log
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import at.bitfire.davdroid.InitCalendarProviderRule
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
@@ -67,7 +66,7 @@ class LocalCalendarTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
calendar.delete()
|
||||
calendar.deleteCollection()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ class LocalEventTest {
|
||||
|
||||
@After
|
||||
fun removeCalendar() {
|
||||
calendar.delete()
|
||||
calendar.deleteCollection()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.vcard4android.BatchOperation
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
@@ -68,7 +67,7 @@ class LocalGroupTest {
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
|
||||
private lateinit var addressBookGroupsAsCategories: LocalTestAddressBook
|
||||
@@ -78,8 +77,8 @@ class LocalGroupTest {
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
|
||||
addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
|
||||
addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
|
||||
addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
|
||||
|
||||
// clear contacts
|
||||
addressBookGroupsAsCategories.clear()
|
||||
|
||||
@@ -5,24 +5,38 @@
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.junit.Assert.assertTrue
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
|
||||
class LocalTestAddressBook(
|
||||
context: Context,
|
||||
provider: ContentProviderClient,
|
||||
override val groupMethod: GroupMethod
|
||||
): LocalAddressBook(context, ACCOUNT, provider) {
|
||||
class LocalTestAddressBook @AssistedInject constructor(
|
||||
@Assisted provider: ContentProviderClient,
|
||||
@Assisted override val groupMethod: GroupMethod,
|
||||
accountSettingsFactory: AccountSettings.Factory,
|
||||
collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext context: Context,
|
||||
logger: Logger,
|
||||
serviceRepository: DavServiceRepository
|
||||
): LocalAddressBook(ACCOUNT, provider, accountSettingsFactory, collectionRepository, context, logger, serviceRepository) {
|
||||
|
||||
companion object {
|
||||
val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test")
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(provider: ContentProviderClient, groupMethod: GroupMethod): LocalTestAddressBook
|
||||
}
|
||||
|
||||
override var mainAccount: Account?
|
||||
get() = throw NotImplementedError()
|
||||
set(_) = throw NotImplementedError()
|
||||
|
||||
override var readOnly: Boolean
|
||||
get() = false
|
||||
set(_) = throw NotImplementedError()
|
||||
@@ -35,4 +49,49 @@ class LocalTestAddressBook(
|
||||
group.delete()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the dirty flag of the given contact.
|
||||
*
|
||||
* @return true if the contact is dirty, false otherwise
|
||||
*
|
||||
* @throws FileNotFoundException if the contact can't be found
|
||||
*/
|
||||
fun isContactDirty(id: Long): Boolean {
|
||||
val uri = ContentUris.withAppendedId(rawContactsSyncUri(), id)
|
||||
provider!!.query(uri, arrayOf(ContactsContract.RawContacts.DIRTY), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst())
|
||||
return cursor.getInt(0) != 0
|
||||
}
|
||||
throw FileNotFoundException()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dirty flag of the given contact group.
|
||||
*
|
||||
* @return true if the group is dirty, false otherwise
|
||||
*
|
||||
* @throws FileNotFoundException if the group can't be found
|
||||
*/
|
||||
fun isGroupDirty(id: Long): Boolean {
|
||||
val uri = ContentUris.withAppendedId(groupsSyncUri(), id)
|
||||
provider!!.query(uri, arrayOf(ContactsContract.Groups.DIRTY), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst())
|
||||
return cursor.getInt(0) != 0
|
||||
}
|
||||
throw FileNotFoundException()
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test")
|
||||
|
||||
fun createAccount(context: Context) {
|
||||
val am = AccountManager.get(context)
|
||||
assertTrue("Couldn't create account for local test address-book", am.addAccountExplicitly(ACCOUNT, null, null))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -55,6 +55,9 @@ class CachedGroupMembershipHandlerTest {
|
||||
}
|
||||
|
||||
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
@@ -70,7 +73,7 @@ class CachedGroupMembershipHandlerTest {
|
||||
|
||||
@Test
|
||||
fun testMembership() {
|
||||
val addressBook = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
|
||||
val addressBook = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
|
||||
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBook, contact, null, null, 0)
|
||||
|
||||
@@ -56,6 +56,9 @@ class GroupMembershipBuilderTest {
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
@@ -71,7 +74,7 @@ class GroupMembershipBuilderTest {
|
||||
val contact = Contact().apply {
|
||||
categories += "TEST GROUP"
|
||||
}
|
||||
val addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
|
||||
val addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsCategories, false).build().also { result ->
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(GroupMembership.CONTENT_ITEM_TYPE, result[0].values[GroupMembership.MIMETYPE])
|
||||
@@ -84,7 +87,7 @@ class GroupMembershipBuilderTest {
|
||||
val contact = Contact().apply {
|
||||
categories += "TEST GROUP"
|
||||
}
|
||||
val addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
|
||||
val addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
|
||||
GroupMembershipBuilder(Uri.EMPTY, null, contact, addressBookGroupsAsVCards, false).build().also { result ->
|
||||
// group membership is constructed during post-processing
|
||||
assertEquals(0, result.size)
|
||||
|
||||
@@ -57,6 +57,9 @@ class GroupMembershipHandlerTest {
|
||||
|
||||
}
|
||||
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
@Inject @ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@@ -71,7 +74,7 @@ class GroupMembershipHandlerTest {
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsCategories() {
|
||||
val addressBookGroupsAsCategories = LocalTestAddressBook(context, provider, GroupMethod.CATEGORIES)
|
||||
val addressBookGroupsAsCategories = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
val addressBookGroupsAsCategoriesGroup = addressBookGroupsAsCategories.findOrCreateGroup("TEST GROUP")
|
||||
|
||||
val contact = Contact()
|
||||
@@ -87,7 +90,7 @@ class GroupMembershipHandlerTest {
|
||||
|
||||
@Test
|
||||
fun testMembership_GroupsAsVCards() {
|
||||
val addressBookGroupsAsVCards = LocalTestAddressBook(context, provider, GroupMethod.GROUP_VCARDS)
|
||||
val addressBookGroupsAsVCards = addressbookFactory.create(provider, GroupMethod.GROUP_VCARDS)
|
||||
|
||||
val contact = Contact()
|
||||
val localContact = LocalContact(addressBookGroupsAsVCards, contact, null, null, 0)
|
||||
|
||||
@@ -7,11 +7,11 @@ package at.bitfire.davdroid.sync
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.resource.LocalCollection
|
||||
|
||||
class LocalTestCollection: LocalCollection<LocalTestResource> {
|
||||
class LocalTestCollection(
|
||||
override val collectionUrl: String = "http://example.com/test/"
|
||||
): LocalCollection<LocalTestResource> {
|
||||
|
||||
override val tag = "LocalTestCollection"
|
||||
override val url: String
|
||||
get() = "https://example.com"
|
||||
override val title = "Local Test Collection"
|
||||
|
||||
override var lastSyncState: SyncState? = null
|
||||
@@ -21,7 +21,7 @@ class LocalTestCollection: LocalCollection<LocalTestResource> {
|
||||
override val readOnly: Boolean
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override fun delete(): Boolean = true
|
||||
override fun deleteCollection(): Boolean = true
|
||||
|
||||
override fun findDeleted() = entries.filter { it.deleted }
|
||||
override fun findDirty() = entries.filter { it.dirty }
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.Awaits
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.Timeout
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncAdapterServicesTest {
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
@Inject
|
||||
lateinit var accountSettingsFactory: AccountSettings.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var collectionRepository: DavCollectionRepository
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@Inject
|
||||
lateinit var syncConditionsFactory: SyncConditions.Factory
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
// test methods should run quickly and not wait 60 seconds for a sync timeout or something like that
|
||||
@get:Rule
|
||||
val timeoutRule: Timeout = Timeout.seconds(5)
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
account = TestAccountAuthenticator.create()
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccountAuthenticator.remove(account)
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
|
||||
private fun syncAdapter(
|
||||
syncWorkerManager: SyncWorkerManager
|
||||
): SyncAdapterService.SyncAdapter =
|
||||
SyncAdapterService.SyncAdapter(
|
||||
accountSettingsFactory = accountSettingsFactory,
|
||||
collectionRepository = collectionRepository,
|
||||
serviceRepository = serviceRepository,
|
||||
context = context,
|
||||
logger = logger,
|
||||
syncConditionsFactory = syncConditionsFactory,
|
||||
syncWorkerManager = syncWorkerManager
|
||||
)
|
||||
|
||||
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_cancellation() {
|
||||
val syncWorkerManager = mockk<SyncWorkerManager>()
|
||||
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
|
||||
|
||||
// assume worker takes a long time
|
||||
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } just Awaits
|
||||
|
||||
runBlocking {
|
||||
val sync = launch {
|
||||
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
|
||||
}
|
||||
|
||||
// simulate incoming cancellation from sync framework
|
||||
syncAdapter.onSyncCanceled()
|
||||
|
||||
// wait for sync to finish (should happen immediately)
|
||||
sync.join()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_returnsAfterTimeout() {
|
||||
val syncWorkerManager = mockk<SyncWorkerManager>()
|
||||
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
|
||||
|
||||
// assume worker takes a long time
|
||||
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } just Awaits
|
||||
|
||||
mockkStatic("kotlinx.coroutines.TimeoutKt") { // mock global extension function
|
||||
// immediate timeout (instead of really waiting)
|
||||
coEvery { withTimeout(any<Long>(), any<suspend CoroutineScope.() -> Unit>()) } throws CancellationException("Simulated timeout")
|
||||
|
||||
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSyncAdapter_onPerformSync_runsInTime() {
|
||||
val syncWorkerManager = mockk<SyncWorkerManager>()
|
||||
val syncAdapter = syncAdapter(syncWorkerManager = syncWorkerManager)
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
mockkObject(workManager) {
|
||||
// don't actually create a worker
|
||||
every { syncWorkerManager.enqueueOneTime(any(), any()) } returns "TheSyncWorker"
|
||||
|
||||
// assume worker immediately returns with success
|
||||
val success = mockk<WorkInfo>()
|
||||
every { success.state } returns WorkInfo.State.SUCCEEDED
|
||||
every { workManager.getWorkInfosForUniqueWorkFlow("TheSyncWorker") } returns flow {
|
||||
emit(listOf(success))
|
||||
delay(60000) // keep the flow active
|
||||
}
|
||||
|
||||
// should just run
|
||||
syncAdapter.onPerformSync(account, Bundle(), CalendarContract.AUTHORITY, mockk(), SyncResult())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,7 +6,6 @@ package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
@@ -522,7 +521,7 @@ class SyncManagerTest {
|
||||
}
|
||||
) = syncManagerFactory.create(
|
||||
account,
|
||||
accountSettingsFactory.forAccount(account),
|
||||
accountSettingsFactory.create(account),
|
||||
arrayOf(),
|
||||
"TestAuthority",
|
||||
HttpClient.Builder(context).build(),
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.spyk
|
||||
import io.mockk.verify
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncerTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var testSyncer: TestSyncer.Factory
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
private lateinit var syncer: TestSyncer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
account = TestAccountAuthenticator.create()
|
||||
syncer = spyk(testSyncer.create(account, emptyArray(), SyncResult()))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccountAuthenticator.remove(account)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testSync_prepare_fails() {
|
||||
val provider = mockk<ContentProviderClient>()
|
||||
every { syncer.prepare(provider) } returns false
|
||||
every { syncer.getSyncEnabledCollections() } returns emptyMap()
|
||||
|
||||
// Should stop the sync after prepare returns false
|
||||
syncer.sync(provider)
|
||||
verify(exactly = 1) { syncer.prepare(provider) }
|
||||
verify(exactly = 0) { syncer.getSyncEnabledCollections() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSync_prepare_succeeds() {
|
||||
val provider = mockk<ContentProviderClient>()
|
||||
every { syncer.prepare(provider) } returns true
|
||||
every { syncer.getSyncEnabledCollections() } returns emptyMap()
|
||||
|
||||
// Should continue the sync after prepare returns true
|
||||
syncer.sync(provider)
|
||||
verify(exactly = 1) { syncer.prepare(provider) }
|
||||
verify(exactly = 1) { syncer.getSyncEnabledCollections() }
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testUpdateCollections_deletesCollection() {
|
||||
val localCollection = mockk<LocalTestCollection>()
|
||||
every { localCollection.collectionUrl } returns "http://delete.the/collection"
|
||||
every { localCollection.deleteCollection() } returns true
|
||||
every { localCollection.title } returns "Collection to be deleted locally"
|
||||
|
||||
// Should delete the localCollection if dbCollection (remote) does not exist
|
||||
val localCollections = mutableListOf(localCollection)
|
||||
val result = syncer.updateCollections(mockk(), localCollections, emptyMap())
|
||||
verify(exactly = 1) { localCollection.deleteCollection() }
|
||||
|
||||
// Updated local collection list should be empty
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateCollections_updatesCollection() {
|
||||
val localCollection = mockk<LocalTestCollection>()
|
||||
val dbCollection = mockk<Collection>()
|
||||
val dbCollections = mapOf("http://update.the/collection".toHttpUrl() to dbCollection)
|
||||
every { dbCollection.url } returns "http://update.the/collection".toHttpUrl()
|
||||
every { localCollection.collectionUrl } returns "http://update.the/collection"
|
||||
every { localCollection.title } returns "The Local Collection"
|
||||
|
||||
// Should update the localCollection if it exists
|
||||
val result = syncer.updateCollections(mockk(), listOf(localCollection), dbCollections)
|
||||
verify(exactly = 1) { syncer.update(localCollection, dbCollection) }
|
||||
|
||||
// Updated local collection list should be same as input
|
||||
assertArrayEquals(arrayOf(localCollection), result.toTypedArray())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateCollections_findsNewCollection() {
|
||||
val dbCollection = mockk<Collection>()
|
||||
every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl()
|
||||
val dbCollections = mapOf(dbCollection.url to dbCollection)
|
||||
|
||||
// Should return the new collection, because it was not updated
|
||||
val result = syncer.updateCollections(mockk(), emptyList(), dbCollections)
|
||||
|
||||
// Updated local collection list contain new entry
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(dbCollection.url.toString(), result[0].collectionUrl)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testCreateLocalCollections() {
|
||||
val provider = mockk<ContentProviderClient>()
|
||||
val localCollection = mockk<LocalTestCollection>()
|
||||
val dbCollection = mockk<Collection>()
|
||||
every { syncer.create(provider, dbCollection) } returns localCollection
|
||||
every { dbCollection.url } returns "http://newly.found/collection".toHttpUrl()
|
||||
|
||||
// Should return list of newly created local collections
|
||||
val result = syncer.createLocalCollections(provider, listOf(dbCollection))
|
||||
assertEquals(listOf(localCollection), result)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testSyncCollectionContents() {
|
||||
val provider = mockk<ContentProviderClient>()
|
||||
val dbCollection1 = mockk<Collection>()
|
||||
val dbCollection2 = mockk<Collection>()
|
||||
val dbCollections = mapOf(
|
||||
"http://newly.found/collection1".toHttpUrl() to dbCollection1,
|
||||
"http://newly.found/collection2".toHttpUrl() to dbCollection2
|
||||
)
|
||||
val localCollection1 = mockk<LocalTestCollection>()
|
||||
val localCollection2 = mockk<LocalTestCollection>()
|
||||
val localCollections = listOf(localCollection1, localCollection2)
|
||||
every { localCollection1.collectionUrl } returns "http://newly.found/collection1"
|
||||
every { localCollection2.collectionUrl } returns "http://newly.found/collection2"
|
||||
|
||||
// Should call the collection content sync on both collections
|
||||
syncer.syncCollectionContents(provider, localCollections, dbCollections)
|
||||
verify(exactly = 1) { syncer.syncCollection(provider, localCollection1, dbCollection1) }
|
||||
verify(exactly = 1) { syncer.syncCollection(provider, localCollection2, dbCollection2) }
|
||||
}
|
||||
|
||||
|
||||
// Test helpers
|
||||
|
||||
class TestSyncer @AssistedInject constructor(
|
||||
@Assisted account: Account,
|
||||
@Assisted extras: Array<String>,
|
||||
@Assisted syncResult: SyncResult
|
||||
) : Syncer<LocalTestCollection>(account, extras, syncResult) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(account: Account, extras: Array<String>, syncResult: SyncResult): TestSyncer
|
||||
}
|
||||
|
||||
override val authority: String
|
||||
get() = ""
|
||||
override val serviceType: String
|
||||
get() = ""
|
||||
|
||||
override fun prepare(provider: ContentProviderClient): Boolean =
|
||||
true
|
||||
|
||||
override fun getLocalCollections(provider: ContentProviderClient): List<LocalTestCollection> =
|
||||
emptyList()
|
||||
|
||||
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
|
||||
emptyList()
|
||||
|
||||
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalTestCollection =
|
||||
LocalTestCollection(remoteCollection.url.toString())
|
||||
|
||||
override fun syncCollection(
|
||||
provider: ContentProviderClient,
|
||||
localCollection: LocalTestCollection,
|
||||
remoteCollection: Collection
|
||||
) {}
|
||||
|
||||
override fun update(localCollection: LocalTestCollection, remoteCollection: Collection) {}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.SyncResult
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
import at.bitfire.dav4jvm.Response
|
||||
|
||||
@@ -54,7 +54,7 @@ class AccountUtilsTest {
|
||||
|
||||
val manager = AccountManager.get(context)
|
||||
try {
|
||||
assertTrue(AccountUtils.createAccount(context, account, userData))
|
||||
assertTrue(SystemAccountUtils.createAccount(context, account, userData))
|
||||
|
||||
// validate user data
|
||||
assertEquals("1", manager.getUserData(account, "int"))
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package at.bitfire.davdroid.sync.account
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkerFactory
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class AccountsCleanupWorkerTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var accountsCleanupWorkerFactory: AccountsCleanupWorker.Factory
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var db: AppDatabase
|
||||
|
||||
@Inject
|
||||
lateinit var settingsManager: SettingsManager
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
lateinit var accountManager: AccountManager
|
||||
lateinit var addressBookAccountType: String
|
||||
lateinit var addressBookAccount: Account
|
||||
lateinit var service: Service
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
service = createTestService(Service.TYPE_CARDDAV)
|
||||
|
||||
// Prepare test account
|
||||
accountManager = AccountManager.get(context)
|
||||
addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
addressBookAccount = Account(
|
||||
"Fancy address book account",
|
||||
addressBookAccountType
|
||||
)
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
// Remove the account here in any case; Nice to have when the test fails
|
||||
accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testDeleteOrphanedAddressBookAccounts_deletesAddressBookAccountWithoutCollection() {
|
||||
// Create address book account without corresponding collection
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, null))
|
||||
|
||||
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
|
||||
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(object: WorkerFactory() {
|
||||
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
|
||||
accountsCleanupWorkerFactory.create(appContext, workerParameters)
|
||||
})
|
||||
.build()
|
||||
worker.deleteOrphanedAddressBookAccounts(addressBookAccounts)
|
||||
|
||||
// Verify account was deleted
|
||||
assertTrue(accountManager.getAccountsByType(addressBookAccountType).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteOrphanedAddressBookAccounts_leavesAddressBookAccountWithCollection() {
|
||||
// Create address book account _with_ corresponding collection and verify
|
||||
val randomCollectionId = 12345L
|
||||
val userData = Bundle(1).apply {
|
||||
putString(USER_DATA_COLLECTION_ID, "$randomCollectionId")
|
||||
}
|
||||
assertTrue(accountManager.addAccountExplicitly(addressBookAccount, null, userData))
|
||||
|
||||
val addressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
|
||||
assertEquals(randomCollectionId, accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID).toLong())
|
||||
|
||||
// Create the collection
|
||||
val collectionDao = db.collectionDao()
|
||||
collectionDao.insert(Collection(
|
||||
randomCollectionId,
|
||||
serviceId = service.id,
|
||||
type = Collection.TYPE_ADDRESSBOOK,
|
||||
url = "http://www.example.com/yay.php".toHttpUrl()
|
||||
))
|
||||
|
||||
// Create worker and run the method
|
||||
val worker = TestListenableWorkerBuilder<AccountsCleanupWorker>(context)
|
||||
.setWorkerFactory(object: WorkerFactory() {
|
||||
override fun createWorker(appContext: Context, workerClassName: String, workerParameters: WorkerParameters) =
|
||||
accountsCleanupWorkerFactory.create(appContext, workerParameters)
|
||||
})
|
||||
.build()
|
||||
worker.deleteOrphanedAddressBookAccounts(addressBookAccounts)
|
||||
|
||||
// Verify account was _not_ deleted
|
||||
assertEquals(addressBookAccount, addressBookAccounts.firstOrNull())
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun createTestService(serviceType: String): Service {
|
||||
val service = Service(id=0, accountName="test", type=serviceType, principal = null)
|
||||
val serviceId = db.serviceDao().insertOrReplace(service)
|
||||
return db.serviceDao().get(serviceId)!!
|
||||
}
|
||||
|
||||
}
|
||||
@@ -52,7 +52,7 @@ class TestAccountAuthenticator: Service() {
|
||||
val accountType = context.getString(R.string.account_type_test)
|
||||
val account = Account("Test Account", accountType)
|
||||
|
||||
assertTrue(AccountUtils.createAccount(context, account, AccountSettings.initialUserData(null)))
|
||||
assertTrue(SystemAccountUtils.createAccount(context, account, AccountSettings.initialUserData(null)))
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class OneTimeSyncWorkerTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
account = TestAccountAuthenticator.create()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccountAuthenticator.remove(account)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testEnqueue_enqueuesWorker() {
|
||||
OneTimeSyncWorker.enqueue(context, account, CalendarContract.AUTHORITY)
|
||||
val workerName = OneTimeSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import androidx.work.WorkerParameters
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import androidx.work.workDataOf
|
||||
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import at.bitfire.davdroid.test.R
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -27,7 +26,6 @@ import io.mockk.mockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -69,21 +67,6 @@ class PeriodicSyncWorkerTest {
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun enable_enqueuesPeriodicWorker() {
|
||||
PeriodicSyncWorker.enable(context, account, CalendarContract.AUTHORITY, 60, false)
|
||||
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
assertTrue(workScheduledOrRunning(context, workerName))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disable_removesPeriodicWorker() {
|
||||
PeriodicSyncWorker.enable(context, account, CalendarContract.AUTHORITY, 60, false)
|
||||
PeriodicSyncWorker.disable(context, account, CalendarContract.AUTHORITY)
|
||||
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
assertFalse(workScheduledOrRunning(context, workerName))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doWork_cancelsItselfOnInvalidAccount() {
|
||||
val invalidAccount = Account("invalid", testContext.getString(R.string.account_type_test))
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import at.bitfire.davdroid.TestUtils
|
||||
import at.bitfire.davdroid.TestUtils.workScheduledOrRunning
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidTest
|
||||
class SyncWorkerManagerTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
@Inject
|
||||
lateinit var syncWorkerManager: SyncWorkerManager
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
lateinit var account: Account
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
// Initialize WorkManager for instrumentation tests.
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
|
||||
account = TestAccountAuthenticator.create()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TestAccountAuthenticator.remove(account)
|
||||
}
|
||||
|
||||
|
||||
// one-time sync workers
|
||||
|
||||
@Test
|
||||
fun testEnqueueOneTime() {
|
||||
val workerName = OneTimeSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
assertFalse(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
|
||||
|
||||
val returnedName = syncWorkerManager.enqueueOneTime(account, CalendarContract.AUTHORITY)
|
||||
assertEquals(workerName, returnedName)
|
||||
assertTrue(TestUtils.workScheduledOrRunningOrSuccessful(context, workerName))
|
||||
}
|
||||
|
||||
|
||||
// periodic sync workers
|
||||
|
||||
@Test
|
||||
fun enablePeriodic() {
|
||||
syncWorkerManager.enablePeriodic(account, CalendarContract.AUTHORITY, 60, false).result.get()
|
||||
|
||||
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
assertTrue(workScheduledOrRunning(context, workerName))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disablePeriodic() {
|
||||
syncWorkerManager.enablePeriodic(account, CalendarContract.AUTHORITY, 60, false).result.get()
|
||||
syncWorkerManager.disablePeriodic(account, CalendarContract.AUTHORITY).result.get()
|
||||
|
||||
val workerName = PeriodicSyncWorker.workerName(account, CalendarContract.AUTHORITY)
|
||||
assertFalse(workScheduledOrRunning(context, workerName))
|
||||
}
|
||||
|
||||
}
|
||||
6
app/src/debug/res/values/colors.xml
Normal file
6
app/src/debug/res/values/colors.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="primaryColor">#E07C25</color>
|
||||
<color name="primaryLightColor">#E5A371</color>
|
||||
<color name="primaryDarkColor">#7C3E07</color>
|
||||
</resources>
|
||||
4
app/src/debug/res/values/strings.xml
Normal file
4
app/src/debug/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">DAVx⁵ Debug</string>
|
||||
</resources>
|
||||
@@ -130,7 +130,9 @@
|
||||
<intent-filter>
|
||||
<action android:name="loginFlow" /> <!-- Ensures this filter matches, even if the sending app is not defining an action -->
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="http" />
|
||||
<data
|
||||
tools:ignore="AppLinkUrlError"
|
||||
android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
@@ -62,7 +62,7 @@ class App: Application(), Configuration.Provider {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
// clean up orphaned accounts in DB from time to time
|
||||
AccountsCleanupWorker.enqueue(this@App)
|
||||
AccountsCleanupWorker.enable(this@App)
|
||||
|
||||
// create/update app shortcuts
|
||||
UiUtils.updateShortcuts(this@App)
|
||||
|
||||
@@ -84,7 +84,7 @@ abstract class AppDatabase: RoomDatabase() {
|
||||
// remove all accounts because they're unfortunately useless without database
|
||||
val am = AccountManager.get(context)
|
||||
for (account in am.getAccountsByType(context.getString(R.string.account_type)))
|
||||
am.removeAccount(account, null, null)
|
||||
am.removeAccountExplicitly(account)
|
||||
}
|
||||
})
|
||||
.build()
|
||||
|
||||
@@ -72,11 +72,28 @@ data class Collection(
|
||||
*/
|
||||
var url: HttpUrl,
|
||||
|
||||
/**
|
||||
* Whether we have the permission to change contents of the collection on the server.
|
||||
* Even if this flag is set, there may still be other reasons why a collection is effectively read-only.
|
||||
*/
|
||||
var privWriteContent: Boolean = true,
|
||||
/**
|
||||
* Whether we have the permission to delete the collection on the server
|
||||
*/
|
||||
var privUnbind: Boolean = true,
|
||||
/**
|
||||
* Whether the user has manually set the "force read-only" flag.
|
||||
* Even if this flag is not set, there may still be other reasons why a collection is effectively read-only.
|
||||
*/
|
||||
var forceReadOnly: Boolean = false,
|
||||
|
||||
/**
|
||||
* Human-readable name of the collection
|
||||
*/
|
||||
var displayName: String? = null,
|
||||
/**
|
||||
* Human-readable description of the collection
|
||||
*/
|
||||
var description: String? = null,
|
||||
|
||||
// CalDAV only
|
||||
|
||||
@@ -72,6 +72,9 @@ interface CollectionDao {
|
||||
@Query("SELECT * FROM collection WHERE sync AND supportsWebPush AND pushTopic IS NOT NULL")
|
||||
suspend fun getPushCapableSyncCollections(): List<Collection>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE pushSubscription IS NOT NULL AND NOT sync")
|
||||
suspend fun getPushRegisteredAndNotSyncable(): List<Collection>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun insert(collection: Collection): Long
|
||||
|
||||
@@ -85,7 +88,7 @@ interface CollectionDao {
|
||||
suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)
|
||||
|
||||
@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionCreated=:updatedAt WHERE id=:id")
|
||||
suspend fun updatePushSubscription(id: Long, pushSubscription: String, updatedAt: Long = System.currentTimeMillis())
|
||||
fun updatePushSubscription(id: Long, pushSubscription: String?, updatedAt: Long = System.currentTimeMillis())
|
||||
|
||||
@Query("UPDATE collection SET sync=:sync WHERE id=:id")
|
||||
suspend fun updateSync(id: Long, sync: Boolean)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import com.google.common.base.Ascii
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -42,6 +43,11 @@ class PlainTextFormatter(
|
||||
lineSeparator = System.lineSeparator()
|
||||
)
|
||||
|
||||
/**
|
||||
* Maximum length of a log line (estimate).
|
||||
*/
|
||||
const val MAX_LENGTH = 10000
|
||||
|
||||
fun shortClassName(className: String) = className
|
||||
.replace(Regex("^at\\.bitfire\\.(dav|cert4an|dav4an|ical4an|vcard4an)droid\\."), ".")
|
||||
.replace(Regex("\\$.*$"), "")
|
||||
@@ -72,7 +78,7 @@ class PlainTextFormatter(
|
||||
}
|
||||
}
|
||||
|
||||
builder.append(r.message)
|
||||
builder.append(truncate(r.message))
|
||||
|
||||
if (withException && r.thrown != null) {
|
||||
val indentedStackTrace = stackTrace(r.thrown)
|
||||
@@ -82,8 +88,15 @@ class PlainTextFormatter(
|
||||
}
|
||||
|
||||
r.parameters?.let {
|
||||
for ((idx, param) in it.withIndex())
|
||||
builder.append("\n\tPARAMETER #").append(idx).append(" = ").append(param)
|
||||
for ((idx, param) in it.withIndex()) {
|
||||
builder.append("\n\tPARAMETER #").append(idx + 1).append(" = ")
|
||||
|
||||
val valStr = if (param == null)
|
||||
"(null)"
|
||||
else
|
||||
truncate(param.toString())
|
||||
builder.append(valStr)
|
||||
}
|
||||
}
|
||||
|
||||
if (lineSeparator != null)
|
||||
@@ -92,4 +105,7 @@ class PlainTextFormatter(
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
private fun truncate(s: String) =
|
||||
Ascii.truncate(s, MAX_LENGTH, "[…]")
|
||||
|
||||
}
|
||||
@@ -67,8 +67,9 @@ class DnsRecordResolver @Inject constructor(
|
||||
val dnsServers = LinkedList<InetAddress>()
|
||||
|
||||
val connectivity = context.getSystemService<ConnectivityManager>()!!
|
||||
@Suppress("DEPRECATION")
|
||||
connectivity.allNetworks.forEach { network ->
|
||||
val active = connectivity.getNetworkInfo(network)?.isConnected ?: false
|
||||
val active = connectivity.getNetworkInfo(network)?.isConnected == true
|
||||
connectivity.getLinkProperties(network)?.let { link ->
|
||||
if (active)
|
||||
// active connection, insert at top of list
|
||||
@@ -129,12 +130,12 @@ class DnsRecordResolver @Inject constructor(
|
||||
|
||||
// Select records which have the minimum priority
|
||||
val minPriority = srvRecords.minOfOrNull { it.priority }
|
||||
val useableRecords = srvRecords.filter { it.priority == minPriority }
|
||||
val usableRecords = srvRecords.filter { it.priority == minPriority }
|
||||
.sortedBy { it.weight != 0 } // and put those with weight 0 first
|
||||
|
||||
val map = TreeMap<Int, SRVRecord>()
|
||||
var runningWeight = 0
|
||||
for (record in useableRecords) {
|
||||
for (record in usableRecords) {
|
||||
val weight = record.weight
|
||||
runningWeight += weight
|
||||
map[runningWeight] = record
|
||||
|
||||
@@ -22,6 +22,21 @@ import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.Socket
|
||||
import java.security.KeyStore
|
||||
import java.security.Principal
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.net.ssl.KeyManager
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import net.openid.appauth.AuthState
|
||||
import net.openid.appauth.AuthorizationService
|
||||
@@ -36,23 +51,6 @@ import okhttp3.Response
|
||||
import okhttp3.brotli.BrotliInterceptor
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.Socket
|
||||
import java.security.KeyStore
|
||||
import java.security.Principal
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.net.ssl.KeyManager
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
class HttpClient @AssistedInject constructor(
|
||||
@Assisted val okHttpClient: OkHttpClient,
|
||||
@@ -319,10 +317,7 @@ class HttpClient @AssistedInject constructor(
|
||||
|
||||
object UserAgentInterceptor: Interceptor {
|
||||
|
||||
// use Locale.ROOT because numbers may be encoded as non-ASCII characters in other locales
|
||||
private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.ROOT)
|
||||
private val userAgentDate = userAgentDateFormat.format(Date(BuildConfig.buildTime))
|
||||
val userAgent = "DAVx5/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4jvm; " +
|
||||
val userAgent = "DAVx5/${BuildConfig.VERSION_NAME} (dav4jvm; " +
|
||||
"okhttp/${OkHttp.VERSION}) Android/${Build.VERSION.RELEASE}"
|
||||
|
||||
init {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package at.bitfire.davdroid.push
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.davdroid.ui.account.AccountActivity
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class PushNotificationManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val notificationRegistry: NotificationRegistry
|
||||
) {
|
||||
|
||||
/**
|
||||
* Generates the notification ID for a push notification.
|
||||
*/
|
||||
private fun notificationId(account: Account, authority: String): Int {
|
||||
return account.name.hashCode() + account.type.hashCode() + authority.hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
fun notify(account: Account, authority: String) {
|
||||
notificationRegistry.notifyIfPossible(notificationId(account, authority)) {
|
||||
NotificationCompat.Builder(context, notificationRegistry.CHANNEL_STATUS)
|
||||
.setSmallIcon(R.drawable.ic_sync)
|
||||
.setContentTitle(context.getString(R.string.sync_notification_pending_push_title))
|
||||
.setContentText(context.getString(R.string.sync_notification_pending_push_message))
|
||||
.setSubText(account.name)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, AccountActivity::class.java).apply {
|
||||
putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Once the sync has been started, the notification is no longer needed and can be dismissed.
|
||||
* It's safe to call this method even if the notification has not been shown.
|
||||
*/
|
||||
fun dismiss(account: Account, authority: String) {
|
||||
NotificationManagerCompat.from(context)
|
||||
.cancel(notificationId(account, authority))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.property.push.NS_WEBDAV_PUSH
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
@@ -35,11 +36,13 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.StringWriter
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -48,7 +51,8 @@ import javax.inject.Inject
|
||||
* To be run as soon as a collection that supports push is changed (selected for sync status
|
||||
* changes, or collection is created, deleted, etc).
|
||||
*
|
||||
* TODO Should run periodically, too. Not required for a first demonstration version.
|
||||
* TODO Should run periodically, too (to refresh registrations that are about to expire).
|
||||
* Not required for a first demonstration version.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
@HiltWorker
|
||||
@@ -85,8 +89,17 @@ class PushRegistrationWorker @AssistedInject constructor(
|
||||
}
|
||||
|
||||
|
||||
private suspend fun requestPushRegistration(collection: Collection, account: Account, endpoint: String) {
|
||||
val settings = accountSettingsFactory.forAccount(account)
|
||||
override suspend fun doWork(): Result {
|
||||
logger.info("Running push registration worker")
|
||||
|
||||
registerSyncable()
|
||||
unregisterNotSyncable()
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private suspend fun registerPushSubscription(collection: Collection, account: Account, endpoint: String) {
|
||||
val settings = accountSettingsFactory.create(account)
|
||||
|
||||
runInterruptible {
|
||||
HttpClient.Builder(applicationContext, settings)
|
||||
@@ -112,7 +125,7 @@ class PushRegistrationWorker @AssistedInject constructor(
|
||||
|
||||
val xml = writer.toString().toRequestBody(DavResource.MIME_XML)
|
||||
DavCollection(httpClient, collection.url).post(xml) { response ->
|
||||
if (response.isSuccessful) runBlocking {
|
||||
if (response.isSuccessful) {
|
||||
response.header("Location")?.let { subscriptionUrl ->
|
||||
collectionRepository.updatePushSubscription(collection.id, subscriptionUrl)
|
||||
}
|
||||
@@ -123,23 +136,61 @@ class PushRegistrationWorker @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
logger.info("Running push registration worker")
|
||||
|
||||
private suspend fun registerSyncable() {
|
||||
val endpoint = preferenceRepository.unifiedPushEndpoint()
|
||||
|
||||
// register push subscription for syncable collections
|
||||
if (endpoint != null)
|
||||
for (collection in collectionRepository.getSyncableAndPushCapable()) {
|
||||
for (collection in collectionRepository.getPushCapableAndSyncable()) {
|
||||
logger.info("Registering push for ${collection.url}")
|
||||
val service = serviceRepository.get(collection.serviceId) ?: continue
|
||||
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
|
||||
|
||||
requestPushRegistration(collection, account, endpoint)
|
||||
serviceRepository.get(collection.serviceId)?.let { service ->
|
||||
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
|
||||
try {
|
||||
registerPushSubscription(collection, account, endpoint)
|
||||
} catch (e: DavException) {
|
||||
// catch possible per-collection exception so that all collections can be processed
|
||||
logger.log(Level.WARNING, "Couldn't register push for ${collection.url}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
logger.info("No UnifiedPush endpoint configured")
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
private suspend fun unregisterPushSubscription(collection: Collection, account: Account, url: HttpUrl) {
|
||||
val settings = accountSettingsFactory.create(account)
|
||||
|
||||
runInterruptible {
|
||||
HttpClient.Builder(applicationContext, settings)
|
||||
.setForeground(true)
|
||||
.build()
|
||||
.use { client ->
|
||||
val httpClient = client.okHttpClient
|
||||
|
||||
try {
|
||||
DavResource(httpClient, url).delete {
|
||||
// deleted
|
||||
}
|
||||
} catch (e: DavException) {
|
||||
logger.log(Level.WARNING, "Couldn't unregister push for ${collection.url}", e)
|
||||
}
|
||||
|
||||
// remove registration URL from DB in any case
|
||||
collectionRepository.updatePushSubscription(collection.id, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun unregisterNotSyncable() {
|
||||
for (collection in collectionRepository.getPushRegisteredAndNotSyncable()) {
|
||||
logger.info("Unregistering push for ${collection.url}")
|
||||
collection.pushSubscription?.toHttpUrlOrNull()?.let { url ->
|
||||
serviceRepository.get(collection.serviceId)?.let { service ->
|
||||
val account = Account(service.accountName, applicationContext.getString(R.string.account_type))
|
||||
unregisterPushSubscription(collection, account, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,15 +9,15 @@ import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.repository.PreferenceRepository
|
||||
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.unifiedpush.android.connector.MessagingReceiver
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class UnifiedPushReceiver: MessagingReceiver() {
|
||||
@@ -40,6 +40,9 @@ class UnifiedPushReceiver: MessagingReceiver() {
|
||||
@Inject
|
||||
lateinit var parsePushMessage: PushMessageParser
|
||||
|
||||
@Inject
|
||||
lateinit var syncWorkerManager: SyncWorkerManager
|
||||
|
||||
|
||||
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
|
||||
// remember new endpoint
|
||||
@@ -71,14 +74,14 @@ class UnifiedPushReceiver: MessagingReceiver() {
|
||||
collectionRepository.getSyncableByTopic(topic)?.let { collection ->
|
||||
serviceRepository.get(collection.serviceId)?.let { service ->
|
||||
val account = accountRepository.fromName(service.accountName)
|
||||
OneTimeSyncWorker.enqueueAllAuthorities(context, account)
|
||||
syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
logger.warning("Got push message without topic, syncing all accounts")
|
||||
for (account in accountRepository.getAll())
|
||||
OneTimeSyncWorker.enqueueAllAuthorities(context, account)
|
||||
syncWorkerManager.enqueueOneTimeAllAuthorities(account, fromPush = true)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,12 @@
|
||||
|
||||
package at.bitfire.davdroid.repository
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.OnAccountsUpdateListener
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
@@ -27,10 +23,10 @@ import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.davdroid.sync.account.AccountUtils
|
||||
import at.bitfire.davdroid.sync.account.AccountsCleanupWorker
|
||||
import at.bitfire.davdroid.sync.account.SystemAccountUtils
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.PeriodicSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -56,6 +52,7 @@ class AccountRepository @Inject constructor(
|
||||
private val logger: Logger,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
private val syncWorkerManager: SyncWorkerManager,
|
||||
private val tasksAppManager: Lazy<TasksAppManager>
|
||||
) {
|
||||
|
||||
@@ -63,7 +60,7 @@ class AccountRepository @Inject constructor(
|
||||
private val accountManager = AccountManager.get(context)
|
||||
|
||||
/**
|
||||
* Creates a new main account with discovered services and enables periodic syncs with
|
||||
* Creates a new account with discovered services and enables periodic syncs with
|
||||
* default sync interval times.
|
||||
*
|
||||
* @param accountName name of the account
|
||||
@@ -80,13 +77,13 @@ class AccountRepository @Inject constructor(
|
||||
val userData = AccountSettings.initialUserData(credentials)
|
||||
logger.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData))
|
||||
|
||||
if (!AccountUtils.createAccount(context, account, userData, credentials?.password))
|
||||
if (!SystemAccountUtils.createAccount(context, account, userData, credentials?.password))
|
||||
return null
|
||||
|
||||
// add entries for account to service DB
|
||||
logger.log(Level.INFO, "Writing account configuration to database", config)
|
||||
try {
|
||||
val accountSettings = accountSettingsFactory.forAccount(account)
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
val defaultSyncInterval = settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL)
|
||||
|
||||
// Configure CardDAV service
|
||||
@@ -135,17 +132,17 @@ class AccountRepository @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun delete(accountName: String): Boolean {
|
||||
// remove account
|
||||
val future = accountManager.removeAccount(fromName(accountName), null, null, null)
|
||||
val account = fromName(accountName)
|
||||
// remove account directly (bypassing the authenticator, which is our own)
|
||||
return try {
|
||||
// wait for operation to complete
|
||||
withContext(Dispatchers.Default) {
|
||||
// blocks calling thread
|
||||
future.result
|
||||
}
|
||||
accountManager.removeAccountExplicitly(account)
|
||||
|
||||
// delete address book accounts
|
||||
LocalAddressBook.deleteByAccount(context, accountName)
|
||||
// delete address books (= address book accounts)
|
||||
serviceRepository.getByAccountAndType(accountName, Service.TYPE_CARDDAV)?.let { service ->
|
||||
collectionRepository.getByService(service.id).forEach { collection ->
|
||||
LocalAddressBook.deleteByCollection(context, collection.id)
|
||||
}
|
||||
}
|
||||
|
||||
// delete from database
|
||||
serviceRepository.deleteByAccount(accountName)
|
||||
@@ -174,7 +171,9 @@ class AccountRepository @Inject constructor(
|
||||
val listener = OnAccountsUpdateListener { accounts ->
|
||||
trySend(accounts.filter { it.type == accountType }.toSet())
|
||||
}
|
||||
accountManager.addOnAccountsUpdatedListener(listener, null, true)
|
||||
withContext(Dispatchers.Default) { // causes disk I/O
|
||||
accountManager.addOnAccountsUpdatedListener(listener, null, true)
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
accountManager.removeOnAccountsUpdatedListener(listener)
|
||||
@@ -203,7 +202,7 @@ class AccountRepository @Inject constructor(
|
||||
throw IllegalArgumentException("Account with name \"$newName\" already exists")
|
||||
|
||||
// remember sync intervals
|
||||
val oldSettings = accountSettingsFactory.forAccount(oldAccount)
|
||||
val oldSettings = accountSettingsFactory.create(oldAccount)
|
||||
val authorities = mutableListOf(
|
||||
context.getString(R.string.address_books_authority),
|
||||
CalendarContract.AUTHORITY
|
||||
@@ -239,27 +238,12 @@ class AccountRepository @Inject constructor(
|
||||
|
||||
// disable periodic syncs for old account
|
||||
syncIntervals.forEach { (authority, _) ->
|
||||
PeriodicSyncWorker.disable(context, oldAccount, authority)
|
||||
syncWorkerManager.disablePeriodic(oldAccount, authority)
|
||||
}
|
||||
|
||||
// update account name references in database
|
||||
serviceRepository.renameAccount(oldName, newName)
|
||||
|
||||
// update main account of address book accounts
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) == PackageManager.PERMISSION_GRANTED)
|
||||
try {
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.use { provider ->
|
||||
for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
|
||||
val addressBook = LocalAddressBook(context, addrBookAccount, provider)
|
||||
if (oldAccount == addressBook.mainAccount)
|
||||
addressBook.mainAccount = Account(newName, oldAccount.type)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't update address book accounts", e)
|
||||
// Couldn't update address book accounts, but this is not a fatal error (will be fixed at next sync)
|
||||
}
|
||||
|
||||
// calendar provider doesn't allow changing account_name of Events
|
||||
// (all events will have to be downloaded again at next sync)
|
||||
|
||||
@@ -272,7 +256,7 @@ class AccountRepository @Inject constructor(
|
||||
}
|
||||
|
||||
// restore sync intervals
|
||||
val newSettings = accountSettingsFactory.forAccount(newAccount)
|
||||
val newSettings = accountSettingsFactory.create(newAccount)
|
||||
for ((authority, interval) in syncIntervals) {
|
||||
if (interval == null)
|
||||
ContentResolver.setIsSyncable(newAccount, authority, 0)
|
||||
|
||||
@@ -6,14 +6,13 @@ package at.bitfire.davdroid.repository
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
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
|
||||
@@ -29,22 +28,24 @@ import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.util.DateUtils
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.Multibinds
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.fortuna.ical4j.model.Component
|
||||
import okhttp3.HttpUrl
|
||||
import java.io.StringWriter
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.fortuna.ical4j.model.Calendar
|
||||
import net.fortuna.ical4j.model.Component
|
||||
import net.fortuna.ical4j.model.ComponentList
|
||||
import net.fortuna.ical4j.model.component.VTimeZone
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* Repository for managing collections.
|
||||
@@ -134,7 +135,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
displayName = displayName,
|
||||
description = description,
|
||||
color = color,
|
||||
timezoneDef = timeZoneId,
|
||||
timezoneId = timeZoneId,
|
||||
supportsVEVENT = supportVEVENT,
|
||||
supportsVTODO = supportVTODO,
|
||||
supportsVJOURNAL = supportVJOURNAL
|
||||
@@ -150,7 +151,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
displayName = displayName,
|
||||
description = description,
|
||||
color = color,
|
||||
timezone = timeZoneId?.let { getVTimeZone(it) },
|
||||
timezone = timeZoneId?.let { getVTimeZone(it)?.toString() },
|
||||
supportsVEVENT = supportVEVENT,
|
||||
supportsVTODO = supportVTODO,
|
||||
supportsVJOURNAL = supportVJOURNAL
|
||||
@@ -169,7 +170,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
val service = serviceRepository.get(collection.serviceId) ?: throw IllegalArgumentException("Service not found")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
HttpClient.Builder(context, accountSettingsFactory.forAccount(account))
|
||||
HttpClient.Builder(context, accountSettingsFactory.create(account))
|
||||
.setForeground(true)
|
||||
.build().use { httpClient ->
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -185,8 +186,14 @@ class DavCollectionRepository @Inject constructor(
|
||||
|
||||
fun getSyncableByTopic(topic: String) = dao.getSyncableByPushTopic(topic)
|
||||
|
||||
fun get(id: Long) = dao.get(id)
|
||||
|
||||
fun getFlow(id: Long) = dao.getFlow(id)
|
||||
|
||||
fun getByService(serviceId: Long) = dao.getByService(serviceId)
|
||||
|
||||
fun getByServiceAndUrl(serviceId: Long, url: String) = dao.getByServiceAndUrl(serviceId, url)
|
||||
|
||||
fun getByServiceAndSync(serviceId: Long) = dao.getByServiceAndSync(serviceId)
|
||||
|
||||
fun getSyncCalendars(serviceId: Long) = dao.getSyncCalendars(serviceId)
|
||||
@@ -196,9 +203,12 @@ class DavCollectionRepository @Inject constructor(
|
||||
fun getSyncTaskLists(serviceId: Long) = dao.getSyncTaskLists(serviceId)
|
||||
|
||||
/** Returns all collections that are both selected for synchronization and push-capable. */
|
||||
suspend fun getSyncableAndPushCapable(): List<Collection> =
|
||||
suspend fun getPushCapableAndSyncable(): List<Collection> =
|
||||
dao.getPushCapableSyncCollections()
|
||||
|
||||
suspend fun getPushRegisteredAndNotSyncable(): List<Collection> =
|
||||
dao.getPushRegisteredAndNotSyncable()
|
||||
|
||||
/**
|
||||
* Inserts or updates the collection. On update it will not update flag values ([Collection.sync],
|
||||
* [Collection.forceReadOnly]), but use the values of the already existing collection.
|
||||
@@ -246,7 +256,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
notifyOnChangeListeners()
|
||||
}
|
||||
|
||||
suspend fun updatePushSubscription(id: Long, subscriptionUrl: String) {
|
||||
fun updatePushSubscription(id: Long, subscriptionUrl: String?) {
|
||||
dao.updatePushSubscription(id, subscriptionUrl)
|
||||
}
|
||||
|
||||
@@ -262,7 +272,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
// helpers
|
||||
|
||||
private suspend fun createOnServer(account: Account, url: HttpUrl, method: String, xmlBody: String) {
|
||||
HttpClient.Builder(context, accountSettingsFactory.forAccount(account))
|
||||
HttpClient.Builder(context, accountSettingsFactory.create(account))
|
||||
.setForeground(true)
|
||||
.build().use { httpClient ->
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -283,7 +293,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
displayName: String?,
|
||||
description: String?,
|
||||
color: Int? = null,
|
||||
timezoneDef: String? = null,
|
||||
timezoneId: String? = null,
|
||||
supportsVEVENT: Boolean = true,
|
||||
supportsVTODO: Boolean = true,
|
||||
supportsVJOURNAL: Boolean = true
|
||||
@@ -339,9 +349,17 @@ class DavCollectionRepository @Inject constructor(
|
||||
text(DavUtils.ARGBtoCalDAVColor(it))
|
||||
}
|
||||
}
|
||||
timezoneDef?.let {
|
||||
insertTag(CalendarTimezone.NAME) {
|
||||
cdsect(it)
|
||||
timezoneId?.let { id ->
|
||||
insertTag(CalendarTimezoneId.NAME) {
|
||||
text(id)
|
||||
}
|
||||
getVTimeZone(id)?.let { vTimezone ->
|
||||
insertTag(CalendarTimezone.NAME) {
|
||||
text(
|
||||
// spec requires "an iCalendar object with exactly one VTIMEZONE component"
|
||||
Calendar(ComponentList(listOf(vTimezone))).toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,8 +393,7 @@ class DavCollectionRepository @Inject constructor(
|
||||
return writer.toString()
|
||||
}
|
||||
|
||||
private fun getVTimeZone(tzId: String): String? =
|
||||
DateUtils.ical4jTimeZone(tzId)?.toString()
|
||||
private fun getVTimeZone(tzId: String): VTimeZone? = DateUtils.ical4jTimeZone(tzId)?.vTimeZone
|
||||
|
||||
|
||||
/*** OBSERVERS ***/
|
||||
|
||||
@@ -9,11 +9,11 @@ import android.content.pm.PackageManager
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.SyncStats
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.text.Collator
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class DavSyncStatsRepository @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
@@ -62,7 +62,12 @@ class DavSyncStatsRepository @Inject constructor(
|
||||
val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority
|
||||
return try {
|
||||
val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo
|
||||
packageManager.getApplicationLabel(appInfo).toString()
|
||||
if (appInfo != null) {
|
||||
packageManager.getApplicationLabel(appInfo).toString()
|
||||
} else {
|
||||
logger.warning("Package name ($packageName) not found for authority: $authority")
|
||||
authority
|
||||
}
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
logger.warning("Application name not found for authority: $authority")
|
||||
authority
|
||||
|
||||
@@ -17,164 +17,67 @@ import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.Groups
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
import android.util.Base64
|
||||
import androidx.annotation.OpenForTesting
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.account.AccountUtils
|
||||
import at.bitfire.davdroid.sync.account.SystemAccountUtils
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import at.bitfire.vcard4android.AndroidAddressBook
|
||||
import at.bitfire.vcard4android.AndroidContact
|
||||
import at.bitfire.vcard4android.AndroidGroup
|
||||
import at.bitfire.vcard4android.Constants
|
||||
import at.bitfire.vcard4android.BatchOperation
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.LinkedList
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A local address book. Requires an own Android account, because Android manages contacts per
|
||||
* account and there is no such thing as "address books". So, DAVx5 creates a "DAVx5
|
||||
* address book" account for every CardDAV address book. These accounts are bound to a
|
||||
* DAVx5 main account.
|
||||
* address book" account for every CardDAV address book.
|
||||
*
|
||||
* @param _addressBookAccount Address book account (not: DAVx5 account) storing the actual Android
|
||||
* contacts. This is the initial value of [addressBookAccount]. However when the address book is renamed,
|
||||
* the new name will only be available in [addressBookAccount], so usually that one should be used.
|
||||
*
|
||||
* @param provider Content provider needed to access and modify the address book
|
||||
*/
|
||||
open class LocalAddressBook @Inject constructor(
|
||||
private val context: Context,
|
||||
account: Account,
|
||||
provider: ContentProviderClient?
|
||||
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
|
||||
|
||||
companion object {
|
||||
|
||||
private val logger
|
||||
get() = Logger.getGlobal()
|
||||
|
||||
const val USER_DATA_MAIN_ACCOUNT_TYPE = "real_account_type"
|
||||
const val USER_DATA_MAIN_ACCOUNT_NAME = "real_account_name"
|
||||
const val USER_DATA_URL = "url"
|
||||
const val USER_DATA_READ_ONLY = "read_only"
|
||||
|
||||
/**
|
||||
* Creates a local address book.
|
||||
*
|
||||
* @param context app context to resolve string resources
|
||||
* @param provider contacts provider client
|
||||
* @param mainAccount main account this address book (account) belongs to
|
||||
* @param info collection where to take the name and settings from
|
||||
* @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info]
|
||||
*/
|
||||
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: Collection, forceReadOnly: Boolean): LocalAddressBook {
|
||||
val account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book))
|
||||
val userData = initialUserData(mainAccount, info.url.toString())
|
||||
logger.log(Level.INFO, "Creating local address book $account", userData)
|
||||
if (!AccountUtils.createAccount(context, account, userData))
|
||||
throw IllegalStateException("Couldn't create address book account")
|
||||
|
||||
val addressBook = LocalAddressBook(context, account, provider)
|
||||
addressBook.updateSyncFrameworkSettings()
|
||||
|
||||
// initialize Contacts Provider Settings
|
||||
val values = ContentValues(2)
|
||||
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
|
||||
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
|
||||
addressBook.settings = values
|
||||
addressBook.readOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
|
||||
|
||||
return addressBook
|
||||
}
|
||||
|
||||
fun deleteByAccount(context: Context, accountName: String) {
|
||||
val mainAccount = Account(accountName, context.getString(R.string.account_type))
|
||||
findAll(context, null, mainAccount).forEach {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and returns all the local address books belonging to a given main account
|
||||
*
|
||||
* @param mainAccount the main account to use
|
||||
* @return list of [mainAccount]'s address books
|
||||
*/
|
||||
fun findAll(context: Context, provider: ContentProviderClient?, mainAccount: Account) = AccountManager.get(context)
|
||||
.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.map { LocalAddressBook(context, it, provider) }
|
||||
.filter {
|
||||
try {
|
||||
it.mainAccount == mainAccount
|
||||
} catch(e: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
|
||||
fun accountName(mainAccount: Account, info: Collection): String {
|
||||
val baos = ByteArrayOutputStream()
|
||||
baos.write(info.url.hashCode())
|
||||
val hash = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)
|
||||
|
||||
val sb = StringBuilder(info.displayName.let {
|
||||
if (it.isNullOrEmpty())
|
||||
info.url.lastSegment
|
||||
else
|
||||
it
|
||||
})
|
||||
sb.append(" (${mainAccount.name} $hash)")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun initialUserData(mainAccount: Account, url: String): Bundle {
|
||||
val bundle = Bundle(3)
|
||||
bundle.putString(USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
|
||||
bundle.putString(USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
|
||||
bundle.putString(USER_DATA_URL, url)
|
||||
return bundle
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and returns the main account of the given address book's account (sub-account)
|
||||
*
|
||||
* @param account the address book account to find the main account for
|
||||
*
|
||||
* @return the associated main account, `null` if none can be found (e.g. when main account has been deleted)
|
||||
*
|
||||
* @throws IllegalArgumentException when [account] is not an address book account
|
||||
*/
|
||||
fun mainAccount(context: Context, account: Account): Account? =
|
||||
if (account.type == context.getString(R.string.account_type_address_book)) {
|
||||
val manager = AccountManager.get(context)
|
||||
val accountName = manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
|
||||
val accountType = manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
|
||||
if (accountName != null && accountType != null)
|
||||
Account(accountName, accountType)
|
||||
else
|
||||
null
|
||||
} else
|
||||
throw IllegalArgumentException("$account is not an address book account")
|
||||
@OpenForTesting
|
||||
open class LocalAddressBook @AssistedInject constructor(
|
||||
@Assisted _addressBookAccount: Account,
|
||||
@Assisted provider: ContentProviderClient,
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext val context: Context,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository
|
||||
): AndroidAddressBook<LocalContact, LocalGroup>(_addressBookAccount, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(addressBookAccount: Account, provider: ContentProviderClient): LocalAddressBook
|
||||
}
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface LocalAddressBookEntryPoint {
|
||||
fun accountSettingsFactory(): AccountSettings.Factory
|
||||
}
|
||||
private val entryPoint = EntryPointAccessors.fromApplication(context, LocalAddressBookEntryPoint::class.java)
|
||||
private val accountSettingsFactory = entryPoint.accountSettingsFactory()
|
||||
|
||||
|
||||
override val tag: String
|
||||
get() = "contacts-${account.name}"
|
||||
get() = "contacts-${addressBookAccount.name}"
|
||||
|
||||
override val title = account.name!!
|
||||
override val title
|
||||
get() = addressBookAccount.name
|
||||
|
||||
/**
|
||||
* Whether contact groups ([LocalGroup]) are included in query results
|
||||
@@ -184,48 +87,31 @@ open class LocalAddressBook @Inject constructor(
|
||||
* but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
|
||||
*/
|
||||
open val groupMethod: GroupMethod by lazy {
|
||||
val accountSettings = accountSettingsFactory.forAccount(requireMainAccount())
|
||||
val manager = AccountManager.get(context)
|
||||
val account = manager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)?.toLongOrNull()?.let { collectionId ->
|
||||
collectionRepository.get(collectionId)?.let { collection ->
|
||||
serviceRepository.get(collection.serviceId)?.let { service ->
|
||||
Account(service.accountName, context.getString(R.string.account_type))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (account == null)
|
||||
throw IllegalArgumentException("Collection of address book account $addressBookAccount does not have an account")
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
accountSettings.getGroupMethod()
|
||||
}
|
||||
val includeGroups
|
||||
private val includeGroups
|
||||
get() = groupMethod == GroupMethod.GROUP_VCARDS
|
||||
|
||||
private var _mainAccount: Account? = null
|
||||
/**
|
||||
* The associated main account which this address book's accounts belong to.
|
||||
*
|
||||
* @throws IllegalArgumentException when [account] is not an address book account or when no main account is assigned
|
||||
*/
|
||||
open var mainAccount: Account?
|
||||
get() {
|
||||
_mainAccount?.let { return it }
|
||||
|
||||
val result = mainAccount(context, account)
|
||||
_mainAccount = result
|
||||
return result
|
||||
}
|
||||
set(newMainAccount) {
|
||||
if (newMainAccount == null)
|
||||
throw IllegalArgumentException("Main account must not be null")
|
||||
|
||||
AccountManager.get(context).let { accountManager ->
|
||||
accountManager.setAndVerifyUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, newMainAccount.name)
|
||||
accountManager.setAndVerifyUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, newMainAccount.type)
|
||||
}
|
||||
|
||||
_mainAccount = newMainAccount
|
||||
}
|
||||
fun requireMainAccount(): Account =
|
||||
mainAccount ?: throw IllegalArgumentException("No main account assigned to address book $account")
|
||||
|
||||
override var url: String
|
||||
get() = AccountManager.get(context).getUserData(account, USER_DATA_URL)
|
||||
@Deprecated("Local collection should be identified by ID, not by URL")
|
||||
override var collectionUrl: String
|
||||
get() = AccountManager.get(context).getUserData(addressBookAccount, USER_DATA_URL)
|
||||
?: throw IllegalStateException("Address book has no URL")
|
||||
set(url) = AccountManager.get(context).setAndVerifyUserData(account, USER_DATA_URL, url)
|
||||
set(url) = AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_URL, url)
|
||||
|
||||
override var readOnly: Boolean
|
||||
get() = AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
|
||||
set(readOnly) = AccountManager.get(context).setAndVerifyUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
|
||||
get() = AccountManager.get(context).getUserData(addressBookAccount, USER_DATA_READ_ONLY) != null
|
||||
set(readOnly) = AccountManager.get(context).setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = syncState?.let { SyncState.fromString(String(it)) }
|
||||
@@ -265,48 +151,104 @@ open class LocalAddressBook @Inject constructor(
|
||||
* Updates the address book settings.
|
||||
*
|
||||
* @param info collection where to take the settings from
|
||||
* @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info]
|
||||
* @param forceReadOnly `true`: set the address book to "force read-only";
|
||||
* `false`: determine read-only flag from [info];
|
||||
* `null`: don't change the existing value
|
||||
*/
|
||||
fun update(info: Collection, forceReadOnly: Boolean) {
|
||||
val newAccountName = accountName(requireMainAccount(), info)
|
||||
fun update(info: Collection, forceReadOnly: Boolean? = null) {
|
||||
logger.log(Level.INFO, "Updating local address book $addressBookAccount with collection $info")
|
||||
val accountManager = AccountManager.get(context)
|
||||
|
||||
if (account.name != newAccountName) {
|
||||
// no need to re-assign contacts to new account, because they will be deleted by contacts provider in any case
|
||||
val accountManager = AccountManager.get(context)
|
||||
val future = accountManager.renameAccount(account, newAccountName, null, null)
|
||||
account = future.result
|
||||
}
|
||||
// Update the account name
|
||||
val newAccountName = accountName(context, info)
|
||||
if (addressBookAccount.name != newAccountName)
|
||||
// rename, move contacts/groups and update [AndroidAddressBook.]account
|
||||
renameAccount(newAccountName)
|
||||
|
||||
val nowReadOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
|
||||
if (nowReadOnly != readOnly) {
|
||||
Constants.log.info("Address book now read-only = $nowReadOnly, updating contacts")
|
||||
// Update the account user data
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_COLLECTION_ID, info.id.toString())
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_URL, info.url.toString())
|
||||
|
||||
// update address book itself
|
||||
readOnly = nowReadOnly
|
||||
// Update force read only
|
||||
if (forceReadOnly != null) {
|
||||
val nowReadOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
|
||||
if (nowReadOnly != readOnly) {
|
||||
logger.info("Address book now read-only = $nowReadOnly, updating contacts")
|
||||
|
||||
// update raw contacts
|
||||
val rawContactValues = ContentValues(1)
|
||||
rawContactValues.put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
|
||||
// update address book itself
|
||||
readOnly = nowReadOnly
|
||||
|
||||
// update data rows
|
||||
val dataValues = ContentValues(1)
|
||||
dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
|
||||
// update raw contacts
|
||||
val rawContactValues = ContentValues(1)
|
||||
rawContactValues.put(RawContacts.RAW_CONTACT_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
|
||||
|
||||
// update group rows
|
||||
val groupValues = ContentValues(1)
|
||||
groupValues.put(Groups.GROUP_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(groupsSyncUri(), groupValues, null, null)
|
||||
// update data rows
|
||||
val dataValues = ContentValues(1)
|
||||
dataValues.put(ContactsContract.Data.IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
|
||||
|
||||
// update group rows
|
||||
val groupValues = ContentValues(1)
|
||||
groupValues.put(Groups.GROUP_IS_READ_ONLY, if (nowReadOnly) 1 else 0)
|
||||
provider!!.update(groupsSyncUri(), groupValues, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
// make sure it will still be synchronized when contacts are updated
|
||||
updateSyncFrameworkSettings()
|
||||
}
|
||||
|
||||
override fun delete(): Boolean {
|
||||
/**
|
||||
* Renames an address book account and moves the contacts and groups (without making them dirty).
|
||||
* Does not keep user data of the old account, so these have to be set again.
|
||||
*
|
||||
* On success, [addressBookAccount] will be updated to the new account name.
|
||||
*
|
||||
* _Note:_ Previously, we had used [AccountManager.renameAccount], but then the contacts can't be moved because there's never
|
||||
* a moment when both accounts are available.
|
||||
*
|
||||
* @param newName the new account name (account type is taken from [addressBookAccount])
|
||||
*
|
||||
* @return whether the account was renamed successfully
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun renameAccount(newName: String): Boolean {
|
||||
val oldAccount = addressBookAccount
|
||||
logger.info("Renaming address book from \"${oldAccount.name}\" to \"$newName\"")
|
||||
|
||||
// create new account
|
||||
val newAccount = Account(newName, oldAccount.type)
|
||||
if (!SystemAccountUtils.createAccount(context, newAccount, Bundle()))
|
||||
return false
|
||||
|
||||
// move contacts and groups to new account
|
||||
val batch = BatchOperation(provider!!)
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newUpdate(groupsSyncUri())
|
||||
.withSelection(Groups.ACCOUNT_NAME + "=? AND " + Groups.ACCOUNT_TYPE + "=?", arrayOf(oldAccount.name, oldAccount.type))
|
||||
.withValue(Groups.ACCOUNT_NAME, newAccount.name)
|
||||
)
|
||||
batch.enqueue(BatchOperation.CpoBuilder
|
||||
.newUpdate(rawContactsSyncUri())
|
||||
.withSelection(RawContacts.ACCOUNT_NAME + "=? AND " + RawContacts.ACCOUNT_TYPE + "=?", arrayOf(oldAccount.name, oldAccount.type))
|
||||
.withValue(RawContacts.ACCOUNT_NAME, newAccount.name)
|
||||
)
|
||||
batch.commit()
|
||||
|
||||
// update AndroidAddressBook.account
|
||||
addressBookAccount = newAccount
|
||||
|
||||
// delete old account
|
||||
val accountManager = AccountManager.get(context)
|
||||
return accountManager.removeAccountExplicitly(account)
|
||||
accountManager.removeAccountExplicitly(oldAccount)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun deleteCollection(): Boolean {
|
||||
val accountManager = AccountManager.get(context)
|
||||
return accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
|
||||
|
||||
@@ -320,15 +262,15 @@ open class LocalAddressBook @Inject constructor(
|
||||
*/
|
||||
fun updateSyncFrameworkSettings() {
|
||||
// Enable sync-ability
|
||||
if (ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) != 1)
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1)
|
||||
if (ContentResolver.getIsSyncable(addressBookAccount, ContactsContract.AUTHORITY) != 1)
|
||||
ContentResolver.setIsSyncable(addressBookAccount, ContactsContract.AUTHORITY, 1)
|
||||
|
||||
// Enable content trigger
|
||||
if (!ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY))
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
if (!ContentResolver.getSyncAutomatically(addressBookAccount, ContactsContract.AUTHORITY))
|
||||
ContentResolver.setSyncAutomatically(addressBookAccount, ContactsContract.AUTHORITY, true)
|
||||
|
||||
// Remove periodic syncs (setSyncAutomatically also creates periodic syncs, which we don't want)
|
||||
for (periodicSync in ContentResolver.getPeriodicSyncs(account, ContactsContract.AUTHORITY))
|
||||
for (periodicSync in ContentResolver.getPeriodicSyncs(addressBookAccount, ContactsContract.AUTHORITY))
|
||||
ContentResolver.removePeriodicSync(periodicSync.account, periodicSync.authority, periodicSync.extras)
|
||||
}
|
||||
|
||||
@@ -466,4 +408,129 @@ open class LocalAddressBook @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface LocalAddressBookCompanionEntryPoint {
|
||||
fun localAddressBookFactory(): Factory
|
||||
fun serviceRepository(): DavServiceRepository
|
||||
fun logger(): Logger
|
||||
}
|
||||
|
||||
const val USER_DATA_URL = "url"
|
||||
const val USER_DATA_COLLECTION_ID = "collection_id"
|
||||
const val USER_DATA_READ_ONLY = "read_only"
|
||||
|
||||
// create/query/delete
|
||||
|
||||
/**
|
||||
* Creates a new local address book.
|
||||
*
|
||||
* @param context app context to resolve string resources
|
||||
* @param provider contacts provider client
|
||||
* @param info collection where to take the name and settings from
|
||||
* @param forceReadOnly `true`: set the address book to "force read-only"; `false`: determine read-only flag from [info]
|
||||
*/
|
||||
fun create(context: Context, provider: ContentProviderClient, info: Collection, forceReadOnly: Boolean): LocalAddressBook {
|
||||
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
|
||||
val logger = entryPoint.logger()
|
||||
|
||||
val account = Account(accountName(context, info), context.getString(R.string.account_type_address_book))
|
||||
val userData = initialUserData(info.url.toString(), info.id.toString())
|
||||
logger.log(Level.INFO, "Creating local address book $account", userData)
|
||||
if (!SystemAccountUtils.createAccount(context, account, userData))
|
||||
throw IllegalStateException("Couldn't create address book account")
|
||||
|
||||
val factory = entryPoint.localAddressBookFactory()
|
||||
val addressBook = factory.create(account, provider)
|
||||
addressBook.updateSyncFrameworkSettings()
|
||||
|
||||
// initialize Contacts Provider Settings
|
||||
val values = ContentValues(2)
|
||||
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
|
||||
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
|
||||
addressBook.settings = values
|
||||
addressBook.readOnly = forceReadOnly || !info.privWriteContent || info.forceReadOnly
|
||||
|
||||
return addressBook
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a [LocalAddressBook] based on its corresponding collection.
|
||||
*
|
||||
* @param id collection ID to look for
|
||||
*
|
||||
* @return The [LocalAddressBook] for the given collection or *null* if not found
|
||||
*/
|
||||
fun findByCollection(context: Context, provider: ContentProviderClient, id: Long): LocalAddressBook? {
|
||||
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
|
||||
val factory = entryPoint.localAddressBookFactory()
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
return accountManager
|
||||
.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.filter { account ->
|
||||
accountManager.getUserData(account, USER_DATA_COLLECTION_ID)?.toLongOrNull() == id
|
||||
}
|
||||
.map { account -> factory.create(account, provider) }
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a [LocalAddressBook] based on its corresponding database collection.
|
||||
*
|
||||
* @param id collection ID to look for
|
||||
*/
|
||||
fun deleteByCollection(context: Context, id: Long) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
val addressBookAccount = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).firstOrNull { account ->
|
||||
accountManager.getUserData(account, USER_DATA_COLLECTION_ID)?.toLongOrNull() == id
|
||||
}
|
||||
if (addressBookAccount != null)
|
||||
accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
/**
|
||||
* Creates a name for the address book account from its corresponding db collection info.
|
||||
*
|
||||
* The address book account name contains
|
||||
* - the collection display name or last URL path segment
|
||||
* - the actual account name
|
||||
* - the collection ID, to make it unique.
|
||||
*
|
||||
* @param info The corresponding collection
|
||||
*/
|
||||
fun accountName(context: Context, info: Collection): String {
|
||||
// Name the address book after given collection display name, otherwise use last URL path segment
|
||||
val sb = StringBuilder(info.displayName.let {
|
||||
if (it.isNullOrEmpty())
|
||||
info.url.lastSegment
|
||||
else
|
||||
it
|
||||
})
|
||||
// Add the actual account name to the address book account name
|
||||
val entryPoint = EntryPointAccessors.fromApplication<LocalAddressBookCompanionEntryPoint>(context)
|
||||
val serviceRepository = entryPoint.serviceRepository()
|
||||
serviceRepository.get(info.serviceId)?.let { service ->
|
||||
sb.append(" (${service.accountName})")
|
||||
}
|
||||
// Add the collection ID for uniqueness
|
||||
sb.append(" #${info.id}")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun initialUserData(url: String, collectionId: String): Bundle {
|
||||
val bundle = Bundle(3)
|
||||
bundle.putString(USER_DATA_COLLECTION_ID, collectionId)
|
||||
bundle.putString(USER_DATA_URL, url)
|
||||
return bundle
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -43,7 +43,11 @@ class LocalCalendar private constructor(
|
||||
get() = Logger.getGlobal()
|
||||
|
||||
fun create(account: Account, provider: ContentProviderClient, info: Collection): Uri {
|
||||
val values = valuesFromCollectionInfo(info, true)
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
if (info.color != null)
|
||||
info.color = Constants.DAVDROID_GREEN_RGBA
|
||||
|
||||
val values = valuesFromCollectionInfo(info, withColor = true)
|
||||
|
||||
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
|
||||
values.put(Calendars.ACCOUNT_NAME, account.name)
|
||||
@@ -65,8 +69,8 @@ class LocalCalendar private constructor(
|
||||
values.put(Calendars.CALENDAR_DISPLAY_NAME,
|
||||
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
|
||||
|
||||
if (withColor)
|
||||
values.put(Calendars.CALENDAR_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
|
||||
if (withColor && info.color != null)
|
||||
values.put(Calendars.CALENDAR_COLOR, info.color)
|
||||
|
||||
if (info.privWriteContent && !info.forceReadOnly) {
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER)
|
||||
@@ -94,7 +98,7 @@ class LocalCalendar private constructor(
|
||||
|
||||
}
|
||||
|
||||
override val url: String?
|
||||
override val collectionUrl: String?
|
||||
get() = name
|
||||
|
||||
override val tag: String
|
||||
@@ -107,6 +111,8 @@ class LocalCalendar private constructor(
|
||||
override val readOnly
|
||||
get() = accessLevel <= Calendars.CAL_ACCESS_READ
|
||||
|
||||
override fun deleteCollection(): Boolean = delete()
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
@@ -127,11 +133,11 @@ class LocalCalendar private constructor(
|
||||
}
|
||||
|
||||
fun update(info: Collection, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
|
||||
|
||||
override fun findDeleted() =
|
||||
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
queryEvents("${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
|
||||
override fun findDirty(): List<LocalEvent> {
|
||||
val dirty = LinkedList<LocalEvent>()
|
||||
|
||||
@@ -14,7 +14,7 @@ interface LocalCollection<out T: LocalResource<*>> {
|
||||
|
||||
/** Address of the remote collection */
|
||||
@Deprecated("Local collection should be identified by ID, not by URL")
|
||||
val url: String?
|
||||
val collectionUrl: String?
|
||||
|
||||
/** collection title (used for user notifications etc.) **/
|
||||
val title: String
|
||||
@@ -32,7 +32,7 @@ interface LocalCollection<out T: LocalResource<*>> {
|
||||
*
|
||||
* @return true if the collection was deleted, false otherwise
|
||||
*/
|
||||
fun delete(): Boolean
|
||||
fun deleteCollection(): Boolean
|
||||
|
||||
/**
|
||||
* Finds local resources of this collection which have been marked as *deleted* by the user
|
||||
|
||||
@@ -7,6 +7,7 @@ package at.bitfire.davdroid.resource
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Principal
|
||||
@@ -24,9 +25,14 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(account: Account, client: ContentProviderClient, info: Collection, owner: Principal?) {
|
||||
val values = valuesFromCollection(info, account, owner, true)
|
||||
create(account, client, values)
|
||||
fun create(account: Account, client: ContentProviderClient, info: Collection, owner: Principal?): Uri {
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
if (info.color != null)
|
||||
info.color = Constants.DAVDROID_GREEN_RGBA
|
||||
|
||||
val values = valuesFromCollection(info, account, owner, withColor = true)
|
||||
|
||||
return create(account, client, values)
|
||||
}
|
||||
|
||||
fun valuesFromCollection(info: Collection, account: Account, owner: Principal?, withColor: Boolean) =
|
||||
@@ -42,8 +48,8 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
|
||||
else
|
||||
Logger.getGlobal().warning("No collection owner given. Will create jtx collection without owner")
|
||||
put(JtxContract.JtxCollection.OWNER_DISPLAYNAME, owner?.displayName)
|
||||
if (withColor)
|
||||
put(JtxContract.JtxCollection.COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
|
||||
if (withColor && info.color != null)
|
||||
put(JtxContract.JtxCollection.COLOR, info.color)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO)
|
||||
@@ -56,16 +62,20 @@ class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Lo
|
||||
override val readOnly: Boolean
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override fun deleteCollection(): Boolean = delete()
|
||||
|
||||
override val tag: String
|
||||
get() = "jtx-${account.name}-$id"
|
||||
override val collectionUrl: String?
|
||||
get() = url
|
||||
override val title: String
|
||||
get() = displayname ?: id.toString()
|
||||
override var lastSyncState: SyncState?
|
||||
get() = SyncState.fromString(syncstate)
|
||||
set(value) { syncstate = value.toString() }
|
||||
|
||||
fun updateCollection(info: Collection, owner: Principal?, withColor: Boolean) {
|
||||
val values = valuesFromCollection(info, account, owner, withColor)
|
||||
fun updateCollection(info: Collection, owner: Principal?, updateColor: Boolean) {
|
||||
val values = valuesFromCollection(info, account, owner, updateColor)
|
||||
update(values)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
// update in tasks provider
|
||||
val values = ContentValues(1)
|
||||
values.put(Tasks._UID, newUid)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
|
||||
// update this task
|
||||
task!!.uid = newUid
|
||||
@@ -86,7 +86,7 @@ class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(Tasks.SYNC_VERSION, task!!.sequence)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
|
||||
if (fileName != null)
|
||||
this.fileName = fileName
|
||||
@@ -97,7 +97,7 @@ class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
if (id != null) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
this.flags = flags
|
||||
|
||||
@@ -6,6 +6,7 @@ package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
@@ -27,18 +28,23 @@ import java.util.logging.Logger
|
||||
*/
|
||||
class LocalTaskList private constructor(
|
||||
account: Account,
|
||||
provider: TaskProvider,
|
||||
provider: ContentProviderClient,
|
||||
providerName: TaskProvider.ProviderName,
|
||||
id: Long
|
||||
): DmfsTaskList<LocalTask>(account, provider, LocalTask.Factory, id), LocalCollection<LocalTask> {
|
||||
): DmfsTaskList<LocalTask>(account, provider, providerName, LocalTask.Factory, id), LocalCollection<LocalTask> {
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(account: Account, provider: TaskProvider, info: Collection): Uri {
|
||||
val values = valuesFromCollectionInfo(info, true)
|
||||
fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, info: Collection): Uri {
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
if (info.color != null)
|
||||
info.color = Constants.DAVDROID_GREEN_RGBA
|
||||
|
||||
val values = valuesFromCollectionInfo(info, withColor = true)
|
||||
values.put(TaskLists.OWNER, account.name)
|
||||
values.put(TaskLists.SYNC_ENABLED, 1)
|
||||
values.put(TaskLists.VISIBLE, 1)
|
||||
return create(account, provider, values)
|
||||
return create(account, provider, providerName, values)
|
||||
}
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
@@ -61,8 +67,8 @@ class LocalTaskList private constructor(
|
||||
values.put(TaskLists.LIST_NAME,
|
||||
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
|
||||
|
||||
if (withColor)
|
||||
values.put(TaskLists.LIST_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
|
||||
if (withColor && info.color != null)
|
||||
values.put(TaskLists.LIST_COLOR, info.color)
|
||||
|
||||
if (info.privWriteContent && !info.forceReadOnly)
|
||||
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_OWNER)
|
||||
@@ -82,7 +88,9 @@ class LocalTaskList private constructor(
|
||||
accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED &&
|
||||
accessLevel <= TaskListColumns.ACCESS_LEVEL_READ
|
||||
|
||||
override val url: String?
|
||||
override fun deleteCollection(): Boolean = delete()
|
||||
|
||||
override val collectionUrl: String?
|
||||
get() = syncId
|
||||
|
||||
override val tag: String
|
||||
@@ -94,7 +102,7 @@ class LocalTaskList private constructor(
|
||||
override var lastSyncState: SyncState?
|
||||
get() {
|
||||
try {
|
||||
provider.client.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
|
||||
provider.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
|
||||
null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
cursor.getString(0)?.let {
|
||||
@@ -109,7 +117,7 @@ class LocalTaskList private constructor(
|
||||
set(state) {
|
||||
val values = ContentValues(1)
|
||||
values.put(TaskLists.SYNC_VERSION, state?.toString())
|
||||
provider.client.update(taskListSyncUri(), values, null, null)
|
||||
provider.update(taskListSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
@@ -148,28 +156,32 @@ class LocalTaskList private constructor(
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalTask.COLUMN_FLAGS, flags)
|
||||
return provider.client.update(tasksSyncUri(), values,
|
||||
return provider.update(tasksSyncUri(), values,
|
||||
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int) =
|
||||
provider.client.delete(tasksSyncUri(),
|
||||
provider.delete(tasksSyncUri(),
|
||||
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()))
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = ContentValues(1)
|
||||
values.putNull(LocalEvent.COLUMN_ETAG)
|
||||
provider.client.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
|
||||
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
|
||||
object Factory: DmfsTaskListFactory<LocalTaskList> {
|
||||
|
||||
override fun newInstance(account: Account, provider: TaskProvider, id: Long) =
|
||||
LocalTaskList(account, provider, id)
|
||||
override fun newInstance(
|
||||
account: Account,
|
||||
provider: ContentProviderClient,
|
||||
providerName: TaskProvider.ProviderName,
|
||||
id: Long
|
||||
) = LocalTaskList(account, provider, providerName, id)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ import java.io.FileNotFoundException
|
||||
import java.util.logging.Logger
|
||||
|
||||
class GroupMembershipHandler(val localContact: LocalContact): DataRowHandler() {
|
||||
|
||||
private val logger: Logger = Logger.getGlobal()
|
||||
|
||||
override fun forMimeType() = GroupMembership.CONTENT_ITEM_TYPE
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ class RefreshCollectionsWorker @AssistedInject constructor(
|
||||
|
||||
// create authenticating OkHttpClient (credentials taken from account settings)
|
||||
runInterruptible {
|
||||
HttpClient.Builder(applicationContext, accountSettingsFactory.forAccount(account))
|
||||
HttpClient.Builder(applicationContext, accountSettingsFactory.create(account))
|
||||
.setForeground(true)
|
||||
.build().use { client ->
|
||||
val httpClient = client.okHttpClient
|
||||
|
||||
@@ -8,14 +8,14 @@ import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Looper
|
||||
import android.provider.CalendarContract
|
||||
import androidx.annotation.WorkerThread
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.sync.SyncUtils
|
||||
import at.bitfire.davdroid.sync.worker.PeriodicSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
@@ -31,140 +31,58 @@ import java.util.logging.Logger
|
||||
/**
|
||||
* Manages settings of an account.
|
||||
*
|
||||
* @param accountOrAddressBookAccount Account to take settings from. If this account is an address book account,
|
||||
* settings will be taken from the corresponding main account instead.
|
||||
* Must not be called from main thread as it uses blocking I/O
|
||||
* and may run migrations.
|
||||
*
|
||||
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
|
||||
* @throws IllegalArgumentException when the account type is not _DAVx5_ or _DAVx5 address book_
|
||||
* @param account account to take settings from
|
||||
*
|
||||
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
|
||||
* @throws IllegalArgumentException when the account is not a DAVx5 account
|
||||
*/
|
||||
@WorkerThread
|
||||
class AccountSettings @AssistedInject constructor(
|
||||
@Assisted accountOrAddressBookAccount: Account,
|
||||
@Assisted val account: Account,
|
||||
@ApplicationContext val context: Context,
|
||||
private val logger: Logger,
|
||||
private val migrationsFactory: AccountSettingsMigrations.Factory,
|
||||
private val settingsManager: SettingsManager
|
||||
private val settingsManager: SettingsManager,
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun forAccount(account: Account): AccountSettings
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val CURRENT_VERSION = 16
|
||||
const val KEY_SETTINGS_VERSION = "version"
|
||||
|
||||
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
|
||||
const val KEY_SYNC_INTERVAL_CALENDARS = "sync_interval_calendars"
|
||||
|
||||
/** Stores the tasks sync interval (in seconds) so that it can be set again when the provider is switched */
|
||||
const val KEY_SYNC_INTERVAL_TASKS = "sync_interval_tasks"
|
||||
|
||||
const val KEY_USERNAME = "user_name"
|
||||
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
|
||||
|
||||
/** OAuth [AuthState] (serialized as JSON) */
|
||||
const val KEY_AUTH_STATE = "auth_state"
|
||||
|
||||
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
|
||||
const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
|
||||
const val KEY_IGNORE_VPNS = "ignore_vpns" // ignore vpns at connection detection
|
||||
|
||||
/** Time range limitation to the past [in days]. Values:
|
||||
*
|
||||
* - null: default value (DEFAULT_TIME_RANGE_PAST_DAYS)
|
||||
* - <0 (typically -1): no limit
|
||||
* - n>0: entries more than n days in the past won't be synchronized
|
||||
*/
|
||||
const val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
|
||||
const val DEFAULT_TIME_RANGE_PAST_DAYS = 90
|
||||
|
||||
/**
|
||||
* Whether a default alarm shall be assigned to received events/tasks which don't have an alarm.
|
||||
* Value can be null (no default alarm) or an integer (default alarm shall be created this
|
||||
* number of minutes before the event/task).
|
||||
* Must not be called from main thread as AccountSettings uses blocking I/O and may run
|
||||
* migrations.
|
||||
*/
|
||||
const val KEY_DEFAULT_ALARM = "default_alarm"
|
||||
|
||||
/** Whether DAVx5 sets the local calendar color to the value from service DB at every sync
|
||||
value = *null* (not existing): true (default);
|
||||
"0" false */
|
||||
const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
|
||||
|
||||
/** Whether DAVx5 populates and uses CalendarContract.Colors
|
||||
value = *null* (not existing) false (default);
|
||||
"1" true */
|
||||
const val KEY_EVENT_COLORS = "event_colors"
|
||||
|
||||
/** Contact group method:
|
||||
*null (not existing)* groups as separate vCards (default);
|
||||
"CATEGORIES" groups are per-contact CATEGORIES
|
||||
*/
|
||||
const val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
|
||||
|
||||
/** UI preference: Show only personal collections
|
||||
value = *null* (not existing) show all collections (default);
|
||||
"1" show only personal collections */
|
||||
const val KEY_SHOW_ONLY_PERSONAL = "show_only_personal"
|
||||
|
||||
const val SYNC_INTERVAL_MANUALLY = -1L
|
||||
|
||||
/** Static property to indicate whether AccountSettings migration is currently running.
|
||||
* **Access must be `synchronized` with `AccountSettings::class.java`.** */
|
||||
@Volatile
|
||||
var currentlyUpdating = false
|
||||
|
||||
fun initialUserData(credentials: Credentials?): Bundle {
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
|
||||
|
||||
if (credentials != null) {
|
||||
if (credentials.username != null)
|
||||
bundle.putString(KEY_USERNAME, credentials.username)
|
||||
|
||||
if (credentials.certificateAlias != null)
|
||||
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
|
||||
|
||||
if (credentials.authState != null)
|
||||
bundle.putString(KEY_AUTH_STATE, credentials.authState.jsonSerializeString())
|
||||
}
|
||||
|
||||
return bundle
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun create(account: Account): AccountSettings
|
||||
}
|
||||
|
||||
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
val account: Account = when (accountOrAddressBookAccount.type) {
|
||||
context.getString(R.string.account_type_address_book) -> {
|
||||
/* argument is an address book account, which is not a main account. However settings are
|
||||
stored in the main account, so resolve and use the main account instead. */
|
||||
LocalAddressBook.mainAccount(context, accountOrAddressBookAccount) ?: throw IllegalArgumentException("Main account of $accountOrAddressBookAccount not found")
|
||||
}
|
||||
|
||||
context.getString(R.string.account_type),
|
||||
"at.bitfire.davdroid.test" /* defined in androidTest/strings/account_type_test */ ->
|
||||
accountOrAddressBookAccount
|
||||
|
||||
else ->
|
||||
throw IllegalArgumentException("Account type ${accountOrAddressBookAccount.type} not supported")
|
||||
}
|
||||
|
||||
init {
|
||||
if (Looper.getMainLooper() == Looper.myLooper())
|
||||
throw IllegalThreadStateException("AccountSettings may not be used on main thread")
|
||||
}
|
||||
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
init {
|
||||
val allowedAccountTypes = arrayOf(
|
||||
context.getString(R.string.account_type),
|
||||
"at.bitfire.davdroid.test" // R.strings.account_type_test in androidTest
|
||||
)
|
||||
if (!allowedAccountTypes.contains(account.type))
|
||||
throw IllegalArgumentException("Invalid account type: ${account.type}")
|
||||
|
||||
// synchronize because account migration must only be run one time
|
||||
synchronized(AccountSettings::class.java) {
|
||||
val versionStr = accountManager.getUserData(this.account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(
|
||||
this.account
|
||||
)
|
||||
val versionStr = accountManager.getUserData(account, KEY_SETTINGS_VERSION) ?: throw InvalidAccountException(account)
|
||||
var version = 0
|
||||
try {
|
||||
version = Integer.parseInt(versionStr)
|
||||
} catch (e: NumberFormatException) {
|
||||
logger.log(Level.SEVERE, "Invalid account version: $versionStr", e)
|
||||
}
|
||||
logger.fine("Account ${this.account.name} has version $version, current version: $CURRENT_VERSION")
|
||||
logger.fine("Account ${account.name} has version $version, current version: $CURRENT_VERSION")
|
||||
|
||||
if (version < CURRENT_VERSION) {
|
||||
if (currentlyUpdating) {
|
||||
@@ -369,10 +287,10 @@ class AccountSettings @AssistedInject constructor(
|
||||
try {
|
||||
if (seconds == null || seconds == SYNC_INTERVAL_MANUALLY) {
|
||||
logger.fine("Disabling periodic sync of $account/$authority")
|
||||
PeriodicSyncWorker.disable(context, account, authority)
|
||||
syncWorkerManager.disablePeriodic(account, authority)
|
||||
} else {
|
||||
logger.fine("Setting periodic sync of $account/$authority to $seconds seconds (wifiOnly=$wiFiOnly)")
|
||||
PeriodicSyncWorker.enable(context, account, authority, seconds, wiFiOnly)
|
||||
syncWorkerManager.enablePeriodic(account, authority, seconds, wiFiOnly)
|
||||
}.result.get() // On operation (enable/disable) failure exception is thrown
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Failed to set sync interval of $account/$authority to $seconds seconds", e)
|
||||
@@ -517,4 +435,89 @@ class AccountSettings @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val CURRENT_VERSION = 17
|
||||
const val KEY_SETTINGS_VERSION = "version"
|
||||
|
||||
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
|
||||
const val KEY_SYNC_INTERVAL_CALENDARS = "sync_interval_calendars"
|
||||
|
||||
/** Stores the tasks sync interval (in seconds) so that it can be set again when the provider is switched */
|
||||
const val KEY_SYNC_INTERVAL_TASKS = "sync_interval_tasks"
|
||||
|
||||
const val KEY_USERNAME = "user_name"
|
||||
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
|
||||
|
||||
/** OAuth [AuthState] (serialized as JSON) */
|
||||
const val KEY_AUTH_STATE = "auth_state"
|
||||
|
||||
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
|
||||
const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
|
||||
const val KEY_IGNORE_VPNS = "ignore_vpns" // ignore vpns at connection detection
|
||||
|
||||
/** Time range limitation to the past [in days]. Values:
|
||||
*
|
||||
* - null: default value (DEFAULT_TIME_RANGE_PAST_DAYS)
|
||||
* - <0 (typically -1): no limit
|
||||
* - n>0: entries more than n days in the past won't be synchronized
|
||||
*/
|
||||
const val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
|
||||
const val DEFAULT_TIME_RANGE_PAST_DAYS = 90
|
||||
|
||||
/**
|
||||
* Whether a default alarm shall be assigned to received events/tasks which don't have an alarm.
|
||||
* Value can be null (no default alarm) or an integer (default alarm shall be created this
|
||||
* number of minutes before the event/task).
|
||||
*/
|
||||
const val KEY_DEFAULT_ALARM = "default_alarm"
|
||||
|
||||
/** Whether DAVx5 sets the local calendar color to the value from service DB at every sync
|
||||
value = *null* (not existing): true (default);
|
||||
"0" false */
|
||||
const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
|
||||
|
||||
/** Whether DAVx5 populates and uses CalendarContract.Colors
|
||||
value = *null* (not existing) false (default);
|
||||
"1" true */
|
||||
const val KEY_EVENT_COLORS = "event_colors"
|
||||
|
||||
/** Contact group method:
|
||||
*null (not existing)* groups as separate vCards (default);
|
||||
"CATEGORIES" groups are per-contact CATEGORIES
|
||||
*/
|
||||
const val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
|
||||
|
||||
/** UI preference: Show only personal collections
|
||||
value = *null* (not existing) show all collections (default);
|
||||
"1" show only personal collections */
|
||||
const val KEY_SHOW_ONLY_PERSONAL = "show_only_personal"
|
||||
|
||||
const val SYNC_INTERVAL_MANUALLY = -1L
|
||||
|
||||
/** Static property to indicate whether AccountSettings migration is currently running.
|
||||
* **Access must be `synchronized` with `AccountSettings::class.java`.** */
|
||||
@Volatile
|
||||
var currentlyUpdating = false
|
||||
|
||||
fun initialUserData(credentials: Credentials?): Bundle {
|
||||
val bundle = Bundle()
|
||||
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
|
||||
|
||||
if (credentials != null) {
|
||||
if (credentials.username != null)
|
||||
bundle.putString(KEY_USERNAME, credentials.username)
|
||||
|
||||
if (credentials.certificateAlias != null)
|
||||
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
|
||||
|
||||
if (credentials.authState != null)
|
||||
bundle.putString(KEY_AUTH_STATE, credentials.authState.jsonSerializeString())
|
||||
}
|
||||
|
||||
return bundle
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Parcel
|
||||
import android.os.RemoteException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Base64
|
||||
@@ -22,21 +20,20 @@ import androidx.preference.PreferenceManager
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
import at.bitfire.davdroid.sync.SyncUtils
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.PeriodicSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidEvent
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.UnknownProperty
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.Lazy
|
||||
import dagger.assisted.Assisted
|
||||
@@ -45,7 +42,6 @@ import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import net.fortuna.ical4j.model.Property
|
||||
import net.fortuna.ical4j.model.property.Url
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ObjectInputStream
|
||||
@@ -56,8 +52,13 @@ class AccountSettingsMigrations @AssistedInject constructor(
|
||||
@Assisted val account: Account,
|
||||
@Assisted val accountSettings: AccountSettings,
|
||||
@ApplicationContext val context: Context,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val db: AppDatabase,
|
||||
private val localAddressBookFactory: LocalAddressBook.Factory,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
private val syncWorkerManager: SyncWorkerManager,
|
||||
private val tasksAppManager: Lazy<TasksAppManager>
|
||||
) {
|
||||
|
||||
@@ -66,9 +67,43 @@ class AccountSettingsMigrations @AssistedInject constructor(
|
||||
fun create(account: Account, accountSettings: AccountSettings): AccountSettingsMigrations
|
||||
}
|
||||
|
||||
|
||||
val accountManager: AccountManager = AccountManager.get(context)
|
||||
|
||||
/**
|
||||
* With DAVx5 4.3.3 address book account names now contain the collection ID as a unique
|
||||
* identifier. We need to update the address book account names.
|
||||
*/
|
||||
@Suppress("unused","FunctionName")
|
||||
fun update_16_17() {
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
try {
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
|
||||
} catch (e: SecurityException) {
|
||||
// Not setting the collection ID will cause the address books to removed and fully re-synced as soon as there are permissions.
|
||||
logger.log(Level.WARNING, "Missing permissions for contacts authority, won't set collection ID for address books", e)
|
||||
null
|
||||
}?.use { provider ->
|
||||
val service = serviceRepository.getByAccountAndType(account.name, Service.TYPE_CARDDAV) ?: return
|
||||
|
||||
// Get all old address books of this account, i.e. the ones which have a "real_account_name" of this account.
|
||||
// After this migration is run, address books won't be associated to accounts anymore but only to their respective collection/URL.
|
||||
val oldAddressBookAccounts = accountManager.getAccountsByType(addressBookAccountType)
|
||||
.filter { addressBookAccount ->
|
||||
account.name == accountManager.getUserData(addressBookAccount, "real_account_name")
|
||||
}
|
||||
|
||||
for (oldAddressBookAccount in oldAddressBookAccounts) {
|
||||
// Old address books only have a URL, so use it to determine the collection ID
|
||||
logger.info("Migrating address book ${oldAddressBookAccount.name}")
|
||||
val url = accountManager.getUserData(oldAddressBookAccount, LocalAddressBook.USER_DATA_URL)
|
||||
collectionRepository.getByServiceAndUrl(service.id, url)?.let { collection ->
|
||||
// Set collection ID and rename the account
|
||||
val localAddressBook = localAddressBookFactory.create(oldAddressBookAccount, provider)
|
||||
localAddressBook.update(collection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Between DAVx5 4.4.1-beta.1 and 4.4.1-rc.1 (both v15), the periodic sync workers were renamed (moved to another
|
||||
@@ -83,7 +118,7 @@ class AccountSettingsMigrations @AssistedInject constructor(
|
||||
/* A maybe existing periodic worker references the old class name (even if it failed and/or is not active). So
|
||||
we need to explicitly disable and prune all workers. Just updating the worker is not enough – WorkManager will update
|
||||
the work details, but not the class name. */
|
||||
val disableOp = PeriodicSyncWorker.disable(context, account, authority)
|
||||
val disableOp = syncWorkerManager.disablePeriodic(account, authority)
|
||||
disableOp.result.get() // block until worker with old name is disabled
|
||||
|
||||
val pruneOp = WorkManager.getInstance(context).pruneWork()
|
||||
@@ -93,13 +128,7 @@ class AccountSettingsMigrations @AssistedInject constructor(
|
||||
if (interval != null && interval != AccountSettings.SYNC_INTERVAL_MANUALLY) {
|
||||
// There's a sync interval for this account/authority; a periodic sync worker should be there, too.
|
||||
val onlyWifi = accountSettings.getSyncWifiOnly()
|
||||
PeriodicSyncWorker.enable(
|
||||
context,
|
||||
account,
|
||||
authority,
|
||||
interval,
|
||||
onlyWifi
|
||||
)
|
||||
syncWorkerManager.enablePeriodic(account, authority, interval, onlyWifi)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,84 +418,6 @@ class AccountSettingsMigrations @AssistedInject constructor(
|
||||
accountManager.setAndVerifyUserData(account, "wifi_only_ssid", null)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("ParcelClassLoader")
|
||||
private fun update_5_6() {
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.use { provider ->
|
||||
val parcel = Parcel.obtain()
|
||||
try {
|
||||
// don't run syncs during the migration
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
|
||||
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 0)
|
||||
ContentResolver.cancelSync(account, null)
|
||||
|
||||
// get previous address book settings (including URL)
|
||||
val raw = ContactsContract.SyncState.get(provider, account)
|
||||
if (raw == null)
|
||||
logger.info("No contacts sync state, ignoring account")
|
||||
else {
|
||||
parcel.unmarshall(raw, 0, raw.size)
|
||||
parcel.setDataPosition(0)
|
||||
val params = parcel.readBundle()!!
|
||||
val url = params.getString("url")?.toHttpUrlOrNull()
|
||||
if (url == null)
|
||||
logger.info("No address book URL, ignoring account")
|
||||
else {
|
||||
// create new address book
|
||||
val info = Collection(url = url, type = Collection.TYPE_ADDRESSBOOK, displayName = account.name)
|
||||
logger.log(Level.INFO, "Creating new address book account", url)
|
||||
val addressBookAccount = Account(
|
||||
LocalAddressBook.accountName(account, info), context.getString(
|
||||
R.string.account_type_address_book))
|
||||
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url.toString())))
|
||||
throw ContactsStorageException("Couldn't create address book account")
|
||||
|
||||
// move contacts to new address book
|
||||
logger.info("Moving contacts from $account to $addressBookAccount")
|
||||
val newAccount = ContentValues(2)
|
||||
newAccount.put(ContactsContract.RawContacts.ACCOUNT_NAME, addressBookAccount.name)
|
||||
newAccount.put(ContactsContract.RawContacts.ACCOUNT_TYPE, addressBookAccount.type)
|
||||
val affected = provider.update(
|
||||
ContactsContract.RawContacts.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
|
||||
.appendQueryParameter(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
|
||||
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true").build(),
|
||||
newAccount,
|
||||
"${ContactsContract.RawContacts.ACCOUNT_NAME}=? AND ${ContactsContract.RawContacts.ACCOUNT_TYPE}=?",
|
||||
arrayOf(account.name, account.type))
|
||||
logger.info("$affected contacts moved to new address book")
|
||||
}
|
||||
|
||||
ContactsContract.SyncState.set(provider, account, null)
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't migrate contacts to new address book", e)
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
// update version number so that further syncs don't repeat the migration
|
||||
accountManager.setAndVerifyUserData(account, AccountSettings.KEY_SETTINGS_VERSION, "6")
|
||||
|
||||
// request sync of new address book account
|
||||
ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 1)
|
||||
accountSettings.setSyncInterval(context.getString(R.string.address_books_authority), 4*3600)
|
||||
}
|
||||
|
||||
/* Android 7.1.1 OpenTasks fix */
|
||||
@Suppress("unused")
|
||||
private fun update_4_5() {
|
||||
// call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available
|
||||
val manager = tasksAppManager.get()
|
||||
manager.selectProvider(manager.currentProvider())
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun update_3_4() {
|
||||
accountSettings.setGroupMethod(GroupMethod.CATEGORIES)
|
||||
}
|
||||
|
||||
// updates from AccountSettings version 2 and below are not supported anymore
|
||||
// updates from AccountSettings version 5 and below are not supported anymore
|
||||
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
package at.bitfire.davdroid.startup
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.startup.StartupPlugin.Companion.PRIORITY_DEFAULT
|
||||
@@ -50,17 +51,23 @@ class CrashHandlerSetup @Inject constructor(
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyFlashScreen()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
val builder = StrictMode.VmPolicy.Builder() // don't use detectAll() because it causes "untagged socket" warnings
|
||||
.detectActivityLeaks()
|
||||
.detectFileUriExposure()
|
||||
.detectLeakedClosableObjects()
|
||||
.detectLeakedRegistrationObjects()
|
||||
.detectLeakedSqlLiteObjects()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
builder.detectContentUriWithoutPermission()
|
||||
/*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) // often triggered by Conscrypt
|
||||
builder.detectNonSdkApiUsage()*/
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
builder.detectUnsafeIntentLaunch()
|
||||
StrictMode.setVmPolicy(builder.penaltyLog().build())
|
||||
|
||||
} else {
|
||||
// release build
|
||||
|
||||
@@ -6,7 +6,6 @@ package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.SyncResult
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
@@ -48,10 +47,15 @@ class AddressBookSyncer @AssistedInject constructor(
|
||||
get() = ContactsContract.AUTHORITY // Address books use the contacts authority for sync
|
||||
|
||||
|
||||
override fun localSyncCollections(provider: ContentProviderClient): List<LocalAddressBook>
|
||||
= LocalAddressBook.findAll(context, provider, account)
|
||||
override fun getLocalCollections(provider: ContentProviderClient): List<LocalAddressBook> =
|
||||
serviceRepository.getByAccountAndType(account.name, serviceType)?.let { service ->
|
||||
// Get _all_ address books; Otherwise address book accounts of unchecked address books will not be removed
|
||||
collectionRepository.getByService(service.id).mapNotNull { collection ->
|
||||
LocalAddressBook.findByCollection(context, provider, collection.id)
|
||||
}
|
||||
}.orEmpty()
|
||||
|
||||
override fun getSyncCollections(serviceId: Long): List<Collection> =
|
||||
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
|
||||
collectionRepository.getByServiceAndSync(serviceId)
|
||||
|
||||
override fun update(localCollection: LocalAddressBook, remoteCollection: Collection) {
|
||||
@@ -63,25 +67,37 @@ class AddressBookSyncer @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, remoteCollection: Collection) {
|
||||
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalAddressBook {
|
||||
logger.log(Level.INFO, "Adding local address book", remoteCollection)
|
||||
LocalAddressBook.create(context, provider, account, remoteCollection, forceAllReadOnly)
|
||||
return LocalAddressBook.create(context, provider, remoteCollection, forceAllReadOnly)
|
||||
}
|
||||
|
||||
override fun syncCollection(provider: ContentProviderClient, localCollection: LocalAddressBook, remoteCollection: Collection) {
|
||||
logger.info("Synchronizing address book $localCollection")
|
||||
logger.info("Synchronizing address book: ${localCollection.addressBookAccount.name}")
|
||||
syncAddressBook(
|
||||
localCollection.account,
|
||||
extras,
|
||||
httpClient,
|
||||
provider,
|
||||
syncResult,
|
||||
remoteCollection
|
||||
account = account,
|
||||
addressBook = localCollection,
|
||||
extras = extras,
|
||||
httpClient = httpClient,
|
||||
provider = provider,
|
||||
syncResult = syncResult,
|
||||
collection = remoteCollection
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes an address book
|
||||
*
|
||||
* @param addressBook local address book
|
||||
* @param extras Sync specific instructions. IE [Syncer.SYNC_EXTRAS_FULL_RESYNC]
|
||||
* @param httpClient
|
||||
* @param provider Content provider to access android contacts
|
||||
* @param syncResult Stores hard and soft sync errors
|
||||
* @param collection The database collection associated with this address book
|
||||
*/
|
||||
private fun syncAddressBook(
|
||||
account: Account,
|
||||
addressBook: LocalAddressBook,
|
||||
extras: Array<String>,
|
||||
httpClient: Lazy<HttpClient>,
|
||||
provider: ContentProviderClient,
|
||||
@@ -89,12 +105,11 @@ class AddressBookSyncer @AssistedInject constructor(
|
||||
collection: Collection
|
||||
) {
|
||||
try {
|
||||
val accountSettings = accountSettingsFactory.forAccount(account)
|
||||
val addressBook = LocalAddressBook(context, account, provider)
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
|
||||
// handle group method change
|
||||
val groupMethod = accountSettings.getGroupMethod().name
|
||||
accountSettings.accountManager.getUserData(account, PREVIOUS_GROUP_METHOD)?.let { previousGroupMethod ->
|
||||
accountSettings.accountManager.getUserData(addressBook.addressBookAccount, PREVIOUS_GROUP_METHOD)?.let { previousGroupMethod ->
|
||||
if (previousGroupMethod != groupMethod) {
|
||||
logger.info("Group method changed, deleting all local contacts/groups")
|
||||
|
||||
@@ -106,10 +121,7 @@ class AddressBookSyncer @AssistedInject constructor(
|
||||
addressBook.syncState = null
|
||||
}
|
||||
}
|
||||
accountSettings.accountManager.setAndVerifyUserData(account, PREVIOUS_GROUP_METHOD, groupMethod)
|
||||
|
||||
logger.info("Synchronizing address book: ${addressBook.url}")
|
||||
logger.info("Taking settings from: ${addressBook.mainAccount}")
|
||||
accountSettings.accountManager.setAndVerifyUserData(addressBook.addressBookAccount, PREVIOUS_GROUP_METHOD, groupMethod)
|
||||
|
||||
val syncManager = contactsSyncManagerFactory.contactsSyncManager(account, accountSettings, httpClient.value, extras, authority, syncResult, provider, addressBook, collection)
|
||||
syncManager.performSync()
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.SyncResult
|
||||
import android.text.format.Formatter
|
||||
import at.bitfire.dav4jvm.DavCalendar
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
@@ -196,20 +195,35 @@ class CalendarSyncManager @AssistedInject constructor(
|
||||
logger.info("Downloading ${bunch.size} iCalendars: $bunch")
|
||||
SyncException.wrapWithRemoteResource(collection.url) {
|
||||
davCollection.multiget(bunch) { response, _ ->
|
||||
SyncException.wrapWithRemoteResource(response.href) wrapResponse@ {
|
||||
/*
|
||||
* Real-world servers may return:
|
||||
*
|
||||
* - unrelated resources
|
||||
* - the collection itself
|
||||
* - the requested resources, but with a different collection URL (for instance, `/cal/1.ics` instead of `/shared-cal/1.ics`).
|
||||
*
|
||||
* So we:
|
||||
*
|
||||
* - ignore unsuccessful responses,
|
||||
* - ignore responses without requested calendar data (should also ignore collections and hopefully unrelated resources), and
|
||||
* - take the last segment of the href as the file name and assume that it's in the requested collection.
|
||||
*/
|
||||
SyncException.wrapWithRemoteResource(response.href) wrapResource@ {
|
||||
if (!response.isSuccess()) {
|
||||
logger.warning("Received non-successful multiget response for ${response.href}")
|
||||
return@wrapResponse
|
||||
logger.warning("Ignoring non-successful multi-get response for ${response.href}")
|
||||
return@wrapResource
|
||||
}
|
||||
|
||||
val iCal = response[CalendarData::class.java]?.iCalendar
|
||||
if (iCal == null) {
|
||||
logger.warning("Ignoring multi-get response without calendar-data")
|
||||
return@wrapResource
|
||||
}
|
||||
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
val scheduleTag = response[ScheduleTag::class.java]?.scheduleTag
|
||||
|
||||
val calendarData = response[CalendarData::class.java]
|
||||
val iCal = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without calendar data")
|
||||
|
||||
processVEvent(
|
||||
response.href.lastSegment,
|
||||
eTag,
|
||||
|
||||
@@ -6,7 +6,7 @@ package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.SyncResult
|
||||
import android.content.ContentUris
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
@@ -38,7 +38,7 @@ class CalendarSyncer @AssistedInject constructor(
|
||||
get() = CalendarContract.AUTHORITY
|
||||
|
||||
|
||||
override fun localSyncCollections(provider: ContentProviderClient): List<LocalCalendar>
|
||||
override fun getLocalCollections(provider: ContentProviderClient): List<LocalCalendar>
|
||||
= AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null)
|
||||
|
||||
override fun prepare(provider: ContentProviderClient): Boolean {
|
||||
@@ -50,7 +50,7 @@ class CalendarSyncer @AssistedInject constructor(
|
||||
return true
|
||||
}
|
||||
|
||||
override fun getSyncCollections(serviceId: Long): List<Collection> =
|
||||
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
|
||||
collectionRepository.getSyncCalendars(serviceId)
|
||||
|
||||
override fun syncCollection(provider: ContentProviderClient, localCollection: LocalCalendar, remoteCollection: Collection) {
|
||||
@@ -74,9 +74,10 @@ class CalendarSyncer @AssistedInject constructor(
|
||||
localCollection.update(remoteCollection, accountSettings.getManageCalendarColors())
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, remoteCollection: Collection) {
|
||||
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalCalendar {
|
||||
logger.log(Level.INFO, "Adding local calendar", remoteCollection)
|
||||
LocalCalendar.create(account, provider, remoteCollection)
|
||||
val uri = LocalCalendar.create(account, provider, remoteCollection)
|
||||
return AndroidCalendar.findByID(account, provider, LocalCalendar.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,7 +7,6 @@ package at.bitfire.davdroid.sync
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentResolver
|
||||
import android.content.SyncResult
|
||||
import android.os.Build
|
||||
import android.text.format.Formatter
|
||||
import at.bitfire.dav4jvm.DavAddressBook
|
||||
@@ -320,24 +319,27 @@ class ContactsSyncManager @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
davCollection.multiget(bunch, contentType, version) { response, _ ->
|
||||
// See CalendarSyncManager for more information about the multi-get response
|
||||
SyncException.wrapWithRemoteResource(response.href) wrapResource@ {
|
||||
if (!response.isSuccess()) {
|
||||
logger.warning("Received non-successful multiget response for ${response.href}")
|
||||
logger.warning("Ignoring non-successful multi-get response for ${response.href}")
|
||||
return@wrapResource
|
||||
}
|
||||
|
||||
val card = response[AddressData::class.java]?.card
|
||||
if (card == null) {
|
||||
logger.warning("Ignoring multi-get response without address-data")
|
||||
return@wrapResource
|
||||
}
|
||||
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
var isJCard = hasJCard // assume that server has sent what we have requested (we ask for jCard only when the server advertises it)
|
||||
response[GetContentType::class.java]?.type?.let { type ->
|
||||
isJCard = type.sameTypeAs(DavUtils.MEDIA_TYPE_JCARD)
|
||||
}
|
||||
|
||||
val addressData = response[AddressData::class.java]
|
||||
val card = addressData?.card
|
||||
?: throw DavException("Received multi-get response without address data")
|
||||
|
||||
processCard(
|
||||
response.href.lastSegment,
|
||||
eTag,
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.SyncResult
|
||||
import android.text.format.Formatter
|
||||
import at.bitfire.dav4jvm.DavCalendar
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
@@ -123,19 +122,22 @@ class JtxSyncManager @AssistedInject constructor(
|
||||
// multiple iCalendars, use calendar-multi-get
|
||||
SyncException.wrapWithRemoteResource(collection.url) {
|
||||
davCollection.multiget(bunch) { response, _ ->
|
||||
// See CalendarSyncManager for more information about the multi-get response
|
||||
SyncException.wrapWithRemoteResource(response.href) wrapResource@ {
|
||||
if (!response.isSuccess()) {
|
||||
logger.warning("Received non-successful multiget response for ${response.href}")
|
||||
logger.warning("Ignoring non-successful multi-get response for ${response.href}")
|
||||
return@wrapResource
|
||||
}
|
||||
|
||||
val iCal = response[CalendarData::class.java]?.iCalendar
|
||||
if (iCal == null) {
|
||||
logger.warning("Ignoring multi-get response without calendar-data")
|
||||
return@wrapResource
|
||||
}
|
||||
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val calendarData = response[CalendarData::class.java]
|
||||
val iCal = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without task data")
|
||||
|
||||
processICalObject(response.href.lastSegment, eTag, StringReader(iCal))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ package at.bitfire.davdroid.sync
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.SyncResult
|
||||
import android.content.ContentUris
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
@@ -15,6 +15,7 @@ import at.bitfire.davdroid.repository.PrincipalRepository
|
||||
import at.bitfire.davdroid.resource.LocalJtxCollection
|
||||
import at.bitfire.ical4android.JtxCollection
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.techbee.jtx.JtxContract
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
@@ -43,7 +44,7 @@ class JtxSyncer @AssistedInject constructor(
|
||||
get() = TaskProvider.ProviderName.JtxBoard.authority
|
||||
|
||||
|
||||
override fun localSyncCollections(provider: ContentProviderClient): List<LocalJtxCollection>
|
||||
override fun getLocalCollections(provider: ContentProviderClient): List<LocalJtxCollection>
|
||||
= JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null)
|
||||
|
||||
override fun prepare(provider: ContentProviderClient): Boolean {
|
||||
@@ -52,6 +53,7 @@ class JtxSyncer @AssistedInject constructor(
|
||||
TaskProvider.checkVersion(context, TaskProvider.ProviderName.JtxBoard)
|
||||
} catch (e: TaskProvider.ProviderTooOldException) {
|
||||
tasksAppManager.get().notifyProviderTooOld(e)
|
||||
syncResult.contentProviderError = true
|
||||
return false // Don't sync
|
||||
}
|
||||
|
||||
@@ -67,7 +69,7 @@ class JtxSyncer @AssistedInject constructor(
|
||||
return true
|
||||
}
|
||||
|
||||
override fun getSyncCollections(serviceId: Long): List<Collection> =
|
||||
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
|
||||
collectionRepository.getSyncJtxCollections(serviceId)
|
||||
|
||||
override fun update(localCollection: LocalJtxCollection, remoteCollection: Collection) {
|
||||
@@ -76,10 +78,18 @@ class JtxSyncer @AssistedInject constructor(
|
||||
localCollection.updateCollection(remoteCollection, owner, accountSettings.getManageCalendarColors())
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, remoteCollection: Collection) {
|
||||
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalJtxCollection {
|
||||
logger.log(Level.INFO, "Adding local jtx collection", remoteCollection)
|
||||
val owner = remoteCollection.ownerId?.let { principalRepository.get(it) }
|
||||
LocalJtxCollection.create(account, provider, remoteCollection, owner)
|
||||
val uri = LocalJtxCollection.create(account, provider, remoteCollection, owner)
|
||||
return JtxCollection.find(
|
||||
account,
|
||||
provider,
|
||||
context,
|
||||
LocalJtxCollection.Factory,
|
||||
"${JtxContract.JtxCollection.ID} = ?",
|
||||
arrayOf("${ContentUris.parseId(uri)}")
|
||||
).first()
|
||||
}
|
||||
|
||||
override fun syncCollection(provider: ContentProviderClient, localCollection: LocalJtxCollection, remoteCollection: Collection) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Service
|
||||
import android.content.AbstractThreadedSyncAdapter
|
||||
import android.content.ContentProviderClient
|
||||
@@ -18,13 +19,18 @@ import android.provider.ContactsContract
|
||||
import androidx.work.WorkManager
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.logging.Level
|
||||
@@ -52,9 +58,12 @@ abstract class SyncAdapterService: Service() {
|
||||
*/
|
||||
class SyncAdapter @Inject constructor(
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
@ApplicationContext context: Context,
|
||||
private val logger: Logger,
|
||||
private val syncConditionsFactory: SyncConditions.Factory
|
||||
private val syncConditionsFactory: SyncConditions.Factory,
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
): AbstractThreadedSyncAdapter(
|
||||
context,
|
||||
true // isSyncable shouldn't be -1 because DAVx5 sets it to 0 or 1.
|
||||
@@ -62,23 +71,38 @@ abstract class SyncAdapterService: Service() {
|
||||
) {
|
||||
|
||||
/**
|
||||
* Completable [Boolean], which will be set to
|
||||
*
|
||||
* - `true` when the related sync worker has finished
|
||||
* - `false` when the sync framework has requested cancellation.
|
||||
*
|
||||
* In any case, the sync framework shouldn't be blocked anymore as soon as a
|
||||
* value is available.
|
||||
* Scope used to wait until the synchronization is finished. Will be cancelled when the sync framework
|
||||
* requests cancellation.
|
||||
*/
|
||||
private val finished = CompletableDeferred<Boolean>()
|
||||
private val waitScope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
// We seem to have to pass this old SyncFramework extra for an Android 7 workaround
|
||||
override fun onPerformSync(accountOrAddressBookAccount: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
// We have to pass this old SyncFramework extra for an Android 7 workaround
|
||||
val upload = extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD)
|
||||
logger.info("Sync request via sync framework for $account $authority (upload=$upload)")
|
||||
logger.info("Sync request via sync framework for $accountOrAddressBookAccount $authority (upload=$upload)")
|
||||
|
||||
// If we should sync an address book account - find the account storing the settings
|
||||
val account = if (accountOrAddressBookAccount.type == context.getString(R.string.account_type_address_book))
|
||||
AccountManager.get(context)
|
||||
.getUserData(accountOrAddressBookAccount, USER_DATA_COLLECTION_ID)
|
||||
?.toLongOrNull()
|
||||
?.let { collectionId ->
|
||||
collectionRepository.get(collectionId)?.let { collection ->
|
||||
serviceRepository.get(collection.serviceId)?.let { service ->
|
||||
Account(service.accountName, context.getString(R.string.account_type))
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
accountOrAddressBookAccount
|
||||
|
||||
if (account == null) {
|
||||
logger.warning("Address book account $accountOrAddressBookAccount doesn't have an associated collection")
|
||||
return
|
||||
}
|
||||
|
||||
val accountSettings = try {
|
||||
accountSettingsFactory.forAccount(account)
|
||||
accountSettingsFactory.create(account)
|
||||
} catch (e: InvalidAccountException) {
|
||||
logger.log(Level.WARNING, "Account doesn't exist anymore", e)
|
||||
return
|
||||
@@ -94,37 +118,40 @@ abstract class SyncAdapterService: Service() {
|
||||
/* Special case for contacts: because address books are separate accounts, changed contacts cause
|
||||
this method to be called with authority = ContactsContract.AUTHORITY. However the sync worker shall be run for the
|
||||
address book authority instead. */
|
||||
val workerAccount = accountSettings.account // main account in case of an address book account
|
||||
val workerAuthority =
|
||||
if (authority == ContactsContract.AUTHORITY)
|
||||
context.getString(R.string.address_books_authority)
|
||||
else
|
||||
authority
|
||||
|
||||
logger.fine("Starting OneTimeSyncWorker for $workerAccount $workerAuthority and waiting for it")
|
||||
val workerName = OneTimeSyncWorker.enqueue(context, workerAccount, workerAuthority, upload = upload)
|
||||
logger.fine("Starting OneTimeSyncWorker for $account $workerAuthority and waiting for it")
|
||||
val workerName = syncWorkerManager.enqueueOneTime(account, authority = workerAuthority, upload = upload)
|
||||
|
||||
// Because we are not allowed to observe worker state on a background thread, we can not
|
||||
// use it to block the sync adapter. Instead we check periodically whether the sync has
|
||||
// finished, putting the thread to sleep in between checks.
|
||||
/* Because we are not allowed to observe worker state on a background thread, we can not
|
||||
use it to block the sync adapter. Instead we use a Flow to get notified when the sync
|
||||
has finished. */
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
withTimeout(10 * 60 * 1000) { // block max. 10 minutes
|
||||
// wait for finished worker state
|
||||
workManager.getWorkInfosForUniqueWorkFlow(workerName).collect { info ->
|
||||
if (info.any { it.state.isFinished })
|
||||
cancel(CancellationException("$workerName has finished"))
|
||||
}
|
||||
val waitJob = waitScope.launch {
|
||||
// wait for finished worker state
|
||||
workManager.getWorkInfosForUniqueWorkFlow(workerName).collect { info ->
|
||||
if (info.any { it.state.isFinished })
|
||||
cancel("$workerName has finished")
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
|
||||
runBlocking {
|
||||
withTimeout(10 * 60 * 1000) { // block max. 10 minutes
|
||||
waitJob.join() // wait until worker has finished
|
||||
}
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
// waiting for work was cancelled, either by timeout or because the worker has finished
|
||||
logger.log(Level.FINE, "Not waiting for OneTimeSyncWorker anymore (this is not an error)", e)
|
||||
logger.fine("Not waiting for OneTimeSyncWorker anymore.")
|
||||
}
|
||||
|
||||
logger.fine("Returning to sync framework")
|
||||
logger.info("Returning to sync framework.")
|
||||
}
|
||||
|
||||
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
@@ -135,7 +162,7 @@ abstract class SyncAdapterService: Service() {
|
||||
logger.info("Sync adapter requested cancellation – won't cancel sync, but also won't block sync framework anymore")
|
||||
|
||||
// unblock sync framework
|
||||
finished.complete(false)
|
||||
waitScope.cancel()
|
||||
}
|
||||
|
||||
override fun onSyncCanceled(thread: Thread) = onSyncCanceled()
|
||||
|
||||
@@ -61,7 +61,7 @@ class SyncConditions @AssistedInject constructor(
|
||||
}
|
||||
|
||||
val wifi = context.getSystemService<WifiManager>()!!
|
||||
val info = wifi.connectionInfo
|
||||
@Suppress("DEPRECATION") val info = wifi.connectionInfo
|
||||
if (info == null || !onlySSIDs.contains(info.ssid.trim('"'))) {
|
||||
logger.info("Connected to wrong WiFi network (${info.ssid}), aborting sync")
|
||||
return false
|
||||
@@ -89,6 +89,7 @@ class SyncConditions @AssistedInject constructor(
|
||||
*/
|
||||
internal fun internetAvailable(): Boolean {
|
||||
val connectivityManager = context.getSystemService<ConnectivityManager>()!!
|
||||
@Suppress("DEPRECATION")
|
||||
return connectivityManager.allNetworks.any { network ->
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network)
|
||||
logger.log(
|
||||
@@ -127,6 +128,7 @@ class SyncConditions @AssistedInject constructor(
|
||||
*/
|
||||
internal fun wifiAvailable(): Boolean {
|
||||
val connectivityManager = context.getSystemService<ConnectivityManager>()!!
|
||||
@Suppress("DEPRECATION")
|
||||
connectivityManager.allNetworks.forEach { network ->
|
||||
connectivityManager.getNetworkCapabilities(network)?.let { capabilities ->
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.app.PendingIntent
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncResult
|
||||
import android.net.Uri
|
||||
import android.os.DeadObjectException
|
||||
import android.os.RemoteException
|
||||
@@ -40,8 +39,10 @@ import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.SyncState
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.repository.DavSyncStatsRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalCollection
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.resource.LocalEvent
|
||||
@@ -155,20 +156,24 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
@Inject
|
||||
lateinit var notificationRegistry: NotificationRegistry
|
||||
|
||||
@Inject
|
||||
lateinit var accountRepository: AccountRepository
|
||||
|
||||
@Inject
|
||||
lateinit var syncStatsRepository: DavSyncStatsRepository
|
||||
|
||||
@Inject
|
||||
lateinit var serviceRepository: DavServiceRepository
|
||||
|
||||
@Inject
|
||||
lateinit var collectionRepository: DavCollectionRepository
|
||||
|
||||
|
||||
init {
|
||||
// required for ServiceLoader -> ical4j -> ical4android
|
||||
Ical4Android.checkThreadContextClassLoader()
|
||||
}
|
||||
|
||||
protected val mainAccount = if (localCollection is LocalAddressBook)
|
||||
localCollection.requireMainAccount()
|
||||
else
|
||||
account
|
||||
|
||||
protected val notificationTag = localCollection.tag
|
||||
|
||||
protected lateinit var davCollection: RemoteType
|
||||
@@ -327,7 +332,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
logger.log(Level.WARNING, "Got 503 Service unavailable, trying again later", e)
|
||||
// determine when to retry
|
||||
syncResult.delayUntil = getDelayUntil(e.retryAfter).epochSecond
|
||||
syncResult.stats.numIoExceptions++ // Indicate a soft error occurred
|
||||
syncResult.stats.numServiceUnavailableExceptions++ // Indicate a soft error occurred
|
||||
}
|
||||
|
||||
// all others
|
||||
@@ -782,19 +787,19 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
is HttpException, is DavException -> {
|
||||
logger.log(Level.SEVERE, "HTTP/DAV exception", e)
|
||||
message = context.getString(R.string.sync_error_http_dav, e.localizedMessage)
|
||||
syncResult.stats.numParseExceptions++ // numIoExceptions would indicate a soft error
|
||||
syncResult.stats.numHttpExceptions++
|
||||
}
|
||||
|
||||
is CalendarStorageException, is ContactsStorageException, is RemoteException -> {
|
||||
logger.log(Level.SEVERE, "Couldn't access local storage", e)
|
||||
message = context.getString(R.string.sync_error_local_storage, e.localizedMessage)
|
||||
syncResult.databaseError = true
|
||||
syncResult.localStorageError = true
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.log(Level.SEVERE, "Unclassified sync error", e)
|
||||
message = e.localizedMessage ?: e::class.java.simpleName
|
||||
syncResult.stats.numParseExceptions++
|
||||
syncResult.stats.numUnclassifiedErrors++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,10 +809,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
contentIntent = Intent(context, AccountSettingsActivity::class.java)
|
||||
contentIntent.putExtra(
|
||||
AccountSettingsActivity.EXTRA_ACCOUNT,
|
||||
if (authority == ContactsContract.AUTHORITY)
|
||||
mainAccount
|
||||
else
|
||||
account
|
||||
account
|
||||
)
|
||||
} else {
|
||||
contentIntent = buildDebugInfoIntent(e, local, remote)
|
||||
@@ -833,7 +835,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
.setContentTitle(localCollection.title)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle(builder).bigText(message))
|
||||
.setSubText(mainAccount.name)
|
||||
.setSubText(account.name)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
@@ -896,7 +898,7 @@ abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: L
|
||||
builder.setSmallIcon(R.drawable.ic_warning_notify)
|
||||
.setContentTitle(notifyInvalidResourceTitle())
|
||||
.setContentText(context.getString(R.string.sync_invalid_resources_ignoring))
|
||||
.setSubText(mainAccount.name)
|
||||
.setSubText(account.name)
|
||||
.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
|
||||
62
app/src/main/kotlin/at/bitfire/davdroid/sync/SyncResult.kt
Normal file
62
app/src/main/kotlin/at/bitfire/davdroid/sync/SyncResult.kt
Normal file
@@ -0,0 +1,62 @@
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
/**
|
||||
* This class represents the results of a sync operation from [Syncer].
|
||||
*
|
||||
* Used by [at.bitfire.davdroid.sync.worker.BaseSyncWorker] to determine whether or not there will be retries etc.
|
||||
*/
|
||||
data class SyncResult(
|
||||
var contentProviderError: Boolean = false,
|
||||
var localStorageError: Boolean = false,
|
||||
var delayUntil: Long = 0,
|
||||
val stats: SyncStats = SyncStats()
|
||||
) {
|
||||
|
||||
/**
|
||||
* Whether a hard error occurred.
|
||||
*/
|
||||
fun hasHardError(): Boolean =
|
||||
contentProviderError
|
||||
|| localStorageError
|
||||
|| stats.numAuthExceptions > 0
|
||||
|| stats.numHttpExceptions > 0
|
||||
|| stats.numUnclassifiedErrors > 0
|
||||
|
||||
/**
|
||||
* Whether a soft error occurred.
|
||||
*/
|
||||
fun hasSoftError(): Boolean =
|
||||
stats.numDeadObjectExceptions > 0
|
||||
|| stats.numIoExceptions > 0
|
||||
|| stats.numServiceUnavailableExceptions > 0
|
||||
|
||||
/**
|
||||
* Whether a hard or a soft error occurred.
|
||||
*/
|
||||
fun hasError(): Boolean =
|
||||
hasHardError() || hasSoftError()
|
||||
|
||||
/**
|
||||
* Holds statistics about the sync operation. Used to determine retries. Also useful for
|
||||
* debugging and customer support when logged.
|
||||
*/
|
||||
data class SyncStats(
|
||||
// Stats
|
||||
var numDeletes: Long = 0,
|
||||
var numEntries: Long = 0,
|
||||
var numInserts: Long = 0,
|
||||
var numSkippedEntries: Long = 0,
|
||||
var numUpdates: Long = 0,
|
||||
|
||||
// Hard errors
|
||||
var numAuthExceptions: Long = 0,
|
||||
var numHttpExceptions: Long = 0,
|
||||
var numUnclassifiedErrors: Long = 0,
|
||||
|
||||
// Soft errors
|
||||
var numDeadObjectExceptions: Long = 0,
|
||||
var numIoExceptions: Long = 0,
|
||||
var numServiceUnavailableExceptions: Long = 0
|
||||
)
|
||||
|
||||
}
|
||||
@@ -33,7 +33,7 @@ object SyncUtils {
|
||||
* Checking the availability of authorities may be relatively expensive, so the
|
||||
* result should be cached for the current operation.
|
||||
*
|
||||
* @return list of available sync authorities for main accounts
|
||||
* @return list of available sync authorities for DAVx5 accounts
|
||||
*/
|
||||
fun syncAuthorities(context: Context): List<String> {
|
||||
val result = mutableListOf(
|
||||
|
||||
@@ -7,8 +7,8 @@ package at.bitfire.davdroid.sync
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.DeadObjectException
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.network.HttpClient
|
||||
@@ -77,7 +77,7 @@ abstract class Syncer<CollectionType: LocalCollection<*>>(
|
||||
abstract val authority: String
|
||||
abstract val serviceType: String
|
||||
|
||||
val accountSettings by lazy { accountSettingsFactory.forAccount(account) }
|
||||
val accountSettings by lazy { accountSettingsFactory.create(account) }
|
||||
val httpClient = lazy { HttpClient.Builder(context, accountSettings).build() }
|
||||
|
||||
/**
|
||||
@@ -85,71 +85,156 @@ abstract class Syncer<CollectionType: LocalCollection<*>>(
|
||||
* remote collection information. Then syncs the actual entries (events, tasks, contacts, etc)
|
||||
* of the remaining, now up-to-date, collections.
|
||||
*/
|
||||
private fun sync(provider: ContentProviderClient) {
|
||||
@VisibleForTesting
|
||||
internal fun sync(provider: ContentProviderClient) {
|
||||
// Collection type specific preparations
|
||||
if (!prepare(provider)) {
|
||||
logger.log(Level.WARNING, "Failed to prepare sync. Won't run sync.")
|
||||
return
|
||||
}
|
||||
|
||||
// Find sync-enabled collections
|
||||
// Find collections in database and provider which should be synced (are sync-enabled)
|
||||
val dbCollections = getSyncEnabledCollections()
|
||||
val localCollections = getLocalCollections(provider)
|
||||
|
||||
// Create/update/delete local collections according to DB
|
||||
val updatedLocalCollections = updateCollections(provider, localCollections, dbCollections)
|
||||
|
||||
// Sync local collection contents (events, contacts, tasks)
|
||||
syncCollectionContents(provider, updatedLocalCollections, dbCollections)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds sync enabled collections in database. They contain collection info which might have
|
||||
* been updated by collection refresh [at.bitfire.davdroid.servicedetection.DavResourceFinder].
|
||||
*
|
||||
* @return The sync enabled collections as hash map identified by their URL
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun getSyncEnabledCollections(): Map<HttpUrl, Collection> {
|
||||
val dbCollections = mutableMapOf<HttpUrl, Collection>()
|
||||
serviceRepository.getByAccountAndType(account.name, serviceType)?.let { service ->
|
||||
for (dbCollection in getSyncCollections(service.id))
|
||||
for (dbCollection in getDbSyncCollections(service.id))
|
||||
dbCollections[dbCollection.url] = dbCollection
|
||||
}
|
||||
return dbCollections
|
||||
}
|
||||
|
||||
// Update/delete local collections and determine new (unknown) remote collections
|
||||
val localSyncCollections = localSyncCollections(provider)
|
||||
val newDbCollections = HashMap(dbCollections) // create a copy
|
||||
for (localCollection in localSyncCollections) {
|
||||
val dbCollection = dbCollections[localCollection.url?.toHttpUrlOrNull()]
|
||||
if (dbCollection == null)
|
||||
// Collection not available in db = on server (anymore), delete obsolete local collection
|
||||
localCollection.delete()
|
||||
else {
|
||||
/**
|
||||
* Updates and deletes local collections.
|
||||
*
|
||||
* - Updates local collections with possibly new info from corresponding database collections.
|
||||
* - Deletes local collections without a corresponding database collection.
|
||||
* - Creates local collections for database collections without local match.
|
||||
*
|
||||
* @param provider Content provider client, used to create local collections
|
||||
* @param localCollections The current local collections
|
||||
* @param dbCollections The current database collections, possibly containing new information
|
||||
*
|
||||
* @return Updated list of local collections (obsolete collections removed, new collections added)
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun updateCollections(
|
||||
provider: ContentProviderClient,
|
||||
localCollections: List<CollectionType>,
|
||||
dbCollections: Map<HttpUrl, Collection>
|
||||
): List<CollectionType> {
|
||||
// create mutable copies of input
|
||||
val updatedLocalCollections = localCollections.toMutableList()
|
||||
val newDbCollections = dbCollections.toMutableMap()
|
||||
|
||||
for (localCollection in localCollections) {
|
||||
val dbCollection = dbCollections[localCollection.collectionUrl?.toHttpUrlOrNull()]
|
||||
if (dbCollection == null) {
|
||||
// Collection not available in db = on server (anymore), delete and remove from the updated list
|
||||
logger.fine("Deleting local collection ${localCollection.title}")
|
||||
localCollection.deleteCollection()
|
||||
updatedLocalCollections -= localCollection
|
||||
} else {
|
||||
// Collection exists locally, update local collection and remove it from "to be created" map
|
||||
logger.fine("Updating local collection ${localCollection.title} with $dbCollection")
|
||||
update(localCollection, dbCollection)
|
||||
newDbCollections -= dbCollection.url
|
||||
}
|
||||
}
|
||||
|
||||
// 3. create new local collections for newly found remote collections
|
||||
for ((_, collection) in newDbCollections)
|
||||
create(provider, collection)
|
||||
// Create local collections which are in DB, but don't exist locally yet
|
||||
if (newDbCollections.isNotEmpty()) {
|
||||
val toBeCreated = newDbCollections.values.toList()
|
||||
logger.log(Level.FINE, "Creating new local collections", toBeCreated.toTypedArray())
|
||||
val newLocalCollections = createLocalCollections(provider, toBeCreated)
|
||||
// Add the newly created collections to the updated list
|
||||
updatedLocalCollections.addAll(newLocalCollections)
|
||||
}
|
||||
|
||||
// 4. sync local collection contents (events, contacts, tasks)
|
||||
for (localCollection in localSyncCollections)
|
||||
dbCollections[localCollection.url?.toHttpUrl()]?.let { dbCollection ->
|
||||
syncCollection(provider, localCollection, dbCollection)
|
||||
}
|
||||
return updatedLocalCollections
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates new local collections from database collections.
|
||||
*
|
||||
* @param provider Content provider client to access local collections
|
||||
* @param dbCollections Database collections to be created as local collections
|
||||
*
|
||||
* @return Newly created local collections
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun createLocalCollections(
|
||||
provider: ContentProviderClient,
|
||||
dbCollections: List<Collection>
|
||||
): List<CollectionType> =
|
||||
dbCollections.map { collection -> create(provider, collection) }
|
||||
|
||||
/**
|
||||
* Synchronize the actual collection contents.
|
||||
*
|
||||
* @param provider Content provider client to access local collections
|
||||
* @param localCollections Collections to be synchronized
|
||||
* @param dbCollections Remote collection information
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun syncCollectionContents(
|
||||
provider: ContentProviderClient,
|
||||
localCollections: List<CollectionType>,
|
||||
dbCollections: Map<HttpUrl, Collection>
|
||||
) = localCollections.forEach { localCollection ->
|
||||
dbCollections[localCollection.collectionUrl?.toHttpUrl()]?.let { dbCollection ->
|
||||
syncCollection(provider, localCollection, dbCollection)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For collection specific sync preparations.
|
||||
*
|
||||
* @param provider Content provider for syncer specific authority
|
||||
* @return *true* to run the sync; *false* to abort
|
||||
*/
|
||||
open fun prepare(provider: ContentProviderClient): Boolean = true
|
||||
|
||||
/**
|
||||
* Get the local collections to be updated after sync
|
||||
* Gets all local collections (not from the database, but from the content provider).
|
||||
*
|
||||
* [Syncer] will remove collections which are returned by this method, but not by
|
||||
* [getDbSyncCollections], and add collections which are returned by [getDbSyncCollections], but not by this method.
|
||||
*
|
||||
* @param provider Content provider to access local collections
|
||||
* @return Local collections to be updated
|
||||
*/
|
||||
abstract fun localSyncCollections(provider: ContentProviderClient): List<CollectionType>
|
||||
abstract fun getLocalCollections(provider: ContentProviderClient): List<CollectionType>
|
||||
|
||||
/**
|
||||
* Get the local database collections which are sync-enabled (should by synchronized)
|
||||
* Get the local database collections which are sync-enabled (should by synchronized).
|
||||
*
|
||||
* [Syncer] will remove collections which are returned by [getLocalCollections], but not by
|
||||
* this method, and add collections which are returned by this method, but not by [getLocalCollections].
|
||||
*
|
||||
* @param serviceId The CalDAV or CardDAV service (account) to be synchronized
|
||||
* @return Database collections to be synchronized
|
||||
*/
|
||||
abstract fun getSyncCollections(serviceId: Long): List<Collection>
|
||||
abstract fun getDbSyncCollections(serviceId: Long): List<Collection>
|
||||
|
||||
/**
|
||||
* Update an existing local collection with remote collection information
|
||||
* Updates an existing local collection (in the content provider) with remote collection information (from the DB).
|
||||
*
|
||||
* @param localCollection The local collection to be updated
|
||||
* @param remoteCollection The new remote collection information
|
||||
@@ -157,15 +242,15 @@ abstract class Syncer<CollectionType: LocalCollection<*>>(
|
||||
abstract fun update(localCollection: CollectionType, remoteCollection: Collection)
|
||||
|
||||
/**
|
||||
* Create a new local collection from remote collection information
|
||||
* Creates a new local collection (in the content provider) from remote collection information (from the DB).
|
||||
*
|
||||
* @param provider The content provider client to create the local collection
|
||||
* @param remoteCollection The remote collection to be created locally
|
||||
*/
|
||||
abstract fun create(provider: ContentProviderClient, remoteCollection: Collection)
|
||||
abstract fun create(provider: ContentProviderClient, remoteCollection: Collection): CollectionType
|
||||
|
||||
/**
|
||||
* Synchronise local with remote collection contents
|
||||
* Synchronizes local with remote collection contents.
|
||||
*
|
||||
* @param provider The content provider client to access the local collection to be updated
|
||||
* @param localCollection The local collection to be synchronized
|
||||
@@ -175,9 +260,10 @@ abstract class Syncer<CollectionType: LocalCollection<*>>(
|
||||
abstract fun syncCollection(provider: ContentProviderClient, localCollection: CollectionType, remoteCollection: Collection)
|
||||
|
||||
/**
|
||||
* Prepares the sync
|
||||
* - acquires content provider
|
||||
* - handles occurring sync errors
|
||||
* Prepares the sync:
|
||||
*
|
||||
* - acquire content provider
|
||||
* - handle occurring sync errors
|
||||
*/
|
||||
operator fun invoke() {
|
||||
logger.log(Level.INFO, "$authority sync of $account initiated", extras.joinToString(", "))
|
||||
@@ -195,7 +281,7 @@ abstract class Syncer<CollectionType: LocalCollection<*>>(
|
||||
- the content provider is not available at all, for instance because the respective
|
||||
system app, like "calendar storage" is disabled */
|
||||
logger.warning("Couldn't connect to content provider of authority $authority")
|
||||
syncResult.stats.numParseExceptions++ // hard sync error
|
||||
syncResult.contentProviderError = true
|
||||
|
||||
return // Don't continue without provider
|
||||
}
|
||||
@@ -210,14 +296,14 @@ abstract class Syncer<CollectionType: LocalCollection<*>>(
|
||||
/* May happen when the remote process dies or (since Android 14) when IPC (for instance with the calendar provider)
|
||||
is suddenly forbidden because our sync process was demoted from a "service process" to a "cached process". */
|
||||
logger.log(Level.WARNING, "Received DeadObjectException, treating as soft error", e)
|
||||
syncResult.stats.numIoExceptions++
|
||||
syncResult.stats.numDeadObjectExceptions++
|
||||
|
||||
} catch (e: InvalidAccountException) {
|
||||
logger.log(Level.WARNING, "Account was removed during synchronization", e)
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't sync $authority", e)
|
||||
syncResult.stats.numParseExceptions++ // Hard sync error
|
||||
syncResult.stats.numUnclassifiedErrors++ // Hard sync error
|
||||
|
||||
} finally {
|
||||
if (httpClient.isInitialized())
|
||||
|
||||
@@ -7,7 +7,7 @@ package at.bitfire.davdroid.sync
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.SyncResult
|
||||
import android.content.ContentUris
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
@@ -17,7 +17,7 @@ import at.bitfire.ical4android.TaskProvider
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
@@ -37,23 +37,21 @@ class TaskSyncer @AssistedInject constructor(
|
||||
fun create(account: Account, authority: String, extras: Array<String>, syncResult: SyncResult): TaskSyncer
|
||||
}
|
||||
|
||||
private lateinit var taskProvider: TaskProvider
|
||||
private val providerName = TaskProvider.ProviderName.fromAuthority(authority)
|
||||
|
||||
override val serviceType: String
|
||||
get() = Service.TYPE_CALDAV
|
||||
|
||||
|
||||
override fun localSyncCollections(provider: ContentProviderClient): List<LocalTaskList>
|
||||
= DmfsTaskList.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)
|
||||
override fun getLocalCollections(provider: ContentProviderClient): List<LocalTaskList>
|
||||
= DmfsTaskList.find(account, LocalTaskList.Factory, provider, providerName, "${TaskLists.SYNC_ENABLED}!=0", null)
|
||||
|
||||
override fun prepare(provider: ContentProviderClient): Boolean {
|
||||
// Acquire task provider
|
||||
val providerName = TaskProvider.ProviderName.fromAuthority(authority)
|
||||
taskProvider = try {
|
||||
TaskProvider.fromProviderClient(context, providerName, provider)
|
||||
// Don't sync if task provider is too old
|
||||
try {
|
||||
TaskProvider.checkVersion(context, providerName)
|
||||
} catch (e: TaskProvider.ProviderTooOldException) {
|
||||
tasksAppManager.get().notifyProviderTooOld(e)
|
||||
syncResult.databaseError = true
|
||||
syncResult.contentProviderError = true
|
||||
return false // Don't sync
|
||||
}
|
||||
|
||||
@@ -69,7 +67,7 @@ class TaskSyncer @AssistedInject constructor(
|
||||
return true
|
||||
}
|
||||
|
||||
override fun getSyncCollections(serviceId: Long): List<Collection> =
|
||||
override fun getDbSyncCollections(serviceId: Long): List<Collection> =
|
||||
collectionRepository.getSyncTaskLists(serviceId)
|
||||
|
||||
override fun update(localCollection: LocalTaskList, remoteCollection: Collection) {
|
||||
@@ -77,9 +75,10 @@ class TaskSyncer @AssistedInject constructor(
|
||||
localCollection.update(remoteCollection, accountSettings.getManageCalendarColors())
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, remoteCollection: Collection) {
|
||||
override fun create(provider: ContentProviderClient, remoteCollection: Collection): LocalTaskList {
|
||||
logger.log(Level.INFO, "Adding local task list", remoteCollection)
|
||||
LocalTaskList.create(account, taskProvider, remoteCollection)
|
||||
val uri = LocalTaskList.create(account, provider, providerName, remoteCollection)
|
||||
return DmfsTaskList.findByID(account, provider, providerName, LocalTaskList.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
override fun syncCollection(provider: ContentProviderClient, localCollection: LocalTaskList, remoteCollection: Collection) {
|
||||
|
||||
@@ -21,20 +21,20 @@ import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.sync.worker.PeriodicSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.davdroid.util.PermissionUtils
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.ical4android.TaskProvider.ProviderName
|
||||
import dagger.Lazy
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Responsible for setting/getting the currently used tasks app, and for communicating with it.
|
||||
@@ -46,7 +46,8 @@ class TasksAppManager @Inject constructor(
|
||||
private val db: AppDatabase,
|
||||
private val logger: Logger,
|
||||
private val notificationRegistry: Lazy<NotificationRegistry>,
|
||||
private val settingsManager: SettingsManager
|
||||
private val settingsManager: SettingsManager,
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -130,7 +131,7 @@ class TasksAppManager @Inject constructor(
|
||||
|
||||
private fun setSyncable(context: Context, account: Account, authority: String, syncable: Boolean) {
|
||||
try {
|
||||
val settings = accountSettingsFactory.forAccount(account)
|
||||
val settings = accountSettingsFactory.create(account)
|
||||
if (syncable) {
|
||||
logger.info("Enabling $authority sync for $account")
|
||||
|
||||
@@ -147,11 +148,11 @@ class TasksAppManager @Inject constructor(
|
||||
ContentResolver.setIsSyncable(account, authority, 0)
|
||||
|
||||
// disable periodic sync worker
|
||||
PeriodicSyncWorker.disable(context, account, authority)
|
||||
syncWorkerManager.disablePeriodic(account, authority)
|
||||
}
|
||||
} catch (e: InvalidAccountException) {
|
||||
// account has already been removed, make sure periodic sync is disabled, too
|
||||
PeriodicSyncWorker.disable(context, account, authority)
|
||||
syncWorkerManager.disablePeriodic(account, authority)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +169,7 @@ class TasksAppManager @Inject constructor(
|
||||
|
||||
val pm = context.packageManager
|
||||
val tasksAppInfo = pm.getPackageInfo(e.provider.packageName, 0)
|
||||
val tasksAppLabel = tasksAppInfo.applicationInfo.loadLabel(pm)
|
||||
val tasksAppLabel = tasksAppInfo.applicationInfo?.loadLabel(pm)
|
||||
|
||||
val notify = NotificationCompat.Builder(context, registry.CHANNEL_SYNC_ERRORS)
|
||||
.setSmallIcon(R.drawable.ic_sync_problem_notify)
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.SyncResult
|
||||
import android.text.format.Formatter
|
||||
import at.bitfire.dav4jvm.DavCalendar
|
||||
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||
@@ -123,18 +122,21 @@ class TasksSyncManager @AssistedInject constructor(
|
||||
// multiple iCalendars, use calendar-multi-get
|
||||
SyncException.wrapWithRemoteResource(collection.url) {
|
||||
davCollection.multiget(bunch) { response, _ ->
|
||||
// See CalendarSyncManager for more information about the multi-get response
|
||||
SyncException.wrapWithRemoteResource(response.href) wrapResource@ {
|
||||
if (!response.isSuccess()) {
|
||||
logger.warning("Received non-successful multiget response for ${response.href}")
|
||||
logger.warning("Ignoring non-successful multi-get response for ${response.href}")
|
||||
return@wrapResource
|
||||
}
|
||||
|
||||
val iCal = response[CalendarData::class.java]?.iCalendar
|
||||
if (iCal == null) {
|
||||
logger.warning("Ignoring multi-get response without calendar-data")
|
||||
return@wrapResource
|
||||
}
|
||||
|
||||
val eTag = response[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val calendarData = response[CalendarData::class.java]
|
||||
val iCal = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without task data")
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
processVTodo(response.href.lastSegment, eTag, StringReader(iCal))
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import at.bitfire.davdroid.ui.setup.LoginActivity
|
||||
|
||||
|
||||
/**
|
||||
* Account authenticator for the main DAVx5 account type.
|
||||
* Account authenticator for the DAVx5 account type.
|
||||
*/
|
||||
class AccountAuthenticatorService: Service() {
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
package at.bitfire.davdroid.sync.account
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
@@ -15,8 +18,10 @@ import androidx.work.WorkerParameters
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.repository.AccountRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_COLLECTION_ID
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.Semaphore
|
||||
@@ -25,14 +30,73 @@ import java.util.logging.Logger
|
||||
|
||||
@HiltWorker
|
||||
class AccountsCleanupWorker @AssistedInject constructor(
|
||||
@Assisted appContext: Context,
|
||||
@Assisted val context: Context,
|
||||
@Assisted workerParameters: WorkerParameters,
|
||||
private val accountRepository: AccountRepository,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
private val db: AppDatabase,
|
||||
private val logger: Logger
|
||||
): Worker(appContext, workerParameters) {
|
||||
): Worker(context, workerParameters) {
|
||||
|
||||
@AssistedFactory
|
||||
@VisibleForTesting
|
||||
interface Factory {
|
||||
fun create(appContext: Context, workerParams: WorkerParameters): AccountsCleanupWorker
|
||||
}
|
||||
|
||||
private val accountManager = AccountManager.get(context)
|
||||
|
||||
override fun doWork(): Result {
|
||||
lockAccountsCleanup()
|
||||
try {
|
||||
cleanupAccounts()
|
||||
} finally {
|
||||
unlockAccountsCleanup()
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun cleanupAccounts() {
|
||||
// Later, accounts which are not in the DB should be deleted here
|
||||
|
||||
// Delete orphaned services in DB – only necessary as long as accounts are implemented as system accounts (not in DB)
|
||||
val accounts = accountRepository.getAll()
|
||||
logger.log(Level.INFO, "Cleaning up accounts. Currently existing accounts:", accounts)
|
||||
val serviceDao = db.serviceDao()
|
||||
if (accounts.isEmpty())
|
||||
serviceDao.deleteAll()
|
||||
else
|
||||
serviceDao.deleteExceptAccounts(accounts.map { it.name }.toTypedArray())
|
||||
|
||||
// Delete orphaned address book accounts (where db collection is missing)
|
||||
val addressBookAccountType = context.getString(R.string.account_type_address_book)
|
||||
deleteOrphanedAddressBookAccounts(accountManager.getAccountsByType(addressBookAccountType))
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes address book accounts if they do not have a corresponding collection
|
||||
*
|
||||
* @param addressBookAccounts Address book accounts to check
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun deleteOrphanedAddressBookAccounts(addressBookAccounts: Array<Account>) {
|
||||
addressBookAccounts.forEach { addressBookAccount ->
|
||||
val collection = accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)
|
||||
?.toLongOrNull()
|
||||
?.let { collectionId ->
|
||||
collectionRepository.get(collectionId)
|
||||
}
|
||||
if (collection == null) {
|
||||
// If no collection for this address book exists, we can delete it
|
||||
logger.info("Deleting address book account without collection: $addressBookAccount")
|
||||
accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
const val NAME = "accounts-cleanup"
|
||||
|
||||
private val mutex = Semaphore(1)
|
||||
@@ -45,9 +109,18 @@ class AccountsCleanupWorker @AssistedInject constructor(
|
||||
fun unlockAccountsCleanup() = mutex.release()
|
||||
|
||||
/**
|
||||
* Enqueues [AccountsCleanupWorker] to be run regularly (but not necessarily now).
|
||||
* Enqueues [AccountsCleanupWorker] to be run once as soon as possible.
|
||||
*/
|
||||
fun enqueue(context: Context) {
|
||||
// run once
|
||||
val rq = OneTimeWorkRequestBuilder<AccountsCleanupWorker>()
|
||||
WorkManager.getInstance(context).enqueue(rq.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues [AccountsCleanupWorker] to be run regularly (but not necessarily now).
|
||||
*/
|
||||
fun enable(context: Context) {
|
||||
// run every day
|
||||
val rq = PeriodicWorkRequestBuilder<AccountsCleanupWorker>(Duration.ofDays(1))
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(NAME, ExistingPeriodicWorkPolicy.UPDATE, rq.build())
|
||||
@@ -55,47 +128,4 @@ class AccountsCleanupWorker @AssistedInject constructor(
|
||||
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
lockAccountsCleanup()
|
||||
try {
|
||||
cleanupAccounts(accountRepository.getAll())
|
||||
} finally {
|
||||
unlockAccountsCleanup()
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun cleanupAccounts(accounts: Array<out Account>) {
|
||||
logger.log(Level.INFO, "Cleaning up accounts. Current accounts in DB:", accounts)
|
||||
|
||||
// Later, accounts which are not in the DB should be deleted here
|
||||
|
||||
val mainAccountType = applicationContext.getString(R.string.account_type)
|
||||
val mainAccountNames = accounts
|
||||
.filter { account -> account.type == mainAccountType }
|
||||
.map { it.name }
|
||||
|
||||
val addressBookAccountType = applicationContext.getString(R.string.account_type_address_book)
|
||||
val addressBooks = accounts
|
||||
.filter { account -> account.type == addressBookAccountType }
|
||||
.map { addressBookAccount -> LocalAddressBook(applicationContext, addressBookAccount, null) }
|
||||
for (addressBook in addressBooks) {
|
||||
try {
|
||||
val mainAccount = addressBook.mainAccount
|
||||
if (mainAccount == null || !mainAccountNames.contains(mainAccount.name))
|
||||
// the main account for this address book doesn't exist anymore
|
||||
addressBook.delete()
|
||||
} catch(e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't delete address book account", e)
|
||||
}
|
||||
}
|
||||
|
||||
// delete orphaned services in DB
|
||||
val serviceDao = db.serviceDao()
|
||||
if (mainAccountNames.isEmpty())
|
||||
serviceDao.deleteAll()
|
||||
else
|
||||
serviceDao.deleteExceptAccounts(mainAccountNames.toTypedArray())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,10 +10,10 @@ import android.content.Context
|
||||
import android.os.Bundle
|
||||
import at.bitfire.davdroid.util.setAndVerifyUserData
|
||||
|
||||
object AccountUtils {
|
||||
object SystemAccountUtils {
|
||||
|
||||
/**
|
||||
* Creates an account and makes sure the user data are set correctly.
|
||||
* Creates a system account and makes sure the user data are set correctly.
|
||||
*
|
||||
* @param context operating context
|
||||
* @param account account to create
|
||||
@@ -7,8 +7,9 @@ package at.bitfire.davdroid.sync.worker
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Build
|
||||
import android.provider.CalendarContract
|
||||
import androidx.annotation.IntDef
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
@@ -19,11 +20,13 @@ import androidx.work.WorkQuery
|
||||
import androidx.work.WorkerParameters
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.push.PushNotificationManager
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.AddressBookSyncer
|
||||
import at.bitfire.davdroid.sync.CalendarSyncer
|
||||
import at.bitfire.davdroid.sync.JtxSyncer
|
||||
import at.bitfire.davdroid.sync.SyncConditions
|
||||
import at.bitfire.davdroid.sync.SyncResult
|
||||
import at.bitfire.davdroid.sync.SyncUtils
|
||||
import at.bitfire.davdroid.sync.Syncer
|
||||
import at.bitfire.davdroid.sync.TaskSyncer
|
||||
@@ -52,9 +55,22 @@ abstract class BaseSyncWorker(
|
||||
const val INPUT_ACCOUNT_TYPE = "accountType"
|
||||
const val INPUT_AUTHORITY = "authority"
|
||||
|
||||
/** set to true for user-initiated sync that skips network checks */
|
||||
/** set to `true` for user-initiated sync that skips network checks */
|
||||
const val INPUT_MANUAL = "manual"
|
||||
|
||||
/** set to `true` for syncs that are caused by local changes */
|
||||
const val INPUT_UPLOAD = "upload"
|
||||
|
||||
/** Whether re-synchronization is requested. One of [NO_RESYNC] (default), [RESYNC] or [FULL_RESYNC]. */
|
||||
const val INPUT_RESYNC = "resync"
|
||||
@IntDef(NO_RESYNC, RESYNC, FULL_RESYNC)
|
||||
annotation class InputResync
|
||||
const val NO_RESYNC = 0
|
||||
/** Re-synchronization is requested. See [Syncer.SYNC_EXTRAS_RESYNC] for details. */
|
||||
const val RESYNC = 1
|
||||
/** Full re-synchronization is requested. See [Syncer.SYNC_EXTRAS_FULL_RESYNC] for details. */
|
||||
const val FULL_RESYNC = 2
|
||||
|
||||
/**
|
||||
* How often this work will be retried to run after soft (network) errors.
|
||||
*
|
||||
@@ -136,6 +152,9 @@ abstract class BaseSyncWorker(
|
||||
@Inject
|
||||
lateinit var notificationRegistry: NotificationRegistry
|
||||
|
||||
@Inject
|
||||
lateinit var pushNotificationManager: PushNotificationManager
|
||||
|
||||
@Inject
|
||||
lateinit var syncConditionsFactory: SyncConditions.Factory
|
||||
|
||||
@@ -159,9 +178,12 @@ abstract class BaseSyncWorker(
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
// Dismiss any pending push notification
|
||||
pushNotificationManager.dismiss(account, authority)
|
||||
|
||||
try {
|
||||
val accountSettings = try {
|
||||
accountSettingsFactory.forAccount(account)
|
||||
accountSettingsFactory.create(account)
|
||||
} catch (e: InvalidAccountException) {
|
||||
val workId = workerParams.id
|
||||
logger.warning("Account $account doesn't exist anymore, cancelling worker $workId")
|
||||
@@ -194,6 +216,9 @@ abstract class BaseSyncWorker(
|
||||
} finally {
|
||||
logger.info("${javaClass.simpleName} finished for $syncTag")
|
||||
runningSyncs -= syncTag
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 31 && stopReason != WorkInfo.STOP_REASON_NOT_STOPPED)
|
||||
logger.warning("Worker was stopped with reason: $stopReason")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,11 +231,11 @@ abstract class BaseSyncWorker(
|
||||
|
||||
// pass possibly supplied flags to the selected syncer
|
||||
val extrasList = mutableListOf<String>()
|
||||
when (inputData.getInt(OneTimeSyncWorker.ARG_RESYNC, OneTimeSyncWorker.NO_RESYNC)) {
|
||||
OneTimeSyncWorker.RESYNC -> extrasList.add(Syncer.SYNC_EXTRAS_RESYNC)
|
||||
OneTimeSyncWorker.FULL_RESYNC -> extrasList.add(Syncer.SYNC_EXTRAS_FULL_RESYNC)
|
||||
when (inputData.getInt(INPUT_RESYNC, NO_RESYNC)) {
|
||||
RESYNC -> extrasList.add(Syncer.SYNC_EXTRAS_RESYNC)
|
||||
FULL_RESYNC -> extrasList.add(Syncer.SYNC_EXTRAS_FULL_RESYNC)
|
||||
}
|
||||
if (inputData.getBoolean(OneTimeSyncWorker.ARG_UPLOAD, false))
|
||||
if (inputData.getBoolean(INPUT_UPLOAD, false))
|
||||
// Comes in through SyncAdapterService and is used only by ContactsSyncManager for an Android 7 workaround.
|
||||
extrasList.add(ContentResolver.SYNC_EXTRAS_UPLOAD)
|
||||
val extras = extrasList.toTypedArray()
|
||||
|
||||
@@ -5,32 +5,17 @@
|
||||
package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import androidx.annotation.IntDef
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkRequest
|
||||
import androidx.work.WorkerParameters
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.sync.SyncDispatcher
|
||||
import at.bitfire.davdroid.sync.SyncUtils
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* One-time sync worker.
|
||||
@@ -48,15 +33,6 @@ class OneTimeSyncWorker @AssistedInject constructor(
|
||||
|
||||
companion object {
|
||||
|
||||
const val ARG_UPLOAD = "upload"
|
||||
|
||||
const val ARG_RESYNC = "resync"
|
||||
@IntDef(NO_RESYNC, RESYNC, FULL_RESYNC)
|
||||
annotation class ArgResync
|
||||
const val NO_RESYNC = 0
|
||||
const val RESYNC = 1
|
||||
const val FULL_RESYNC = 2
|
||||
|
||||
/**
|
||||
* Unique work name of this worker. Can also be used as tag.
|
||||
*
|
||||
@@ -69,87 +45,6 @@ class OneTimeSyncWorker @AssistedInject constructor(
|
||||
fun workerName(account: Account, authority: String): String =
|
||||
"onetime-sync $authority ${account.type}/${account.name}"
|
||||
|
||||
/**
|
||||
* Requests immediate synchronization of an account with all applicable
|
||||
* authorities (contacts, calendars, …).
|
||||
*
|
||||
* @see enqueue
|
||||
*/
|
||||
fun enqueueAllAuthorities(
|
||||
context: Context,
|
||||
account: Account,
|
||||
manual: Boolean = false,
|
||||
@ArgResync resync: Int = NO_RESYNC,
|
||||
upload: Boolean = false
|
||||
) {
|
||||
for (authority in SyncUtils.syncAuthorities(context))
|
||||
enqueue(context, account, authority, manual = manual, resync = resync, upload = upload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests immediate synchronization of an account with a specific authority.
|
||||
*
|
||||
* @param account account to sync
|
||||
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY])
|
||||
* @param manual user-initiated sync (ignores network checks)
|
||||
* @param resync whether to request (full) re-synchronization or not
|
||||
* @param upload see [ContentResolver.SYNC_EXTRAS_UPLOAD] used only for contacts sync
|
||||
* and android 7 workaround
|
||||
* @return existing or newly created worker name
|
||||
*/
|
||||
fun enqueue(
|
||||
context: Context,
|
||||
account: Account,
|
||||
authority: String,
|
||||
manual: Boolean = false,
|
||||
@ArgResync resync: Int = NO_RESYNC,
|
||||
upload: Boolean = false
|
||||
): String {
|
||||
// Worker arguments
|
||||
val argumentsBuilder = Data.Builder()
|
||||
.putString(INPUT_AUTHORITY, authority)
|
||||
.putString(INPUT_ACCOUNT_NAME, account.name)
|
||||
.putString(INPUT_ACCOUNT_TYPE, account.type)
|
||||
if (manual)
|
||||
argumentsBuilder.putBoolean(INPUT_MANUAL, true)
|
||||
if (resync != NO_RESYNC)
|
||||
argumentsBuilder.putInt(ARG_RESYNC, resync)
|
||||
argumentsBuilder.putBoolean(ARG_UPLOAD, upload)
|
||||
|
||||
// build work request
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED) // require a network connection
|
||||
.build()
|
||||
val workRequest = OneTimeWorkRequestBuilder<OneTimeSyncWorker>()
|
||||
.addTag(workerName(account, authority))
|
||||
.addTag(commonTag(account, authority))
|
||||
.setInputData(argumentsBuilder.build())
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS, // 30 sec
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
|
||||
/* OneTimeSyncWorker is started by user or sync framework when there are local changes.
|
||||
In both cases, synchronization should be done as soon as possible, so we set expedited. */
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
|
||||
// enqueue and start syncing
|
||||
val name = workerName(account, authority)
|
||||
val request = workRequest.build()
|
||||
Logger.getGlobal().log(Level.INFO, "Enqueueing unique worker: $name, tags = ${request.tags}")
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
name,
|
||||
/* If sync is already running, just continue.
|
||||
Existing retried work will not be replaced (for instance when
|
||||
PeriodicSyncWorker enqueues another scheduled sync). */
|
||||
ExistingWorkPolicy.KEEP,
|
||||
request
|
||||
)
|
||||
return name
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,22 +6,14 @@ package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.Operation
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import at.bitfire.davdroid.sync.SyncDispatcher
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Handles scheduled sync requests.
|
||||
@@ -46,6 +38,12 @@ class PeriodicSyncWorker @AssistedInject constructor(
|
||||
syncDispatcher: SyncDispatcher
|
||||
) : BaseSyncWorker(appContext, workerParams, syncDispatcher.dispatcher) {
|
||||
|
||||
@AssistedFactory
|
||||
@VisibleForTesting
|
||||
interface Factory {
|
||||
fun create(appContext: Context, workerParams: WorkerParameters): PeriodicSyncWorker
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
@@ -60,59 +58,6 @@ class PeriodicSyncWorker @AssistedInject constructor(
|
||||
fun workerName(account: Account, authority: String): String =
|
||||
"periodic-sync $authority ${account.type}/${account.name}"
|
||||
|
||||
/**
|
||||
* Activate scheduled synchronization of an account with a specific authority.
|
||||
*
|
||||
* @param account account to sync
|
||||
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]])
|
||||
* @param interval interval between recurring syncs in seconds
|
||||
* @return operation object to check when and whether activation was successful
|
||||
*/
|
||||
fun enable(context: Context, account: Account, authority: String, interval: Long, syncWifiOnly: Boolean): Operation {
|
||||
val arguments = Data.Builder()
|
||||
.putString(INPUT_AUTHORITY, authority)
|
||||
.putString(INPUT_ACCOUNT_NAME, account.name)
|
||||
.putString(INPUT_ACCOUNT_TYPE, account.type)
|
||||
.build()
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(
|
||||
if (syncWifiOnly)
|
||||
NetworkType.UNMETERED
|
||||
else
|
||||
NetworkType.CONNECTED
|
||||
).build()
|
||||
val workRequest = PeriodicWorkRequestBuilder<PeriodicSyncWorker>(interval, TimeUnit.SECONDS)
|
||||
.addTag(workerName(account, authority))
|
||||
.addTag(commonTag(account, authority))
|
||||
.setInputData(arguments)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
return WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
workerName(account, authority),
|
||||
// if a periodic sync exists already, we want to update it with the new interval
|
||||
// and/or new required network type (applies on next iteration of periodic worker)
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
workRequest
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables scheduled synchronization of an account for a specific authority.
|
||||
*
|
||||
* @param account account to sync
|
||||
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]])
|
||||
* @return operation object to check process state of work cancellation
|
||||
*/
|
||||
fun disable(context: Context, account: Account, authority: String): Operation =
|
||||
WorkManager.getInstance(context)
|
||||
.cancelUniqueWork(workerName(account, authority))
|
||||
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
@VisibleForTesting
|
||||
interface Factory {
|
||||
fun create(appContext: Context, workerParams: WorkerParameters): PeriodicSyncWorker
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.sync.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import androidx.work.BackoffPolicy
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.Operation
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkRequest
|
||||
import at.bitfire.davdroid.push.PushNotificationManager
|
||||
import at.bitfire.davdroid.sync.SyncUtils
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_ACCOUNT_NAME
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_ACCOUNT_TYPE
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_AUTHORITY
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_MANUAL
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_RESYNC
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.INPUT_UPLOAD
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.InputResync
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.NO_RESYNC
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker.Companion.commonTag
|
||||
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker.Companion.workerName
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* For building and managing synchronization workers (both one-time and periodic).
|
||||
*
|
||||
* One-time sync workers can be enqueued. Periodic sync workers can be enabled and disabled.
|
||||
*/
|
||||
class SyncWorkerManager @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
val logger: Logger,
|
||||
val pushNotificationManager: PushNotificationManager
|
||||
) {
|
||||
|
||||
// one-time sync workers
|
||||
|
||||
/**
|
||||
* Builds a one-time sync worker for a specific account and authority.
|
||||
*
|
||||
* Arguments: see [enqueueOneTime]
|
||||
*
|
||||
* @return one-time sync work request for the given arguments
|
||||
*/
|
||||
fun buildOneTime(
|
||||
account: Account,
|
||||
authority: String,
|
||||
manual: Boolean = false,
|
||||
@InputResync resync: Int = NO_RESYNC,
|
||||
upload: Boolean = false
|
||||
): OneTimeWorkRequest {
|
||||
// worker arguments
|
||||
val argumentsBuilder = Data.Builder()
|
||||
.putString(INPUT_AUTHORITY, authority)
|
||||
.putString(INPUT_ACCOUNT_NAME, account.name)
|
||||
.putString(INPUT_ACCOUNT_TYPE, account.type)
|
||||
if (manual)
|
||||
argumentsBuilder.putBoolean(INPUT_MANUAL, true)
|
||||
if (resync != NO_RESYNC)
|
||||
argumentsBuilder.putInt(INPUT_RESYNC, resync)
|
||||
argumentsBuilder.putBoolean(INPUT_UPLOAD, upload)
|
||||
|
||||
// build work request
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED) // require a network connection
|
||||
.build()
|
||||
return OneTimeWorkRequestBuilder<OneTimeSyncWorker>()
|
||||
.addTag(workerName(account, authority))
|
||||
.addTag(commonTag(account, authority))
|
||||
.setInputData(argumentsBuilder.build())
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS, // 30 sec
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
|
||||
/* OneTimeSyncWorker is started by user or sync framework when there are local changes.
|
||||
In both cases, synchronization should be done as soon as possible, so we set expedited. */
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
|
||||
// build work request
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests immediate synchronization of an account with a specific authority.
|
||||
*
|
||||
* @param account account to sync
|
||||
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY])
|
||||
* @param manual user-initiated sync (ignores network checks)
|
||||
* @param resync whether to request (full) re-synchronization or not
|
||||
* @param upload see [ContentResolver.SYNC_EXTRAS_UPLOAD] – only used for contacts sync and Android 7 workaround
|
||||
* @param fromPush whether this sync is initiated by a push notification
|
||||
*
|
||||
* @return existing or newly created worker name
|
||||
*/
|
||||
fun enqueueOneTime(
|
||||
account: Account,
|
||||
authority: String,
|
||||
manual: Boolean = false,
|
||||
@InputResync resync: Int = NO_RESYNC,
|
||||
upload: Boolean = false,
|
||||
fromPush: Boolean = false
|
||||
): String {
|
||||
// enqueue and start syncing
|
||||
val name = workerName(account, authority)
|
||||
val request = buildOneTime(
|
||||
account = account,
|
||||
authority = authority,
|
||||
manual = manual,
|
||||
resync = resync,
|
||||
upload = upload
|
||||
)
|
||||
if (fromPush) {
|
||||
logger.fine("Showing push sync pending notification for $name")
|
||||
pushNotificationManager.notify(account, authority)
|
||||
}
|
||||
logger.info("Enqueueing unique worker: $name, tags = ${request.tags}")
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
name,
|
||||
/* If sync is already running, just continue.
|
||||
Existing retried work will not be replaced (for instance when
|
||||
PeriodicSyncWorker enqueues another scheduled sync). */
|
||||
ExistingWorkPolicy.KEEP,
|
||||
request
|
||||
)
|
||||
return name
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests immediate synchronization of an account with all applicable
|
||||
* authorities (contacts, calendars, …).
|
||||
*
|
||||
* Arguments: see [enqueueOneTime]
|
||||
*/
|
||||
fun enqueueOneTimeAllAuthorities(
|
||||
account: Account,
|
||||
manual: Boolean = false,
|
||||
@InputResync resync: Int = NO_RESYNC,
|
||||
upload: Boolean = false,
|
||||
fromPush: Boolean = false
|
||||
) {
|
||||
for (authority in SyncUtils.syncAuthorities(context))
|
||||
enqueueOneTime(
|
||||
account = account,
|
||||
authority = authority,
|
||||
manual = manual,
|
||||
resync = resync,
|
||||
upload = upload,
|
||||
fromPush = fromPush
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// periodic sync workers
|
||||
|
||||
/**
|
||||
* Builds a periodic sync worker for a specific account and authority.
|
||||
*
|
||||
* Arguments: see [enablePeriodic]
|
||||
*
|
||||
* @return periodic sync work request for the given arguments
|
||||
*/
|
||||
fun buildPeriodic(account: Account, authority: String, interval: Long, syncWifiOnly: Boolean): PeriodicWorkRequest {
|
||||
val arguments = Data.Builder()
|
||||
.putString(INPUT_AUTHORITY, authority)
|
||||
.putString(INPUT_ACCOUNT_NAME, account.name)
|
||||
.putString(INPUT_ACCOUNT_TYPE, account.type)
|
||||
.build()
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(
|
||||
if (syncWifiOnly)
|
||||
NetworkType.UNMETERED
|
||||
else
|
||||
NetworkType.CONNECTED
|
||||
).build()
|
||||
return PeriodicWorkRequestBuilder<PeriodicSyncWorker>(interval, TimeUnit.SECONDS)
|
||||
.addTag(PeriodicSyncWorker.workerName(account, authority))
|
||||
.addTag(commonTag(account, authority))
|
||||
.setInputData(arguments)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate periodic synchronization of an account with a specific authority.
|
||||
*
|
||||
* @param account account to sync
|
||||
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]])
|
||||
* @param interval interval between recurring syncs in seconds
|
||||
* @return operation object to check when and whether activation was successful
|
||||
*/
|
||||
fun enablePeriodic(account: Account, authority: String, interval: Long, syncWifiOnly: Boolean): Operation {
|
||||
val workRequest = buildPeriodic(account, authority, interval, syncWifiOnly)
|
||||
return WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
PeriodicSyncWorker.workerName(account, authority),
|
||||
// if a periodic sync exists already, we want to update it with the new interval
|
||||
// and/or new required network type (applies on next iteration of periodic worker)
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
workRequest
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables periodic synchronization of an account for a specific authority.
|
||||
*
|
||||
* @param account account to sync
|
||||
* @param authority authority to sync (for instance: [CalendarContract.AUTHORITY]])
|
||||
* @return operation object to check process state of work cancellation
|
||||
*/
|
||||
fun disablePeriodic(account: Account, authority: String): Operation =
|
||||
WorkManager.getInstance(context)
|
||||
.cancelUniqueWork(PeriodicSyncWorker.workerName(account, authority))
|
||||
|
||||
}
|
||||
@@ -284,14 +284,6 @@ fun AboutApp(licenseInfoProvider: AboutActivity.AppLicenseInfoProvider? = null)
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
val buildTime = LocalDateTime.ofEpochSecond(BuildConfig.buildTime / 1000, 0, ZoneOffset.UTC)
|
||||
val dateFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
|
||||
Text(
|
||||
stringResource(R.string.about_build_date, dateFormatter.format(buildTime)),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.about_copyright),
|
||||
|
||||
@@ -21,6 +21,7 @@ import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.sync.SyncUtils
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.davdroid.ui.account.AccountProgress
|
||||
import at.bitfire.davdroid.ui.intro.IntroPage
|
||||
import at.bitfire.davdroid.ui.intro.IntroPageFactory
|
||||
@@ -48,7 +49,8 @@ class AccountsModel @AssistedInject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
private val db: AppDatabase,
|
||||
introPageFactory: IntroPageFactory,
|
||||
private val logger: Logger
|
||||
private val logger: Logger,
|
||||
private val syncWorkerManager: SyncWorkerManager
|
||||
): ViewModel() {
|
||||
|
||||
@AssistedFactory
|
||||
@@ -213,7 +215,7 @@ class AccountsModel @AssistedInject constructor(
|
||||
|
||||
// Enqueue sync worker for all accounts and authorities. Will sync once internet is available
|
||||
for (account in accountRepository.getAll())
|
||||
OneTimeSyncWorker.enqueueAllAuthorities(context, account, manual = true)
|
||||
syncWorkerManager.enqueueOneTimeAllAuthorities(account, manual = true)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -43,19 +43,19 @@ import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -73,6 +73,7 @@ import at.bitfire.davdroid.ui.composable.ProgressBar
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@@ -140,13 +141,11 @@ fun AccountsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val refreshState = rememberPullToRefreshState(
|
||||
enabled = { showSyncAll }
|
||||
)
|
||||
LaunchedEffect(refreshState.isRefreshing) {
|
||||
if (refreshState.isRefreshing) {
|
||||
onSyncAll()
|
||||
refreshState.endRefresh()
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
delay(300)
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,11 +224,14 @@ fun AccountsScreen(
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { padding ->
|
||||
Box(Modifier.padding(padding)) {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = { isRefreshing = true; onSyncAll() },
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(refreshState.nestedScrollConnection)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
// background image
|
||||
@@ -293,12 +295,6 @@ fun AccountsScreen(
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// indicate when the user pulls down
|
||||
PullToRefreshContainer(
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
state = refreshState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,7 +312,9 @@ fun AccountsScreen_Preview_Empty() {
|
||||
Text("Menu entries")
|
||||
}
|
||||
},
|
||||
accounts = emptyList()
|
||||
accounts = emptyList(),
|
||||
showAddAccount = AccountsModel.FABStyle.WithText,
|
||||
showSyncAll = false
|
||||
)
|
||||
}
|
||||
|
||||
@@ -347,12 +345,19 @@ fun AccountList(
|
||||
) {
|
||||
Column(modifier) {
|
||||
if (accounts.isEmpty())
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.account_list_welcome),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp, bottom = 32.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.account_list_empty),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
|
||||
@@ -8,10 +8,14 @@ import android.app.Activity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import at.bitfire.davdroid.ui.composable.SafeAndroidUriHandler
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
@@ -32,8 +36,11 @@ fun AppTheme(
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content
|
||||
)
|
||||
val uriHandler = SafeAndroidUriHandler(LocalContext.current)
|
||||
CompositionLocalProvider(LocalUriHandler provides uriHandler) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.text.format.DateUtils
|
||||
import android.text.format.Formatter
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -127,7 +128,7 @@ class DebugInfoModel @AssistedInject constructor(
|
||||
// create debug info directory
|
||||
val debugDir = LogFileHandler.debugDir(context) ?: throw IOException("Couldn't create debug info directory")
|
||||
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
// create log file from EXTRA_LOGS or log file
|
||||
if (details.logs != null) {
|
||||
val file = File(debugDir, FILE_LOGS)
|
||||
@@ -148,7 +149,6 @@ class DebugInfoModel @AssistedInject constructor(
|
||||
localResource = details.localResource,
|
||||
remoteResource = details.remoteResource
|
||||
)
|
||||
|
||||
generateDebugInfo(
|
||||
syncAccount = details.account,
|
||||
syncAuthority = details.authority,
|
||||
@@ -164,6 +164,7 @@ class DebugInfoModel @AssistedInject constructor(
|
||||
*
|
||||
* Note: Part of this method and all of it's helpers (listed below) should probably be extracted in the future
|
||||
*/
|
||||
@WorkerThread
|
||||
private fun generateDebugInfo(syncAccount: Account?, syncAuthority: String?, cause: Throwable?, localResource: String?, remoteResource: String?) {
|
||||
val debugInfoFile = File(LogFileHandler.debugDir(context), FILE_DEBUG_INFO)
|
||||
debugInfoFile.printWriter().use { writer ->
|
||||
@@ -239,9 +240,9 @@ class DebugInfoModel @AssistedInject constructor(
|
||||
val info = pm.getPackageInfo(packageName, 0)
|
||||
val appInfo = info.applicationInfo
|
||||
val notes = mutableListOf<String>()
|
||||
if (!appInfo.enabled)
|
||||
if (appInfo?.enabled == false)
|
||||
notes += "disabled"
|
||||
if (appInfo.flags.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0)
|
||||
if (appInfo?.flags?.and(ApplicationInfo.FLAG_EXTERNAL_STORAGE) != 0)
|
||||
notes += "<em>on external storage</em>"
|
||||
table.addLine(
|
||||
info.packageName, info.versionName, PackageInfoCompat.getLongVersionCode(info),
|
||||
@@ -257,7 +258,7 @@ class DebugInfoModel @AssistedInject constructor(
|
||||
// system info
|
||||
val locales: Any = LocaleList.getAdjustedDefault()
|
||||
writer.append(
|
||||
"\nSYSTEM INFORMATION\n\n" +
|
||||
"\n\nSYSTEM INFORMATION\n\n" +
|
||||
"Android version: ${Build.VERSION.RELEASE} (${Build.DISPLAY})\n" +
|
||||
"Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})\n\n" +
|
||||
"Locale(s): $locales\n" +
|
||||
@@ -305,7 +306,7 @@ class DebugInfoModel @AssistedInject constructor(
|
||||
|
||||
// connectivity
|
||||
context.getSystemService<ConnectivityManager>()?.let { connectivityManager ->
|
||||
writer.append("\nCONNECTIVITY\n\n")
|
||||
writer.append("\n\nCONNECTIVITY\n\n")
|
||||
val activeNetwork = connectivityManager.activeNetwork
|
||||
connectivityManager.allNetworks.sortedByDescending { it == activeNetwork }.forEach { network ->
|
||||
val properties = connectivityManager.getLinkProperties(network)
|
||||
@@ -340,7 +341,7 @@ class DebugInfoModel @AssistedInject constructor(
|
||||
writer.append('\n')
|
||||
}
|
||||
|
||||
writer.append("\nCONFIGURATION\n\n")
|
||||
writer.append("\n\nCONFIGURATION\n")
|
||||
// notifications
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
writer.append("\nNotifications")
|
||||
@@ -366,7 +367,7 @@ class DebugInfoModel @AssistedInject constructor(
|
||||
// permissions
|
||||
writer.append("Permissions:\n")
|
||||
val ownPkgInfo = context.packageManager.getPackageInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_PERMISSIONS)
|
||||
for (permission in ownPkgInfo.requestedPermissions) {
|
||||
for (permission in ownPkgInfo.requestedPermissions.orEmpty()) {
|
||||
val shortPermission = permission.removePrefix("android.permission.")
|
||||
writer.append(" - $shortPermission: ")
|
||||
.append(
|
||||
@@ -379,34 +380,21 @@ class DebugInfoModel @AssistedInject constructor(
|
||||
}
|
||||
writer.append('\n')
|
||||
|
||||
// accounts (grouped by main account)
|
||||
writer.append("\nACCOUNTS\n\n")
|
||||
// accounts
|
||||
writer.append("\nACCOUNTS")
|
||||
val accountManager = AccountManager.get(context)
|
||||
val addressBookAccounts = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).toMutableList()
|
||||
for (account in accountRepository.getAll()) {
|
||||
dumpMainAccount(account, writer)
|
||||
for (account in accountRepository.getAll())
|
||||
dumpAccount(account, writer)
|
||||
|
||||
val iter = addressBookAccounts.iterator()
|
||||
while (iter.hasNext()) {
|
||||
val addressBookAccount = iter.next()
|
||||
val mainAccount = Account(
|
||||
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_MAIN_ACCOUNT_NAME),
|
||||
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_MAIN_ACCOUNT_TYPE)
|
||||
)
|
||||
if (mainAccount == account) {
|
||||
dumpAddressBookAccount(addressBookAccount, accountManager, writer)
|
||||
iter.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
val addressBookAccounts = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).toMutableList()
|
||||
if (addressBookAccounts.isNotEmpty()) {
|
||||
writer.append("Address book accounts without main account:\n")
|
||||
writer.append("ADDRESS BOOK ACCOUNTS\n\n")
|
||||
for (account in addressBookAccounts)
|
||||
dumpAddressBookAccount(account, accountManager, writer)
|
||||
}
|
||||
|
||||
// database dump
|
||||
writer.append("\nDATABASE DUMP\n\n")
|
||||
writer.append("\n\nDATABASE DUMP\n\n")
|
||||
db.dump(writer, arrayOf("webdav_document"))
|
||||
|
||||
// app settings
|
||||
@@ -473,12 +461,13 @@ class DebugInfoModel @AssistedInject constructor(
|
||||
*
|
||||
* Note: Helper method of [generateDebugInfo].
|
||||
*/
|
||||
private fun dumpMainAccount(account: Account, writer: Writer) {
|
||||
@WorkerThread
|
||||
private fun dumpAccount(account: Account, writer: Writer) {
|
||||
writer.append("\n\n - Account: ${account.name}\n")
|
||||
writer.append(dumpAccount(account, AccountDumpInfo.mainAccount(context, account)))
|
||||
try {
|
||||
val accountSettings = accountSettingsFactory.forAccount(account)
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
|
||||
writer.append(dumpAccount(account, accountSettings, AccountDumpInfo.caldavAccount(context, account)))
|
||||
try {
|
||||
val credentials = accountSettings.credentials()
|
||||
val authStr = mutableListOf<String>()
|
||||
if (credentials.username != null)
|
||||
@@ -501,10 +490,10 @@ class DebugInfoModel @AssistedInject constructor(
|
||||
}
|
||||
writer.append(
|
||||
"\n Contact group method: ${accountSettings.getGroupMethod()}\n" +
|
||||
" Time range (past days): ${accountSettings.getTimeRangePastDays()}\n" +
|
||||
" Default alarm (min before): ${accountSettings.getDefaultAlarm()}\n" +
|
||||
" Manage calendar colors: ${accountSettings.getManageCalendarColors()}\n" +
|
||||
" Use event colors: ${accountSettings.getEventColors()}\n"
|
||||
" Time range (past days): ${accountSettings.getTimeRangePastDays()}\n" +
|
||||
" Default alarm (min before): ${accountSettings.getDefaultAlarm()}\n" +
|
||||
" Manage calendar colors: ${accountSettings.getManageCalendarColors()}\n" +
|
||||
" Use event colors: ${accountSettings.getEventColors()}\n"
|
||||
)
|
||||
|
||||
writer.append("\nSync workers:\n")
|
||||
@@ -523,10 +512,11 @@ class DebugInfoModel @AssistedInject constructor(
|
||||
*/
|
||||
private fun dumpAddressBookAccount(account: Account, accountManager: AccountManager, writer: Writer) {
|
||||
writer.append(" * Address book: ${account.name}\n")
|
||||
val table = dumpAccount(account, AccountDumpInfo.addressBookAccount(account))
|
||||
val table = dumpAccount(account, null, AccountDumpInfo.addressBookAccount(account))
|
||||
writer.append(TextTable.indent(table, 4))
|
||||
.append("URL: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_URL)}\n")
|
||||
.append(" Read-only: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_READ_ONLY) ?: 0}\n")
|
||||
.append("Collection ID: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_COLLECTION_ID)}\n")
|
||||
.append(" URL: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_URL)}\n")
|
||||
.append(" Read-only: ${accountManager.getUserData(account, LocalAddressBook.USER_DATA_READ_ONLY) ?: 0}\n\n")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -536,7 +526,7 @@ class DebugInfoModel @AssistedInject constructor(
|
||||
*
|
||||
* @return the requested information
|
||||
*/
|
||||
private fun dumpAccount(account: Account, infos: Iterable<AccountDumpInfo>): String {
|
||||
private fun dumpAccount(account: Account, accountSettings: AccountSettings?, infos: Iterable<AccountDumpInfo>): String {
|
||||
val table = TextTable("Authority", "isSyncable", "syncAutomatically", "Interval", "Entries")
|
||||
for (info in infos) {
|
||||
var nrEntries = "—"
|
||||
@@ -550,12 +540,11 @@ class DebugInfoModel @AssistedInject constructor(
|
||||
} catch (e: Exception) {
|
||||
nrEntries = e.toString()
|
||||
}
|
||||
val accountSettings = accountSettingsFactory.forAccount(account)
|
||||
table.addLine(
|
||||
info.authority,
|
||||
ContentResolver.getIsSyncable(account, info.authority),
|
||||
ContentResolver.getSyncAutomatically(account, info.authority), // content-triggered sync
|
||||
accountSettings.getSyncInterval(info.authority)?.takeIf { it >= 0 }?.let {"${it/60} min"},
|
||||
accountSettings?.getSyncInterval(info.authority)?.takeIf { it >= 0 }?.let {"${it/60} min"},
|
||||
nrEntries
|
||||
)
|
||||
}
|
||||
@@ -612,7 +601,7 @@ data class AccountDumpInfo(
|
||||
|
||||
companion object {
|
||||
|
||||
fun mainAccount(context: Context, account: Account) = listOf(
|
||||
fun caldavAccount(context: Context, account: Account) = listOf(
|
||||
AccountDumpInfo(account, context.getString(R.string.address_books_authority), null, null),
|
||||
AccountDumpInfo(account, CalendarContract.AUTHORITY, CalendarContract.Events.CONTENT_URI.asCalendarSyncAdapter(account), "event(s)"),
|
||||
AccountDumpInfo(account, TaskProvider.ProviderName.JtxBoard.authority, JtxContract.JtxICalObject.CONTENT_URI.asJtxSyncAdapter(account), "jtx Board ICalObject(s)"),
|
||||
|
||||
@@ -151,7 +151,6 @@ class NotificationRegistry @Inject constructor(
|
||||
logger.warning("Notifications disabled, not showing notification $id")
|
||||
}
|
||||
|
||||
|
||||
// specific common notifications
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,12 +11,18 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -35,21 +41,26 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
|
||||
import at.bitfire.davdroid.ui.composable.BasicTopAppBar
|
||||
import at.bitfire.davdroid.ui.composable.CardWithImage
|
||||
import at.bitfire.davdroid.ui.composable.RadioWithSwitch
|
||||
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TasksScreen(onNavUp: () -> Unit) {
|
||||
AppTheme {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
BasicTopAppBar(
|
||||
titleStringRes = R.string.intro_tasks_title,
|
||||
onNavigateUp = onNavUp
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.intro_tasks_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onNavUp
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
@@ -159,7 +170,7 @@ fun TasksCard(
|
||||
stringResource(R.string.intro_tasks_tasks_org_info),
|
||||
HtmlCompat.FROM_HTML_MODE_COMPACT
|
||||
).toAnnotatedString()
|
||||
ClickableTextWithLink(summary)
|
||||
Text(summary)
|
||||
},
|
||||
isSelected = tasksOrgSelected,
|
||||
isToggled = tasksOrgInstalled,
|
||||
|
||||
@@ -25,9 +25,8 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.ExperimentalTextApi
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.UrlAnnotation
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -51,14 +50,12 @@ object UiUtils {
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface UiUtilsEntryPoint {
|
||||
fun logger(): Logger
|
||||
fun settingsManager(): SettingsManager
|
||||
}
|
||||
|
||||
const val SHORTCUT_SYNC_ALL = "syncAllAccounts"
|
||||
|
||||
private val logger: Logger
|
||||
get() = Logger.getGlobal()
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
fun adaptiveIconPainterResource(@DrawableRes id: Int): Painter {
|
||||
@@ -93,13 +90,12 @@ object UiUtils {
|
||||
.build()
|
||||
)
|
||||
} catch(e: Exception) {
|
||||
val logger = EntryPointAccessors.fromApplication(context, UiUtilsEntryPoint::class.java).logger()
|
||||
logger.log(Level.WARNING, "Couldn't update dynamic shortcut(s)", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalTextApi::class)
|
||||
@Composable
|
||||
fun Spanned.toAnnotatedString() = buildAnnotatedString {
|
||||
val spanned = this@toAnnotatedString
|
||||
@@ -121,8 +117,8 @@ object UiUtils {
|
||||
)
|
||||
}
|
||||
is URLSpan -> {
|
||||
addUrlAnnotation(
|
||||
UrlAnnotation(span.url),
|
||||
addLink(
|
||||
LinkAnnotation.Url(span.url),
|
||||
start = start, end = end
|
||||
)
|
||||
addStyle(
|
||||
@@ -133,8 +129,11 @@ object UiUtils {
|
||||
start = start, end = end
|
||||
)
|
||||
}
|
||||
else ->
|
||||
else -> {
|
||||
val context = LocalContext.current
|
||||
val logger = EntryPointAccessors.fromApplication(context, UiUtilsEntryPoint::class.java).logger()
|
||||
logger.warning("Ignoring unknown span type ${span.javaClass.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.accounts.Account
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -29,7 +28,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -41,8 +40,7 @@ import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -54,12 +52,12 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -79,6 +77,7 @@ import at.bitfire.davdroid.ui.composable.ProgressBar
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@@ -201,14 +200,6 @@ fun AccountScreen(
|
||||
if (invalidAccount)
|
||||
onFinish()
|
||||
|
||||
val pullRefreshState = rememberPullToRefreshState()
|
||||
LaunchedEffect(pullRefreshState.isRefreshing) {
|
||||
if (pullRefreshState.isRefreshing) {
|
||||
onSync()
|
||||
pullRefreshState.endRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
LaunchedEffect(error) {
|
||||
if (error != null)
|
||||
@@ -218,6 +209,14 @@ fun AccountScreen(
|
||||
}
|
||||
}
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
delay(300)
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
// tabs calculation
|
||||
var nextIdx = -1
|
||||
|
||||
@@ -298,70 +297,71 @@ fun AccountScreen(
|
||||
SnackbarHost(snackbarHostState)
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
Modifier
|
||||
.padding(padding)
|
||||
.nestedScroll(pullRefreshState.nestedScrollConnection)
|
||||
Column(
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
Column {
|
||||
if (nrPages > 0) {
|
||||
TabRow(selectedTabIndex = pagerState.currentPage) {
|
||||
if (idxCalDav != null) {
|
||||
Tab(
|
||||
selected = pagerState.currentPage == idxCalDav,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pagerState.scrollToPage(idxCalDav)
|
||||
}
|
||||
if (nrPages > 0) {
|
||||
TabRow(selectedTabIndex = pagerState.currentPage) {
|
||||
if (idxCalDav != null) {
|
||||
Tab(
|
||||
selected = pagerState.currentPage == idxCalDav,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pagerState.scrollToPage(idxCalDav)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.account_caldav),
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (idxCardDav != null) {
|
||||
Tab(
|
||||
selected = pagerState.currentPage == idxCardDav,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pagerState.scrollToPage(idxCardDav)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.account_carddav),
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (idxWebcal != null) {
|
||||
Tab(
|
||||
selected = pagerState.currentPage == idxWebcal,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pagerState.scrollToPage(idxWebcal)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.account_webcal),
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.account_caldav),
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
pagerState,
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) { index ->
|
||||
if (idxCardDav != null) {
|
||||
Tab(
|
||||
selected = pagerState.currentPage == idxCardDav,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pagerState.scrollToPage(idxCardDav)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.account_carddav),
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (idxWebcal != null) {
|
||||
Tab(
|
||||
selected = pagerState.currentPage == idxWebcal,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pagerState.scrollToPage(idxWebcal)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.account_webcal),
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
pagerState,
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) { index ->
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = { isRefreshing = true; onSync() }
|
||||
) {
|
||||
when (index) {
|
||||
idxCardDav ->
|
||||
AccountScreen_ServiceTab(
|
||||
@@ -424,18 +424,12 @@ fun AccountScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PullToRefreshContainer(
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun AccountScreen_Actions(
|
||||
accountName: String,
|
||||
canCreateAddressBook: Boolean,
|
||||
@@ -512,7 +506,7 @@ fun AccountScreen_Actions(
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
CompositionLocalProvider(
|
||||
LocalMinimumInteractiveComponentEnforcement provides false
|
||||
LocalMinimumInteractiveComponentSize provides Dp.Unspecified
|
||||
) {
|
||||
Checkbox(
|
||||
checked = showOnlyPersonal.onlyPersonal,
|
||||
|
||||
@@ -24,7 +24,7 @@ import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
@@ -47,14 +47,15 @@ class AccountScreenModel @AssistedInject constructor(
|
||||
@Assisted val account: Account,
|
||||
private val accountRepository: AccountRepository,
|
||||
accountProgressUseCase: AccountProgressUseCase,
|
||||
accountSettingsFactory: AccountSettings.Factory,
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext val context: Context,
|
||||
getBindableHomesetsFromService: GetBindableHomeSetsFromServiceUseCase,
|
||||
getServiceCollectionPager: GetServiceCollectionPagerUseCase,
|
||||
private val logger: Logger,
|
||||
serviceRepository: DavServiceRepository,
|
||||
private val tasksAppManager: TasksAppManager
|
||||
private val syncWorkerManager: SyncWorkerManager,
|
||||
tasksAppManager: TasksAppManager
|
||||
): ViewModel() {
|
||||
|
||||
@AssistedFactory
|
||||
@@ -67,18 +68,19 @@ class AccountScreenModel @AssistedInject constructor(
|
||||
!accounts.contains(account)
|
||||
}
|
||||
|
||||
private val settings = accountSettingsFactory.forAccount(account)
|
||||
private val refreshSettingsSignal = MutableLiveData(Unit)
|
||||
val showOnlyPersonal = refreshSettingsSignal.switchMap<Unit, AccountSettings.ShowOnlyPersonal> {
|
||||
object : LiveData<AccountSettings.ShowOnlyPersonal>() {
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val settings = accountSettingsFactory.create(account)
|
||||
postValue(settings.getShowOnlyPersonal())
|
||||
}
|
||||
}
|
||||
}
|
||||
}.asFlow()
|
||||
fun setShowOnlyPersonal(showOnlyPersonal: Boolean) = viewModelScope.launch(Dispatchers.IO) {
|
||||
val settings = accountSettingsFactory.create(account)
|
||||
settings.setShowOnlyPersonal(showOnlyPersonal)
|
||||
refreshSettingsSignal.postValue(Unit)
|
||||
}
|
||||
@@ -162,7 +164,7 @@ class AccountScreenModel @AssistedInject constructor(
|
||||
|
||||
// synchronize again
|
||||
val newAccount = Account(context.getString(R.string.account_type), newName)
|
||||
OneTimeSyncWorker.enqueueAllAuthorities(context, newAccount, manual = true)
|
||||
syncWorkerManager.enqueueOneTimeAllAuthorities(newAccount, manual = true)
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't rename account", e)
|
||||
error = e.localizedMessage
|
||||
@@ -177,7 +179,7 @@ class AccountScreenModel @AssistedInject constructor(
|
||||
}
|
||||
|
||||
fun sync() {
|
||||
OneTimeSyncWorker.enqueueAllAuthorities(context, account, manual = true)
|
||||
syncWorkerManager.enqueueOneTimeAllAuthorities(account, manual = true)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,18 +3,15 @@ package at.bitfire.davdroid.ui.account
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Credentials
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.sync.Syncer
|
||||
import at.bitfire.davdroid.sync.TasksAppManager
|
||||
import at.bitfire.davdroid.sync.worker.OneTimeSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.BaseSyncWorker
|
||||
import at.bitfire.davdroid.sync.worker.SyncWorkerManager
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.assisted.Assisted
|
||||
@@ -24,17 +21,22 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.logging.Logger
|
||||
|
||||
@HiltViewModel(assistedFactory = AccountSettingsModel.Factory::class)
|
||||
class AccountSettingsModel @AssistedInject constructor(
|
||||
@Assisted val account: Account,
|
||||
accountSettingsFactory: AccountSettings.Factory,
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
@ApplicationContext val context: Context,
|
||||
private val logger: Logger,
|
||||
private val settings: SettingsManager,
|
||||
private val tasksAppManager: TasksAppManager
|
||||
private val syncWorkerManager: SyncWorkerManager,
|
||||
tasksAppManager: TasksAppManager
|
||||
): ViewModel(), SettingsManager.OnChangeListener {
|
||||
|
||||
@AssistedFactory
|
||||
@@ -42,32 +44,42 @@ class AccountSettingsModel @AssistedInject constructor(
|
||||
fun create(account: Account): AccountSettingsModel
|
||||
}
|
||||
|
||||
private val accountSettings = accountSettingsFactory.forAccount(account)
|
||||
|
||||
// settings
|
||||
var syncIntervalContacts by mutableStateOf<Long?>(null)
|
||||
var syncIntervalCalendars by mutableStateOf<Long?>(null)
|
||||
data class UiState(
|
||||
val syncIntervalContacts: Long? = null,
|
||||
val syncIntervalCalendars: Long? = null,
|
||||
val syncIntervalTasks: Long? = null,
|
||||
|
||||
val syncWifiOnly: Boolean = false,
|
||||
val syncWifiOnlySSIDs: List<String>? = null,
|
||||
val ignoreVpns: Boolean = false,
|
||||
|
||||
val credentials: Credentials = Credentials(),
|
||||
|
||||
val timeRangePastDays: Int? = null,
|
||||
val defaultAlarmMinBefore: Int? = null,
|
||||
val manageCalendarColors: Boolean = false,
|
||||
val eventColors: Boolean = false,
|
||||
|
||||
val contactGroupMethod: GroupMethod = GroupMethod.GROUP_VCARDS
|
||||
)
|
||||
|
||||
private val _uiState = MutableStateFlow(UiState())
|
||||
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
|
||||
|
||||
private val tasksProvider = tasksAppManager.currentProvider()
|
||||
var syncIntervalTasks by mutableStateOf<Long?>(null)
|
||||
|
||||
var syncWifiOnly by mutableStateOf(false)
|
||||
var syncWifiOnlySSIDs by mutableStateOf<List<String>?>(null)
|
||||
var ignoreVpns by mutableStateOf(false)
|
||||
|
||||
var credentials by mutableStateOf(Credentials())
|
||||
|
||||
var timeRangePastDays by mutableStateOf<Int?>(null)
|
||||
var defaultAlarmMinBefore by mutableStateOf<Int?>(null)
|
||||
var manageCalendarColors by mutableStateOf(false)
|
||||
var eventColors by mutableStateOf(false)
|
||||
|
||||
var contactGroupMethod by mutableStateOf(GroupMethod.GROUP_VCARDS)
|
||||
/**
|
||||
* Only acquire account settings on a worker thread!
|
||||
*/
|
||||
private val accountSettings by lazy { accountSettingsFactory.create(account) }
|
||||
|
||||
|
||||
init {
|
||||
settings.addOnChangeListener(this)
|
||||
reload()
|
||||
viewModelScope.launch {
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
@@ -76,32 +88,32 @@ class AccountSettingsModel @AssistedInject constructor(
|
||||
}
|
||||
|
||||
override fun onSettingsChanged() {
|
||||
reload()
|
||||
}
|
||||
|
||||
private fun reload() {
|
||||
logger.info("Reloading settings")
|
||||
|
||||
Snapshot.withMutableSnapshot {
|
||||
syncIntervalContacts = accountSettings.getSyncInterval(context.getString(R.string.address_books_authority))
|
||||
syncIntervalCalendars = accountSettings.getSyncInterval(CalendarContract.AUTHORITY)
|
||||
syncIntervalTasks = tasksProvider?.let { accountSettings.getSyncInterval(it.authority) }
|
||||
|
||||
syncWifiOnly = accountSettings.getSyncWifiOnly()
|
||||
syncWifiOnlySSIDs = accountSettings.getSyncWifiOnlySSIDs()
|
||||
ignoreVpns = accountSettings.getIgnoreVpns()
|
||||
|
||||
credentials = accountSettings.credentials()
|
||||
|
||||
timeRangePastDays = accountSettings.getTimeRangePastDays()
|
||||
defaultAlarmMinBefore = accountSettings.getDefaultAlarm()
|
||||
manageCalendarColors = accountSettings.getManageCalendarColors()
|
||||
eventColors = accountSettings.getEventColors()
|
||||
|
||||
contactGroupMethod = accountSettings.getGroupMethod()
|
||||
viewModelScope.launch {
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reload() = withContext(Dispatchers.Default) {
|
||||
logger.info("Reloading settings")
|
||||
_uiState.value = UiState(
|
||||
syncIntervalContacts = accountSettings.getSyncInterval(context.getString(R.string.address_books_authority)),
|
||||
syncIntervalCalendars = accountSettings.getSyncInterval(CalendarContract.AUTHORITY),
|
||||
syncIntervalTasks = tasksProvider?.let { accountSettings.getSyncInterval(it.authority) },
|
||||
|
||||
syncWifiOnly = accountSettings.getSyncWifiOnly(),
|
||||
syncWifiOnlySSIDs = accountSettings.getSyncWifiOnlySSIDs(),
|
||||
ignoreVpns = accountSettings.getIgnoreVpns(),
|
||||
|
||||
credentials = accountSettings.credentials(),
|
||||
|
||||
timeRangePastDays = accountSettings.getTimeRangePastDays(),
|
||||
defaultAlarmMinBefore = accountSettings.getDefaultAlarm(),
|
||||
manageCalendarColors = accountSettings.getManageCalendarColors(),
|
||||
eventColors = accountSettings.getEventColors(),
|
||||
|
||||
contactGroupMethod = accountSettings.getGroupMethod(),
|
||||
)
|
||||
}
|
||||
|
||||
fun updateContactsSyncInterval(syncInterval: Long) {
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
@@ -126,27 +138,27 @@ class AccountSettingsModel @AssistedInject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSyncWifiOnly(wifiOnly: Boolean) {
|
||||
fun updateSyncWifiOnly(wifiOnly: Boolean) = CoroutineScope(Dispatchers.Default).launch {
|
||||
accountSettings.setSyncWiFiOnly(wifiOnly)
|
||||
reload()
|
||||
}
|
||||
|
||||
fun updateSyncWifiOnlySSIDs(ssids: List<String>?) {
|
||||
fun updateSyncWifiOnlySSIDs(ssids: List<String>?) = CoroutineScope(Dispatchers.Default).launch {
|
||||
accountSettings.setSyncWifiOnlySSIDs(ssids)
|
||||
reload()
|
||||
}
|
||||
|
||||
fun updateIgnoreVpns(ignoreVpns: Boolean) {
|
||||
fun updateIgnoreVpns(ignoreVpns: Boolean) = CoroutineScope(Dispatchers.Default).launch {
|
||||
accountSettings.setIgnoreVpns(ignoreVpns)
|
||||
reload()
|
||||
}
|
||||
|
||||
fun updateCredentials(credentials: Credentials) {
|
||||
fun updateCredentials(credentials: Credentials) = CoroutineScope(Dispatchers.Default).launch {
|
||||
accountSettings.credentials(credentials)
|
||||
reload()
|
||||
}
|
||||
|
||||
fun updateTimeRangePastDays(days: Int?) {
|
||||
fun updateTimeRangePastDays(days: Int?) = CoroutineScope(Dispatchers.Default).launch {
|
||||
accountSettings.setTimeRangePastDays(days)
|
||||
reload()
|
||||
|
||||
@@ -158,28 +170,28 @@ class AccountSettingsModel @AssistedInject constructor(
|
||||
resyncCalendars(fullResync = days == null, tasks = false)
|
||||
}
|
||||
|
||||
fun updateDefaultAlarm(minBefore: Int?) {
|
||||
fun updateDefaultAlarm(minBefore: Int?) = CoroutineScope(Dispatchers.Default).launch {
|
||||
accountSettings.setDefaultAlarm(minBefore)
|
||||
reload()
|
||||
|
||||
resyncCalendars(fullResync = true, tasks = false)
|
||||
}
|
||||
|
||||
fun updateManageCalendarColors(manage: Boolean) {
|
||||
fun updateManageCalendarColors(manage: Boolean) = CoroutineScope(Dispatchers.Default).launch {
|
||||
accountSettings.setManageCalendarColors(manage)
|
||||
reload()
|
||||
|
||||
resyncCalendars(fullResync = false, tasks = true)
|
||||
}
|
||||
|
||||
fun updateEventColors(manageColors: Boolean) {
|
||||
fun updateEventColors(manageColors: Boolean) = CoroutineScope(Dispatchers.Default).launch {
|
||||
accountSettings.setEventColors(manageColors)
|
||||
reload()
|
||||
|
||||
resyncCalendars(fullResync = true, tasks = false)
|
||||
}
|
||||
|
||||
fun updateContactGroupMethod(groupMethod: GroupMethod) {
|
||||
fun updateContactGroupMethod(groupMethod: GroupMethod) = CoroutineScope(Dispatchers.Default).launch {
|
||||
accountSettings.setGroupMethod(groupMethod)
|
||||
reload()
|
||||
|
||||
@@ -193,8 +205,8 @@ class AccountSettingsModel @AssistedInject constructor(
|
||||
* Initiates calendar re-synchronization.
|
||||
*
|
||||
* @param fullResync whether sync shall download all events again
|
||||
* (_true_: sets [Syncer.SYNC_EXTRAS_FULL_RESYNC],
|
||||
* _false_: sets [Syncer.SYNC_EXTRAS_RESYNC])
|
||||
* (_true_: sets [at.bitfire.davdroid.sync.Syncer.SYNC_EXTRAS_FULL_RESYNC],
|
||||
* _false_: sets [at.bitfire.davdroid.sync.Syncer.SYNC_EXTRAS_RESYNC])
|
||||
* @param tasks whether tasks shall be synchronized, too (false: only events, true: events and tasks)
|
||||
*/
|
||||
private fun resyncCalendars(fullResync: Boolean, tasks: Boolean) {
|
||||
@@ -214,10 +226,10 @@ class AccountSettingsModel @AssistedInject constructor(
|
||||
private fun resync(authority: String, fullResync: Boolean) {
|
||||
val resync =
|
||||
if (fullResync)
|
||||
OneTimeSyncWorker.FULL_RESYNC
|
||||
BaseSyncWorker.FULL_RESYNC
|
||||
else
|
||||
OneTimeSyncWorker.RESYNC
|
||||
OneTimeSyncWorker.enqueue(context, account, authority, resync = resync)
|
||||
BaseSyncWorker.RESYNC
|
||||
syncWorkerManager.enqueueOneTime(account, authority = authority, resync = resync)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Contacts
|
||||
import androidx.compose.material.icons.filled.Event
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Password
|
||||
import androidx.compose.material.icons.filled.SyncProblem
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
@@ -23,6 +24,7 @@ import androidx.compose.material.icons.outlined.Task
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
@@ -30,6 +32,7 @@ import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -70,6 +73,7 @@ fun AccountSettingsScreen(
|
||||
val model = hiltViewModel { factory: AccountSettingsModel.Factory ->
|
||||
factory.create(account)
|
||||
}
|
||||
val uiState by model.uiState.collectAsState()
|
||||
val canAccessWifiSsid by PermissionUtils.rememberCanAccessWifiSsid()
|
||||
|
||||
AppTheme {
|
||||
@@ -80,35 +84,35 @@ fun AccountSettingsScreen(
|
||||
// Sync settings
|
||||
canAccessWifiSsid = canAccessWifiSsid,
|
||||
onSyncWifiOnlyPermissionsAction = onNavWifiPermissionsScreen,
|
||||
contactsSyncInterval = model.syncIntervalContacts,
|
||||
contactsSyncInterval = uiState.syncIntervalContacts,
|
||||
onUpdateContactsSyncInterval = model::updateContactsSyncInterval,
|
||||
calendarSyncInterval = model.syncIntervalCalendars,
|
||||
calendarSyncInterval = uiState.syncIntervalCalendars,
|
||||
onUpdateCalendarSyncInterval = model::updateCalendarSyncInterval,
|
||||
tasksSyncInterval = model.syncIntervalTasks,
|
||||
tasksSyncInterval = uiState.syncIntervalTasks,
|
||||
onUpdateTasksSyncInterval = model::updateTasksSyncInterval,
|
||||
syncOnlyOnWifi = model.syncWifiOnly,
|
||||
syncOnlyOnWifi = uiState.syncWifiOnly,
|
||||
onUpdateSyncOnlyOnWifi = model::updateSyncWifiOnly,
|
||||
onlyOnSsids = model.syncWifiOnlySSIDs,
|
||||
onlyOnSsids = uiState.syncWifiOnlySSIDs,
|
||||
onUpdateOnlyOnSsids = model::updateSyncWifiOnlySSIDs,
|
||||
ignoreVpns = model.ignoreVpns,
|
||||
ignoreVpns = uiState.ignoreVpns,
|
||||
onUpdateIgnoreVpns = model::updateIgnoreVpns,
|
||||
|
||||
// Authentication Settings
|
||||
credentials = model.credentials,
|
||||
credentials = uiState.credentials,
|
||||
onUpdateCredentials = model::updateCredentials,
|
||||
|
||||
// CalDav Settings
|
||||
timeRangePastDays = model.timeRangePastDays,
|
||||
timeRangePastDays = uiState.timeRangePastDays,
|
||||
onUpdateTimeRangePastDays = model::updateTimeRangePastDays,
|
||||
defaultAlarmMinBefore = model.defaultAlarmMinBefore,
|
||||
defaultAlarmMinBefore = uiState.defaultAlarmMinBefore,
|
||||
onUpdateDefaultAlarmMinBefore = model::updateDefaultAlarm,
|
||||
manageCalendarColors = model.manageCalendarColors,
|
||||
manageCalendarColors = uiState.manageCalendarColors,
|
||||
onUpdateManageCalendarColors = model::updateManageCalendarColors,
|
||||
eventColors = model.eventColors,
|
||||
eventColors = uiState.eventColors,
|
||||
onUpdateEventColors = model::updateEventColors,
|
||||
|
||||
// CardDav Settings
|
||||
contactGroupMethod = model.contactGroupMethod,
|
||||
contactGroupMethod = uiState.contactGroupMethod,
|
||||
onUpdateContactGroupMethod = model::updateContactGroupMethod,
|
||||
)
|
||||
}
|
||||
@@ -393,13 +397,23 @@ fun SyncSettings(
|
||||
onDismiss = { showWifiOnlySsidsDialog = false }
|
||||
)
|
||||
|
||||
if (LocalInspectionMode.current || (onlyOnSsids != null && !canAccessWifiSsid))
|
||||
if (LocalInspectionMode.current || onlyOnSsids != null)
|
||||
ActionCard(
|
||||
icon = Icons.Default.SyncProblem,
|
||||
icon = if (!canAccessWifiSsid) Icons.Default.SyncProblem else Icons.Default.Info,
|
||||
actionText = stringResource(R.string.settings_sync_wifi_only_ssids_permissions_action),
|
||||
onAction = onSyncWifiOnlyPermissionsAction
|
||||
) {
|
||||
Text(stringResource(R.string.settings_sync_wifi_only_ssids_permissions_required))
|
||||
Column {
|
||||
if (!canAccessWifiSsid)
|
||||
Text(stringResource(R.string.settings_sync_wifi_only_ssids_permissions_required))
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.wifi_permissions_background_location_disclaimer, stringResource(
|
||||
R.string.app_name)
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SwitchSetting(
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Contacts
|
||||
import androidx.compose.material.icons.filled.RemoveCircle
|
||||
import androidx.compose.material.icons.filled.Task
|
||||
import androidx.compose.material.icons.filled.Today
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -112,6 +113,9 @@ fun CollectionList_Item(
|
||||
modifier = modifier.clickable(onClick = onShowDetails)
|
||||
|
||||
ElevatedCard(
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
),
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(Modifier.height(IntrinsicSize.Max)) {
|
||||
|
||||
@@ -17,6 +17,10 @@ import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import java.text.Collator
|
||||
import java.time.ZoneId
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -24,11 +28,6 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.Collator
|
||||
import java.time.ZoneId
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@HiltViewModel(assistedFactory = CreateCalendarModel.Factory::class)
|
||||
class CreateCalendarModel @AssistedInject constructor(
|
||||
@@ -75,7 +74,7 @@ class CreateCalendarModel @AssistedInject constructor(
|
||||
val color: Int = Css3Color.entries.random().argb,
|
||||
val displayName: String = "",
|
||||
val description: String = "",
|
||||
val timeZoneId: String? = TimeZone.getDefault().id,
|
||||
val timeZoneId: String? = null,
|
||||
val supportVEVENT: Boolean = true,
|
||||
val supportVTODO: Boolean = true,
|
||||
val supportVJOURNAL: Boolean = true,
|
||||
|
||||
@@ -6,7 +6,9 @@ import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -116,6 +118,34 @@ fun WifiPermissionsScreenContent(
|
||||
Modifier
|
||||
.padding(8.dp)
|
||||
.verticalScroll(rememberScrollState())) {
|
||||
|
||||
// Disclaimer
|
||||
Row {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.wifi_permissions_background_location_disclaimer, stringResource(
|
||||
R.string.app_name)
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
|
||||
)
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.wifi_permissions_background_location_disclaimer2, stringResource(
|
||||
R.string.app_name)
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Icon(Icons.Default.CloudOff, null, modifier = Modifier.padding(8.dp))
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
// Permission switches
|
||||
Text(
|
||||
stringResource(R.string.wifi_permissions_intro),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
@@ -150,25 +180,11 @@ fun WifiPermissionsScreenContent(
|
||||
)
|
||||
val context = LocalContext.current
|
||||
OutlinedButton(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
onClick = { PermissionUtils.showAppSettings(context) }
|
||||
) {
|
||||
Text(stringResource(R.string.permissions_app_settings))
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 16.dp))
|
||||
|
||||
// Disclaimer
|
||||
Row {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.wifi_permissions_background_location_disclaimer, stringResource(
|
||||
R.string.app_name)
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(Icons.Default.CloudOff, null, modifier = Modifier.padding(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ fun ActionCard(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(icon, "", Modifier
|
||||
.align(Alignment.CenterVertically)
|
||||
.align(Alignment.Top)
|
||||
.padding(8.dp))
|
||||
content()
|
||||
}
|
||||
@@ -72,6 +72,9 @@ fun ActionCard_Sample() {
|
||||
icon = Icons.Default.NotificationAdd,
|
||||
actionText = "Some Action"
|
||||
) {
|
||||
Text("Some Content")
|
||||
Column {
|
||||
Text("Some Content. Some Content. Some Content. Some Content. ")
|
||||
Text("Other Content. Other Content. Other Content. Other Content. Other Content. Other Content. Other Content. ", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,17 @@ import androidx.compose.ui.unit.dp
|
||||
fun Assistant(
|
||||
nextLabel: String? = null,
|
||||
nextEnabled: Boolean = true,
|
||||
isLoading: Boolean = false,
|
||||
onNext: () -> Unit = {},
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (isLoading)
|
||||
ProgressBar(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp))
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.composable
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import at.bitfire.davdroid.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Deprecated("Directly use TopAppBar instead.", replaceWith = ReplaceWith("TopAppBar"))
|
||||
fun BasicTopAppBar(
|
||||
@StringRes titleStringRes: Int,
|
||||
actions: @Composable () -> Unit = {},
|
||||
onNavigateUp: () -> Unit
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(titleStringRes)) },
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onNavigateUp
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -45,6 +46,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.AppTheme
|
||||
import at.bitfire.davdroid.ui.M3ColorScheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@@ -56,48 +58,51 @@ fun IntroScreen(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) { pages[it].ComposePage() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(90.dp)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
PositionIndicator(
|
||||
index = pagerState.currentPage,
|
||||
max = pages.size,
|
||||
Scaffold { paddingValues ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = 128.dp)
|
||||
.align(Alignment.Center)
|
||||
.fillMaxWidth(),
|
||||
selectedIndicatorColor = MaterialTheme.colorScheme.onPrimary,
|
||||
unselectedIndicatorColor = MaterialTheme.colorScheme.tertiary,
|
||||
indicatorSize = 15f
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) { pages[it].ComposePage() }
|
||||
|
||||
ButtonWithIcon(
|
||||
icon = if (pagerState.currentPage + 1 == pagerState.pageCount) {
|
||||
Icons.Default.Check
|
||||
} else {
|
||||
Icons.AutoMirrored.Default.ArrowForward
|
||||
},
|
||||
contentDescription = stringResource(R.string.intro_next),
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxWidth()
|
||||
.height(90.dp)
|
||||
.background(M3ColorScheme.primaryLight)
|
||||
) {
|
||||
if (pagerState.currentPage + 1 == pagerState.pageCount) {
|
||||
onDonePressed()
|
||||
} else scope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
PositionIndicator(
|
||||
index = pagerState.currentPage,
|
||||
max = pages.size,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = 128.dp)
|
||||
.align(Alignment.Center)
|
||||
.fillMaxWidth(),
|
||||
selectedIndicatorColor = MaterialTheme.colorScheme.onPrimary,
|
||||
unselectedIndicatorColor = MaterialTheme.colorScheme.tertiary,
|
||||
indicatorSize = 15f
|
||||
)
|
||||
|
||||
ButtonWithIcon(
|
||||
icon = if (pagerState.currentPage + 1 == pagerState.pageCount) {
|
||||
Icons.Default.Check
|
||||
} else {
|
||||
Icons.AutoMirrored.Default.ArrowForward
|
||||
},
|
||||
contentDescription = stringResource(R.string.intro_next),
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
color = M3ColorScheme.tertiaryLight
|
||||
) {
|
||||
if (pagerState.currentPage + 1 == pagerState.pageCount) {
|
||||
onDonePressed()
|
||||
} else scope.launch {
|
||||
pagerState.animateScrollToPage(pagerState.currentPage + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,11 +224,12 @@ fun ButtonWithIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 56.dp,
|
||||
color: Color = MaterialTheme.colorScheme.tertiary,
|
||||
contentColor: Color = contentColorFor(backgroundColor = color),
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
color = color,
|
||||
contentColor = contentColorFor(backgroundColor = color),
|
||||
contentColor = contentColor,
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.aspectRatio(1f),
|
||||
|
||||
@@ -44,7 +44,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.composable.Assistant
|
||||
import at.bitfire.davdroid.ui.composable.ProgressBar
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
|
||||
@Composable
|
||||
@@ -93,17 +92,12 @@ fun AccountDetailsPageContent(
|
||||
creatingAccount: Boolean
|
||||
) {
|
||||
Assistant(
|
||||
nextLabel = stringResource(R.string.login_add_account),
|
||||
nextLabel = stringResource(R.string.login_finish),
|
||||
onNext = onCreateAccount,
|
||||
nextEnabled = !creatingAccount && accountName.isNotBlank() && !accountNameAlreadyExists
|
||||
nextEnabled = !creatingAccount && accountName.isNotBlank() && !accountNameAlreadyExists,
|
||||
isLoading = creatingAccount
|
||||
) {
|
||||
Column(Modifier.padding(8.dp)) {
|
||||
if (creatingAccount)
|
||||
ProgressBar(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp))
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
|
||||
@@ -39,7 +39,6 @@ import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
|
||||
import at.bitfire.davdroid.ui.composable.Assistant
|
||||
import at.bitfire.davdroid.ui.composable.PasswordTextField
|
||||
import at.bitfire.davdroid.ui.composable.SelectClientCertificateCard
|
||||
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
|
||||
|
||||
object AdvancedLogin : LoginType {
|
||||
|
||||
@@ -134,8 +133,8 @@ fun AdvancedLoginScreen(
|
||||
.fragment(Constants.MANUAL_FRAGMENT_SERVICE_DISCOVERY)
|
||||
.build()
|
||||
val urlInfo = HtmlCompat.fromHtml(stringResource(R.string.login_base_url_info, manualUrl), HtmlCompat.FROM_HTML_MODE_COMPACT)
|
||||
ClickableTextWithLink(
|
||||
urlInfo.toAnnotatedString(),
|
||||
Text(
|
||||
text = urlInfo.toAnnotatedString(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -32,7 +32,6 @@ import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
|
||||
import at.bitfire.davdroid.ui.composable.ProgressBar
|
||||
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
|
||||
|
||||
@Composable
|
||||
fun DetectResourcesPage(
|
||||
@@ -125,8 +124,12 @@ fun DetectResourcesPageContent_NothingFound(
|
||||
.appendPath(Constants.HOMEPAGE_PATH_TESTED_SERVICES)
|
||||
.withStatParams("DetectResourcesPage")
|
||||
.build()
|
||||
ClickableTextWithLink(
|
||||
HtmlCompat.fromHtml(stringResource(R.string.login_see_tested_services, urlServices), HtmlCompat.FROM_HTML_MODE_COMPACT).toAnnotatedString(),
|
||||
val testedServices = HtmlCompat.fromHtml(
|
||||
stringResource(R.string.login_see_tested_services, urlServices),
|
||||
HtmlCompat.FROM_HTML_MODE_COMPACT
|
||||
).toAnnotatedString()
|
||||
Text(
|
||||
text = testedServices,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
|
||||
@@ -36,7 +36,6 @@ import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
|
||||
import at.bitfire.davdroid.ui.composable.Assistant
|
||||
import at.bitfire.davdroid.ui.composable.PasswordTextField
|
||||
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
|
||||
|
||||
object EmailLogin : LoginType {
|
||||
|
||||
@@ -122,8 +121,8 @@ fun EmailLoginScreen(
|
||||
.fragment(Constants.MANUAL_FRAGMENT_SERVICE_DISCOVERY)
|
||||
.build()
|
||||
val emailInfo = HtmlCompat.fromHtml(stringResource(R.string.login_email_address_info, manualUrl), HtmlCompat.FROM_HTML_MODE_COMPACT)
|
||||
ClickableTextWithLink(
|
||||
emailInfo.toAnnotatedString(),
|
||||
Text(
|
||||
text = emailInfo.toAnnotatedString(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -39,7 +39,6 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.ExperimentalTextApi
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -51,7 +50,6 @@ import at.bitfire.davdroid.Constants.withStatParams
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
|
||||
import at.bitfire.davdroid.ui.setup.GoogleLogin.GOOGLE_POLICY_URL
|
||||
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
@@ -135,7 +133,6 @@ object GoogleLogin : LoginType {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTextApi::class)
|
||||
@Composable
|
||||
fun GoogleLoginScreen(
|
||||
email: String,
|
||||
@@ -257,16 +254,16 @@ fun GoogleLoginScreen(
|
||||
privacyPolicyUrl.toString()
|
||||
), 0
|
||||
).toAnnotatedString()
|
||||
ClickableTextWithLink(
|
||||
privacyPolicyNote,
|
||||
Text(
|
||||
text = privacyPolicyNote,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
val limitedUseNote = HtmlCompat.fromHtml(
|
||||
stringResource(R.string.login_google_client_limited_use, context.getString(R.string.app_name), GOOGLE_POLICY_URL), 0
|
||||
).toAnnotatedString()
|
||||
ClickableTextWithLink(
|
||||
limitedUseNote,
|
||||
Text(
|
||||
text = limitedUseNote,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 12.dp)
|
||||
)
|
||||
|
||||
@@ -37,7 +37,6 @@ import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
|
||||
import at.bitfire.davdroid.ui.composable.Assistant
|
||||
import at.bitfire.davdroid.ui.composable.PasswordTextField
|
||||
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
|
||||
|
||||
object UrlLogin : LoginType {
|
||||
|
||||
@@ -126,8 +125,8 @@ fun UrlLoginScreen(
|
||||
.fragment(Constants.MANUAL_FRAGMENT_SERVICE_DISCOVERY)
|
||||
.build()
|
||||
val urlInfo = HtmlCompat.fromHtml(stringResource(R.string.login_base_url_info, manualUrl), HtmlCompat.FROM_HTML_MODE_COMPACT)
|
||||
ClickableTextWithLink(
|
||||
urlInfo.toAnnotatedString(),
|
||||
Text(
|
||||
text = urlInfo.toAnnotatedString(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -12,7 +12,6 @@ import android.text.format.Formatter
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -30,7 +29,8 @@ import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -40,8 +40,7 @@ import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -50,7 +49,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -68,8 +66,8 @@ import at.bitfire.davdroid.db.WebDavMountWithQuota
|
||||
import at.bitfire.davdroid.ui.AppTheme
|
||||
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
|
||||
import at.bitfire.davdroid.ui.composable.ProgressBar
|
||||
import at.bitfire.davdroid.ui.widget.ClickableTextWithLink
|
||||
import at.bitfire.davdroid.util.DavUtils
|
||||
import kotlinx.coroutines.delay
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
@Composable
|
||||
@@ -108,11 +106,11 @@ fun WebdavMountsScreen(
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
val refreshState = rememberPullToRefreshState()
|
||||
LaunchedEffect(refreshState.isRefreshing) {
|
||||
if (refreshState.isRefreshing) {
|
||||
onRefreshQuota()
|
||||
refreshState.endRefresh()
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
delay(300)
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,10 +153,11 @@ fun WebdavMountsScreen(
|
||||
contentDescription = stringResource(R.string.webdav_add_mount_add)
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.nestedScroll(refreshState.nestedScrollConnection)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = { isRefreshing = true; onRefreshQuota() },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
@@ -176,7 +175,7 @@ fun WebdavMountsScreen(
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
@@ -190,11 +189,6 @@ fun WebdavMountsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PullToRefreshContainer(
|
||||
state = refreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,7 +216,7 @@ fun HintText() {
|
||||
),
|
||||
0
|
||||
).toAnnotatedString()
|
||||
ClickableTextWithLink(
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
@@ -260,8 +254,10 @@ fun WebdavMountsItem(
|
||||
)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
ElevatedCard(
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -356,33 +352,59 @@ fun WebdavMountsItem(
|
||||
@Composable
|
||||
@Preview
|
||||
fun WebdavMountsScreen_Preview_Empty() {
|
||||
WebdavMountsScreen(
|
||||
mountInfos = emptyList(),
|
||||
refreshingQuota = false
|
||||
)
|
||||
AppTheme {
|
||||
WebdavMountsScreen(
|
||||
mountInfos = emptyList(),
|
||||
refreshingQuota = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun WebdavMountsScreen_Preview_TwoMounts() {
|
||||
WebdavMountsScreen(
|
||||
mountInfos = listOf(
|
||||
WebDavMountWithQuota(
|
||||
AppTheme {
|
||||
WebdavMountsScreen(
|
||||
mountInfos = listOf(
|
||||
WebDavMountWithQuota(
|
||||
mount = WebDavMount(
|
||||
id = 0,
|
||||
name = "Preview Webdav Mount 1",
|
||||
url = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("example.com")
|
||||
.build()
|
||||
),
|
||||
quotaAvailable = 1024 * 1024 * 1024,
|
||||
quotaUsed = 512 * 1024 * 1024
|
||||
),
|
||||
WebDavMountWithQuota(
|
||||
mount = WebDavMount(
|
||||
id = 1,
|
||||
name = "Preview Webdav Mount 2",
|
||||
url = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("example.com")
|
||||
.build()
|
||||
),
|
||||
quotaAvailable = 1024 * 1024 * 1024,
|
||||
quotaUsed = 512 * 1024 * 1024
|
||||
)
|
||||
),
|
||||
refreshingQuota = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun WebdavMountsItem_Preview() {
|
||||
AppTheme {
|
||||
WebdavMountsItem(
|
||||
info = WebDavMountWithQuota(
|
||||
mount = WebDavMount(
|
||||
id = 0,
|
||||
name = "Preview Webdav Mount 1",
|
||||
url = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("example.com")
|
||||
.build()
|
||||
),
|
||||
quotaAvailable = 1024 * 1024 * 1024,
|
||||
quotaUsed = 512 * 1024 * 1024
|
||||
),
|
||||
WebDavMountWithQuota(
|
||||
mount = WebDavMount(
|
||||
id = 1,
|
||||
name = "Preview Webdav Mount 2",
|
||||
name = "Preview Webdav Mount",
|
||||
url = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("example.com")
|
||||
@@ -391,28 +413,8 @@ fun WebdavMountsScreen_Preview_TwoMounts() {
|
||||
quotaAvailable = 1024 * 1024 * 1024,
|
||||
quotaUsed = 512 * 1024 * 1024
|
||||
)
|
||||
),
|
||||
refreshingQuota = true
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun WebdavMountsItem_Preview() {
|
||||
WebdavMountsItem(
|
||||
info = WebDavMountWithQuota(
|
||||
mount = WebDavMount(
|
||||
id = 0,
|
||||
name = "Preview Webdav Mount",
|
||||
url = HttpUrl.Builder()
|
||||
.scheme("https")
|
||||
.host("example.com")
|
||||
.build()
|
||||
),
|
||||
quotaAvailable = 1024 * 1024 * 1024,
|
||||
quotaUsed = 512 * 1024 * 1024
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.widget
|
||||
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.ExperimentalTextApi
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
|
||||
@OptIn(ExperimentalTextApi::class)
|
||||
@Composable
|
||||
fun ClickableTextWithLink(
|
||||
text: AnnotatedString,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = TextStyle.Default
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
ClickableText(
|
||||
text = text,
|
||||
style = style.copy(color = LocalContentColor.current),
|
||||
modifier = modifier
|
||||
) { index ->
|
||||
// Get the tapped position, and check if there's any link
|
||||
text.getUrlAnnotations(index, index).firstOrNull()?.item?.url?.let { url ->
|
||||
uriHandler.openUri(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user