Rewrite AccountActivity to M3 (#752)

* [WIP] Separate AccountScreen, use M3 elements

* [WIP] Use UseCases for complex flow calculations

* [WIP] Move account deletion logic into AccountRepository

* Move rename operation to repository

* Adapt FABs

* Don't use snackbars to show when the collection list is refreshed

* New collection list layout (bitfireAT/davx5#159)

* [WIP] Create AccountModel from within screen

* [WIP] Clean up AccountScreen

* [WIP] CreateAddressBook

* [WIP] Create address book / calendar screen

* [WIP] Begin CollectionScreen

* [WIP] CollectionScreen

* Error handling

* String resources

* Optimizations, remove unnecessary things
This commit is contained in:
Ricki Hirner 2024-04-30 11:25:29 +02:00 committed by GitHub
parent c33ea84c77
commit b13c6b0e6f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 3654 additions and 2902 deletions

View file

@ -140,6 +140,9 @@
android:parentActivityName=".ui.AccountsActivity"
android:exported="true">
</activity>
<activity
android:name=".ui.account.CollectionActivity"
android:parentActivityName=".ui.account.AccountActivity" />
<activity
android:name=".ui.account.CreateAddressBookActivity"
android:parentActivityName=".ui.account.AccountActivity" />

View file

@ -4,7 +4,6 @@
package at.bitfire.davdroid.db
import androidx.lifecycle.LiveData
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Delete
@ -13,18 +12,16 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface CollectionDao {
@Query("SELECT DISTINCT color FROM collection WHERE serviceId=:id")
fun colorsByServiceLive(id: Long): LiveData<List<Int>>
@Query("SELECT * FROM collection WHERE id=:id")
fun get(id: Long): Collection?
@Query("SELECT * FROM collection WHERE id=:id")
fun getLive(id: Long): LiveData<Collection>
fun getFlow(id: Long): Flow<Collection?>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId")
fun getByService(serviceId: Long): List<Collection>
@ -35,6 +32,9 @@ interface CollectionDao {
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE")
fun getByServiceAndType(serviceId: Long, type: String): List<Collection>
@Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND type=:type")
suspend fun anyOfType(serviceId: Long, type: String): Boolean
/**
* Returns collections which
* - support VEVENT and/or VTODO (= supported calendar collections), or
@ -69,14 +69,17 @@ interface CollectionDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(collection: Collection): Long
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAsync(collection: Collection): Long
@Update
fun update(collection: Collection)
@Query("UPDATE collection SET forceReadOnly=:forceReadOnly WHERE id=:id")
fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)
suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)
@Query("UPDATE collection SET sync=:sync WHERE id=:id")
fun updateSync(id: Long, sync: Boolean)
suspend fun updateSync(id: Long, sync: Boolean)
/**
* Tries to insert new row, but updates existing row if already present.

View file

@ -8,6 +8,7 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import at.bitfire.davdroid.util.lastSegment
import okhttp3.HttpUrl
@Entity(tableName = "homeset",
@ -35,4 +36,8 @@ data class HomeSet(
var privBind: Boolean = true,
var displayName: String? = null
)
) {
fun title() = displayName ?: url.lastSegment()
}

View file

@ -4,13 +4,13 @@
package at.bitfire.davdroid.db
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface HomeSetDao {
@ -24,11 +24,11 @@ interface HomeSetDao {
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId")
fun getByService(serviceId: Long): List<HomeSet>
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
fun getBindableByService(serviceId: Long): List<HomeSet>
@Query("SELECT * FROM homeset WHERE serviceId=(SELECT id FROM service WHERE accountName=:accountName AND type=:serviceType) AND privBind ORDER BY displayName, url COLLATE NOCASE")
fun getBindableByAccountAndServiceTypeFlow(accountName: String, serviceType: String): Flow<List<HomeSet>>
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
fun getLiveBindableByService(serviceId: Long): LiveData<List<HomeSet>>
fun getBindableByServiceFlow(serviceId: Long): Flow<List<HomeSet>>
@Insert
fun insert(homeSet: HomeSet): Long

View file

@ -4,7 +4,6 @@
package at.bitfire.davdroid.db
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
@ -19,7 +18,7 @@ interface PrincipalDao {
fun get(id: Long): Principal
@Query("SELECT * FROM principal WHERE id=:id")
fun getLive(id: Long): LiveData<Principal?>
suspend fun getAsync(id: Long): Principal
@Query("SELECT * FROM principal WHERE serviceId=:serviceId")
fun getByService(serviceId: Long): List<Principal>

View file

@ -4,12 +4,11 @@
package at.bitfire.davdroid.db
import androidx.lifecycle.LiveData
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface ServiceDao {
@ -18,20 +17,11 @@ interface ServiceDao {
fun getByAccountAndType(accountName: String, type: String): Service?
@Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type")
fun getLiveByAccountAndType(accountName: String, type: String): LiveData<Service?>
@Query("SELECT id FROM service WHERE accountName=:accountName")
fun getIdsByAccount(accountName: String): List<Long>
fun getByAccountAndTypeFlow(accountName: String, type: String): Flow<Service?>
@Query("SELECT id FROM service WHERE accountName=:accountName")
suspend fun getIdsByAccountAsync(accountName: String): List<Long>
@Query("SELECT id FROM service WHERE accountName=:accountName AND type=:type")
fun getIdByAccountAndType(accountName: String, type: String): LiveData<Long>
@Query("SELECT type, id FROM service WHERE accountName=:accountName")
fun getServiceTypeAndIdsByAccount(accountName: String): LiveData<List<ServiceTypeAndId>>
@Query("SELECT * FROM service WHERE id=:id")
fun get(id: Long): Service?
@ -45,11 +35,6 @@ interface ServiceDao {
fun deleteExceptAccounts(accountNames: Array<String>)
@Query("UPDATE service SET accountName=:newName WHERE accountName=:oldName")
fun renameAccount(oldName: String, newName: String)
suspend fun renameAccount(oldName: String, newName: String)
}
data class ServiceTypeAndId(
@ColumnInfo(name = "type") val type: String,
@ColumnInfo(name = "id") val id: Long
)
}

View file

@ -4,11 +4,11 @@
package at.bitfire.davdroid.db
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface SyncStatsDao {
@ -17,5 +17,6 @@ interface SyncStatsDao {
fun insertOrReplace(syncStats: SyncStats)
@Query("SELECT * FROM syncstats WHERE collectionId=:id")
fun getLiveByCollectionId(id: Long): LiveData<List<SyncStats>>
fun getByCollectionIdFlow(id: Long): Flow<List<SyncStats>>
}

View file

@ -0,0 +1,304 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.app.Application
import android.content.ContentResolver
import android.content.pm.PackageManager
import android.provider.CalendarContract
import android.provider.ContactsContract
import androidx.core.content.ContextCompat
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.servicedetection.DavResourceFinder
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.syncadapter.AccountUtils
import at.bitfire.davdroid.syncadapter.AccountsCleanupWorker
import at.bitfire.davdroid.syncadapter.BaseSyncWorker
import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.vcard4android.GroupMethod
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext
import java.util.logging.Level
import javax.inject.Inject
/**
* Repository for managing CalDAV/CardDAV accounts.
*
* *Note:* This class is not related to address book accounts, which are managed by
* [at.bitfire.davdroid.resource.LocalAddressBook].
*/
class AccountRepository @Inject constructor(
val context: Application,
val db: AppDatabase,
val settingsManager: SettingsManager,
val serviceRepository: DavServiceRepository
) {
private val accountType = context.getString(R.string.account_type)
private val accountManager = AccountManager.get(context)
/**
* Creates a new main account with discovered services and enables periodic syncs with
* default sync interval times.
*
* @param accountName name of the account
* @param credentials server credentials
* @param config discovered server capabilities for syncable authorities
* @param groupMethod whether CardDAV contact groups are separate VCards or as contact categories
*
* @return account if account creation was successful; null otherwise (for instance because an account with this name already exists)
*/
fun create(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? {
val account = account(accountName)
// create Android account
val userData = AccountSettings.initialUserData(credentials)
Logger.log.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData))
if (!AccountUtils.createAccount(context, account, userData, credentials?.password))
return null
// add entries for account to service DB
Logger.log.log(Level.INFO, "Writing account configuration to database", config)
try {
val accountSettings = AccountSettings(context, account)
val defaultSyncInterval = settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL)
// Configure CardDAV service
val addrBookAuthority = context.getString(R.string.address_books_authority)
if (config.cardDAV != null) {
// insert CardDAV service
val id = insertService(accountName, Service.TYPE_CARDDAV, config.cardDAV)
// initial CardDAV account settings
accountSettings.setGroupMethod(groupMethod)
// start CardDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
// set default sync interval and enable sync regardless of permissions
ContentResolver.setIsSyncable(account, addrBookAuthority, 1)
accountSettings.setSyncInterval(addrBookAuthority, defaultSyncInterval)
} else
ContentResolver.setIsSyncable(account, addrBookAuthority, 0)
// Configure CalDAV service
if (config.calDAV != null) {
// insert CalDAV service
val id = insertService(accountName, Service.TYPE_CALDAV, config.calDAV)
// start CalDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
// set default sync interval and enable sync regardless of permissions
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
accountSettings.setSyncInterval(CalendarContract.AUTHORITY, defaultSyncInterval)
// if task provider present, set task sync interval and enable sync
val taskProvider = TaskUtils.currentProvider(context)
if (taskProvider != null) {
ContentResolver.setIsSyncable(account, taskProvider.authority, 1)
accountSettings.setSyncInterval(taskProvider.authority, defaultSyncInterval)
// further changes will be handled by TasksWatcher on app start or when tasks app is (un)installed
Logger.log.info("Tasks provider ${taskProvider.authority} found. Tasks sync enabled.")
} else
Logger.log.info("No tasks provider found. Did not enable tasks sync.")
} else
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0)
} catch(e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Couldn't access account settings", e)
return null
}
return account
}
suspend fun delete(accountName: String): Boolean {
// remove account
val future = accountManager.removeAccount(account(accountName), null, null, null)
return try {
// wait for operation to complete
withContext(Dispatchers.Default) {
// blocks calling thread
future.result
}
true
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't remove account $accountName", e)
false
}
}
fun exists(accountName: String): Boolean =
if (accountName.isEmpty())
false
else
accountManager
.getAccountsByType(accountType)
.contains(Account(accountName, accountType))
fun getAll(): Array<Account> = accountManager.getAccountsByType(accountType)
fun getAllFlow() = callbackFlow<Set<Account>> {
val listener = OnAccountsUpdateListener { accounts ->
trySend(accounts.filter { it.type == accountType }.toSet())
}
accountManager.addOnAccountsUpdatedListener(listener, null, true)
awaitClose {
accountManager.removeOnAccountsUpdatedListener(listener)
}
}
/**
* Renames an account.
*
* **Not**: It is highly advised to re-sync the account after renaming in order to restore
* a consistent state.
*
* @param oldName current name of the account
* @param newName new name the account shall be re named to
*
* @throws InvalidAccountException if the account does not exist
* @throws IllegalArgumentException if the new account name already exists
* @throws Exception (or sub-classes) on other errors
*/
suspend fun rename(oldName: String, newName: String) {
val oldAccount = account(oldName)
val newAccount = account(newName)
// check whether new account name already exists
if (accountManager.getAccountsByType(context.getString(R.string.account_type)).contains(newAccount))
throw IllegalArgumentException("Account with name \"$newName\" already exists")
// remember sync intervals
val oldSettings = AccountSettings(context, oldAccount)
val authorities = mutableListOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY
)
val tasksProvider = TaskUtils.currentProvider(context)
tasksProvider?.authority?.let { authorities.add(it) }
val syncIntervals = authorities.map { Pair(it, oldSettings.getSyncInterval(it)) }
// rename account
try {
/* https://github.com/bitfireAT/davx5/issues/135
Lock accounts cleanup so that the AccountsCleanupWorker doesn't run while we rename the account
because this can cause problems when:
1. The account is renamed.
2. The AccountsCleanupWorker is called BEFORE the services table is updated.
AccountsCleanupWorker removes the "orphaned" services because they belong to the old account which doesn't exist anymore
3. Now the services would be renamed, but they're not here anymore. */
AccountsCleanupWorker.lockAccountsCleanup()
// rename account
val future = accountManager.renameAccount(oldAccount, newName, null, null)
// wait for operation to complete
withContext(Dispatchers.Default) {
// blocks calling thread
val newNameFromApi: Account = future.result
if (newNameFromApi.name != newName)
throw IllegalStateException("renameAccount returned ${newNameFromApi.name} instead of $newName")
}
// account renamed, cancel maybe running synchronization of old account
BaseSyncWorker.cancelAllWork(context, oldAccount)
// disable periodic syncs for old account
syncIntervals.forEach { (authority, _) ->
PeriodicSyncWorker.disable(context, oldAccount, authority)
}
// update account name references in database
serviceRepository.onAccountRenamed(oldName, newName)
// update main account of address book accounts
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) == PackageManager.PERMISSION_GRANTED)
try {
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.use { provider ->
for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
val addressBook = LocalAddressBook(context, addrBookAccount, provider)
if (oldAccount == addressBook.mainAccount)
addressBook.mainAccount = Account(newName, oldAccount.type)
}
}
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't update address book accounts", e)
// Couldn't update address book accounts, but this is not a fatal error (will be fixed at next sync)
}
// calendar provider doesn't allow changing account_name of Events
// (all events will have to be downloaded again at next sync)
// update account_name of local tasks
try {
LocalTaskList.onRenameAccount(context, oldAccount.name, newName)
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't propagate new account name to tasks provider", e)
// Couldn't update task lists, but this is not a fatal error (will be fixed at next sync)
}
// restore sync intervals
val newSettings = AccountSettings(context, newAccount)
for ((authority, interval) in syncIntervals) {
if (interval == null)
ContentResolver.setIsSyncable(newAccount, authority, 0)
else {
ContentResolver.setIsSyncable(newAccount, authority, 1)
newSettings.setSyncInterval(authority, interval)
}
}
} finally {
// release AccountsCleanupWorker mutex at the end of this async coroutine
AccountsCleanupWorker.unlockAccountsCleanup()
}
}
// helpers
private fun account(accountName: String) = Account(accountName, accountType)
private fun insertService(accountName: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long {
// insert service
val service = Service(0, accountName, type, info.principal)
val serviceId = db.serviceDao().insertOrReplace(service)
// insert home sets
val homeSetDao = db.homeSetDao()
for (homeSet in info.homeSets)
homeSetDao.insertOrUpdateByUrl(HomeSet(0, serviceId, true, homeSet))
// insert collections
val collectionDao = db.collectionDao()
for (collection in info.collections.values) {
collection.serviceId = serviceId
collectionDao.insertOrUpdateByUrl(collection)
}
return serviceId
}
}

View file

@ -0,0 +1,289 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import android.accounts.Account
import android.app.Application
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.property.caldav.NS_APPLE_ICAL
import at.bitfire.dav4jvm.property.caldav.NS_CALDAV
import at.bitfire.dav4jvm.property.carddav.NS_CARDDAV
import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.ical4android.util.DateUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import java.io.StringWriter
import java.util.UUID
import javax.inject.Inject
class DavCollectionRepository @Inject constructor(
val context: Application,
db: AppDatabase
) {
private val serviceDao = db.serviceDao()
private val dao = db.collectionDao()
suspend fun anyWebcal(serviceId: Long) =
dao.anyOfType(serviceId, Collection.TYPE_WEBCAL)
suspend fun createAddressBook(
account: Account,
homeSet: HomeSet,
displayName: String,
description: String?
) {
val folderName = UUID.randomUUID().toString()
val url = homeSet.url.newBuilder()
.addPathSegment(folderName)
.addPathSegment("") // trailing slash
.build()
// create collection on server
createOnServer(
account = account,
url = url,
method = "MKCOL",
xmlBody = generateMkColXml(
addressBook = true,
displayName = displayName,
description = description
)
)
// no HTTP error -> create collection locally
val collection = Collection(
serviceId = homeSet.serviceId,
homeSetId = homeSet.id,
url = url,
type = Collection.TYPE_ADDRESSBOOK, //if (addressBook) Collection.TYPE_ADDRESSBOOK else Collection.TYPE_CALENDAR,
displayName = displayName,
description = description
)
dao.insertAsync(collection)
}
suspend fun createCalendar(
account: Account,
homeSet: HomeSet,
color: Int?,
displayName: String,
description: String?,
timeZoneId: String?,
supportVEVENT: Boolean,
supportVTODO: Boolean,
supportVJOURNAL: Boolean
) {
val folderName = UUID.randomUUID().toString()
val url = homeSet.url.newBuilder()
.addPathSegment(folderName)
.addPathSegment("") // trailing slash
.build()
// create collection on server
createOnServer(
account = account,
url = url,
method = "MKCALENDAR",
xmlBody = generateMkColXml(
addressBook = false,
displayName = displayName,
description = description,
color = color,
timezoneDef = timeZoneId,
supportsVEVENT = supportVEVENT,
supportsVTODO = supportVTODO,
supportsVJOURNAL = supportVJOURNAL
)
)
// no HTTP error -> create collection locally
val collection = Collection(
serviceId = homeSet.serviceId,
homeSetId = homeSet.id,
url = url,
type = Collection.TYPE_CALENDAR,
displayName = displayName,
description = description,
color = color,
timezone = timeZoneId?.let { getVTimeZone(it) },
supportsVEVENT = supportVEVENT,
supportsVTODO = supportVTODO,
supportsVJOURNAL = supportVJOURNAL
)
dao.insertAsync(collection)
// Trigger service detection (because the collection may actually have other properties than the ones we have inserted).
// Some servers are known to change the supported components (VEVENT, …) after creation.
RefreshCollectionsWorker.enqueue(context, homeSet.serviceId)
}
/** Deletes the given collection from the server and the database. */
suspend fun delete(collection: Collection) {
val service = serviceDao.get(collection.serviceId) ?: throw IllegalArgumentException("Service not found")
val account = Account(service.accountName, context.getString(R.string.account_type))
HttpClient.Builder(context, AccountSettings(context, account))
.setForeground(true)
.build().use { httpClient ->
withContext(Dispatchers.IO) {
runInterruptible {
DavResource(httpClient.okHttpClient, collection.url).delete() {
// success, otherwise an exception would have been thrown
dao.delete(collection)
}
}
}
}
}
fun getFlow(id: Long) = dao.getFlow(id)
suspend fun setForceReadOnly(id: Long, forceReadOnly: Boolean) {
dao.updateForceReadOnly(id, forceReadOnly)
}
suspend fun setSync(id: Long, forceReadOnly: Boolean) {
dao.updateSync(id, forceReadOnly)
}
// helpers
private suspend fun createOnServer(account: Account, url: HttpUrl, method: String, xmlBody: String) {
HttpClient.Builder(context, AccountSettings(context, account))
.setForeground(true)
.build().use { httpClient ->
withContext(Dispatchers.IO) {
runInterruptible {
DavResource(httpClient.okHttpClient, url).mkCol(
xmlBody = xmlBody,
method = method
) {
// success, otherwise an exception would have been thrown
}
}
}
}
}
private fun generateMkColXml(
addressBook: Boolean,
displayName: String?,
description: String?,
color: Int? = null,
timezoneDef: String? = null,
supportsVEVENT: Boolean = true,
supportsVTODO: Boolean = true,
supportsVJOURNAL: Boolean = true
): String {
val writer = StringWriter()
val serializer = XmlUtils.newSerializer()
serializer.apply {
setOutput(writer)
startDocument("UTF-8", null)
setPrefix("", NS_WEBDAV)
setPrefix("CAL", NS_CALDAV)
setPrefix("CARD", NS_CARDDAV)
if (addressBook)
startTag(NS_WEBDAV, "mkcol")
else
startTag(NS_CALDAV, "mkcalendar")
startTag(NS_WEBDAV, "set")
startTag(NS_WEBDAV, "prop")
startTag(NS_WEBDAV, "resourcetype")
startTag(NS_WEBDAV, "collection")
endTag(NS_WEBDAV, "collection")
if (addressBook) {
startTag(NS_CARDDAV, "addressbook")
endTag(NS_CARDDAV, "addressbook")
} else {
startTag(NS_CALDAV, "calendar")
endTag(NS_CALDAV, "calendar")
}
endTag(NS_WEBDAV, "resourcetype")
displayName?.let {
startTag(NS_WEBDAV, "displayname")
text(it)
endTag(NS_WEBDAV, "displayname")
}
if (addressBook) {
// addressbook-specific properties
description?.let {
startTag(NS_CARDDAV, "addressbook-description")
text(it)
endTag(NS_CARDDAV, "addressbook-description")
}
} else {
// calendar-specific properties
description?.let {
startTag(NS_CALDAV, "calendar-description")
text(it)
endTag(NS_CALDAV, "calendar-description")
}
color?.let {
startTag(NS_APPLE_ICAL, "calendar-color")
text(DavUtils.ARGBtoCalDAVColor(it))
endTag(NS_APPLE_ICAL, "calendar-color")
}
timezoneDef?.let {
startTag(NS_CALDAV, "calendar-timezone")
cdsect(it)
endTag(NS_CALDAV, "calendar-timezone")
}
if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) {
// Only if there's at least one not explicitly supported calendar component set,
// otherwise don't include the property, which means "supports everything".
if (supportsVEVENT) {
startTag(NS_CALDAV, "comp")
attribute(null, "name", "VEVENT")
endTag(NS_CALDAV, "comp")
}
if (supportsVTODO) {
startTag(NS_CALDAV, "comp")
attribute(null, "name", "VTODO")
endTag(NS_CALDAV, "comp")
}
if (supportsVJOURNAL) {
startTag(NS_CALDAV, "comp")
attribute(null, "name", "VJOURNAL")
endTag(NS_CALDAV, "comp")
}
}
}
endTag(NS_WEBDAV, "prop")
endTag(NS_WEBDAV, "set")
if (addressBook)
endTag(NS_WEBDAV, "mkcol")
else
endTag(NS_CALDAV, "mkcalendar")
endDocument()
}
return writer.toString()
}
private fun getVTimeZone(tzId: String): String? =
DateUtils.ical4jTimeZone(tzId)?.toString()
}

