mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-02-23 10:28:06 -05:00
Compare commits
10 Commits
v4.4.3-ose
...
debug-buil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46e8c4522b | ||
|
|
d00353ba9c | ||
|
|
dc0d4f371a | ||
|
|
3d198f5454 | ||
|
|
1802740a2d | ||
|
|
138e517d23 | ||
|
|
166b2ac220 | ||
|
|
450a418994 | ||
|
|
d4e9e2a8f7 | ||
|
|
ecc59dda99 |
@@ -18,8 +18,8 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "at.bitfire.davdroid"
|
||||
|
||||
versionCode = 404030003
|
||||
versionName = "4.4.3"
|
||||
versionCode = 404030200
|
||||
versionName = "4.4.3.2"
|
||||
|
||||
setProperty("archivesBaseName", "davx5-ose-$versionName")
|
||||
|
||||
@@ -82,6 +82,9 @@ android {
|
||||
|
||||
signingConfig = signingConfigs.findByName("bitfire")
|
||||
}
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
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.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
|
||||
|
||||
@HiltAndroidTest
|
||||
class LocalAddressBookTest {
|
||||
|
||||
@get:Rule
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@Inject
|
||||
lateinit var addressbookFactory: LocalTestAddressBook.Factory
|
||||
|
||||
@Inject
|
||||
@ApplicationContext
|
||||
lateinit var context: Context
|
||||
|
||||
lateinit var addressBook: LocalTestAddressBook
|
||||
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
hiltRule.inject()
|
||||
|
||||
addressBook = addressbookFactory.create(provider, GroupMethod.CATEGORIES)
|
||||
LocalTestAddressBook.createAccount(context)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
// remove address book
|
||||
addressBook.deleteCollection()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tests whether contacts are moved (and not lost) when an address book is renamed.
|
||||
*/
|
||||
@Test
|
||||
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))
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,8 +5,11 @@
|
||||
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
|
||||
@@ -15,21 +18,19 @@ 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 @AssistedInject constructor(
|
||||
@Assisted provider: ContentProviderClient,
|
||||
@Assisted override val groupMethod: GroupMethod,
|
||||
@ApplicationContext context: Context,
|
||||
accountSettingsFactory: AccountSettings.Factory,
|
||||
collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext context: Context,
|
||||
logger: Logger,
|
||||
serviceRepository: DavServiceRepository
|
||||
): LocalAddressBook(ACCOUNT, provider, context, accountSettingsFactory, collectionRepository, logger, serviceRepository) {
|
||||
|
||||
companion object {
|
||||
val ACCOUNT = Account("LocalTestAddressBook", "at.bitfire.davdroid.test")
|
||||
}
|
||||
): LocalAddressBook(ACCOUNT, provider, accountSettingsFactory, collectionRepository, context, logger, serviceRepository) {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -48,4 +49,49 @@ class LocalTestAddressBook @AssistedInject constructor(
|
||||
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))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,6 @@ package at.bitfire.davdroid.sync
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.SyncResult
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.sync.account.TestAccountAuthenticator
|
||||
import dagger.assisted.Assisted
|
||||
|
||||
@@ -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
|
||||
|
||||
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>
|
||||
@@ -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()
|
||||
|
||||
@@ -88,8 +88,15 @@ class PlainTextFormatter(
|
||||
}
|
||||
|
||||
r.parameters?.let {
|
||||
for ((idx, param) in it.withIndex())
|
||||
builder.append("\n\tPARAMETER #").append(idx).append(" = ").append(truncate(param.toString()))
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.Groups
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
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
|
||||
@@ -30,6 +31,7 @@ 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.BatchOperation
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
@@ -48,138 +50,22 @@ import java.util.logging.Logger
|
||||
* account and there is no such thing as "address books". So, DAVx5 creates a "DAVx5
|
||||
* address book" account for every CardDAV address book.
|
||||
*
|
||||
* @param addressBookAccount Address book account (not: DAVx5 account) storing the actual android 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
|
||||
*/
|
||||
@OpenForTesting
|
||||
open class LocalAddressBook @AssistedInject constructor(
|
||||
@Assisted addressBookAccount: Account,
|
||||
@Assisted _addressBookAccount: Account,
|
||||
@Assisted provider: ContentProviderClient,
|
||||
@ApplicationContext val context: Context,
|
||||
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> {
|
||||
|
||||
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"
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
}
|
||||
): AndroidAddressBook<LocalContact, LocalGroup>(_addressBookAccount, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -188,9 +74,10 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
|
||||
|
||||
override val tag: String
|
||||
get() = "contacts-${account.name}"
|
||||
get() = "contacts-${addressBookAccount.name}"
|
||||
|
||||
override val title = addressBookAccount.name
|
||||
override val title
|
||||
get() = addressBookAccount.name
|
||||
|
||||
/**
|
||||
* Whether contact groups ([LocalGroup]) are included in query results
|
||||
@@ -218,13 +105,13 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
|
||||
@Deprecated("Local collection should be identified by ID, not by URL")
|
||||
override var collectionUrl: String
|
||||
get() = AccountManager.get(context).getUserData(account, USER_DATA_URL)
|
||||
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)) }
|
||||
@@ -269,20 +156,18 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
* `null`: don't change the existing value
|
||||
*/
|
||||
fun update(info: Collection, forceReadOnly: Boolean? = null) {
|
||||
logger.log(Level.INFO, "Updating local address book $account with collection $info")
|
||||
logger.log(Level.INFO, "Updating local address book $addressBookAccount with collection $info")
|
||||
val accountManager = AccountManager.get(context)
|
||||
|
||||
// Update the account name
|
||||
val newAccountName = accountName(context, info)
|
||||
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 future = accountManager.renameAccount(account, newAccountName, null, null)
|
||||
account = future.result
|
||||
}
|
||||
if (addressBookAccount.name != newAccountName)
|
||||
// rename, move contacts/groups and update [AndroidAddressBook.]account
|
||||
renameAccount(newAccountName)
|
||||
|
||||
// Update the account user data
|
||||
accountManager.setAndVerifyUserData(account, USER_DATA_COLLECTION_ID, info.id.toString())
|
||||
accountManager.setAndVerifyUserData(account, USER_DATA_URL, info.url.toString())
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_COLLECTION_ID, info.id.toString())
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_URL, info.url.toString())
|
||||
|
||||
// Update force read only
|
||||
if (forceReadOnly != null) {
|
||||
@@ -314,9 +199,56 @@ open class LocalAddressBook @AssistedInject constructor(
|
||||
updateSyncFrameworkSettings()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
accountManager.removeAccountExplicitly(oldAccount)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun deleteCollection(): Boolean {
|
||||
val accountManager = AccountManager.get(context)
|
||||
return accountManager.removeAccountExplicitly(account)
|
||||
return accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
|
||||
|
||||
@@ -330,15 +262,15 @@ open class LocalAddressBook @AssistedInject 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)
|
||||
}
|
||||
|
||||
@@ -476,4 +408,129 @@ open class LocalAddressBook @AssistedInject 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -74,7 +73,7 @@ class AddressBookSyncer @AssistedInject constructor(
|
||||
}
|
||||
|
||||
override fun syncCollection(provider: ContentProviderClient, localCollection: LocalAddressBook, remoteCollection: Collection) {
|
||||
logger.info("Synchronizing address book $localCollection")
|
||||
logger.info("Synchronizing address book: ${localCollection.addressBookAccount.name}")
|
||||
syncAddressBook(
|
||||
account = account,
|
||||
addressBook = localCollection,
|
||||
@@ -110,7 +109,7 @@ class AddressBookSyncer @AssistedInject constructor(
|
||||
|
||||
// handle group method change
|
||||
val groupMethod = accountSettings.getGroupMethod().name
|
||||
accountSettings.accountManager.getUserData(addressBook.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")
|
||||
|
||||
@@ -122,9 +121,7 @@ class AddressBookSyncer @AssistedInject constructor(
|
||||
addressBook.syncState = null
|
||||
}
|
||||
}
|
||||
accountSettings.accountManager.setAndVerifyUserData(addressBook.account, PREVIOUS_GROUP_METHOD, groupMethod)
|
||||
|
||||
logger.info("Synchronizing address book: ${addressBook.collectionUrl}")
|
||||
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,
|
||||
|
||||
@@ -7,7 +7,6 @@ package at.bitfire.davdroid.sync
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.SyncResult
|
||||
import android.provider.CalendarContract
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.SyncResult
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
@@ -54,7 +53,7 @@ class JtxSyncer @AssistedInject constructor(
|
||||
TaskProvider.checkVersion(context, TaskProvider.ProviderName.JtxBoard)
|
||||
} catch (e: TaskProvider.ProviderTooOldException) {
|
||||
tasksAppManager.get().notifyProviderTooOld(e)
|
||||
syncResult.databaseError = true
|
||||
syncResult.contentProviderError = true
|
||||
return false // Don't sync
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -333,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
|
||||
@@ -788,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++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
}
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -282,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
|
||||
}
|
||||
@@ -297,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())
|
||||
|
||||
@@ -8,7 +8,6 @@ import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.SyncResult
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.db.Service
|
||||
@@ -52,7 +51,7 @@ class TaskSyncer @AssistedInject constructor(
|
||||
TaskProvider.checkVersion(context, providerName)
|
||||
} catch (e: TaskProvider.ProviderTooOldException) {
|
||||
tasksAppManager.get().notifyProviderTooOld(e)
|
||||
syncResult.databaseError = true
|
||||
syncResult.contentProviderError = true
|
||||
return false // Don't sync
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -27,20 +26,21 @@ 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
|
||||
import at.bitfire.davdroid.ui.NotificationRegistry
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import java.util.Collections
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Collections
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
abstract class BaseSyncWorker(
|
||||
context: Context,
|
||||
|
||||
@@ -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,20 +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.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 ->
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -12,10 +12,9 @@ import java.util.logging.Logger
|
||||
* [AccountManager.setUserData] has been found to be unreliable at times. This extension function
|
||||
* checks whether the user data has actually been set and retries up to ten times before failing silently.
|
||||
*
|
||||
* Note: In the future we want to store accounts + associated data in the database, never calling
|
||||
* so this method will become obsolete then.
|
||||
* It should only be used to store the reference to the database (like the collection ID that this account represents).
|
||||
* Everything else should be in the DB.
|
||||
*/
|
||||
@Deprecated("Don't use AccountManager to store user data; use DB instead")
|
||||
fun AccountManager.setAndVerifyUserData(account: Account, key: String, value: String?) {
|
||||
for (i in 1..10) {
|
||||
setUserData(account, key, value)
|
||||
|
||||
@@ -4,18 +4,43 @@
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.LogRecord
|
||||
|
||||
class PlainTextFormatterTest {
|
||||
|
||||
private val minimum = PlainTextFormatter(
|
||||
withTime = false,
|
||||
withSource = false,
|
||||
withException = false,
|
||||
lineSeparator = null
|
||||
)
|
||||
|
||||
@Test
|
||||
fun test_format_TruncatesMessage() {
|
||||
val formatter = PlainTextFormatter.DEFAULT
|
||||
val result = formatter.format(LogRecord(Level.INFO, "a".repeat(50000)))
|
||||
// PlainTextFormatter.MAX_LENGTH is 10,000, so the message should be truncated to 10,000 + something
|
||||
assert(result.length <= 10100)
|
||||
fun test_format_param_null() {
|
||||
val result = minimum.format(LogRecord(Level.INFO, "Message").apply {
|
||||
parameters = arrayOf(null)
|
||||
})
|
||||
assertEquals("Message\n\tPARAMETER #1 = (null)", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_format_param_object() {
|
||||
val result = minimum.format(LogRecord(Level.INFO, "Message").apply {
|
||||
parameters = arrayOf(object {
|
||||
override fun toString() = "SomeObject[]"
|
||||
})
|
||||
})
|
||||
assertEquals("Message\n\tPARAMETER #1 = SomeObject[]", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_format_truncatesMessage() {
|
||||
val result = minimum.format(LogRecord(Level.INFO, "a".repeat(50000)))
|
||||
// PlainTextFormatter.MAX_LENGTH is 10,000
|
||||
assertEquals(10000, result.length)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
[versions]
|
||||
android-agp = "8.7.1"
|
||||
android-desugaring = "2.1.2"
|
||||
androidx-activityCompose = "1.9.2"
|
||||
androidx-activityCompose = "1.9.3"
|
||||
androidx-appcompat = "1.7.0"
|
||||
androidx-arch = "2.2.0"
|
||||
androidx-browser = "1.8.0"
|
||||
@@ -21,11 +21,11 @@ androidx-work = "2.9.1"
|
||||
bitfire-cert4android = "f1cc9b9ca3"
|
||||
bitfire-dav4jvm = "fbd95a5f5a"
|
||||
bitfire-ical4android = "b75f33972a"
|
||||
bitfire-vcard4android = "5439c1f63c"
|
||||
bitfire-vcard4android = "13840ade05"
|
||||
compose-accompanist = "0.36.0"
|
||||
compose-bom = "2024.09.03"
|
||||
compose-bom = "2024.10.00"
|
||||
dnsjava = "3.6.0"
|
||||
glance = "1.1.0"
|
||||
glance = "1.1.1"
|
||||
guava = "33.3.1-android"
|
||||
hilt = "2.52"
|
||||
# keep in sync with ksp version
|
||||
|
||||
Reference in New Issue
Block a user