Compare commits

...

10 Commits

Author SHA1 Message Date
Arnau Mora
46e8c4522b Updated name, package and color
Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
2024-10-25 17:47:23 +02:00
Sunik Kupfer
d00353ba9c Replace android sync framework result class with our own (#1094)
* Use our own SyncResult data class

* Minor comment changes

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-10-25 14:13:43 +02:00
Ricki Hirner
dc0d4f371a More compatible processing of multiget responses (#1099)
* Ignore multi-get responses without calendar/contact data

* Add comment
2024-10-25 12:44:54 +02:00
Arnau Mora
3d198f5454 LocalAddressBook: rename account to addressbookAccount (#1095)
* Upgraded vcard4android

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Replaced all usages of addressBookAccount

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>

* Minor changes

---------

Signed-off-by: Arnau Mora Gras <arnyminerz@proton.me>
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
2024-10-22 14:07:53 +02:00
Ricki Hirner
1802740a2d Version bump to 4.4.3.2 2024-10-20 16:32:12 +02:00
Ricki Hirner
138e517d23 LocalAddressBook: move contacts when renaming the address book account (#1084)
* LocalAddressBook: move contacts when renaming the address book account

* Don't make contacts dirty when moving

* Move isDirty to tests because it's only required for tests

* We don't have to set the user-data twice

* Add test for groups
2024-10-20 16:31:31 +02:00
Ricki Hirner
166b2ac220 Bump version to 4.4.3.1 2024-10-18 16:53:28 +02:00
Ricki Hirner
450a418994 Don't crash when logging null parameter (#1081) 2024-10-18 16:51:26 +02:00
Ricki Hirner
d4e9e2a8f7 Reduce warnings, lint 2024-10-17 16:40:09 +02:00
Ricki Hirner
ecc59dda99 Update dependencies 2024-10-17 16:32:35 +02:00
30 changed files with 614 additions and 267 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">DAVx⁵ Debug</string>
</resources>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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