View file

@ -0,0 +1,24 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import android.accounts.Account
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Service
import javax.inject.Inject
class DavHomeSetRepository @Inject constructor(
db: AppDatabase
) {
val dao = db.homeSetDao()
fun getAddressBookHomeSetsFlow(account: Account) =
dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CARDDAV)
fun getCalendarHomeSetsFlow(account: Account) =
dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CALDAV)
}

View file

@ -0,0 +1,27 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Service
import javax.inject.Inject
class DavServiceRepository @Inject constructor(
private val db: AppDatabase
) {
private val dao = db.serviceDao()
fun getCalDavServiceFlow(accountName: String) =
dao.getByAccountAndTypeFlow(accountName, Service.TYPE_CALDAV)
fun getCardDavServiceFlow(accountName: String) =
dao.getByAccountAndTypeFlow(accountName, Service.TYPE_CARDDAV)
suspend fun onAccountRenamed(oldName: String, newName: String) {
dao.renameAccount(oldName, newName)
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.repository
import android.app.Application
import android.content.pm.PackageManager
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.log.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.text.Collator
import javax.inject.Inject
class DavSyncStatsRepository @Inject constructor(
val context: Application,
db: AppDatabase
) {
private val dao = db.syncStatsDao()
data class LastSynced(
val appName: String,
val lastSynced: Long
)
fun getLastSyncedFlow(collectionId: Long): Flow<List<LastSynced>> =
dao.getByCollectionIdFlow(collectionId).map { list ->
val collator = Collator.getInstance()
list.map { stats ->
LastSynced(
appName = appNameFromAuthority(stats.authority),
lastSynced = stats.lastSync
)
}.sortedWith { a, b ->
collator.compare(a.appName, b.appName)
}
}
/**
* Tries to find the application name for given authority. Returns the authority if not
* found.
*
* @param authority authority to find the application name for (ie "at.techbee.jtx")
* @return the application name of authority (ie "jtx Board")
*/
private fun appNameFromAuthority(authority: String): String {
val packageManager = context.packageManager
val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority
return try {
val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo
packageManager.getApplicationLabel(appInfo).toString()
} catch (e: PackageManager.NameNotFoundException) {
Logger.log.warning("Application name not found for authority: $authority")
authority
}
}
}

View file

@ -11,7 +11,6 @@ import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.hilt.work.HiltWorker
import androidx.lifecycle.map
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
@ -67,6 +66,7 @@ import at.bitfire.davdroid.ui.account.AccountSettingsActivity
import at.bitfire.davdroid.util.DavUtils.parent
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runInterruptible
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
@ -166,15 +166,16 @@ class RefreshCollectionsWorker @AssistedInject constructor(
}
/**
* Will tell whether a refresh worker with given service id and state exists
* Observes whether a refresh worker with given service id and state exists.
*
* @param workerName name of worker to find
* @param workState state of worker to match
* @return boolean true if worker with matching state was found
*
* @return flow that emits `true` if worker with matching state was found (otherwise `false`)
*/
fun exists(context: Context, workerName: String, workState: WorkInfo.State = WorkInfo.State.RUNNING) =
WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(workerName).map {
workInfoList -> workInfoList.any { workInfo -> workInfo.state == workState }
fun existsFlow(context: Context, workerName: String, workState: WorkInfo.State = WorkInfo.State.RUNNING) =
WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(workerName).map { workInfoList ->
workInfoList.any { workInfo -> workInfo.state == workState }
}
}

View file

@ -1,166 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.app.Application
import android.content.ContentResolver
import android.provider.CalendarContract
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.servicedetection.DavResourceFinder
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.vcard4android.GroupMethod
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.callbackFlow
import java.util.logging.Level
import javax.inject.Inject
/**
* Repository for managing CalDAV/CardDAV accounts.
*
* *Note:* This class is not related to address book accounts, which are managed by
* [at.bitfire.davdroid.resource.LocalAddressBook].
*/
class AccountRepository @Inject constructor(
val context: Application,
val db: AppDatabase,
val settingsManager: SettingsManager
) {
val accountType = context.getString(R.string.account_type)
val accountManager = AccountManager.get(context)
/**
* Creates a new main account with discovered services and enables periodic syncs with
* default sync interval times.
*
* @param accountName name of the account
* @param credentials server credentials
* @param config discovered server capabilities for syncable authorities
* @param groupMethod whether CardDAV contact groups are separate VCards or as contact categories
*
* @return account if account creation was successful; null otherwise (for instance because an account with this name already exists)
*/
fun create(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? {
val account = Account(accountName, context.getString(R.string.account_type))
// create Android account
val userData = AccountSettings.initialUserData(credentials)
Logger.log.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData))
if (!AccountUtils.createAccount(context, account, userData, credentials?.password))
return null
// add entries for account to service DB
Logger.log.log(Level.INFO, "Writing account configuration to database", config)
try {
val accountSettings = AccountSettings(context, account)
val defaultSyncInterval = settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL)
// Configure CardDAV service
val addrBookAuthority = context.getString(R.string.address_books_authority)
if (config.cardDAV != null) {
// insert CardDAV service
val id = insertService(accountName, Service.TYPE_CARDDAV, config.cardDAV)
// initial CardDAV account settings
accountSettings.setGroupMethod(groupMethod)
// start CardDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
// set default sync interval and enable sync regardless of permissions
ContentResolver.setIsSyncable(account, addrBookAuthority, 1)
accountSettings.setSyncInterval(addrBookAuthority, defaultSyncInterval)
} else
ContentResolver.setIsSyncable(account, addrBookAuthority, 0)
// Configure CalDAV service
if (config.calDAV != null) {
// insert CalDAV service
val id = insertService(accountName, Service.TYPE_CALDAV, config.calDAV)
// start CalDAV service detection (refresh collections)
RefreshCollectionsWorker.enqueue(context, id)
// set default sync interval and enable sync regardless of permissions
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)
accountSettings.setSyncInterval(CalendarContract.AUTHORITY, defaultSyncInterval)
// if task provider present, set task sync interval and enable sync
val taskProvider = TaskUtils.currentProvider(context)
if (taskProvider != null) {
ContentResolver.setIsSyncable(account, taskProvider.authority, 1)
accountSettings.setSyncInterval(taskProvider.authority, defaultSyncInterval)
// further changes will be handled by TasksWatcher on app start or when tasks app is (un)installed
Logger.log.info("Tasks provider ${taskProvider.authority} found. Tasks sync enabled.")
} else
Logger.log.info("No tasks provider found. Did not enable tasks sync.")
} else
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0)
} catch(e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Couldn't access account settings", e)
return null
}
return account
}
private fun insertService(accountName: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long {
// insert service
val service = Service(0, accountName, type, info.principal)
val serviceId = db.serviceDao().insertOrReplace(service)
// insert home sets
val homeSetDao = db.homeSetDao()
for (homeSet in info.homeSets)
homeSetDao.insertOrUpdateByUrl(HomeSet(0, serviceId, true, homeSet))
// insert collections
val collectionDao = db.collectionDao()
for (collection in info.collections.values) {
collection.serviceId = serviceId
collectionDao.insertOrUpdateByUrl(collection)
}
return serviceId
}
fun exists(accountName: String): Boolean =
if (accountName.isEmpty())
false
else
accountManager
.getAccountsByType(accountType)
.contains(Account(accountName, accountType))
fun getAll() = accountManager.getAccountsByType(accountType)
fun getAllFlow() = callbackFlow<Set<Account>> {
val listener = OnAccountsUpdateListener { accounts ->
trySend(accounts.filter { it.type == accountType }.toSet())
}
accountManager.addOnAccountsUpdatedListener(listener, null, true)
awaitClose {
accountManager.removeOnAccountsUpdatedListener(listener)
}
}
}

View file

@ -15,8 +15,6 @@ import android.provider.CalendarContract
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import androidx.lifecycle.LiveData
import androidx.lifecycle.map
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.WorkInfo
@ -34,6 +32,8 @@ import at.bitfire.davdroid.ui.account.WifiPermissionsActivity
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.ical4android.TaskProvider
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
@ -84,14 +84,15 @@ abstract class BaseSyncWorker(
"sync-$authority ${account.type}/${account.name}"
/**
* Will tell whether >0 sync workers (both [PeriodicSyncWorker] and [OneTimeSyncWorker])
* Observes whether >0 sync workers (both [PeriodicSyncWorker] and [OneTimeSyncWorker])
* exist, belonging to given account and authorities, and which are/is in the given worker state.
*
* @param workStates list of states of workers to match
* @param account the account which the workers belong to
* @param authorities type of sync work, ie [CalendarContract.AUTHORITY]
* @param whichTag function to generate tag that should be observed for given account and authority
* @return *true* if at least one worker with matching query was found; *false* otherwise
*
* @return flow that emits `true` if at least one worker with matching query was found; `false` otherwise
*/
fun exists(
context: Context,
@ -101,15 +102,15 @@ abstract class BaseSyncWorker(
whichTag: (account: Account, authority: String) -> String = { account, authority ->
commonTag(account, authority)
}
): LiveData<Boolean> {
val workQuery = WorkQuery.Builder
.fromStates(workStates)
): Flow<Boolean> {
val workQuery = WorkQuery.Builder.fromStates(workStates)
if (account != null && authorities != null)
workQuery.addTags(
authorities.map { authority -> whichTag(account, authority) }
)
return WorkManager.getInstance(context)
.getWorkInfosLiveData(workQuery.build()).map { workInfoList ->
.getWorkInfosFlow(workQuery.build())
.map { workInfoList ->
workInfoList.isNotEmpty()
}
}

View file

@ -17,11 +17,12 @@ import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.syncadapter.AccountRepository
import at.bitfire.davdroid.syncadapter.BaseSyncWorker
import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker
import at.bitfire.davdroid.syncadapter.SyncUtils
import at.bitfire.davdroid.ui.account.AccountProgress
import at.bitfire.davdroid.util.broadcastReceiverFlow
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.awaitClose
@ -41,13 +42,6 @@ class AccountsModel @Inject constructor(
// UI state
/** Tri-state enum to represent active / pending / idle status */
enum class Progress {
Active, // syncing or refreshing
Pending, // sync pending
Idle
}
enum class FABStyle {
WithText,
Standard,
@ -56,7 +50,7 @@ class AccountsModel @Inject constructor(
data class AccountInfo(
val name: Account,
val progress: Progress
val progress: AccountProgress
)
private val accounts = accountRepository.getAllFlow()
@ -88,15 +82,15 @@ class AccountsModel @Inject constructor(
info.tags.contains(BaseSyncWorker.commonTag(account, authority))
}
)
} -> Progress.Active
} -> AccountProgress.Active
workInfos.any { info ->
info.state == WorkInfo.State.ENQUEUED && authorities.any { authority ->
info.tags.contains(OneTimeSyncWorker.workerName(account, authority))
}
} -> Progress.Pending
} -> AccountProgress.Pending
else -> Progress.Idle
else -> AccountProgress.Idle
}
AccountInfo(account, progress)

View file

@ -67,7 +67,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.account.progressAlpha
import at.bitfire.davdroid.ui.account.AccountProgress
import at.bitfire.davdroid.ui.composable.ActionCard
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
@ -316,7 +316,7 @@ fun AccountsScreen_Preview_OneAccount() {
accounts = listOf(
AccountsModel.AccountInfo(
Account("Account Name", "test"),
AccountsModel.Progress.Idle
AccountProgress.Idle
)
)
)
@ -354,23 +354,22 @@ fun AccountList(
modifier = Modifier
.clickable { onClickAccount(account) }
.fillMaxWidth()
.padding(vertical = 4.dp)
.padding(bottom = 8.dp)
) {
Column {
val progressAlpha = progressAlpha(progress)
val progressAlpha = progress.rememberAlpha()
when (progress) {
AccountsModel.Progress.Active ->
AccountProgress.Active ->
LinearProgressIndicator(
//color = MaterialTheme.colors.onSecondary,
modifier = Modifier
.alpha(progressAlpha)
.fillMaxWidth()
)
AccountsModel.Progress.Pending,
AccountsModel.Progress.Idle ->
AccountProgress.Pending,
AccountProgress.Idle ->
LinearProgressIndicator(
progress = 1f,
//color = MaterialTheme.colors.onSecondary,
modifier = Modifier
.alpha(progressAlpha)
.fillMaxWidth()
@ -383,15 +382,15 @@ fun AccountList(
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.size(48.dp)
.size(32.dp)
)
Text(
text = account.name,
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(top = 8.dp)
.padding(top = 4.dp)
.fillMaxWidth()
)
}
@ -407,7 +406,7 @@ fun AccountList_Preview_Idle() {
listOf(
AccountsModel.AccountInfo(
Account("Account Name", "test"),
AccountsModel.Progress.Idle
AccountProgress.Idle
)
)
)
@ -419,7 +418,7 @@ fun AccountList_Preview_SyncPending() {
AccountList(listOf(
AccountsModel.AccountInfo(
Account("Account Name", "test"),
AccountsModel.Progress.Pending
AccountProgress.Pending
)
))
}
@ -430,7 +429,7 @@ fun AccountList_Preview_Syncing() {
AccountList(listOf(
AccountsModel.AccountInfo(
Account("Account Name", "test"),
AccountsModel.Progress.Active
AccountProgress.Active
)
))
}

View file

@ -4,755 +4,64 @@
package at.bitfire.davdroid.ui.account
import android.Manifest
import AccountScreen
import android.accounts.Account
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.AlertDialog
import androidx.compose.material.Checkbox
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.SnackbarDuration
import androidx.compose.material.SnackbarHost
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DriveFileRenameOutline
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.outlined.RuleFolder
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker
import at.bitfire.davdroid.ui.AccountsModel
import at.bitfire.davdroid.ui.M2Theme
import at.bitfire.davdroid.ui.PermissionsActivity
import at.bitfire.davdroid.ui.composable.ActionCard
import at.bitfire.davdroid.util.TaskUtils
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
import dagger.hilt.android.components.ActivityComponent
@AndroidEntryPoint
class AccountActivity : AppCompatActivity() {
@EntryPoint
@InstallIn(ActivityComponent::class)
interface AccountScreenEntryPoint {
fun accountModelAssistedFactory(): AccountScreenModel.Factory
}
companion object {
const val EXTRA_ACCOUNT = "account"
}
@Inject
lateinit var modelFactory: AccountModel.Factory
val model by viewModels<AccountModel> {
val account = intent.getParcelableExtra(EXTRA_ACCOUNT) as? Account
?: throw IllegalArgumentException("AccountActivity requires EXTRA_ACCOUNT")
object: ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T: ViewModel> create(modelClass: Class<T>) =
modelFactory.create(account) as T
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
model.invalid.observe(this) { invalid ->
if (invalid)
// account does not exist anymore
finish()
}
model.renameAccountError.observe(this) { error ->
if (error != null) {
Toast.makeText(this, error, Toast.LENGTH_LONG).show()
model.renameAccountError.value = null
}
}
val account = intent.getParcelableExtra(EXTRA_ACCOUNT) as? Account
?: throw IllegalArgumentException("AccountActivity requires EXTRA_ACCOUNT")
setContent {
M2Theme {
val cardDavSvc by model.cardDavSvc.observeAsState()
val canCreateAddressBook by model.canCreateAddressBook.observeAsState(false)
val cardDavRefreshing by model.cardDavRefreshing.observeAsState(false)
val cardDavSyncPending by model.cardDavSyncPending.observeAsState(false)
val cardDavSyncing by model.cardDavSyncing.observeAsState(false)
val cardDavProgress: AccountsModel.Progress = when {
cardDavRefreshing || cardDavSyncing -> AccountsModel.Progress.Active
cardDavSyncPending -> AccountsModel.Progress.Pending
else -> AccountsModel.Progress.Idle
}
val addressBooks by model.addressBooksPager.observeAsState()
val calDavSvc by model.calDavSvc.observeAsState()
val canCreateCalendar by model.canCreateCalendar.observeAsState(false)
val calDavRefreshing by model.calDavRefreshing.observeAsState(false)
val calDavSyncPending by model.calDavSyncPending.observeAsState(false)
val calDavSyncing by model.calDavSyncing.observeAsState(false)
val calDavProgress: AccountsModel.Progress = when {
calDavRefreshing || calDavSyncing -> AccountsModel.Progress.Active
calDavSyncPending -> AccountsModel.Progress.Pending
else -> AccountsModel.Progress.Idle
}
val calendars by model.calendarsPager.observeAsState()
val subscriptions by model.webcalPager.observeAsState()
var installIcsx5 by remember { mutableStateOf(false) }
AccountOverview(
account = model.account,
showOnlyPersonal =
model.showOnlyPersonal.observeAsState(
AccountSettings.ShowOnlyPersonal(onlyPersonal = false, locked = true)
).value,
onSetShowOnlyPersonal = {
model.setShowOnlyPersonal(it)
},
hasCardDav = cardDavSvc != null,
canCreateAddressBook = canCreateAddressBook,
cardDavProgress = cardDavProgress,
cardDavRefreshing = cardDavRefreshing,
addressBooks = addressBooks?.flow?.collectAsLazyPagingItems(),
hasCalDav = calDavSvc != null,
canCreateCalendar = canCreateCalendar,
calDavProgress = calDavProgress,
calDavRefreshing = calDavRefreshing,
calendars = calendars?.flow?.collectAsLazyPagingItems(),
subscriptions = subscriptions?.flow?.collectAsLazyPagingItems(),
onUpdateCollectionSync = { collectionId, sync ->
model.setCollectionSync(collectionId, sync)
},
onChangeForceReadOnly = { id, forceReadOnly ->
model.setCollectionForceReadOnly(id, forceReadOnly)
},
onSubscribe = { item ->
installIcsx5 = !subscribeWebcal(item)
},
installIcsx5 = installIcsx5,
onRefreshCollections = {
cardDavSvc?.let { svc ->
RefreshCollectionsWorker.enqueue(this@AccountActivity, svc.id)
}
calDavSvc?.let { svc ->
RefreshCollectionsWorker.enqueue(this@AccountActivity, svc.id)
}
},
onSync = {
OneTimeSyncWorker.enqueueAllAuthorities(this, model.account, manual = true)
},
onAccountSettings = {
val intent = Intent(this, AccountSettingsActivity::class.java)
intent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, model.account)
startActivity(intent, null)
},
onRenameAccount = { newName ->
model.renameAccount(newName)
},
onDeleteAccount = {
model.deleteAccount()
},
onNavigateUp = ::onSupportNavigateUp
)
}
}
}
/**
* Subscribes to a Webcal using a compatible app like ICSx5.
*
* @return true if a compatible Webcal app is installed, false otherwise
*/
private fun subscribeWebcal(item: Collection): Boolean {
// subscribe
var uri = Uri.parse(item.source.toString())
when {
uri.scheme.equals("http", true) -> uri = uri.buildUpon().scheme("webcal").build()
uri.scheme.equals("https", true) -> uri = uri.buildUpon().scheme("webcals").build()
}
val intent = Intent(Intent.ACTION_VIEW, uri)
item.displayName?.let { intent.putExtra("title", it) }
item.color?.let { intent.putExtra("color", it) }
if (packageManager.resolveActivity(intent, 0) != null) {
startActivity(intent)
return true
}
return false
}
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class)
@Composable
fun AccountOverview(
account: Account,
showOnlyPersonal: AccountSettings.ShowOnlyPersonal,
onSetShowOnlyPersonal: (showOnlyPersonal: Boolean) -> Unit,
hasCardDav: Boolean,
canCreateAddressBook: Boolean,
cardDavProgress: AccountsModel.Progress,
cardDavRefreshing: Boolean,
addressBooks: LazyPagingItems<Collection>?,
hasCalDav: Boolean,
canCreateCalendar: Boolean,
calDavProgress: AccountsModel.Progress,
calDavRefreshing: Boolean,
calendars: LazyPagingItems<Collection>?,
subscriptions: LazyPagingItems<Collection>?,
onUpdateCollectionSync: (collectionId: Long, sync: Boolean) -> Unit = { _, _ -> },
onChangeForceReadOnly: (collectionId: Long, forceReadOnly: Boolean) -> Unit = { _, _ -> },
onSubscribe: (Collection) -> Unit = {},
installIcsx5: Boolean = false,
onRefreshCollections: () -> Unit = {},
onSync: () -> Unit = {},
onAccountSettings: () -> Unit = {},
onRenameAccount: (newName: String) -> Unit = {},
onDeleteAccount: () -> Unit = {},
onNavigateUp: () -> Unit = {}
) {
val context = LocalContext.current
val pullRefreshing by remember { mutableStateOf(false) }
val pullRefreshState = rememberPullRefreshState(
pullRefreshing,
onRefresh = onRefreshCollections
)
// tabs calculation
var nextIdx = -1
@Suppress("KotlinConstantConditions")
val idxCardDav: Int? = if (hasCardDav) ++nextIdx else null
val idxCalDav: Int? = if (hasCalDav) ++nextIdx else null
val idxWebcal: Int? = if ((subscriptions?.itemCount ?: 0) > 0) ++nextIdx else null
val nrPages =
(if (idxCardDav != null) 1 else 0) +
(if (idxCalDav != null) 1 else 0) +
(if (idxWebcal != null) 1 else 0)
val pagerState = rememberPagerState(pageCount = { nrPages })
// snackbar
val snackbarHostState = remember { SnackbarHostState() }
AccountOverview_SnackbarContent(
snackbarHostState = snackbarHostState,
currentPageIsCardDav = pagerState.currentPage == idxCardDav,
cardDavRefreshing = cardDavRefreshing,
calDavRefreshing = calDavRefreshing
)
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up))
}
AccountScreen(
account = account,
onAccountSettings = {
val intent = Intent(this, AccountSettingsActivity::class.java)
intent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
startActivity(intent, null)
},
title = {
Text(
account.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
onCreateAddressBook = {
val intent = Intent(this, CreateAddressBookActivity::class.java)
intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, account)
startActivity(intent)
},
actions = {
AccountOverview_Actions(
account = account,
canCreateAddressBook = canCreateAddressBook,
canCreateCalendar = canCreateCalendar,
showOnlyPersonal = showOnlyPersonal,
onSetShowOnlyPersonal = onSetShowOnlyPersonal,
currentPage = pagerState.currentPage,
idxCardDav = idxCardDav,
idxCalDav = idxCalDav,
onRenameAccount = onRenameAccount,
onDeleteAccount = onDeleteAccount,
onAccountSettings = onAccountSettings
)
}
onCreateCalendar = {
val intent = Intent(this, CreateCalendarActivity::class.java)
intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, account)
startActivity(intent)
},
onCollectionDetails = { collection ->
val intent = Intent(this, CollectionActivity::class.java)
intent.putExtra(CollectionActivity.EXTRA_ACCOUNT, account)
intent.putExtra(CollectionActivity.EXTRA_COLLECTION_ID, collection.id)
startActivity(intent, null)
},
onNavUp = ::onSupportNavigateUp,
onFinish = ::finish
)
},
floatingActionButton = {
Column {
FloatingActionButton(
onClick = onRefreshCollections,
backgroundColor = MaterialTheme.colors.background,
modifier = Modifier.padding(bottom = 16.dp)
) {
// Material 3: add Tooltip
Icon(Icons.Outlined.RuleFolder, stringResource(R.string.account_refresh_collections))
}
if (pagerState.currentPage == idxCardDav || pagerState.currentPage == idxCalDav)
FloatingActionButton(onClick = onSync) {
// Material 3: add Tooltip
Icon(Icons.Default.Sync, stringResource(R.string.account_synchronize_now))
}
}
},
snackbarHost = {
SnackbarHost(snackbarHostState)
},
modifier = Modifier.pullRefresh(pullRefreshState)
) { padding ->
Column {
if (nrPages > 0) {
val scope = rememberCoroutineScope()
TabRow(
selectedTabIndex = pagerState.currentPage,
modifier = Modifier.padding(padding)
) {
if (idxCardDav != null)
Tab(
selected = pagerState.currentPage == idxCardDav,
onClick = {
scope.launch {
pagerState.scrollToPage(idxCardDav)
}
}
) {
Text(
stringResource(R.string.account_carddav).uppercase(),
modifier = Modifier.padding(8.dp)
)
}
if (idxCalDav != null) {
Tab(
selected = pagerState.currentPage == idxCalDav,
onClick = {
scope.launch {
pagerState.scrollToPage(idxCalDav)
}
}
) {
Text(
stringResource(R.string.account_caldav).uppercase(),
modifier = Modifier.padding(8.dp)
)
}
}
if (idxWebcal != null) {
Tab(
selected = pagerState.currentPage == idxWebcal,
onClick = {
scope.launch {
pagerState.scrollToPage(idxWebcal)
}
}
) {
Text(
stringResource(R.string.account_webcal).uppercase(),
modifier = Modifier.padding(8.dp)
)
}
}
}
HorizontalPager(
pagerState,
verticalAlignment = Alignment.Top,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) { index ->
Box {
when (index) {
idxCardDav ->
ServiceTab(
requiredPermissions = listOf(Manifest.permission.WRITE_CONTACTS),
progress = cardDavProgress,
collections = addressBooks,
onUpdateCollectionSync = onUpdateCollectionSync,
onChangeForceReadOnly = onChangeForceReadOnly
)
idxCalDav -> {
val permissions = mutableListOf(Manifest.permission.WRITE_CALENDAR)
TaskUtils.currentProvider(context)?.let { tasksProvider ->
permissions += tasksProvider.permissions
}
ServiceTab(
requiredPermissions = permissions,
progress = calDavProgress,
collections = calendars,
onUpdateCollectionSync = onUpdateCollectionSync,
onChangeForceReadOnly = onChangeForceReadOnly
)
}
idxWebcal -> {
Column {
if (installIcsx5)
ActionCard(
icon = Icons.Default.Event,
actionText = stringResource(R.string.account_install_icsx5),
onAction = {
val installIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=at.bitfire.icsdroid"))
if (context.packageManager.resolveActivity(installIntent, 0) != null)
context.startActivity(installIntent)
},
modifier = Modifier.padding(top = 8.dp)
) {
Text(stringResource(R.string.account_no_webcal_handler_found))
}
else
Text(
stringResource(R.string.account_webcal_external_app),
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp)
)
ServiceTab(
requiredPermissions = listOf(Manifest.permission.WRITE_CALENDAR),
progress = calDavProgress,
collections = subscriptions,
onSubscribe = onSubscribe
)
}
}
}
PullRefreshIndicator(
refreshing = pullRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}
}
}
}
}
@Preview
@Composable
fun AccountOverview_CardDAV_CalDAV() {
AccountOverview(
account = Account("test@example.com", "test"),
showOnlyPersonal = AccountSettings.ShowOnlyPersonal(false, true),
onSetShowOnlyPersonal = {},
hasCardDav = true,
canCreateAddressBook = false,
cardDavProgress = AccountsModel.Progress.Active,
cardDavRefreshing = false,
addressBooks = null,
hasCalDav = true,
canCreateCalendar = true,
calDavProgress = AccountsModel.Progress.Pending,
calDavRefreshing = false,
calendars = null,
subscriptions = null
)
}
@Composable
fun AccountOverview_Actions(
account: Account,
canCreateAddressBook: Boolean,
canCreateCalendar: Boolean,
showOnlyPersonal: AccountSettings.ShowOnlyPersonal,
onSetShowOnlyPersonal: (showOnlyPersonal: Boolean) -> Unit,
currentPage: Int,
idxCardDav: Int?,
idxCalDav: Int?,
onRenameAccount: (newName: String) -> Unit,
onDeleteAccount: () -> Unit,
onAccountSettings: () -> Unit
) {
val context = LocalContext.current
var showDeleteAccountDialog by remember { mutableStateOf(false) }
var showRenameAccountDialog by remember { mutableStateOf(false) }
var overflowOpen by remember { mutableStateOf(false) }
IconButton(onClick = onAccountSettings) {
Icon(Icons.Default.Settings, stringResource(R.string.account_settings))
}
IconButton(onClick = { overflowOpen = !overflowOpen }) {
Icon(Icons.Default.MoreVert, stringResource(R.string.options_menu))
}
DropdownMenu(
expanded = overflowOpen,
onDismissRequest = { overflowOpen = false }
) {
// TAB-SPECIFIC ACTIONS
// create collection
if (currentPage == idxCardDav && canCreateAddressBook) {
// create address book
DropdownMenuItem(onClick = {
val intent = Intent(context, CreateAddressBookActivity::class.java)
intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, account)
context.startActivity(intent)
overflowOpen = false
}) {
Icon(
Icons.Default.CreateNewFolder,
contentDescription = stringResource(R.string.create_addressbook),
modifier = Modifier.padding(end = 8.dp)
)
Text(stringResource(R.string.create_addressbook))
}
} else if (currentPage == idxCalDav && canCreateCalendar) {
// create calendar
DropdownMenuItem(onClick = {
val intent = Intent(context, CreateCalendarActivity::class.java)
intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, account)
context.startActivity(intent)
overflowOpen = false
}) {
Icon(
Icons.Default.CreateNewFolder,
contentDescription = stringResource(R.string.create_calendar),
modifier = Modifier.padding(end = 8.dp)
)
Text(stringResource(R.string.create_calendar))
}
}
// GENERAL ACTIONS
// show only personal
DropdownMenuItem(
onClick = {
onSetShowOnlyPersonal(!showOnlyPersonal.onlyPersonal)
overflowOpen = false
},
enabled = !showOnlyPersonal.locked
) {
Text(stringResource(R.string.account_only_personal))
Checkbox(
checked = showOnlyPersonal.onlyPersonal,
enabled = !showOnlyPersonal.locked,
onCheckedChange = {
onSetShowOnlyPersonal(it)
overflowOpen = false
}
)
}
// rename account
DropdownMenuItem(onClick = {
showRenameAccountDialog = true
overflowOpen = false
}) {
Icon(
Icons.Default.DriveFileRenameOutline,
contentDescription = stringResource(R.string.account_rename),
modifier = Modifier.padding(end = 8.dp)
)
Text(stringResource(R.string.account_rename))
}
// delete account
DropdownMenuItem(onClick = {
showDeleteAccountDialog = true
overflowOpen = false
}) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.account_delete),
modifier = Modifier.padding(end = 8.dp)
)
Text(stringResource(R.string.account_delete))
}
}
// modal dialogs
if (showRenameAccountDialog)
RenameAccountDialog(
oldName = account.name,
onRenameAccount = { newName ->
onRenameAccount(newName)
showRenameAccountDialog = false
},
onDismiss = { showRenameAccountDialog = false }
)
if (showDeleteAccountDialog)
DeleteAccountDialog(
onConfirm = onDeleteAccount,
onDismiss = { showDeleteAccountDialog = false }
)
}
@Composable
fun AccountOverview_SnackbarContent(
snackbarHostState: SnackbarHostState,
currentPageIsCardDav: Boolean,
cardDavRefreshing: Boolean,
calDavRefreshing: Boolean
) {
val context = LocalContext.current
// show snackbar when refreshing collection list
val currentTabRefreshing =
if (currentPageIsCardDav)
cardDavRefreshing
else
calDavRefreshing
LaunchedEffect(currentTabRefreshing) {
if (currentTabRefreshing)
snackbarHostState.showSnackbar(
context.getString(R.string.account_refreshing_collections),
duration = SnackbarDuration.Indefinite
)
}
}
@Composable
@Preview
fun DeleteAccountDialog(
onConfirm: () -> Unit = {},
onDismiss: () -> Unit = {}
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.account_delete_confirmation_title)) },
text = { Text(stringResource(R.string.account_delete_confirmation_text)) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(android.R.string.ok).uppercase())
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel).uppercase())
}
}
)
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun ServiceTab(
requiredPermissions: List<String>,
progress: AccountsModel.Progress,
collections: LazyPagingItems<Collection>?,
onUpdateCollectionSync: (collectionId: Long, sync: Boolean) -> Unit = { _, _ -> },
onChangeForceReadOnly: (collectionId: Long, forceReadOnly: Boolean) -> Unit = { _, _ -> },
onSubscribe: (Collection) -> Unit = {},
) {
val context = LocalContext.current
Column {
// progress indicator
val progressAlpha = progressAlpha(progress)
when (progress) {
AccountsModel.Progress.Active -> LinearProgressIndicator(
color = MaterialTheme.colors.secondary,
modifier = Modifier
.alpha(progressAlpha)
.fillMaxWidth()
)
AccountsModel.Progress.Pending,
AccountsModel.Progress.Idle -> LinearProgressIndicator(
color = MaterialTheme.colors.secondary,
progress = 1f,
modifier = Modifier
.alpha(progressAlpha)
.fillMaxWidth()
)
}
// permissions warning
val permissionsState = rememberMultiplePermissionsState(requiredPermissions)
if (!permissionsState.allPermissionsGranted)
ActionCard(
icon = Icons.Default.SyncProblem,
actionText = stringResource(R.string.account_manage_permissions),
onAction = {
val intent = Intent(context, PermissionsActivity::class.java)
context.startActivity(intent)
}
) {
Text(stringResource(R.string.account_missing_permissions))
}
// collection list
if (collections != null)
CollectionsList(
collections,
onChangeSync = onUpdateCollectionSync,
onChangeForceReadOnly = onChangeForceReadOnly,
onSubscribe = onSubscribe,
modifier = Modifier.weight(1f)
)
}
}
@Composable
fun progressAlpha(progress: AccountsModel.Progress): Float {
val progressAlpha by animateFloatAsState(
when (progress) {
AccountsModel.Progress.Active -> 1f
AccountsModel.Progress.Pending -> 0.5f
AccountsModel.Progress.Idle -> 0f
},
label = "progressAlpha",
animationSpec = tween(500)
)
return progressAlpha
}

View file

@ -1,651 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.annotation.SuppressLint
import android.app.Application
import android.content.ContentResolver
import android.content.pm.PackageManager
import android.os.Handler
import android.os.Looper
import android.provider.CalendarContract
import android.provider.ContactsContract
import androidx.annotation.MainThread
import androidx.annotation.WorkerThread
import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.map
import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.work.WorkInfo
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.property.caldav.NS_APPLE_ICAL
import at.bitfire.dav4jvm.property.caldav.NS_CALDAV
import at.bitfire.dav4jvm.property.carddav.NS_CARDDAV
import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.syncadapter.AccountsCleanupWorker
import at.bitfire.davdroid.syncadapter.BaseSyncWorker
import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker
import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.util.TaskUtils
import at.bitfire.ical4android.util.DateUtils
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import net.fortuna.ical4j.model.Calendar
import java.io.StringWriter
import java.util.Optional
import java.util.logging.Level
class AccountModel @AssistedInject constructor(
val context: Application,
val db: AppDatabase,
@Assisted val account: Account
): ViewModel(), OnAccountsUpdateListener {
@AssistedFactory
interface Factory {
fun create(account: Account): AccountModel
}
companion object {
const val PAGER_SIZE = 20
}
/** whether the account is invalid and the AccountActivity shall be closed */
val invalid = MutableLiveData<Boolean>()
private val settings = AccountSettings(context, account)
private val refreshSettingsSignal = MutableLiveData(Unit)
val showOnlyPersonal = refreshSettingsSignal.switchMap<Unit, AccountSettings.ShowOnlyPersonal> {
object : LiveData<AccountSettings.ShowOnlyPersonal>() {
init {
viewModelScope.launch(Dispatchers.IO) {
postValue(settings.getShowOnlyPersonal())
}
}
}
}
fun setShowOnlyPersonal(showOnlyPersonal: Boolean) = viewModelScope.launch(Dispatchers.IO) {
settings.setShowOnlyPersonal(showOnlyPersonal)
refreshSettingsSignal.postValue(Unit)
}
val accountManager: AccountManager = AccountManager.get(context)
val cardDavSvc = db.serviceDao().getLiveByAccountAndType(account.name, Service.TYPE_CARDDAV)
val bindableAddressBookHomesets = cardDavSvc.switchMap { svc ->
if (svc != null)
db.homeSetDao().getLiveBindableByService(svc.id)
else
MutableLiveData(emptyList())
}
val canCreateAddressBook = bindableAddressBookHomesets.map { homeSets ->
homeSets.isNotEmpty()
}
val cardDavRefreshing = cardDavSvc.switchMap { svc ->
if (svc == null)
return@switchMap null
RefreshCollectionsWorker.exists(context, RefreshCollectionsWorker.workerName(svc.id))
}
val cardDavSyncPending = BaseSyncWorker.exists(
context,
listOf(WorkInfo.State.ENQUEUED),
account,
listOf(context.getString(R.string.address_books_authority)),
whichTag = { account, authority ->
// we are only interested in pending OneTimeSyncWorkers because there's always a pending PeriodicSyncWorker
OneTimeSyncWorker.workerName(account, authority)
}
)
val cardDavSyncing = BaseSyncWorker.exists(
context,
listOf(WorkInfo.State.RUNNING),
account,
listOf(context.getString(R.string.address_books_authority))
)
val addressBooksPager = CollectionPager(db, cardDavSvc, Collection.TYPE_ADDRESSBOOK, showOnlyPersonal)
private val tasksProvider = TaskUtils.currentProviderFlow(context, viewModelScope)
val calDavSvc = db.serviceDao().getLiveByAccountAndType(account.name, Service.TYPE_CALDAV)
val bindableCalendarHomesets = calDavSvc.switchMap { svc ->
if (svc != null)
db.homeSetDao().getLiveBindableByService(svc.id)
else
MutableLiveData(emptyList())
}
val canCreateCalendar = bindableCalendarHomesets.map { homeSets ->
homeSets.isNotEmpty()
}
val calDavRefreshing = calDavSvc.switchMap { svc ->
if (svc == null)
return@switchMap null
RefreshCollectionsWorker.exists(context, RefreshCollectionsWorker.workerName(svc.id))
}
val calDavSyncPending = tasksProvider.asLiveData().switchMap { tasks ->
BaseSyncWorker.exists(
context,
listOf(WorkInfo.State.ENQUEUED),
account,
listOfNotNull(CalendarContract.AUTHORITY, tasks?.authority),
whichTag = { account, authority ->
// we are only interested in pending OneTimeSyncWorkers because there's always a pending PeriodicSyncWorker
OneTimeSyncWorker.workerName(account, authority)
}
)
}
val calDavSyncing = tasksProvider.asLiveData().switchMap { tasks ->
BaseSyncWorker.exists(
context,
listOf(WorkInfo.State.RUNNING),
account,
listOfNotNull(CalendarContract.AUTHORITY, tasks?.authority)
)
}
val calendarsPager = CollectionPager(db, calDavSvc, Collection.TYPE_CALENDAR, showOnlyPersonal)
val webcalPager = CollectionPager(db, calDavSvc, Collection.TYPE_WEBCAL, showOnlyPersonal)
val renameAccountError = MutableLiveData<String>()
init {
accountManager.addOnAccountsUpdatedListener(this, null, true)
}
override fun onCleared() {
super.onCleared()
accountManager.removeOnAccountsUpdatedListener(this)
}
override fun onAccountsUpdated(accounts: Array<out Account>) {
if (!accounts.contains(account))
invalid.postValue(true)
}
// actions
/**
* Will try to rename the [account] to given name.
*
* @param newName new account name
*/
fun renameAccount(newName: String) {
val oldAccount = account
// remember sync intervals
val oldSettings = try {
AccountSettings(context, oldAccount)
} catch (e: InvalidAccountException) {
renameAccountError.postValue(context.getString(R.string.account_invalid))
invalid.postValue(true)
return
}
val authorities = mutableListOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY
)
tasksProvider.value?.authority?.let { authorities.add(it) }
val syncIntervals = authorities.map { Pair(it, oldSettings.getSyncInterval(it)) }
val accountManager = AccountManager.get(context)
// check whether name is already taken
if (accountManager.getAccountsByType(context.getString(R.string.account_type)).map { it.name }.contains(newName)) {
Logger.log.log(Level.WARNING, "Account with name \"$newName\" already exists")
renameAccountError.postValue(context.getString(R.string.account_rename_exists_already))
return
}
try {
/* https://github.com/bitfireAT/davx5/issues/135
Lock accounts cleanup so that the AccountsCleanupWorker doesn't run while we rename the account
because this can cause problems when:
1. The account is renamed.
2. The AccountsCleanupWorker is called BEFORE the services table is updated.
AccountsCleanupWorker removes the "orphaned" services because they belong to the old account which doesn't exist anymore
3. Now the services would be renamed, but they're not here anymore. */
AccountsCleanupWorker.lockAccountsCleanup()
// Renaming account
accountManager.renameAccount(oldAccount, newName, @MainThread {
if (it.result?.name == newName /* account has new name -> success */)
viewModelScope.launch(Dispatchers.Default + NonCancellable) {
try {
onAccountRenamed(accountManager, oldAccount, newName, syncIntervals)
} finally {
// release AccountsCleanupWorker mutex at the end of this async coroutine
AccountsCleanupWorker.unlockAccountsCleanup()
}
} else
// release AccountsCleanupWorker mutex now
AccountsCleanupWorker.unlockAccountsCleanup()
// close AccountActivity with old name
invalid.postValue(true)
}, null)
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't rename account", e)
renameAccountError.postValue(context.getString(R.string.account_rename_couldnt_rename))
}
}
/**
* Called when an account has been renamed.
*
* @param oldAccount the old account
* @param newName the new account
* @param syncIntervals map with entries of type (authority -> sync interval) of the old account
*/
@SuppressLint("Recycle")
@WorkerThread
fun onAccountRenamed(accountManager: AccountManager, oldAccount: Account, newName: String, syncIntervals: List<Pair<String, Long?>>) {
// account has now been renamed
Logger.log.info("Updating account name references")
// disable periodic workers of old account
syncIntervals.forEach { (authority, _) ->
PeriodicSyncWorker.disable(context, oldAccount, authority)
}
// cancel maybe running synchronization
BaseSyncWorker.cancelAllWork(context, oldAccount)
/*for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)))
SyncWorker.cancelSync(context, addrBookAccount)*/
// update account name references in database
try {
db.serviceDao().renameAccount(oldAccount.name, newName)
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't update service DB", e)
renameAccountError.postValue(context.getString(R.string.account_rename_couldnt_rename))
return
}
// update main account of address book accounts
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) == PackageManager.PERMISSION_GRANTED)
try {
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.use { provider ->
for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
val addressBook = LocalAddressBook(context, addrBookAccount, provider)
if (oldAccount == addressBook.mainAccount)
addressBook.mainAccount = Account(newName, oldAccount.type)
}
}
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't update address book accounts", e)
}
// calendar provider doesn't allow changing account_name of Events
// (all events will have to be downloaded again)
// update account_name of local tasks
try {
LocalTaskList.onRenameAccount(context, oldAccount.name, newName)
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't propagate new account name to tasks provider", e)
}
// retain sync intervals
val newAccount = Account(newName, oldAccount.type)
val newSettings = AccountSettings(context, newAccount)
for ((authority, interval) in syncIntervals) {
if (interval == null)
ContentResolver.setIsSyncable(newAccount, authority, 0)
else {
ContentResolver.setIsSyncable(newAccount, authority, 1)
newSettings.setSyncInterval(authority, interval)
}
}
// synchronize again
OneTimeSyncWorker.enqueueAllAuthorities(context, newAccount, manual = true)
}
/** Deletes the account from the system (won't touch collections on the server). */
fun deleteAccount() {
val accountManager = AccountManager.get(context)
accountManager.removeAccount(account, null, { future ->
try {
if (future.result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT))
Handler(Looper.getMainLooper()).post {
invalid.postValue(true)
}
} catch(e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't remove account", e)
}
}, null)
}
val createCollectionResult = MutableLiveData<Optional<Exception>>()
/**
* Creates a WebDAV collection using MKCOL or MKCALENDAR.
*
* @param homeSet home set into which the collection shall be created
* @param addressBook *true* if an address book shall be created, *false* if a calendar should be created
* @param name name (path segment) of the collection
*/
fun createCollection(
homeSet: HomeSet,
addressBook: Boolean,
name: String,
displayName: String?,
description: String?,
color: Int? = null,
timeZoneId: String? = null,
supportsVEVENT: Boolean? = null,
supportsVTODO: Boolean? = null,
supportsVJOURNAL: Boolean? = null
) = viewModelScope.launch(Dispatchers.IO) {
HttpClient.Builder(context, AccountSettings(context, account))
.setForeground(true)
.build().use { httpClient ->
try {
// delete on server
val url = homeSet.url.newBuilder()
.addPathSegment(name)
.addPathSegment("") // trailing slash
.build()
val dav = DavResource(httpClient.okHttpClient, url)
val xml = generateMkColXml(
addressBook = addressBook,
displayName = displayName,
description = description,
color = color,
timezoneDef = timeZoneId?.let { tzId ->
DateUtils.ical4jTimeZone(tzId)?.let { tz ->
val cal = Calendar()
cal.components += tz.vTimeZone
cal.toString()
}
},
supportsVEVENT = supportsVEVENT,
supportsVTODO = supportsVTODO,
supportsVJOURNAL = supportsVJOURNAL
)
dav.mkCol(
xmlBody = xml,
method = if (addressBook) "MKCOL" else "MKCALENDAR"
) {
// success, otherwise an exception would have been thrown
}
// no HTTP error -> create collection locally
val collection = Collection(
serviceId = homeSet.serviceId,
homeSetId = homeSet.id,
url = url,
type = if (addressBook) Collection.TYPE_ADDRESSBOOK else Collection.TYPE_CALENDAR,
displayName = displayName,
description = description
)
db.collectionDao().insert(collection)
// trigger service detection (because the collection may actually have other properties than the ones we have inserted)
RefreshCollectionsWorker.enqueue(context, homeSet.serviceId)
// post success
createCollectionResult.postValue(Optional.empty())
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't create collection", e)
// post error
createCollectionResult.postValue(Optional.of(e))
}
}
}
private fun generateMkColXml(
addressBook: Boolean,
displayName: String?,
description: String?,
color: Int? = null,
timezoneDef: String? = null,
supportsVEVENT: Boolean? = null,
supportsVTODO: Boolean? = null,
supportsVJOURNAL: Boolean? = null
): String {
val writer = StringWriter()
val serializer = XmlUtils.newSerializer()
serializer.apply {
setOutput(writer)
startDocument("UTF-8", null)
setPrefix("", NS_WEBDAV)
setPrefix("CAL", NS_CALDAV)
setPrefix("CARD", NS_CARDDAV)
if (addressBook)
startTag(NS_WEBDAV, "mkcol")
else
startTag(NS_CALDAV, "mkcalendar")
startTag(NS_WEBDAV, "set")
startTag(NS_WEBDAV, "prop")
startTag(NS_WEBDAV, "resourcetype")
startTag(NS_WEBDAV, "collection")
endTag(NS_WEBDAV, "collection")
if (addressBook) {
startTag(NS_CARDDAV, "addressbook")
endTag(NS_CARDDAV, "addressbook")
} else {
startTag(NS_CALDAV, "calendar")
endTag(NS_CALDAV, "calendar")
}
endTag(NS_WEBDAV, "resourcetype")
displayName?.let {
startTag(NS_WEBDAV, "displayname")
text(it)
endTag(NS_WEBDAV, "displayname")
}
if (addressBook) {
// addressbook-specific properties
description?.let {
startTag(NS_CARDDAV, "addressbook-description")
text(it)
endTag(NS_CARDDAV, "addressbook-description")
}
} else {
// calendar-specific properties
description?.let {
startTag(NS_CALDAV, "calendar-description")
text(it)
endTag(NS_CALDAV, "calendar-description")
}
color?.let {
startTag(NS_APPLE_ICAL, "calendar-color")
text(DavUtils.ARGBtoCalDAVColor(it))
endTag(NS_APPLE_ICAL, "calendar-color")
}
timezoneDef?.let {
startTag(NS_CALDAV, "calendar-timezone")
cdsect(it)
endTag(NS_CALDAV, "calendar-timezone")
}
if (supportsVEVENT != null || supportsVTODO != null || supportsVJOURNAL != null) {
// only if there's at least one explicitly supported calendar component set, otherwise don't include the property
if (supportsVEVENT != false) {
startTag(NS_CALDAV, "comp")
attribute(null, "name", "VEVENT")
endTag(NS_CALDAV, "comp")
}
if (supportsVTODO != false) {
startTag(NS_CALDAV, "comp")
attribute(null, "name", "VTODO")
endTag(NS_CALDAV, "comp")
}
if (supportsVJOURNAL != false) {
startTag(NS_CALDAV, "comp")
attribute(null, "name", "VJOURNAL")
endTag(NS_CALDAV, "comp")
}
}
}
endTag(NS_WEBDAV, "prop")
endTag(NS_WEBDAV, "set")
if (addressBook)
endTag(NS_WEBDAV, "mkcol")
else
endTag(NS_CALDAV, "mkcalendar")
endDocument()
}
return writer.toString()
}
val deleteCollectionResult = MutableLiveData<Optional<Exception>>()
/** Deletes the given collection from the database and the server. */
fun deleteCollection(collection: Collection) = viewModelScope.launch(Dispatchers.IO) {
HttpClient.Builder(context, AccountSettings(context, account))
.setForeground(true)
.build().use { httpClient ->
try {
// delete on server
val davResource = DavResource(httpClient.okHttpClient, collection.url)
davResource.delete(null) {}
// delete in database
db.collectionDao().delete(collection)
// post success
deleteCollectionResult.postValue(Optional.empty())
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't delete collection", e)
// post error
deleteCollectionResult.postValue(Optional.of(e))
}
}
}
fun setCollectionSync(id: Long, sync: Boolean) = viewModelScope.launch(Dispatchers.IO) {
db.collectionDao().updateSync(id, sync)
}
fun setCollectionForceReadOnly(id: Long, forceReadOnly: Boolean) = viewModelScope.launch(Dispatchers.IO) {
db.collectionDao().updateForceReadOnly(id, forceReadOnly)
}
// helpers
fun getCollectionOwner(collection: Collection): LiveData<String?> {
val id = collection.ownerId ?: return MutableLiveData(null)
return db.principalDao().getLive(id).map { principal ->
if (principal == null)
return@map null
principal.displayName ?: principal.url.toString()
}
}
fun getCollectionLastSynced(collection: Collection): LiveData<Map<String, Long>> {
return db.syncStatsDao().getLiveByCollectionId(collection.id).map { syncStatsList ->
val syncStatsMap = syncStatsList.associateBy { it.authority }
val interestingAuthorities = listOfNotNull(
ContactsContract.AUTHORITY,
CalendarContract.AUTHORITY,
TaskUtils.currentProvider(context)?.authority
)
val result = mutableMapOf<String, Long>()
for (authority in interestingAuthorities) {
val lastSync = syncStatsMap[authority]?.lastSync
if (lastSync != null)
result[getAppNameFromAuthority(authority)] = lastSync
}
result
}
}
/**
* Tries to find the application name for given authority. Returns the authority if not
* found.
*
* @param authority authority to find the application name for (ie "at.techbee.jtx")
* @return the application name of authority (ie "jtx Board")
*/
private fun getAppNameFromAuthority(authority: String): String {
val packageManager = context.packageManager
val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority
return try {
val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo
packageManager.getApplicationLabel(appInfo).toString()
} catch (e: PackageManager.NameNotFoundException) {
Logger.log.warning("Application name not found for authority: $authority")
authority
}
}
class CollectionPager(
val db: AppDatabase,
service: LiveData<Service?>,
private val collectionType: String,
showOnlyPersonal: LiveData<AccountSettings.ShowOnlyPersonal>
) : MediatorLiveData<Pager<Int, Collection>?>() {
var _serviceId: Long? = null
var _onlyPersonal: Boolean? = null
init {
addSource(service) {
_serviceId = it?.id
calculate()
}
addSource(showOnlyPersonal) {
_onlyPersonal = it.onlyPersonal
calculate()
}
}
fun calculate() {
val serviceId = _serviceId ?: return
val onlyPersonal = _onlyPersonal ?: return
value = Pager(
config = PagingConfig(PAGER_SIZE),
pagingSourceFactory = {
if (onlyPersonal)
db.collectionDao().pagePersonalByServiceAndType(serviceId, collectionType)
else
db.collectionDao().pageByServiceAndType(serviceId, collectionType)
}
)
}
}
}

View file

@ -0,0 +1,32 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
/** Tri-state enum to represent active / pending / idle status */
enum class AccountProgress {
Active, // syncing or refreshing
Pending, // sync pending
Idle; // idle
@Composable
fun rememberAlpha(): Float {
val progressAlpha by animateFloatAsState(
when (this@AccountProgress) {
Active -> 1f
Pending -> 0.5f
Idle -> 0f
},
label = "progressAlpha",
animationSpec = tween(500)
)
return progressAlpha
}
}

View file

@ -0,0 +1,78 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import android.accounts.Account
import android.app.Application
import androidx.work.WorkInfo
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.syncadapter.BaseSyncWorker
import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
class AccountProgressUseCase @Inject constructor(
val context: Application
) {
operator fun invoke(
account: Account,
serviceFlow: Flow<Service?>,
authoritiesFlow: Flow<List<String>>
): Flow<AccountProgress> {
val serviceRefreshing = isServiceRefreshing(serviceFlow)
val syncPending = isSyncPending(account, authoritiesFlow)
val syncRunning = isSyncRunning(account, authoritiesFlow)
return combine(serviceRefreshing, syncPending, syncRunning) { refreshing, pending, syncing ->
when {
refreshing || syncing -> AccountProgress.Active
pending -> AccountProgress.Pending
else -> AccountProgress.Idle
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun isServiceRefreshing(serviceFlow: Flow<Service?>): Flow<Boolean> =
serviceFlow.flatMapLatest { service ->
if (service == null)
flowOf(false)
else
RefreshCollectionsWorker.existsFlow(context, RefreshCollectionsWorker.workerName(service.id))
}
@OptIn(ExperimentalCoroutinesApi::class)
fun isSyncPending(account: Account, authoritiesFlow: Flow<List<String>>): Flow<Boolean> =
authoritiesFlow.flatMapLatest { authorities ->
BaseSyncWorker.exists(
context = context,
workStates = listOf(WorkInfo.State.ENQUEUED),
account = account,
authorities = authorities,
whichTag = { _, authority ->
// we are only interested in pending OneTimeSyncWorkers because there's always a pending PeriodicSyncWorker
OneTimeSyncWorker.workerName(account, authority)
}
)
}
@OptIn(ExperimentalCoroutinesApi::class)
fun isSyncRunning(account: Account, authoritiesFlow: Flow<List<String>>): Flow<Boolean> =
authoritiesFlow.flatMapLatest { authorities ->
BaseSyncWorker.exists(
context = context,
workStates = listOf(WorkInfo.State.RUNNING),
account = account,
authorities = authorities
)
}
}

View file

@ -0,0 +1,679 @@
import android.Manifest
import android.accounts.Account
import android.app.Activity
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DriveFileRenameOutline
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.outlined.RuleFolder
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.PermissionsActivity
import at.bitfire.davdroid.ui.account.AccountActivity
import at.bitfire.davdroid.ui.account.AccountProgress
import at.bitfire.davdroid.ui.account.AccountScreenModel
import at.bitfire.davdroid.ui.account.CollectionsList
import at.bitfire.davdroid.ui.account.RenameAccountDialog
import at.bitfire.davdroid.ui.composable.ActionCard
import at.bitfire.davdroid.util.TaskUtils
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import dagger.hilt.android.EntryPointAccessors
import kotlinx.coroutines.launch
@Composable
fun AccountScreen(
account: Account,
onAccountSettings: () -> Unit,
onCreateAddressBook: () -> Unit,
onCreateCalendar: () -> Unit,
onCollectionDetails: (Collection) -> Unit,
onNavUp: () -> Unit,
onFinish: () -> Unit
) {
val context = LocalContext.current as Activity
val entryPoint = EntryPointAccessors.fromActivity(context, AccountActivity.AccountScreenEntryPoint::class.java)
val model = viewModel<AccountScreenModel>(
factory = AccountScreenModel.factoryFromAccount(entryPoint.accountModelAssistedFactory(), account)
)
val addressBooksPager by model.addressBooksPager.collectAsState(null)
val calendarsPager by model.calendarsPager.collectAsState(null)
val subscriptionsPager by model.webcalPager.collectAsState(null)
AccountScreen(
accountName = account.name,
error = model.error,
onResetError = model::resetError,
invalidAccount = model.invalidAccount.collectAsStateWithLifecycle(false).value,
showOnlyPersonal = model.showOnlyPersonal.collectAsStateWithLifecycle(
initialValue = AccountSettings.ShowOnlyPersonal(onlyPersonal = false, locked = false)
).value,
onSetShowOnlyPersonal = model::setShowOnlyPersonal,
hasCardDav = model.hasCardDav.collectAsStateWithLifecycle(false).value,
canCreateAddressBook = model.canCreateAddressBook.collectAsStateWithLifecycle(false).value,
cardDavProgress = model.cardDavProgress.collectAsStateWithLifecycle(AccountProgress.Idle).value,
addressBooks = addressBooksPager?.flow?.collectAsLazyPagingItems(),
hasCalDav = model.hasCalDav.collectAsStateWithLifecycle(initialValue = false).value,
canCreateCalendar = model.canCreateCalendar.collectAsStateWithLifecycle(false).value,
calDavProgress = model.calDavProgress.collectAsStateWithLifecycle(AccountProgress.Idle).value,
calendars = calendarsPager?.flow?.collectAsLazyPagingItems(),
hasWebcal = model.hasWebcal.collectAsStateWithLifecycle(false).value,
subscriptions = subscriptionsPager?.flow?.collectAsLazyPagingItems(),
onUpdateCollectionSync = model::setCollectionSync,
onSubscribe = { collection ->
// subscribe
var uri = Uri.parse(collection.source.toString())
when {
uri.scheme.equals("http", true) -> uri = uri.buildUpon().scheme("webcal").build()
uri.scheme.equals("https", true) -> uri = uri.buildUpon().scheme("webcals").build()
}
val intent = Intent(Intent.ACTION_VIEW, uri)
collection.displayName?.let { intent.putExtra("title", it) }
collection.color?.let { intent.putExtra("color", it) }
if (context.packageManager.resolveActivity(intent, 0) != null)
context.startActivity(intent)
else
model.noWebcalApp()
},
onCollectionDetails = onCollectionDetails,
showNoWebcalApp = model.showNoWebcalApp,
resetShowNoWebcalApp = model::resetShowNoWebcalApp,
onRefreshCollections = model::refreshCollections,
onSync = model::sync,
onAccountSettings = onAccountSettings,
onCreateAddressBook = onCreateAddressBook,
onCreateCalendar = onCreateCalendar,
onRenameAccount = model::renameAccount,
onDeleteAccount = model::deleteAccount,
onNavUp = onNavUp,
onFinish = onFinish
)
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun AccountScreen(
accountName: String,
error: String? = null,
onResetError: () -> Unit = {},
invalidAccount: Boolean = false,
showOnlyPersonal: AccountSettings.ShowOnlyPersonal,
onSetShowOnlyPersonal: (showOnlyPersonal: Boolean) -> Unit = {},
hasCardDav: Boolean,
canCreateAddressBook: Boolean,
cardDavProgress: AccountProgress,
addressBooks: LazyPagingItems<Collection>?,
hasCalDav: Boolean,
canCreateCalendar: Boolean,
calDavProgress: AccountProgress,
calendars: LazyPagingItems<Collection>?,
hasWebcal: Boolean,
subscriptions: LazyPagingItems<Collection>?,
onUpdateCollectionSync: (collectionId: Long, sync: Boolean) -> Unit = { _, _ -> },
onSubscribe: (Collection) -> Unit = {},
onCollectionDetails: (Collection) -> Unit = {},
showNoWebcalApp: Boolean = false,
resetShowNoWebcalApp: () -> Unit = {},
onRefreshCollections: () -> Unit = {},
onSync: () -> Unit = {},
onAccountSettings: () -> Unit = {},
onCreateAddressBook: () -> Unit = {},
onCreateCalendar: () -> Unit = {},
onRenameAccount: (newName: String) -> Unit = {},
onDeleteAccount: () -> Unit = {},
onNavUp: () -> Unit = {},
onFinish: () -> Unit = {}
) {
AppTheme {
val context = LocalContext.current
val scope = rememberCoroutineScope()
if (invalidAccount)
onFinish()
val pullRefreshState = rememberPullToRefreshState()
LaunchedEffect(pullRefreshState.isRefreshing) {
if (pullRefreshState.isRefreshing) {
onSync()
pullRefreshState.endRefresh()
}
}
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(error) {
if (error != null)
scope.launch {
snackbarHostState.showSnackbar(error)
onResetError()
}
}
// tabs calculation
var nextIdx = -1
@Suppress("KotlinConstantConditions")
val idxCardDav: Int? = if (hasCardDav) ++nextIdx else null
val idxCalDav: Int? = if (hasCalDav) ++nextIdx else null
val idxWebcal: Int? = if (hasWebcal) ++nextIdx else null
val nrPages =
(if (idxCardDav != null) 1 else 0) +
(if (idxCalDav != null) 1 else 0) +
(if (idxWebcal != null) 1 else 0)
val pagerState = rememberPagerState(pageCount = { nrPages })
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onNavUp) {
Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up))
}
},
title = {
Text(
text = accountName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
actions = {
AccountScreen_Actions(
accountName = accountName,
canCreateAddressBook = canCreateAddressBook,
onCreateAddressBook = onCreateAddressBook,
canCreateCalendar = canCreateCalendar,
onCreateCalendar = onCreateCalendar,
showOnlyPersonal = showOnlyPersonal,
onSetShowOnlyPersonal = onSetShowOnlyPersonal,
currentPage = pagerState.currentPage,
idxCardDav = idxCardDav,
idxCalDav = idxCalDav,
onRenameAccount = onRenameAccount,
onDeleteAccount = onDeleteAccount,
onAccountSettings = onAccountSettings
)
}
)
},
floatingActionButton = {
Column(horizontalAlignment = Alignment.End) {
ExtendedFloatingActionButton(
text = {
Text(stringResource(R.string.account_refresh_collections))
},
icon = {
Icon(Icons.Outlined.RuleFolder, stringResource(R.string.account_refresh_collections))
},
onClick = onRefreshCollections,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(bottom = 16.dp)
)
if (pagerState.currentPage == idxCardDav || pagerState.currentPage == idxCalDav)
ExtendedFloatingActionButton(
text = {
Text(stringResource(R.string.account_synchronize_now))
},
icon = {
Icon(Icons.Default.Sync, stringResource(R.string.account_synchronize_now))
},
onClick = onSync
)
}
},
snackbarHost = {
SnackbarHost(snackbarHostState)
}
) { padding ->
Box(
Modifier
.padding(padding)
.nestedScroll(pullRefreshState.nestedScrollConnection)
) {
Column {
if (nrPages > 0) {
TabRow(selectedTabIndex = pagerState.currentPage) {
if (idxCardDav != null)
Tab(
selected = pagerState.currentPage == idxCardDav,
onClick = {
scope.launch {
pagerState.scrollToPage(idxCardDav)
}
}
) {
Text(
stringResource(R.string.account_carddav),
modifier = Modifier.padding(8.dp)
)
}
if (idxCalDav != null) {
Tab(
selected = pagerState.currentPage == idxCalDav,
onClick = {
scope.launch {
pagerState.scrollToPage(idxCalDav)
}
}
) {
Text(
stringResource(R.string.account_caldav),
modifier = Modifier.padding(8.dp)
)
}
}
if (idxWebcal != null) {
Tab(
selected = pagerState.currentPage == idxWebcal,
onClick = {
scope.launch {
pagerState.scrollToPage(idxWebcal)
}
}
) {
Text(
stringResource(R.string.account_webcal),
modifier = Modifier.padding(8.dp)
)
}
}
}
HorizontalPager(
pagerState,
verticalAlignment = Alignment.Top,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) { index ->
when (index) {
idxCardDav ->
AccountScreen_ServiceTab(
requiredPermissions = listOf(Manifest.permission.WRITE_CONTACTS),
progress = cardDavProgress,
collections = addressBooks,
onUpdateCollectionSync = onUpdateCollectionSync,
onCollectionDetails = onCollectionDetails
)
idxCalDav -> {
val permissions = mutableListOf(Manifest.permission.WRITE_CALENDAR)
TaskUtils.currentProvider(context)?.let { tasksProvider ->
permissions += tasksProvider.permissions
}
AccountScreen_ServiceTab(
requiredPermissions = permissions,
progress = calDavProgress,
collections = calendars,
onUpdateCollectionSync = onUpdateCollectionSync,
onCollectionDetails = onCollectionDetails
)
}
idxWebcal -> {
LaunchedEffect(showNoWebcalApp) {
if (showNoWebcalApp) {
if (snackbarHostState.showSnackbar(
message = context.getString(R.string.account_no_webcal_handler_found),
actionLabel = context.getString(R.string.account_install_icsx5),
duration = SnackbarDuration.Long
) == SnackbarResult.ActionPerformed
) {
val installIntent = Intent(
Intent.ACTION_VIEW,
Uri.parse("market://details?id=at.bitfire.icsdroid")
)
if (context.packageManager.resolveActivity(installIntent, 0) != null)
context.startActivity(installIntent)
}
resetShowNoWebcalApp()
}
}
Column {
Text(
stringResource(R.string.account_webcal_external_app),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp)
)
AccountScreen_ServiceTab(
requiredPermissions = listOf(Manifest.permission.WRITE_CALENDAR),
progress = calDavProgress,
collections = subscriptions,
onSubscribe = onSubscribe
)
}
}
}
}
}
}
PullToRefreshContainer(
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}
}
}
@Composable
fun AccountScreen_Actions(
accountName: String,
canCreateAddressBook: Boolean,
onCreateAddressBook: () -> Unit,
canCreateCalendar: Boolean,
onCreateCalendar: () -> Unit,
showOnlyPersonal: AccountSettings.ShowOnlyPersonal,
onSetShowOnlyPersonal: (showOnlyPersonal: Boolean) -> Unit,
currentPage: Int,
idxCardDav: Int?,
idxCalDav: Int?,
onRenameAccount: (newName: String) -> Unit,
onDeleteAccount: () -> Unit,
onAccountSettings: () -> Unit
) {
val context = LocalContext.current
var showDeleteAccountDialog by remember { mutableStateOf(false) }
var showRenameAccountDialog by remember { mutableStateOf(false) }
var overflowOpen by remember { mutableStateOf(false) }
IconButton(onClick = onAccountSettings) {
Icon(Icons.Default.Settings, stringResource(R.string.account_settings))
}
IconButton(onClick = { overflowOpen = !overflowOpen }) {
Icon(Icons.Default.MoreVert, stringResource(R.string.options_menu))
}
DropdownMenu(
expanded = overflowOpen,
onDismissRequest = { overflowOpen = false }
) {
// TAB-SPECIFIC ACTIONS
// create collection
if (currentPage == idxCardDav && canCreateAddressBook) {
// create address book
DropdownMenuItem(
leadingIcon = {
Icon(
Icons.Default.CreateNewFolder,
contentDescription = stringResource(R.string.create_addressbook),
modifier = Modifier.padding(end = 8.dp)
)
},
text = {
Text(stringResource(R.string.create_addressbook))
},
onClick = {
onCreateAddressBook()
overflowOpen = false
}
)
} else if (currentPage == idxCalDav && canCreateCalendar) {
// create calendar
DropdownMenuItem(
leadingIcon = {
Icon(
Icons.Default.CreateNewFolder,
contentDescription = stringResource(R.string.create_calendar),
modifier = Modifier.padding(end = 8.dp)
)
},
text = {
Text(stringResource(R.string.create_calendar))
},
onClick = {
onCreateCalendar()
overflowOpen = false
}
)
}
// GENERAL ACTIONS
// show only personal
DropdownMenuItem(
leadingIcon = {
Checkbox(
checked = showOnlyPersonal.onlyPersonal,
enabled = !showOnlyPersonal.locked,
onCheckedChange = {
onSetShowOnlyPersonal(it)
overflowOpen = false
}
)
},
text = {
Text(stringResource(R.string.account_only_personal))
},
onClick = {
onSetShowOnlyPersonal(!showOnlyPersonal.onlyPersonal)
overflowOpen = false
},
enabled = !showOnlyPersonal.locked
)
// rename account
DropdownMenuItem(
leadingIcon = {
Icon(
Icons.Default.DriveFileRenameOutline,
contentDescription = stringResource(R.string.account_rename),
modifier = Modifier.padding(end = 8.dp)
)
},
text = {
Text(stringResource(R.string.account_rename))
},
onClick = {
showRenameAccountDialog = true
overflowOpen = false
}
)
// delete account
DropdownMenuItem(
leadingIcon = {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.account_delete),
modifier = Modifier.padding(end = 8.dp)
)
},
text = {
Text(stringResource(R.string.account_delete))
},
onClick = {
showDeleteAccountDialog = true
overflowOpen = false
}
)
}
// modal dialogs
if (showRenameAccountDialog)
RenameAccountDialog(
oldName = accountName,
onRenameAccount = { newName ->
onRenameAccount(newName)
showRenameAccountDialog = false
},
onDismiss = { showRenameAccountDialog = false }
)
if (showDeleteAccountDialog)
DeleteAccountDialog(
onConfirm = onDeleteAccount,
onDismiss = { showDeleteAccountDialog = false }
)
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun AccountScreen_ServiceTab(
requiredPermissions: List<String>,
progress: AccountProgress,
collections: LazyPagingItems<Collection>?,
onUpdateCollectionSync: (collectionId: Long, sync: Boolean) -> Unit = { _, _ -> },
onSubscribe: (Collection) -> Unit = {},
onCollectionDetails: ((Collection) -> Unit)? = null
) {
val context = LocalContext.current
Column {
// progress indicator
val progressAlpha = progress.rememberAlpha()
when (progress) {
AccountProgress.Active -> LinearProgressIndicator(
modifier = Modifier
.alpha(progressAlpha)
.fillMaxWidth()
)
AccountProgress.Pending,
AccountProgress.Idle -> LinearProgressIndicator(
progress = { 1f },
modifier = Modifier
.alpha(progressAlpha)
.fillMaxWidth()
)
}
// permissions warning
if (!LocalInspectionMode.current) {
val permissionsState = rememberMultiplePermissionsState(requiredPermissions)
if (!permissionsState.allPermissionsGranted)
ActionCard(
icon = Icons.Default.SyncProblem,
actionText = stringResource(R.string.account_manage_permissions),
onAction = {
val intent = Intent(context, PermissionsActivity::class.java)
context.startActivity(intent)
},
modifier = Modifier.padding(8.dp)
) {
Text(stringResource(R.string.account_missing_permissions))
}
// collection list
if (collections != null)
CollectionsList(
collections,
onChangeSync = onUpdateCollectionSync,
onSubscribe = onSubscribe,
onCollectionDetails = onCollectionDetails,
modifier = Modifier.weight(1f)
)
}
}
}
@Preview
@Composable
fun AccountScreen_Preview() {
AccountScreen(
accountName = "test@example.com",
showOnlyPersonal = AccountSettings.ShowOnlyPersonal(false, true),
hasCardDav = true,
canCreateAddressBook = false,
cardDavProgress = AccountProgress.Active,
addressBooks = null,
hasCalDav = true,
canCreateCalendar = true,
calDavProgress = AccountProgress.Pending,
calendars = null,
hasWebcal = true,
subscriptions = null
)
}
@Composable
@Preview
fun DeleteAccountDialog(
onConfirm: () -> Unit = {},
onDismiss: () -> Unit = {}
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.account_delete_confirmation_title)) },
text = { Text(stringResource(R.string.account_delete_confirmation_text)) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(android.R.string.ok))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
}
)
}

View file

@ -0,0 +1,191 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import android.accounts.Account
import android.app.Application
import android.provider.CalendarContract
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.asFlow
import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavServiceRepository
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker
import at.bitfire.davdroid.util.TaskUtils
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.logging.Level
class AccountScreenModel @AssistedInject constructor(
val context: Application,
private val accountRepository: AccountRepository,
private val collectionRepository: DavCollectionRepository,
serviceRepository: DavServiceRepository,
accountProgressUseCase: AccountProgressUseCase,
getBindableHomesetsFromServiceUseCase: GetBindableHomeSetsFromServiceUseCase,
getServiceCollectionPagerUseCase: GetServiceCollectionPagerUseCase,
@Assisted val account: Account
): ViewModel() {
@AssistedFactory
interface Factory {
fun create(account: Account): AccountScreenModel
}
companion object {
fun factoryFromAccount(assistedFactory: Factory, account: Account) = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return assistedFactory.create(account) as T
}
}
}
/** whether the account is invalid and the screen shall be closed */
val invalidAccount = accountRepository.getAllFlow().map { accounts ->
!accounts.contains(account)
}
private val settings = AccountSettings(context, account)
private val refreshSettingsSignal = MutableLiveData(Unit)
val showOnlyPersonal = refreshSettingsSignal.switchMap<Unit, AccountSettings.ShowOnlyPersonal> {
object : LiveData<AccountSettings.ShowOnlyPersonal>() {
init {
viewModelScope.launch(Dispatchers.IO) {
postValue(settings.getShowOnlyPersonal())
}
}
}
}.asFlow()
fun setShowOnlyPersonal(showOnlyPersonal: Boolean) = viewModelScope.launch(Dispatchers.IO) {
settings.setShowOnlyPersonal(showOnlyPersonal)
refreshSettingsSignal.postValue(Unit)
}
private val cardDavSvc = serviceRepository
.getCardDavServiceFlow(account.name)
.stateIn(viewModelScope, initialValue = null, started = SharingStarted.Eagerly)
val hasCardDav = cardDavSvc.map { it != null }
val bindableAddressBookHomesets = getBindableHomesetsFromServiceUseCase(cardDavSvc)
val canCreateAddressBook = bindableAddressBookHomesets.map { homeSets ->
homeSets.isNotEmpty()
}
val cardDavProgress: Flow<AccountProgress> = accountProgressUseCase(
account = account,
serviceFlow = cardDavSvc,
authoritiesFlow = flowOf(listOf(context.getString(R.string.address_books_authority)))
)
val addressBooksPager = getServiceCollectionPagerUseCase(cardDavSvc, Collection.TYPE_ADDRESSBOOK, showOnlyPersonal)
private val calDavSvc = serviceRepository
.getCalDavServiceFlow(account.name)
.stateIn(viewModelScope, initialValue = null, started = SharingStarted.Eagerly)
val hasCalDav = calDavSvc.map { it != null }
val bindableCalendarHomesets = getBindableHomesetsFromServiceUseCase(calDavSvc)
val canCreateCalendar = bindableCalendarHomesets.map { homeSets ->
homeSets.isNotEmpty()
}
private val tasksProvider = TaskUtils.currentProviderFlow(context, viewModelScope)
private val calDavAuthorities = tasksProvider.map { tasks ->
listOfNotNull(CalendarContract.AUTHORITY, tasks?.authority)
}
val calDavProgress = accountProgressUseCase(
account = account,
serviceFlow = calDavSvc,
authoritiesFlow = calDavAuthorities
)
val calendarsPager = getServiceCollectionPagerUseCase(calDavSvc, Collection.TYPE_CALENDAR, showOnlyPersonal)
val hasWebcal = calDavSvc.map { service ->
if (service != null)
collectionRepository.anyWebcal(service.id)
else
false
}
val webcalPager = getServiceCollectionPagerUseCase(calDavSvc, Collection.TYPE_WEBCAL, showOnlyPersonal)
var error by mutableStateOf<String?>(null)
private set
fun resetError() { error = null }
var showNoWebcalApp by mutableStateOf(false)
private set
fun noWebcalApp() { showNoWebcalApp = true }
fun resetShowNoWebcalApp() { showNoWebcalApp = false }
// actions
/** Deletes the account from the system (won't touch collections on the server). */
fun deleteAccount() {
viewModelScope.launch {
accountRepository.delete(account.name)
}
}
fun refreshCollections() {
cardDavSvc.value?.let { svc ->
RefreshCollectionsWorker.enqueue(context, svc.id)
}
calDavSvc.value?.let { svc ->
RefreshCollectionsWorker.enqueue(context, svc.id)
}
}
/**
* Renames the [account] to given name.
*
* @param newName new account name
*/
fun renameAccount(newName: String) {
viewModelScope.launch {
try {
accountRepository.rename(account.name, newName)
// synchronize again
val newAccount = Account(context.getString(R.string.account_type), newName)
OneTimeSyncWorker.enqueueAllAuthorities(context, newAccount, manual = true)
} catch (e: Exception) {
Logger.log.log(Level.SEVERE, "Couldn't rename account", e)
error = e.localizedMessage
}
}
}
fun setCollectionSync(id: Long, sync: Boolean) {
viewModelScope.launch {
collectionRepository.setSync(id, sync)
}
}
fun sync() {
OneTimeSyncWorker.enqueueAllAuthorities(context, account, manual = true)
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import android.accounts.Account
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.TaskStackBuilder
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.components.ActivityComponent
@AndroidEntryPoint
class CollectionActivity: AppCompatActivity() {
@EntryPoint
@InstallIn(ActivityComponent::class)
interface CollectionEntryPoint {
fun collectionModelAssistedFactory(): CollectionScreenModel.Factory
}
companion object {
const val EXTRA_ACCOUNT = "account"
const val EXTRA_COLLECTION_ID = "collection_id"
}
val account by lazy { intent.getParcelableExtra<Account>(EXTRA_ACCOUNT)!! }
val collectionId by lazy { intent.getLongExtra(EXTRA_COLLECTION_ID, -1) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CollectionScreen(
collectionId = collectionId,
onFinish = ::finish,
onNavUp = ::onSupportNavigateUp
)
}
}
override fun supportShouldUpRecreateTask(targetIntent: Intent) = true
override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) {
builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
}
}

View file

@ -1,150 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import android.text.format.DateUtils
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import okhttp3.HttpUrl.Companion.toHttpUrl
@Composable
fun CollectionPropertiesDialog(
collection: Collection,
onDismiss: () -> Unit,
model: AccountModel = viewModel()
) {
val owner by model.getCollectionOwner(collection).observeAsState()
val lastSynced by model.getCollectionLastSynced(collection).observeAsState(emptyMap())
Dialog(onDismissRequest = onDismiss) {
Card {
CollectionPropertiesContent(
collection = collection,
owner = owner,
lastSynced = lastSynced
)
}
}
}
@Composable
fun CollectionPropertiesContent(
collection: Collection,
owner: String?,
lastSynced: Map<String, Long>
) {
val context = LocalContext.current
Column(Modifier.padding(16.dp)) {
// URL
Text(stringResource(R.string.collection_properties_url), style = MaterialTheme.typography.h5)
SelectionContainer {
Text(
collection.url.toString(),
style = MaterialTheme.typography.body2.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.padding(bottom = 16.dp)
)
}
// Owner
if (owner != null) {
Text(stringResource(R.string.collection_properties_owner), style = MaterialTheme.typography.h5)
Text(
text = owner,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(bottom = 16.dp)
)
}
// Last synced (for all applicable authorities)
Text(stringResource(R.string.collection_properties_sync_time), style = MaterialTheme.typography.h5)
if (lastSynced.isEmpty())
Text(
stringResource(R.string.collection_properties_sync_time_never),
modifier = Modifier.padding(bottom = 8.dp)
)
else
for ((app, timestamp) in lastSynced.entries) {
Text(
text = app,
style = MaterialTheme.typography.body2
)
val timeStr = DateUtils.getRelativeDateTimeString(
context, timestamp, DateUtils.SECOND_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0
).toString()
Text(
text = timeStr,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(bottom = 8.dp)
)
}
Spacer(Modifier.height(8.dp))
if (collection.supportsWebPush) {
collection.pushTopic?.let { topic ->
Text(
stringResource(R.string.collection_properties_push_support),
style = MaterialTheme.typography.h5
)
Text(
stringResource(R.string.collection_properties_push_support_web_push),
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(bottom = 8.dp)
)
}
val subscribedStr =
collection.pushSubscriptionCreated?.let { timestamp ->
val timeStr = DateUtils.getRelativeDateTimeString(
context, timestamp, DateUtils.SECOND_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0
).toString()
stringResource(R.string.collection_properties_push_subscribed_at, timeStr)
} ?: stringResource(R.string.collection_properties_push_subscribed_never)
Text(
text = subscribedStr,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(bottom = 16.dp)
)
}
}
}
@Composable
@Preview
fun CollectionPropertiesDialog_Sample() {
CollectionPropertiesContent(
collection = Collection(
id = 1,
type = Collection.TYPE_ADDRESSBOOK,
url = "https://example.com".toHttpUrl(),
displayName = "Display Name",
description = "Description",
supportsWebPush = true,
pushTopic = "push-topic"
),
owner = "Owner",
lastSynced = emptyMap()
)
}

View file

@ -0,0 +1,395 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import android.app.Activity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBox
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CloudSync
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.DoNotDisturbOn
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.R
import at.bitfire.davdroid.repository.DavSyncStatsRepository
import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.composable.ExceptionInfoDialog
import dagger.hilt.android.EntryPointAccessors
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun CollectionScreen(
collectionId: Long,
onFinish: () -> Unit,
onNavUp: () -> Unit
) {
val context = LocalContext.current as Activity
val entryPoint = EntryPointAccessors.fromActivity(context, CollectionActivity.CollectionEntryPoint::class.java)
val model = viewModel<CollectionScreenModel>(
factory = CollectionScreenModel.factoryFromCollection(entryPoint.collectionModelAssistedFactory(), collectionId)
)
val collectionOrNull by model.collection.collectAsStateWithLifecycle(null)
if (model.invalid) {
onFinish()
return
}
val collection = collectionOrNull ?: return
CollectionScreen(
inProgress = model.inProgress,
error = model.error,
onResetError = model::resetError,
color = collection.color,
sync = collection.sync,
onSetSync = model::setSync,
privWriteContent = collection.privWriteContent,
forceReadOnly = collection.forceReadOnly,
onSetForceReadOnly = model::setForceReadOnly,
title = collection.title(),
displayName = collection.displayName,
description = collection.description,
owner = model.owner.collectAsStateWithLifecycle(null).value,
lastSynced = model.lastSynced.collectAsStateWithLifecycle(emptyList()).value,
url = collection.url.toString(),
onDelete = model::delete,
onNavUp = onNavUp
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CollectionScreen(
inProgress: Boolean,
error: Exception? = null,
onResetError: () -> Unit = {},
color: Int?,
sync: Boolean,
onSetSync: (Boolean) -> Unit = {},
privWriteContent: Boolean,
forceReadOnly: Boolean,
onSetForceReadOnly: (Boolean) -> Unit = {},
title: String,
displayName: String? = null,
description: String? = null,
owner: String? = null,
lastSynced: List<DavSyncStatsRepository.LastSynced> = emptyList(),
supportsWebPush: Boolean = false,
url: String,
onDelete: () -> Unit = {},
onNavUp: () -> Unit = {}
) {
AppTheme {
if (error != null)
ExceptionInfoDialog(
exception = error,
onDismiss = onResetError
)
Scaffold(
topBar = {
MediumTopAppBar(
navigationIcon = {
IconButton(onClick = onNavUp) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.navigate_up))
}
},
title = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
actions = {
var showDeleteDialog by remember { mutableStateOf(false) }
IconButton(
onClick = { showDeleteDialog = true },
enabled = !inProgress
) {
Icon(Icons.Default.DeleteForever, contentDescription = stringResource(R.string.collection_delete))
}
if (showDeleteDialog)
DeleteCollectionDialog(
displayName = title,
onDismiss = { showDeleteDialog = false },
onConfirm = {
onDelete()
showDeleteDialog = false
}
)
}
)
}
) { padding ->
Column(
Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
) {
if (inProgress)
LinearProgressIndicator(
Modifier
.fillMaxWidth()
.padding(bottom = 8.dp))
if (color != null) {
Box(
Modifier
.background(Color(color))
.fillMaxWidth()
.height(16.dp)
)
Spacer(Modifier.height(8.dp))
}
Column(Modifier.padding(8.dp)) {
CollectionScreen_Entry(
icon = Icons.Default.Sync,
title = stringResource(R.string.collection_synchronization),
text =
if (sync)
stringResource(R.string.collection_synchronization_on)
else
stringResource(R.string.collection_synchronization_off),
control = {
Switch(
checked = sync,
onCheckedChange = onSetSync
)
}
)
CollectionScreen_Entry(
icon = Icons.Default.DoNotDisturbOn,
title = stringResource(R.string.collection_read_only),
text = when {
!privWriteContent -> stringResource(R.string.collection_read_only_by_server)
forceReadOnly -> stringResource(R.string.collection_read_only_forced)
else -> stringResource(R.string.collection_read_write)
},
control = {
Switch(
checked = forceReadOnly || !privWriteContent,
enabled = privWriteContent,
onCheckedChange = onSetForceReadOnly
)
}
)
if (displayName != null)
CollectionScreen_Entry(
title = stringResource(R.string.collection_title),
text = title
)
if (description != null)
CollectionScreen_Entry(
title = stringResource(R.string.collection_description),
text = description
)
if (owner != null)
CollectionScreen_Entry(
icon = Icons.Default.AccountBox,
title = stringResource(R.string.collection_owner),
text = owner
)
if (supportsWebPush)
CollectionScreen_Entry(
icon = Icons.Default.CloudSync,
title = stringResource(R.string.collection_push_support),
text = stringResource(R.string.collection_push_web_push)
)
Column(Modifier.padding(start = 44.dp)) {
if (sync && lastSynced.isNotEmpty()) {
val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
for (lastSync in lastSynced) {
Text(
text = stringResource(R.string.collection_last_sync, lastSync.appName),
style = MaterialTheme.typography.titleMedium
)
val time = ZonedDateTime.ofInstant(Instant.ofEpochMilli(lastSync.lastSynced), ZoneId.systemDefault())
Text(
text = formatter.format(time),
style = MaterialTheme.typography.bodyLarge
)
Spacer(Modifier.height(16.dp))
}
}
Text(
text = stringResource(R.string.collection_url),
style = MaterialTheme.typography.titleMedium
)
SelectionContainer {
Text(
text = url,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier
)
}
}
}
}
}
}
}
@Composable
fun CollectionScreen_Entry(
icon: ImageVector? = null,
title: String? = null,
text: String? = null,
control: @Composable (() -> Unit)? = null
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 16.dp)
) {
if (icon != null)
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.padding(end = 12.dp)
.size(32.dp)
)
else
Spacer(Modifier.width(44.dp))
Column(Modifier.weight(1f)) {
if (title != null)
Text(
text = title,
style = MaterialTheme.typography.titleMedium
)
if (text != null)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge
)
}
if (control != null)
control()
}
}
@Composable
@Preview
fun CollectionScreen_Preview() {
CollectionScreen(
inProgress = true,
color = 0xff14c0c4.toInt(),
sync = true,
privWriteContent = true,
forceReadOnly = false,
url = "https://example.com/calendar",
title = "Some Calendar, with some additional text to make it wrap around and stuff.",
displayName = "Some Calendar, with some additional text to make it wrap around and stuff.",
description = "This is some description of the calendar. It can be long and wrap around.",
owner = "Some One",
lastSynced = listOf(
DavSyncStatsRepository.LastSynced(
appName = "Some Content Provider",
lastSynced = 1234567890
)
),
supportsWebPush = true
)
}
@Composable
fun DeleteCollectionDialog(
displayName: String,
onDismiss: () -> Unit = {},
onConfirm: () -> Unit = {}
) {
AlertDialog(
icon = {
Icon(Icons.Default.DeleteForever, contentDescription = null)
},
title = {
Text(stringResource(R.string.collection_delete))
},
text = {
Text(stringResource(R.string.collection_delete_warning, displayName))
},
confirmButton = {
Button(onClick = onConfirm) {
Text(stringResource(R.string.dialog_delete))
}
},
dismissButton = {
OutlinedButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
},
onDismissRequest = onDismiss
)
}
@Composable
@Preview
fun DeleteCollectionDialog_Preview() {
DeleteCollectionDialog(
displayName = "Some Calendar"
)
}

View file

@ -0,0 +1,112 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavSyncStatsRepository
import at.bitfire.davdroid.util.lastSegment
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class CollectionScreenModel @AssistedInject constructor(
@Assisted val collectionId: Long,
db: AppDatabase,
private val collectionRepository: DavCollectionRepository,
syncStatsRepository: DavSyncStatsRepository
): ViewModel() {
@AssistedFactory
interface Factory {
fun create(collectionId: Long): CollectionScreenModel
}
companion object {
fun factoryFromCollection(assistedFactory: Factory, collectionId: Long) = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return assistedFactory.create(collectionId) as T
}
}
}
var invalid by mutableStateOf(false)
val collection = collectionRepository.getFlow(collectionId)
.map {
if (it == null)
invalid = true
it
}
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val principalDao = db.principalDao()
val owner: Flow<String?> = collection.map { collection ->
collection?.ownerId?.let { ownerId ->
val principal = principalDao.getAsync(ownerId)
principal.displayName ?: principal.url.lastSegment()
}
}
val lastSynced = syncStatsRepository.getLastSyncedFlow(collectionId)
/** Whether an operation (like deleting the collection) is currently in progress */
var inProgress by mutableStateOf(false)
private set
var error by mutableStateOf<Exception?>(null)
private set
/** Scope for operations that must not be cancelled. */
private val noCancellationScope = CoroutineScope(SupervisorJob())
/**
* Deletes the collection from the database and the server.
*/
fun delete() {
val collection = collection.value ?: return
inProgress = true
noCancellationScope.launch {
try {
collectionRepository.delete(collection)
} catch (e: Exception) {
error = e
} finally {
inProgress = false
}
}
}
fun resetError() {
error = null
}
fun setForceReadOnly(forceReadOnly: Boolean) {
viewModelScope.launch {
collectionRepository.setForceReadOnly(collectionId, forceReadOnly)
}
}
fun setSync(sync: Boolean) {
viewModelScope.launch {
collectionRepository.setSync(collectionId, sync)
}
}
}

View file

@ -5,46 +5,43 @@
package at.bitfire.davdroid.ui.account
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Checkbox
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.EventNote
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.RemoveCircle
import androidx.compose.material.icons.filled.Task
import androidx.compose.material.icons.filled.Today
import androidx.compose.material.icons.outlined.Task
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
@ -57,13 +54,13 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
fun CollectionsList(
collections: LazyPagingItems<Collection>,
onChangeSync: (collectionId: Long, sync: Boolean) -> Unit,
onChangeForceReadOnly: (collectionId: Long, forceReadOnly: Boolean) -> Unit,
modifier: Modifier = Modifier,
onSubscribe: (collection: Collection) -> Unit = {}
onSubscribe: (collection: Collection) -> Unit = {},
onCollectionDetails: ((collection: Collection) -> Unit)? = null
) {
LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Top),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top),
modifier = modifier
) {
items(
@ -72,162 +69,129 @@ fun CollectionsList(
) { index ->
collections[index]?.let { item ->
if (item.type == Collection.TYPE_WEBCAL)
CollectionList_Subscription(
CollectionsList_Item_Webcal(
item,
onSubscribe = {
onSubscribe(item)
}
onSubscribe = { onSubscribe(item) }
)
else
CollectionList_Item(
CollectionsList_Item_Standard(
item,
onChangeSync = { sync ->
onChangeSync(item.id, sync)
},
onChangeForceReadOnly = { forceReadOnly ->
onChangeForceReadOnly(item.id, forceReadOnly)
}
onChangeSync = { onChangeSync(item.id, it) },
onCollectionDetails = onCollectionDetails
)
}
}
// make sure we can scroll down far enough so that the last item is not covered by a FAB
item {
Spacer(Modifier.height(140.dp))
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CollectionList_Item(
collection: Collection,
onChangeSync: (sync: Boolean) -> Unit = {},
onChangeForceReadOnly: (forceReadOnly: Boolean) -> Unit = {}
color: Color? = null,
title: String,
description: String? = null,
addressBook: Boolean = false,
calendar: Boolean = false,
todoList: Boolean = false,
journal: Boolean = false,
readOnly: Boolean = false,
onShowDetails: (() -> Unit)? = null,
syncControl: @Composable () -> Unit
) {
val context = LocalContext.current
var modifier = Modifier.fillMaxWidth()
if (onShowDetails != null)
modifier = modifier.clickable(onClick = onShowDetails)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(IntrinsicSize.Min)
OutlinedCard(
elevation = CardDefaults.cardElevation(1.dp),
modifier = modifier
) {
if (collection.type == Collection.TYPE_CALENDAR) {
val color = collection.color?.let { Color(it) } ?: Color.Transparent
Box(
Modifier
.background(color)
.fillMaxHeight()
.width(4.dp)
)
}
Box {
Column {
if (color != null)
Box(
Modifier
.background(color)
.fillMaxWidth()
.height(8.dp)
)
Switch(
checked = collection.sync,
onCheckedChange = onChangeSync,
modifier = Modifier
.padding(horizontal = 4.dp)
.semantics {
contentDescription = context.getString(R.string.account_synchronize_this_collection)
}
)
Column(Modifier.padding(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold))
Column(
modifier = Modifier.weight(1f)
) {
Text(
collection.title(),
style = MaterialTheme.typography.body1
)
collection.description?.let { description ->
Text(
description,
style = MaterialTheme.typography.body2
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 8.dp)
) {
FlowRow(
verticalArrangement = Arrangement.Center,
horizontalArrangement = Arrangement.End,
maxItemsInEachRow = 2,
modifier = Modifier.fillMaxHeight()
) {
if (collection.readOnly())
Icon(Icons.Default.RemoveCircle, stringResource(R.string.account_read_only))
if (collection.supportsVEVENT == true)
Icon(Icons.Default.Today, stringResource(R.string.account_calendar))
if (collection.supportsVTODO == true)
Icon(Icons.Outlined.Task, stringResource(R.string.account_task_list))
if (collection.supportsVJOURNAL == true)
Icon(Icons.AutoMirrored.Default.EventNote, stringResource(R.string.account_journal))
}
var showOverflow by remember { mutableStateOf(false) }
var showPropertiesDialog by remember { mutableStateOf(false) }
var showDeleteCollectionDialog by remember { mutableStateOf(false) }
IconButton(onClick = { showOverflow = true }) {
Icon(Icons.Default.MoreVert, stringResource(R.string.options_menu))
}
DropdownMenu(
expanded = showOverflow,
onDismissRequest = { showOverflow = false }
) {
// force read-only (only show for collections that are modifiable on the server)
if (collection.privWriteContent)
DropdownMenuItem(
onClick = {
onChangeForceReadOnly(!collection.forceReadOnly)
showOverflow = false
if (description != null)
Text(description, style = MaterialTheme.typography.bodyMedium)
}
syncControl()
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.collection_force_read_only))
Checkbox(
checked = collection.readOnly(),
onCheckedChange = { forceReadOnly ->
onChangeForceReadOnly(forceReadOnly)
showOverflow = false
}
)
}
}
if (addressBook)
CollectionList_Item_Chip(Icons.Default.Contacts, stringResource(R.string.account_contacts))
// show properties
DropdownMenuItem(onClick = {
showPropertiesDialog = true
showOverflow = false
}) {
Text(stringResource(R.string.collection_properties))
if (calendar)
CollectionList_Item_Chip(Icons.Default.Today, stringResource(R.string.account_calendar))
if (todoList)
CollectionList_Item_Chip(Icons.Default.Task, stringResource(R.string.account_task_list))
if (journal)
CollectionList_Item_Chip(Icons.AutoMirrored.Default.EventNote, stringResource(R.string.account_journal))
if (readOnly)
CollectionList_Item_Chip(Icons.Default.RemoveCircle, stringResource(R.string.account_read_only))
}
}
// delete collection (only show when required privilege is available)
if (collection.privUnbind)
DropdownMenuItem(onClick = {
showDeleteCollectionDialog = true
showOverflow = false
}) {
Text(stringResource(R.string.delete_collection))
}
}
if (showDeleteCollectionDialog)
DeleteCollectionDialog(
collection = collection,
onDismiss = { showDeleteCollectionDialog = false }
)
if (showPropertiesDialog)
CollectionPropertiesDialog(
collection = collection,
onDismiss = { showPropertiesDialog = false }
)
}
}
}
@Composable
@Preview
fun CollectionsList_Item_Sample() {
fun CollectionsList_Item_Standard(
collection: Collection,
onChangeSync: (sync: Boolean) -> Unit = {},
onCollectionDetails: ((collection: Collection) -> Unit)? = null
) {
CollectionList_Item(
color = collection.color?.let { Color(it) },
title = collection.title(),
description = collection.description,
addressBook = collection.type == Collection.TYPE_ADDRESSBOOK,
calendar = collection.supportsVEVENT == true,
todoList = collection.supportsVTODO == true,
journal = collection.supportsVJOURNAL == true,
readOnly = collection.readOnly(),
onShowDetails = {
if (onCollectionDetails != null)
onCollectionDetails(collection)
}
) {
val context = LocalContext.current
Switch(
checked = collection.sync,
onCheckedChange = onChangeSync,
modifier = Modifier
.padding(start = 4.dp, top = 4.dp, bottom = 4.dp)
.semantics {
contentDescription = context.getString(R.string.account_synchronize_this_collection)
}
)
}
}
@Composable
@Preview
fun CollectionsList_Item_Standard_Preview() {
CollectionsList_Item_Standard(
Collection(
type = Collection.TYPE_CALENDAR,
url = "https://example.com/caldav/sample".toHttpUrl(),
@ -243,50 +207,30 @@ fun CollectionsList_Item_Sample() {
}
@Composable
fun CollectionList_Subscription(
item: Collection,
fun CollectionsList_Item_Webcal(
collection: Collection,
onSubscribe: () -> Unit = {}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(IntrinsicSize.Min)
CollectionList_Item(
color = collection.color?.let { Color(it) },
title = collection.title(),
description = collection.description,
calendar = true,
readOnly = true
) {
val color = item.color?.let { Color(it) } ?: Color.Transparent
Box(
Modifier
.background(color)
.fillMaxHeight()
.width(4.dp)
)
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1f)
OutlinedButton(
onClick = onSubscribe,
modifier = Modifier.padding(start = 4.dp)
) {
Text(
item.title(),
style = MaterialTheme.typography.body1
)
item.description?.let { description ->
Text(
description,
style = MaterialTheme.typography.body2
)
}
}
TextButton(onClick = onSubscribe) {
Text("Subscribe".uppercase())
Text("Subscribe")
}
}
}
@Composable
@Preview
fun CollectionList_Subscription_Preview() {
CollectionList_Subscription(
fun CollectionList_Item_Webcal_Preview() {
CollectionsList_Item_Webcal(
Collection(
type = Collection.TYPE_WEBCAL,
url = "https://example.com/caldav/sample".toHttpUrl(),
@ -295,4 +239,13 @@ fun CollectionList_Subscription_Preview() {
color = 0xffff0000.toInt()
)
)
}
@Composable
fun CollectionList_Item_Chip(icon: ImageVector, text: String) {
SuggestionChip(
icon = { Icon(icon, contentDescription = text) },
label = { Text(text) },
onClick = {}
)
}

View file

@ -8,243 +8,47 @@ import android.accounts.Account
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.app.TaskStackBuilder
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.ui.M2Theme
import at.bitfire.davdroid.ui.widget.ExceptionInfoDialog
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.AndroidEntryPoint
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.apache.commons.lang3.StringUtils
import java.util.UUID
import javax.inject.Inject
import dagger.hilt.android.components.ActivityComponent
@AndroidEntryPoint
class CreateAddressBookActivity: AppCompatActivity() {
@EntryPoint
@InstallIn(ActivityComponent::class)
interface CreateAddressBookEntryPoint {
fun createAddressBookModelAssistedFactory(): CreateAddressBookModel.Factory
}
companion object {
const val EXTRA_ACCOUNT = "account"
}
val account by lazy { intent.getParcelableExtra<Account>(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") }
@Inject
lateinit var modelFactory: AccountModel.Factory
val model by viewModels<AccountModel> {
object: ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
modelFactory.create(account) as T
}
val account by lazy {
intent.getParcelableExtra<Account>(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set")
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
M2Theme {
var displayName by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var homeSet by remember { mutableStateOf<HomeSet?>(null) }
var isCreating by remember { mutableStateOf(false) }
model.createCollectionResult.observeAsState().value?.let { result ->
if (result.isEmpty)
finish()
else
ExceptionInfoDialog(
exception = result.get(),
onDismiss = {
isCreating = false
model.createCollectionResult.value = null
}
)
}
val onCreateCollection = {
if (!isCreating) {
isCreating = true
homeSet?.let { homeSet ->
model.createCollection(
homeSet = homeSet,
addressBook = true,
name = UUID.randomUUID().toString(),
displayName = StringUtils.trimToNull(displayName),
description = StringUtils.trimToNull(description)
)
}
}
}
val homeSets by model.bindableAddressBookHomesets.observeAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.create_addressbook)) },
navigationIcon = {
IconButton(onClick = { onSupportNavigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.navigate_up))
}
},
actions = {
val isCreateEnabled = !isCreating && displayName.isNotEmpty() && homeSet != null
IconButton(
enabled = isCreateEnabled,
onClick = { onCreateCollection() }
) {
Text(stringResource(R.string.create_collection_create).uppercase())
}
}
)
}
) { padding ->
Column(
Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
) {
if (isCreating)
LinearProgressIndicator(
color = MaterialTheme.colors.secondary,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
homeSets?.let { homeSets ->
AddressBookForm(
displayName = displayName,
onDisplayNameChange = { displayName = it },
description = description,
onDescriptionChange = { description = it },
homeSets = homeSets,
homeSet = homeSet,
onHomeSetSelected = { homeSet = it },
onCreateCollection = {
onCreateCollection()
}
)
}
}
}
}
CreateAddressBookScreen(
account = account,
onNavUp = ::onSupportNavigateUp,
onFinish = ::finish
)
}
}
override fun supportShouldUpRecreateTask(targetIntent: Intent) = true
override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) {
builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account)
}
@Composable
private fun AddressBookForm(
displayName: String,
onDisplayNameChange: (String) -> Unit = {},
description: String,
onDescriptionChange: (String) -> Unit = {},
homeSet: HomeSet?,
homeSets: List<HomeSet>,
onHomeSetSelected: (HomeSet) -> Unit = {},
onCreateCollection: () -> Unit = {}
) {
Column(Modifier
.fillMaxWidth()
.padding(8.dp)
) {
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
value = displayName,
onValueChange = onDisplayNameChange,
label = { Text(stringResource(R.string.create_collection_display_name)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
OutlinedTextField(
value = description,
onValueChange = onDescriptionChange,
label = { Text(stringResource(R.string.create_collection_description_optional)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
onCreateCollection()
}
),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
HomeSetSelection(
homeSet = homeSet,
homeSets = homeSets,
onHomeSetSelected = onHomeSetSelected
)
}
}
@Composable
@Preview
private fun AddressBookForm_Preview() {
AddressBookForm(
displayName = "Display Name",
description = "Some longer description that is optional",
homeSets = listOf(
HomeSet(1, 0, false, "http://example.com/".toHttpUrl()),
HomeSet(2, 0, false, "http://example.com/".toHttpUrl(), displayName = "Home Set 2")
),
homeSet = null
)
builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
}
}

View file

@ -0,0 +1,107 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import android.accounts.Account
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavHomeSetRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class CreateAddressBookModel @AssistedInject constructor(
private val collectionRepository: DavCollectionRepository,
homeSetRepository: DavHomeSetRepository,
@Assisted val account: Account
): ViewModel() {
@AssistedFactory
interface Factory {
fun create(account: Account): CreateAddressBookModel
}
companion object {
fun factoryFromAccount(assistedFactory: Factory, account: Account) = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return assistedFactory.create(account) as T
}
}
}
val addressBookHomeSets = homeSetRepository.getAddressBookHomeSetsFlow(account)
// UI state
data class UiState(
val error: Exception? = null,
val success: Boolean = false,
val displayName: String = "",
val description: String = "",
val selectedHomeSet: HomeSet? = null,
val isCreating: Boolean = false
) {
val canCreate = !isCreating && displayName.isNotBlank() && selectedHomeSet != null
}
var uiState by mutableStateOf(UiState())
private set
fun resetError() {
uiState = uiState.copy(error = null)
}
fun setDisplayName(displayName: String) {
uiState = uiState.copy(displayName = displayName)
}
fun setDescription(description: String) {
uiState = uiState.copy(description = description)
}
fun setHomeSet(homeSet: HomeSet) {
uiState = uiState.copy(selectedHomeSet = homeSet)
}
// actions
/* Creating collections shouldn't be cancelled when the view is destroyed, otherwise we might
end up with collections on the server that are not represented in the database/UI. */
private val createCollectionScope = CoroutineScope(SupervisorJob())
fun createAddressBook() {
val homeSet = uiState.selectedHomeSet ?: return
uiState = uiState.copy(isCreating = true)
createCollectionScope.launch {
uiState = try {
collectionRepository.createAddressBook(
account = account,
homeSet = homeSet,
displayName = uiState.displayName,
description = uiState.description
)
uiState.copy(isCreating = false, success = true)
} catch (e: Exception) {
uiState.copy(isCreating = false, error = e)
}
}
}
}

View file

@ -0,0 +1,207 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import android.accounts.Account
import android.app.Activity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.composable.ExceptionInfoDialog
import dagger.hilt.android.EntryPointAccessors
import okhttp3.HttpUrl.Companion.toHttpUrl
@Composable
fun CreateAddressBookScreen(
account: Account,
onNavUp: () -> Unit = {},
onFinish: () -> Unit = {}
) {
val context = LocalContext.current as Activity
val entryPoint = EntryPointAccessors.fromActivity(context, CreateAddressBookActivity.CreateAddressBookEntryPoint::class.java)
val model = viewModel<CreateAddressBookModel>(
factory = CreateAddressBookModel.factoryFromAccount(entryPoint.createAddressBookModelAssistedFactory(), account)
)
val uiState = model.uiState
if (uiState.success)
onFinish()
CreateAddressBookScreen(
error = uiState.error,
onResetError = model::resetError,
displayName = uiState.displayName,
onSetDisplayName = model::setDisplayName,
description = uiState.description,
onSetDescription = model::setDescription,
homeSets = model.addressBookHomeSets.collectAsStateWithLifecycle(emptyList()).value,
selectedHomeSet = uiState.selectedHomeSet,
onSelectHomeSet = model::setHomeSet,
canCreate = uiState.canCreate,
isCreating = uiState.isCreating,
onCreate = model::createAddressBook,
onNavUp = onNavUp
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateAddressBookScreen(
error: Exception? = null,
onResetError: () -> Unit = {},
displayName: String = "",
onSetDisplayName: (String) -> Unit = {},
description: String = "",
onSetDescription: (String) -> Unit = {},
homeSets: List<HomeSet>,
selectedHomeSet: HomeSet? = null,
onSelectHomeSet: (HomeSet) -> Unit = {},
canCreate: Boolean = false,
isCreating: Boolean = false,
onCreate: () -> Unit = {},
onNavUp: () -> Unit = {}
) {
AppTheme {
if (error != null)
ExceptionInfoDialog(
exception = error,
onDismiss = onResetError
)
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.create_addressbook)) },
navigationIcon = {
IconButton(onClick = onNavUp) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.navigate_up))
}
}
)
}
) { padding ->
Column(
Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
) {
if (isCreating)
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
Column(
Modifier
.fillMaxWidth()
.padding(8.dp)
) {
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
value = displayName,
onValueChange = onSetDisplayName,
label = { Text(stringResource(R.string.create_collection_display_name)) },
singleLine = true,
enabled = !isCreating,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
OutlinedTextField(
value = description,
onValueChange = onSetDescription,
label = { Text(stringResource(R.string.create_collection_description_optional)) },
supportingText = { Text(stringResource(R.string.create_collection_optional)) },
singleLine = true,
enabled = !isCreating,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
onCreate()
}
),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
HomeSetSelection(
homeSet = selectedHomeSet,
homeSets = homeSets,
onSelectHomeSet = onSelectHomeSet,
enabled = !isCreating,
modifier = Modifier.padding(vertical = 8.dp)
)
Text(
stringResource(R.string.create_addressbook_maybe_not_supported),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp)
)
Button(
onClick = onCreate,
enabled = canCreate
) {
Text(stringResource(R.string.create_addressbook))
}
}
}
}
}
}
@Composable
@Preview
fun CreateAddressBookScreen_Preview() {
CreateAddressBookScreen(
displayName = "Address Book",
homeSets = listOf(
HomeSet(0, 0, true, "https://example.com/some/homeset".toHttpUrl())
)
)
}

View file

@ -8,204 +8,42 @@ import android.accounts.Account
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Checkbox
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.app.TaskStackBuilder
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.ui.M2Theme
import at.bitfire.davdroid.ui.composable.MultipleChoiceInputDialog
import at.bitfire.davdroid.ui.widget.CalendarColorPickerDialog
import at.bitfire.davdroid.ui.widget.ExceptionInfoDialog
import at.bitfire.davdroid.ui.AppTheme
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.apache.commons.lang3.StringUtils
import java.text.Collator
import java.time.ZoneId
import java.time.format.TextStyle
import java.util.Locale
import java.util.UUID
import javax.inject.Inject
import dagger.hilt.android.components.ActivityComponent
@AndroidEntryPoint
class CreateCalendarActivity: AppCompatActivity() {
@EntryPoint
@InstallIn(ActivityComponent::class)
interface CreateCalendarEntryPoint {
fun createCalendarModelAssistedFactory(): CreateCalendarModel.Factory
}
companion object {
const val EXTRA_ACCOUNT = "account"
}
val account by lazy { intent.getParcelableExtra<Account>(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") }
@Inject
lateinit var modelFactory: AccountModel.Factory
val accountModel by viewModels<AccountModel> {
object: ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
modelFactory.create(account) as T
}
val account by lazy {
intent.getParcelableExtra<Account>(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set")
}
val model: Model by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
M2Theme {
var displayName by remember { mutableStateOf("") }
var color by remember { mutableIntStateOf(Constants.DAVDROID_GREEN_RGBA) }
var description by remember { mutableStateOf("") }
var homeSet by remember { mutableStateOf<HomeSet?>(null) }
var timeZoneId by remember { mutableStateOf<String>(ZoneId.systemDefault().id) }
var supportVEVENT by remember { mutableStateOf(true) }
var supportVTODO by remember { mutableStateOf(false) }
var supportVJOURNAL by remember { mutableStateOf(false) }
var isCreating by remember { mutableStateOf(false) }
accountModel.createCollectionResult.observeAsState().value?.let { result ->
if (result.isEmpty)
finish()
else
ExceptionInfoDialog(
exception = result.get(),
onDismiss = {
isCreating = false
accountModel.createCollectionResult.value = null
}
)
}
val onCreateCollection = {
if (!isCreating) {
isCreating = true
homeSet?.let { homeSet ->
accountModel.createCollection(
homeSet = homeSet,
addressBook = false,
name = UUID.randomUUID().toString(),
displayName = StringUtils.trimToNull(displayName),
description = StringUtils.trimToNull(description),
color = color,
timeZoneId = timeZoneId,
supportsVEVENT = supportVEVENT,
supportsVTODO = supportVTODO,
supportsVJOURNAL = supportVJOURNAL
)
}
}
}
val homeSets by accountModel.bindableCalendarHomesets.observeAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.create_calendar)) },
navigationIcon = {
IconButton(onClick = { onSupportNavigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.navigate_up))
}
},
actions = {
val isCreateEnabled = !isCreating && displayName.isNotBlank() && homeSet != null
IconButton(
enabled = isCreateEnabled,
onClick = { onCreateCollection() }
) {
Text(stringResource(R.string.create_collection_create).uppercase())
}
}
)
}
) { padding ->
Column(Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
) {
if (isCreating)
LinearProgressIndicator(
color = MaterialTheme.colors.secondary,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
homeSets?.let { homeSets ->
CalendarForm(
displayName = displayName,
onDisplayNameChange = { displayName = it },
color = color,
onColorChange = { color = it },
description = description,
onDescriptionChange = { description = it },
timeZoneId = timeZoneId,
onTimeZoneSelected = { timeZoneId = it },
supportVEVENT = supportVEVENT,
onSupportVEVENTChange = { supportVEVENT = it },
supportVTODO = supportVTODO,
onSupportVTODOChange = { supportVTODO = it },
supportVJOURNAL = supportVJOURNAL,
onSupportVJOURNALChange = { supportVJOURNAL = it },
homeSet = homeSet,
homeSets = homeSets,
onHomeSetSelected = { homeSet = it }
)
}
}
}
AppTheme {
CreateCalendarScreen(
account = account,
onNavUp = ::onSupportNavigateUp,
onFinish = ::finish
)
}
}
}
@ -213,225 +51,7 @@ class CreateCalendarActivity: AppCompatActivity() {
override fun supportShouldUpRecreateTask(targetIntent: Intent) = true
override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) {
builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, accountModel.account)
}
@Composable
fun CalendarForm(
displayName: String,
onDisplayNameChange: (String) -> Unit = {},
color: Int,
onColorChange: (Int) -> Unit = {},
description: String,
onDescriptionChange: (String) -> Unit = {},
timeZoneId: String,
onTimeZoneSelected: (String) -> Unit = {},
supportVEVENT: Boolean,
onSupportVEVENTChange: (Boolean) -> Unit = {},
supportVTODO: Boolean,
onSupportVTODOChange: (Boolean) -> Unit = {},
supportVJOURNAL: Boolean,
onSupportVJOURNALChange: (Boolean) -> Unit = {},
homeSet: HomeSet?,
homeSets: List<HomeSet>,
onHomeSetSelected: (HomeSet) -> Unit = {}
) {
Column(Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
value = displayName,
onValueChange = onDisplayNameChange,
label = { Text(stringResource(R.string.create_collection_display_name)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next
),
modifier = Modifier
.weight(1f)
.padding(end = 8.dp)
.focusRequester(focusRequester)
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
var showColorPicker by remember { mutableStateOf(false) }
Box(Modifier
.background(color = Color(color), shape = CircleShape)
.clickable {
showColorPicker = true
}
.size(32.dp)
)
if (showColorPicker) {
CalendarColorPickerDialog(
onSelectColor = {
onColorChange(it)
showColorPicker = false
},
onDismiss = { showColorPicker = false }
)
}
}
OutlinedTextField(
value = description,
onValueChange = onDescriptionChange,
label = { Text(stringResource(R.string.create_collection_description_optional)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next
),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
) {
Column(Modifier.weight(1f)) {
Text(
stringResource(R.string.create_calendar_time_zone),
style = MaterialTheme.typography.body1
)
Text(
ZoneId.of(timeZoneId).getDisplayName(TextStyle.FULL_STANDALONE, Locale.getDefault()),
style = MaterialTheme.typography.body2
)
}
var showTimeZoneDialog by remember { mutableStateOf(false) }
TextButton(
enabled =
if (LocalInspectionMode.current)
true
else
model.timeZoneDefs.observeAsState().value != null,
onClick = { showTimeZoneDialog = true }
) {
Text("Select timezone".uppercase())
}
if (showTimeZoneDialog) {
model.timeZoneDefs.observeAsState().value?.let { timeZoneDefs ->
MultipleChoiceInputDialog(
title = "Select timezone",
namesAndValues = timeZoneDefs,
initialValue = timeZoneId,
onValueSelected = {
onTimeZoneSelected(it)
showTimeZoneDialog = false
},
onDismiss = { showTimeZoneDialog = false }
)
}
}
}
Text(
stringResource(R.string.create_calendar_type),
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(top = 16.dp)
)
CheckboxRow(
labelId = R.string.create_calendar_type_vevent,
checked = supportVEVENT,
onCheckedChange = onSupportVEVENTChange
)
CheckboxRow(
labelId = R.string.create_calendar_type_vtodo,
checked = supportVTODO,
onCheckedChange = onSupportVTODOChange
)
CheckboxRow(
labelId = R.string.create_calendar_type_vjournal,
checked = supportVJOURNAL,
onCheckedChange = onSupportVJOURNALChange
)
HomeSetSelection(
homeSet = homeSet,
homeSets = homeSets,
onHomeSetSelected = onHomeSetSelected
)
}
}
@Composable
fun CheckboxRow(
@StringRes labelId: Int,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange
)
Text(
text = stringResource(labelId),
style = MaterialTheme.typography.body1,
modifier = Modifier
.clickable { onCheckedChange(!checked) }
.weight(1f)
)
}
}
@Composable
@Preview
fun CalendarForm_Preview() {
CalendarForm(
displayName = "My Calendar",
color = Color.Magenta.toArgb(),
description = "This is my calendar",
timeZoneId = "Europe/Vienna",
supportVEVENT = true,
supportVTODO = false,
supportVJOURNAL = false,
homeSet = null,
homeSets = emptyList()
)
}
@HiltViewModel
class Model @Inject constructor() : ViewModel() {
/**
* List of available time zones as <display name, ID> pairs.
*/
val timeZoneDefs = MutableLiveData<List<Pair<String, String>>>()
init {
viewModelScope.launch(Dispatchers.IO) {
val timeZones = mutableListOf<Pair<String, String>>()
// iterate over Android time zones and take those with ical4j VTIMEZONE into consideration
val locale = Locale.getDefault()
for (id in ZoneId.getAvailableZoneIds()) {
timeZones += Pair(
ZoneId.of(id).getDisplayName(TextStyle.FULL, locale),
id
)
}
val collator = Collator.getInstance()
timeZoneDefs.postValue(timeZones.sortedBy { collator.getCollationKey(it.first) })
}
}
builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
}
}

View file

@ -0,0 +1,168 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import android.accounts.Account
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.repository.DavCollectionRepository
import at.bitfire.davdroid.repository.DavHomeSetRepository
import at.bitfire.ical4android.Css3Color
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import java.text.Collator
import java.time.ZoneId
import java.time.format.TextStyle
import java.util.Locale
import java.util.TimeZone
class CreateCalendarModel @AssistedInject constructor(
private val collectionRepository: DavCollectionRepository,
homeSetRepository: DavHomeSetRepository,
@Assisted val account: Account
): ViewModel() {
@AssistedFactory
interface Factory {
fun create(account: Account): CreateCalendarModel
}
companion object {
fun factoryFromAccount(assistedFactory: Factory, account: Account) = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return assistedFactory.create(account) as T
}
}
}
val calendarHomeSets = homeSetRepository.getCalendarHomeSetsFlow(account)
data class TimeZoneInfo(
val id: String,
val displayName: String,
)
/** List of available time zones as <display name, ID> pairs. */
val timeZones: Flow<List<TimeZoneInfo>> = flow {
val timeZones = mutableListOf<TimeZoneInfo>()
val locale = Locale.getDefault()
for (id in ZoneId.getAvailableZoneIds())
timeZones += TimeZoneInfo(
id,
ZoneId.of(id).getDisplayName(TextStyle.FULL, locale),
)
val collator = Collator.getInstance()
val result = timeZones.sortedBy { collator.getCollationKey(it.displayName) }
emit(result)
}.flowOn(Dispatchers.Default)
// UI state
data class UiState(
val error: Exception? = null,
val success: Boolean = false,
val color: Int = Css3Color.entries.random().argb,
val displayName: String = "",
val description: String = "",
val timeZoneId: String? = TimeZone.getDefault().id,
val supportVEVENT: Boolean = true,
val supportVTODO: Boolean = true,
val supportVJOURNAL: Boolean = true,
val homeSet: HomeSet? = null,
val isCreating: Boolean = false
) {
val canCreate = !isCreating && displayName.isNotBlank() && homeSet != null
}
var uiState by mutableStateOf(UiState())
private set
fun resetError() {
uiState = uiState.copy(error = null)
}
fun setColor(color: Int) {
uiState = uiState.copy(color = color)
}
fun setDisplayName(displayName: String) {
uiState = uiState.copy(displayName = displayName)
}
fun setDescription(description: String) {
uiState = uiState.copy(description = description)
}
fun setTimeZoneId(timeZoneId: String?) {
uiState = uiState.copy(timeZoneId = timeZoneId)
}
fun setSupportVEVENT(supportVEVENT: Boolean) {
uiState = uiState.copy(supportVEVENT = supportVEVENT)
}
fun setSupportVTODO(supportVTODO: Boolean) {
uiState = uiState.copy(supportVTODO = supportVTODO)
}
fun setSupportVJOURNAL(supportVJOURNAL: Boolean) {
uiState = uiState.copy(supportVJOURNAL = supportVJOURNAL)
}
fun setHomeSet(homeSet: HomeSet) {
uiState = uiState.copy(homeSet = homeSet)
}
// actions
/* Creating collections shouldn't be cancelled when the view is destroyed, otherwise we might
end up with collections on the server that are not represented in the database/UI. */
private val createCollectionScope = CoroutineScope(SupervisorJob())
fun createCalendar() {
val homeSet = uiState.homeSet ?: return
uiState = uiState.copy(isCreating = true)
createCollectionScope.launch {
uiState = try {
collectionRepository.createCalendar(
account = account,
homeSet = homeSet,
color = uiState.color,
displayName = uiState.displayName,
description = uiState.description,
timeZoneId = uiState.timeZoneId,
supportVEVENT = uiState.supportVEVENT,
supportVTODO = uiState.supportVTODO,
supportVJOURNAL = uiState.supportVJOURNAL
)
uiState.copy(isCreating = false, success = true)
} catch (e: Exception) {
uiState.copy(isCreating = false, error = e)
}
}
}
}

View file

@ -0,0 +1,376 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import android.accounts.Account
import android.app.Activity
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.ui.AppTheme
import at.bitfire.davdroid.ui.composable.ExceptionInfoDialog
import at.bitfire.davdroid.ui.widget.CalendarColorPickerDialog
import at.bitfire.ical4android.Css3Color
import dagger.hilt.android.EntryPointAccessors
import okhttp3.HttpUrl.Companion.toHttpUrl
@Composable
fun CreateCalendarScreen(
account: Account,
onFinish: () -> Unit,
onNavUp: () -> Unit
) {
val context = LocalContext.current as Activity
val entryPoint = EntryPointAccessors.fromActivity(context, CreateCalendarActivity.CreateCalendarEntryPoint::class.java)
val model = viewModel<CreateCalendarModel>(
factory = CreateCalendarModel.factoryFromAccount(entryPoint.createCalendarModelAssistedFactory(), account)
)
val uiState = model.uiState
if (uiState.success)
onFinish()
CreateCalendarScreen(
isCreating = uiState.isCreating,
error = uiState.error,
onResetError = model::resetError,
color = uiState.color,
onSetColor = model::setColor,
displayName = uiState.displayName,
onSetDisplayName = model::setDisplayName,
description = uiState.description,
onSetDescription = model::setDescription,
timeZones = model.timeZones.collectAsStateWithLifecycle(emptyList()).value,
timeZone = uiState.timeZoneId,
onSelectTimeZone = model::setTimeZoneId,
supportVEVENT = uiState.supportVEVENT,
onSetSupportVEVENT = model::setSupportVEVENT,
supportVTODO = uiState.supportVTODO,
onSetSupportVTODO = model::setSupportVTODO,
supportVJOURNAL = uiState.supportVJOURNAL,
onSetSupportVJOURNAL = model::setSupportVJOURNAL,
homeSets = model.calendarHomeSets.collectAsStateWithLifecycle(emptyList()).value,
selectedHomeSet = uiState.homeSet,
onSelectHomeSet = model::setHomeSet,
canCreate = uiState.canCreate,
onCreate = model::createCalendar,
onNavUp = onNavUp
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateCalendarScreen(
error: Exception? = null,
onResetError: () -> Unit = {},
color: Int = Css3Color.green.argb,
onSetColor: (Int) -> Unit = {},
displayName: String = "",
onSetDisplayName: (String) -> Unit = {},
description: String = "",
onSetDescription: (String) -> Unit = {},
timeZones: List<CreateCalendarModel.TimeZoneInfo>,
timeZone: String? = null,
onSelectTimeZone: (String?) -> Unit = {},
supportVEVENT: Boolean = true,
onSetSupportVEVENT: (Boolean) -> Unit = {},
supportVTODO: Boolean = true,
onSetSupportVTODO: (Boolean) -> Unit = {},
supportVJOURNAL: Boolean = true,
onSetSupportVJOURNAL: (Boolean) -> Unit = {},
homeSets: List<HomeSet>,
selectedHomeSet: HomeSet? = null,
onSelectHomeSet: (HomeSet) -> Unit = {},
canCreate: Boolean = false,
isCreating: Boolean = false,
onCreate: () -> Unit = {},
onNavUp: () -> Unit = {}
) {
val context = LocalContext.current
AppTheme {
if (error != null)
ExceptionInfoDialog(
exception = error,
onDismiss = onResetError
)
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.create_calendar)) },
navigationIcon = {
IconButton(onClick = onNavUp) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.navigate_up))
}
}
)
}
) { padding ->
Column(
Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
) {
if (isCreating)
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
Column(
Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
value = displayName,
onValueChange = onSetDisplayName,
label = { Text(stringResource(R.string.create_collection_display_name)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Next
),
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester)
.padding(end = 8.dp)
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
var showColorPicker by remember { mutableStateOf(false) }
Box(Modifier
.background(color = Color(color), shape = RoundedCornerShape(4.dp))
.clickable {
showColorPicker = true
}
.size(48.dp)
.semantics {
contentDescription = context.getString(R.string.create_collection_color)
}
)
if (showColorPicker) {
CalendarColorPickerDialog(
onSelectColor = { color ->
onSetColor(color)
showColorPicker = false
},
onDismiss = { showColorPicker = false }
)
}
}
OutlinedTextField(
value = description,
onValueChange = onSetDescription,
label = { Text(stringResource(R.string.create_collection_description_optional)) },
supportingText = { Text(stringResource(R.string.create_collection_optional)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { onCreate() }
),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = Modifier.padding(top = 8.dp)
) {
OutlinedTextField(
label = { Text(stringResource(R.string.create_calendar_time_zone_optional)) },
value = timeZone ?: stringResource(R.string.create_calendar_time_zone_none),
onValueChange = { /* read-only */ },
supportingText = { Text(stringResource(R.string.create_collection_optional)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
readOnly = true,
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.fillMaxHeight()
) {
Text(
text = stringResource(R.string.create_calendar_time_zone_none),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.clickable {
onSelectTimeZone(null)
expanded = false
}
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
)
for (tz in timeZones)
Text(
text = tz.displayName,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.clickable {
onSelectTimeZone(tz.id)
expanded = false
}
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
Text(
stringResource(R.string.create_calendar_type),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 16.dp)
)
CheckBoxRow(
label = stringResource(R.string.create_calendar_type_vevent),
value = supportVEVENT,
onValueChange = onSetSupportVEVENT
)
CheckBoxRow(
label = stringResource(R.string.create_calendar_type_vtodo),
value = supportVTODO,
onValueChange = onSetSupportVTODO
)
CheckBoxRow(
label = stringResource(R.string.create_calendar_type_vjournal),
value = supportVJOURNAL,
onValueChange = onSetSupportVJOURNAL
)
HomeSetSelection(
homeSet = selectedHomeSet,
homeSets = homeSets,
onSelectHomeSet = onSelectHomeSet,
modifier = Modifier.padding(vertical = 8.dp)
)
Text(
stringResource(R.string.create_calendar_maybe_not_supported),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp)
)
Button(
onClick = onCreate,
enabled = canCreate
) {
Text(stringResource(R.string.create_calendar))
}
}
}
}
}
}
@Composable
fun CheckBoxRow(
label: String,
value: Boolean,
onValueChange: (Boolean) -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { onValueChange(!value) }
) {
Checkbox(
checked = value,
onCheckedChange = onValueChange
)
Text(
label,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
}
}
@Composable
@Preview
fun CreateCalendarScreenPreview() {
CreateCalendarScreen(
timeZones = listOf(
CreateCalendarModel.TimeZoneInfo(
id = "Europe/Vienna",
displayName = "Vienna (Europe)"
)
),
timeZone = "Europe/Vienna",
homeSets = listOf(
HomeSet(
id = 0,
serviceId = 0,
personal = true,
url = "https://example.com/some/homeset".toHttpUrl()
)
)
)
}

View file

@ -1,69 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.RadioButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.util.DavUtils
@Composable
fun HomeSetSelection(
homeSet: HomeSet?,
homeSets: List<HomeSet>,
onHomeSetSelected: (HomeSet) -> Unit
) {
// select first home set if none is selected
LaunchedEffect(homeSets) {
if (homeSet == null)
homeSets.firstOrNull()?.let(onHomeSetSelected)
}
Text(
text = stringResource(R.string.create_collection_home_set),
style = MaterialTheme.typography.body1,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, bottom = 8.dp)
)
for (item in homeSets) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = homeSet == item,
onClick = { onHomeSetSelected(item) }
)
Column(
Modifier
.clickable { onHomeSetSelected(item) }
.weight(1f)) {
Text(
text = item.displayName ?: DavUtils.lastSegmentOfUrl(item.url),
style = MaterialTheme.typography.body1
)
Text(
text = item.url.encodedPath,
style = MaterialTheme.typography.caption.copy(fontFamily = FontFamily.Monospace)
)
}
}
}
}

View file

@ -1,205 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Card
import androidx.compose.material.Checkbox
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.ui.widget.ExceptionInfoDialog
import okhttp3.HttpUrl.Companion.toHttpUrl
import kotlin.jvm.optionals.getOrNull
@Composable
fun DeleteCollectionDialog(
collection: Collection,
onDismiss: () -> Unit,
model: AccountModel = viewModel()
) {
var started by remember { mutableStateOf(false) }
val result by model.deleteCollectionResult.observeAsState()
fun dismiss() {
// dismiss dialog, reset data so that it can be shown from start again
onDismiss()
started = false
model.deleteCollectionResult.value = null
}
if (result?.isEmpty == true) {
// finished without error
dismiss()
return
}
Dialog(
properties = DialogProperties(
dismissOnClickOutside =
result != null || // finished with error message
!started // not started
),
onDismissRequest = ::dismiss
) {
Card {
DeleteCollectionDialog_Content(
collection = collection,
started = started,
result = result?.getOrNull(),
onDeleteCollection = {
started = true
model.deleteCollection(collection)
},
onCancel = ::dismiss
)
}
}
}
@Composable
fun DeleteCollectionDialog_Content(
collection: Collection,
started: Boolean,
result: Exception?,
onDeleteCollection: () -> Unit = {},
onCancel: () -> Unit = {}
) {
Column(Modifier.padding(16.dp)) {
Text(
stringResource(R.string.delete_collection_confirm_title),
style = MaterialTheme.typography.h6
)
Text(
stringResource(R.string.delete_collection_confirm_warning, collection.title()),
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(vertical = 8.dp)
)
when {
result != null -> ExceptionInfoDialog(
exception = result,
remoteResource = collection.url,
onDismiss = onCancel
)
started -> {
LinearProgressIndicator(
color = MaterialTheme.colors.secondary,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
}
else ->
DeleteCollectionDialog_Confirmation(
onDeleteCollection = onDeleteCollection,
onCancel = onCancel
)
}
}
}
@Composable
fun DeleteCollectionDialog_Confirmation(
onDeleteCollection: () -> Unit = {},
onCancel: () -> Unit = {}
) {
var confirmed by remember { mutableStateOf(false) }
Row(Modifier.padding(vertical = 8.dp)) {
Checkbox(
checked = confirmed,
onCheckedChange = {
confirmed = !confirmed
}
)
Text(
stringResource(R.string.delete_collection_data_shall_be_deleted),
style = MaterialTheme.typography.body1,
modifier = Modifier.clickable { confirmed = !confirmed }
)
}
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()
) {
TextButton(
onClick = onCancel
) {
Text(stringResource(android.R.string.cancel).uppercase())
}
TextButton(
onClick = onDeleteCollection,
enabled = confirmed
) {
Text(stringResource(R.string.delete_collection).uppercase())
}
}
}
@Composable
@Preview
fun DeleteCollectionDialog_Preview() {
DeleteCollectionDialog_Content(
collection = Collection(
type = Collection.TYPE_CALENDAR,
url = "https://example.com/calendar".toHttpUrl(),
),
started = false,
result = null
)
}
@Composable
@Preview
fun DeleteCollectionDialog_Preview_Deleting() {
DeleteCollectionDialog_Content(
collection = Collection(
type = Collection.TYPE_CALENDAR,
url = "https://example.com/calendar".toHttpUrl(),
),
started = true,
result = null
)
}
@Composable
@Preview
fun DeleteCollectionDialog_Preview_Error() {
DeleteCollectionDialog_Content(
collection = Collection(
type = Collection.TYPE_CALENDAR,
url = "https://example.com/calendar".toHttpUrl(),
),
started = true,
result = Exception("Test error")
)
}

View file

@ -0,0 +1,31 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.db.Service
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
class GetBindableHomeSetsFromServiceUseCase @Inject constructor(
db: AppDatabase
) {
private val homeSetDao = db.homeSetDao()
@OptIn(ExperimentalCoroutinesApi::class)
operator fun invoke(serviceFlow: Flow<Service?>): Flow<List<HomeSet>> =
serviceFlow.flatMapLatest { service ->
if (service == null)
flowOf(emptyList())
else
homeSetDao.getBindableByServiceFlow(service.id)
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import androidx.paging.Pager
import androidx.paging.PagingConfig
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.settings.AccountSettings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
class GetServiceCollectionPagerUseCase @Inject constructor(
val db: AppDatabase
) {
companion object {
const val PAGER_SIZE = 20
}
operator fun invoke(
serviceFlow: Flow<Service?>,
collectionType: String,
showOnlyPersonalFlow: Flow<AccountSettings.ShowOnlyPersonal?>
): Flow<Pager<Int, Collection>?> =
combine(serviceFlow, showOnlyPersonalFlow) { service, onlyPersonal ->
if (service == null)
return@combine null
Pager(
config = PagingConfig(PAGER_SIZE),
pagingSourceFactory = {
if (onlyPersonal?.onlyPersonal == true)
db.collectionDao().pagePersonalByServiceAndType(service.id, collectionType)
else
db.collectionDao().pageByServiceAndType(service.id, collectionType)
}
)
}
}

View file

@ -0,0 +1,122 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.account
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.HomeSet
import okhttp3.HttpUrl.Companion.toHttpUrl
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeSetSelection(
homeSet: HomeSet?,
homeSets: List<HomeSet>,
onSelectHomeSet: (HomeSet) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
Column(modifier) {
// select first home set if none is selected
LaunchedEffect(homeSets) {
if (homeSet == null)
homeSets.firstOrNull()?.let(onSelectHomeSet)
}
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
label = { Text(stringResource(R.string.create_collection_home_set)) },
value = homeSet?.title() ?: "",
onValueChange = { /* read-only */ },
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
Column(Modifier.padding(horizontal = 8.dp)
) {
for (item in homeSets) {
Column(
modifier = Modifier
.clickable(enabled = enabled) {
onSelectHomeSet(item)
expanded = false
}
.padding(vertical = 8.dp)
) {
Text(
text = item.title(),
style = MaterialTheme.typography.bodyLarge
)
Text(
text = item.url.encodedPath,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
}
}
@Composable
@Preview
fun HomeSetSelection_Preview() {
val homeSets = listOf(
HomeSet(
id = 0,
serviceId = 0,
personal = true,
url = "https://example.com/homeset/first".toHttpUrl()
),
HomeSet(
id = 0,
serviceId = 0,
personal = true,
url = "https://example.com/homeset/second".toHttpUrl()
)
)
HomeSetSelection(
homeSet = homeSets.last(),
homeSets = homeSets,
onSelectHomeSet = {}
)
}

View file

@ -14,9 +14,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Button
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier

View file

@ -2,24 +2,25 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.widget
package at.bitfire.davdroid.ui.composable
import android.accounts.Account
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.AlertDialog
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Error
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.R
@ -52,14 +53,16 @@ fun ExceptionInfoDialog(
Icon(Icons.Rounded.Error, null)
Text(
text = stringResource(titleRes),
modifier = Modifier.weight(1f).padding(start = 8.dp)
modifier = Modifier
.weight(1f)
.padding(start = 8.dp)
)
}
},
text = {
Text(
exception::class.java.name + "\n" + exception.localizedMessage,
style = MaterialTheme.typography.body1
style = MaterialTheme.typography.bodyLarge
)
},
dismissButton = {
@ -73,13 +76,22 @@ fun ExceptionInfoDialog(
context.startActivity(intent.build())
}
) {
Text(stringResource(R.string.exception_show_details).uppercase())
Text(stringResource(R.string.exception_show_details))
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.ok).uppercase())
Text(stringResource(android.R.string.ok))
}
}
)
}
@Composable
@Preview
fun ExceptionInfoDialog_Preview() {
ExceptionInfoDialog(
exception = Exception("Test exception"),
onDismiss = {}
)
}

View file

@ -8,7 +8,6 @@ import android.app.Activity
import android.os.Build
import android.security.KeyChain
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme

View file

@ -26,8 +26,8 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.AndroidViewModel
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.ui.M2Theme
import at.bitfire.davdroid.ui.M2Colors
import at.bitfire.davdroid.ui.M2Theme
import com.github.appintro.AppIntro2
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn

View file

@ -11,8 +11,6 @@ import androidx.lifecycle.ViewModel
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.util.DavUtils.toURIorNull
import org.apache.commons.lang3.StringUtils
import java.net.URI
import java.net.URISyntaxException
class AdvancedLoginModel: ViewModel() {

View file

@ -4,7 +4,6 @@
package at.bitfire.davdroid.ui.setup
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth

View file

@ -4,7 +4,6 @@
package at.bitfire.davdroid.ui.setup
import androidx.activity.compose.BackHandler
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel

View file

@ -13,10 +13,10 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.repository.AccountRepository
import at.bitfire.davdroid.servicedetection.DavResourceFinder
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.syncadapter.AccountRepository
import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers

View file

@ -10,12 +10,7 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.util.DavUtils.toURIorNull
import dagger.hilt.android.lifecycle.HiltViewModel
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.apache.commons.lang3.StringUtils
import java.net.URI
import java.net.URISyntaxException
import javax.inject.Inject
class UrlLoginModel: ViewModel() {

View file

@ -60,6 +60,7 @@ object DavUtils {
return String.format(Locale.ROOT, "#%06X%02X", color, alpha)
}
@Deprecated("Use HttpUrl.lastSegment in UrlUtils")
fun lastSegmentOfUrl(url: HttpUrl): String {
// the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy
val segments = LinkedList(url.pathSegments)
@ -214,4 +215,4 @@ object DavUtils {
null
}
}
}

View file

@ -0,0 +1,10 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.util
import okhttp3.HttpUrl
fun HttpUrl.lastSegment(): String =
pathSegments.lastOrNull { it.isNotEmpty() } ?: "/"

View file

@ -334,7 +334,7 @@
<!--collection management-->
<string name="create_addressbook">Crea una llibreta d\'adreces</string>
<string name="create_calendar">Crea un calendari</string>
<string name="create_calendar_time_zone">Zona horària predeterminada</string>
<string name="create_calendar_time_zone_optional">Zona horària predeterminada</string>
<string name="create_calendar_type">Possibles entrades de calendari</string>
<string name="create_calendar_type_vevent">Esdeveniments</string>
<string name="create_calendar_type_vtodo">Tasques</string>

View file

@ -320,7 +320,7 @@
<!--collection management-->
<string name="create_addressbook">Adressbuch erstellen</string>
<string name="create_calendar">Kalender anlegen</string>
<string name="create_calendar_time_zone">Standardzeitzone</string>
<string name="create_calendar_time_zone_optional">Standardzeitzone</string>
<string name="create_calendar_type">Mögliche Kalendereinträge</string>
<string name="create_calendar_type_vevent">Termine</string>
<string name="create_calendar_type_vtodo">Aufgaben</string>

View file

@ -333,7 +333,7 @@
<!--collection management-->
<string name="create_addressbook">Sortu helbide liburua</string>
<string name="create_calendar">Sortu egutegia</string>
<string name="create_calendar_time_zone">Ordu-zona lehenetsia</string>
<string name="create_calendar_time_zone_optional">Ordu-zona lehenetsia</string>
<string name="create_calendar_type">Egutegi sarrera posibleak</string>
<string name="create_calendar_type_vevent">Gertaerak</string>
<string name="create_calendar_type_vtodo">Zereginak</string>

View file

@ -335,7 +335,7 @@
<!--collection management-->
<string name="create_addressbook">Crear libreta de enderezos</string>
<string name="create_calendar">Crear calendario</string>
<string name="create_calendar_time_zone">Zona horaria por defecto</string>
<string name="create_calendar_time_zone_optional">Zona horaria por defecto</string>
<string name="create_calendar_type">Entradas posibles no calendario</string>
<string name="create_calendar_type_vevent">Eventos</string>
<string name="create_calendar_type_vtodo">Tarefas</string>

View file

@ -331,7 +331,7 @@
<!--collection management-->
<string name="create_addressbook">アドレス帳を作成</string>
<string name="create_calendar">カレンダーを作成</string>
<string name="create_calendar_time_zone">デフォルトのタイムゾーン</string>
<string name="create_calendar_time_zone_optional">デフォルトのタイムゾーン</string>
<string name="create_calendar_type">可能なカレンダーエントリー</string>
<string name="create_calendar_type_vevent">予定</string>
<string name="create_calendar_type_vtodo">タスク</string>

View file

@ -159,7 +159,7 @@
<string name="create_addressbook">Opprett adressebok</string>
<string name="create_addressbook_display_name_hint">Min adressebok</string>
<string name="create_calendar">Opprett kalender</string>
<string name="create_calendar_time_zone">Tidssone</string>
<string name="create_calendar_time_zone_optional">Tidssone</string>
<string name="create_calendar_type">Mulige kalenderhendelser</string>
<string name="create_calendar_type_vevent">Hendelser</string>
<string name="create_calendar_type_vtodo">Oppgaver</string>

View file

@ -334,7 +334,7 @@
<!--collection management-->
<string name="create_addressbook">Adresboek aanmaken</string>
<string name="create_calendar">Kalender aanmaken</string>
<string name="create_calendar_time_zone">Standaard tijdzone</string>
<string name="create_calendar_time_zone_optional">Standaard tijdzone</string>
<string name="create_calendar_type">Mogelijke kalender-items</string>
<string name="create_calendar_type_vevent">Gebeurtenissen</string>
<string name="create_calendar_type_vtodo">Taken</string>

View file

@ -336,7 +336,7 @@
<!--collection management-->
<string name="create_addressbook">Creează agendă de adrese</string>
<string name="create_calendar">Creează un calendar</string>
<string name="create_calendar_time_zone">Fus orar implicit</string>
<string name="create_calendar_time_zone_optional">Fus orar implicit</string>
<string name="create_calendar_type">Posibile intrări din calendar</string>
<string name="create_calendar_type_vevent">Evenimente</string>
<string name="create_calendar_type_vtodo">Sarcini</string>

View file

@ -338,7 +338,7 @@
<!--collection management-->
<string name="create_addressbook">Создать адресную книгу</string>
<string name="create_calendar">Создать календарь</string>
<string name="create_calendar_time_zone">Часовой пояс по умолчанию</string>
<string name="create_calendar_time_zone_optional">Часовой пояс по умолчанию</string>
<string name="create_calendar_type">Возможные записи календаря</string>
<string name="create_calendar_type_vevent">События</string>
<string name="create_calendar_type_vtodo">Задачи</string>

View file

@ -167,7 +167,7 @@
<string name="create_addressbook">Ustvari imenik</string>
<string name="create_addressbook_display_name_hint">Moj imenik</string>
<string name="create_calendar">Ustvari koledar</string>
<string name="create_calendar_time_zone">Časovna zona</string>
<string name="create_calendar_time_zone_optional">Časovna zona</string>
<string name="create_calendar_type">Mogoči koledarski vnosi</string>
<string name="create_calendar_type_vevent">Dogodki</string>
<string name="create_calendar_type_vtodo">Naloge</string>

View file

@ -332,7 +332,7 @@
<!--collection management-->
<string name="create_addressbook">创建通讯录</string>
<string name="create_calendar">创建日历</string>
<string name="create_calendar_time_zone">默认时区</string>
<string name="create_calendar_time_zone_optional">默认时区</string>
<string name="create_calendar_type">可能使用的日历类型</string>
<string name="create_calendar_type_vevent">事件</string>
<string name="create_calendar_type_vtodo">任务</string>

View file

@ -9,6 +9,7 @@
<string name="account_title_address_book">DAVx⁵ Address book</string>
<string name="address_books_authority" translatable="false">at.bitfire.davdroid.addressbooks</string>
<string name="address_books_authority_title">Address books</string>
<string name="dialog_delete">Delete</string>
<string name="dialog_remove">Remove</string>
<string name="dialog_deny">Cancel</string>
<string name="field_required">This field is required</string>
@ -116,7 +117,7 @@
<string name="logging_notification_view_share">View/share</string>
<string name="logging_notification_disable">Disable</string>
<!-- AccountsActivity -->
<!-- AccountsScreen -->
<string name="navigation_drawer_subtitle">CalDAV/CardDAV Sync Adapter</string>
<string name="navigation_drawer_about">About / License</string>
<string name="navigation_drawer_beta_feedback">Beta feedback</string>
@ -209,7 +210,7 @@
<string name="app_settings_tasks_provider">Tasks app</string>
<string name="app_settings_tasks_provider_none">No compatible tasks app found</string>
<!-- AccountActivity -->
<!-- AccountScreen -->
<string name="account_carddav">CardDAV</string>
<string name="account_caldav">CalDAV</string>
<string name="account_webcal">Webcal</string>
@ -229,12 +230,12 @@
<string name="account_synchronize_this_collection">synchronize this collection</string>
<string name="account_read_only">read-only</string>
<string name="account_calendar">calendar</string>
<string name="account_task_list">task list</string>
<string name="account_contacts">contacts</string>
<string name="account_journal">journal</string>
<string name="account_task_list">tasks</string>
<string name="account_only_personal">Show only personal</string>
<string name="account_refresh_collections">Refresh collections</string>
<string name="account_refreshing_collections">Refreshing collection list</string>
<string name="account_webcal_external_app">"Webcal subscriptions can be synchronized with external apps."</string>
<string name="account_refresh_collections">Refresh list</string>
<string name="account_webcal_external_app">Webcal subscriptions can be synchronized with external apps.</string>
<string name="account_no_webcal_handler_found">No Webcal-capable app found</string>
<string name="account_install_icsx5">Install ICSx⁵</string>
@ -376,38 +377,41 @@
<item>Groups are per-contact categories</item>
</string-array>
<!-- collection management -->
<!-- CreateAddressBookScreen, CreateCalendarScreen -->
<string name="create_addressbook">Create address book</string>
<string name="create_addressbook_maybe_not_supported">Address book creation over CardDAV may not be supported by the server.</string>
<string name="create_calendar">Create calendar</string>
<string name="create_calendar_time_zone">Default time zone</string>
<string name="create_calendar_time_zone_optional">Default time zone*</string>
<string name="create_calendar_time_zone_none"></string>
<string name="create_calendar_type">Possible calendar entries</string>
<string name="create_calendar_type_vevent">Events</string>
<string name="create_calendar_type_vtodo">Tasks</string>
<string name="create_calendar_type_vjournal">Notes / journal</string>
<string name="create_calendar_maybe_not_supported">Calendar creation over CalDAV may not be supported by the server.</string>
<string name="create_collection_color">Color</string>
<string name="create_collection_creating">Creating collection</string>
<string name="create_collection_display_name">Title</string>
<string name="create_collection_display_name_required">Title is required</string>
<string name="create_collection_description">Description</string>
<string name="create_collection_description_optional">Description (optional)</string>
<string name="create_collection_optional">optional</string>
<string name="create_collection_home_set">Storage location</string>
<string name="create_collection_home_set_required">Storage location is required</string>
<string name="create_collection_description_optional">Description*</string>
<string name="create_collection_create">Create</string>
<string name="delete_collection">Delete collection</string>
<string name="delete_collection_confirm_title">Are you sure?</string>
<string name="delete_collection_confirm_warning">This collection (%s) and all its data will be removed permanently.</string>
<string name="delete_collection_data_shall_be_deleted">These data shall be deleted from the server.</string>
<string name="collection_force_read_only">Force read-only</string>
<string name="collection_properties">Properties</string>
<string name="collection_properties_sync_time">Last synced</string>
<string name="collection_properties_sync_time_never">Never synced</string>
<string name="collection_properties_url">Address (URL)</string>
<string name="collection_properties_owner">Owner</string>
<string name="collection_properties_push_support">Push support</string>
<string name="collection_properties_push_support_web_push">Yes (over Web Push)</string>
<string name="collection_properties_push_subscribed_at">Subscribed at %s</string>
<string name="collection_properties_push_subscribed_never">Not yet subscribed</string>
<string name="create_collection_optional">* optional</string>
<!-- CollectionScreen -->
<string name="collection_delete">Delete collection</string>
<string name="collection_delete_warning">This collection (%s) and all its data will be removed permanently, both locally and on the server.</string>
<string name="collection_synchronization">Synchronization</string>
<string name="collection_synchronization_on">Synchronization enabled</string>
<string name="collection_synchronization_off">Synchronization disabled</string>
<string name="collection_read_only">Read-only</string>
<string name="collection_read_only_by_server">Read-only (by server)</string>
<string name="collection_read_only_forced">Read-only (only locally)</string>
<string name="collection_read_write">Read/write</string>
<string name="collection_title">Title</string>
<string name="collection_description">Description</string>
<string name="collection_owner">Owner</string>
<string name="collection_push_support">Push support</string>
<string name="collection_push_web_push">Server advertises Push support</string>
<string name="collection_last_sync">Last sync (%s)</string>
<string name="collection_url">Address (URL)</string>
<!-- debugging and DebugInfoActivity -->
<string name="authority_debug_provider" translatable="false">at.bitfire.davdroid.debug</string>