Rewrite AccountActivity to Compose (#617)

* [WIP] Add AccountActivity2 in Compose

* Make paging collections work when data changes

* [WIP] Add ProgressIndicator TODO

* [WIP] CardDAV: add swipe-to-refresh

* [WIP] Correctly use Pager

* [WIP] Only show Webcal tab when there are subscriptions

* [WIP] Implement collection properties dialog

* Implement "create collection" and "show only personal collection"

* [WIP] Add collection overflow menu items

* Show color as left border, max. 2 icons per row

* [WIP] Delete collection dialog

* Add "delete collection"

* Implement "Force read-only"

* Delete old XML classes and resources

* Add permissions warning

* Implement "Rename account"

* Case-insensitive sorting, minor changes

* Horizontal arrangement

* Less integration of Webcal subscriptions (other layout)

* Accessibility

* Collection list: provide ID als key for lazy list

* Only show "Create addressbook/calendar" when there's at least one writable homeset
This commit is contained in:
Ricki Hirner 2024-03-08 16:32:55 +01:00 committed by GitHub
parent 2e669812b1
commit 962dab7cf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 1878 additions and 2248 deletions

View file

@ -161,6 +161,7 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.base)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.paging)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.preference)
implementation(libs.androidx.security)
implementation(libs.androidx.swiperefreshlayout)

View file

@ -138,7 +138,7 @@
</activity>
<activity
android:name=".ui.account.AccountActivity"
android:name=".ui.account.AccountActivity2"
android:parentActivityName=".ui.AccountsActivity"
android:theme="@style/AppTheme.NoActionBar"
android:exported="true">
@ -146,14 +146,14 @@
<activity
android:name=".ui.account.CreateAddressBookActivity"
android:theme="@style/AppTheme.NoActionBar"
android:parentActivityName=".ui.account.AccountActivity" />
android:parentActivityName=".ui.account.AccountActivity2" />
<activity
android:name=".ui.account.CreateCalendarActivity"
android:label="@string/create_calendar"
android:parentActivityName=".ui.account.AccountActivity" />
android:parentActivityName=".ui.account.AccountActivity2" />
<activity
android:name=".ui.account.SettingsActivity"
android:parentActivityName=".ui.account.AccountActivity" />
android:parentActivityName=".ui.account.AccountActivity2" />
<activity
android:name=".ui.account.WifiPermissionsActivity"
android:label="@string/wifi_permissions_label"

View file

@ -13,6 +13,7 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import org.jetbrains.annotations.Async.Execute
@Dao
interface CollectionDao {
@ -32,7 +33,7 @@ interface CollectionDao {
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND homeSetId IS :homeSetId")
fun getByServiceAndHomeset(serviceId: Long, homeSetId: Long?): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName, url")
@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>
/**
@ -41,13 +42,13 @@ interface CollectionDao {
* - have supportsVEVENT = supportsVTODO = null (= address books)
*/
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type " +
"AND (supportsVTODO OR supportsVEVENT OR supportsVJOURNAL OR (supportsVEVENT IS NULL AND supportsVTODO IS NULL AND supportsVJOURNAL IS NULL)) ORDER BY displayName, URL")
"AND (supportsVTODO OR supportsVEVENT OR supportsVJOURNAL OR (supportsVEVENT IS NULL AND supportsVTODO IS NULL AND supportsVJOURNAL IS NULL)) ORDER BY displayName COLLATE NOCASE, URL COLLATE NOCASE")
fun pageByServiceAndType(serviceId: Long, type: String): PagingSource<Int, Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND sync")
fun getByServiceAndSync(serviceId: Long): List<Collection>
@Query("SELECT collection.* FROM collection, homeset WHERE collection.serviceId=:serviceId AND type=:type AND homeSetId=homeset.id AND homeset.personal ORDER BY collection.displayName, collection.url")
@Query("SELECT collection.* FROM collection, homeset WHERE collection.serviceId=:serviceId AND type=:type AND homeSetId=homeset.id AND homeset.personal ORDER BY collection.displayName COLLATE NOCASE, collection.url COLLATE NOCASE")
fun pagePersonalByServiceAndType(serviceId: Long, type: String): PagingSource<Int, Collection>
@Deprecated("Use getByServiceAndUrl instead")
@ -57,13 +58,13 @@ interface CollectionDao {
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND url=:url")
fun getByServiceAndUrl(serviceId: Long, url: String): Collection?
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVEVENT AND sync ORDER BY displayName, url")
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVEVENT AND sync ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE")
fun getSyncCalendars(serviceId: Long): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND (supportsVTODO OR supportsVJOURNAL) AND sync ORDER BY displayName, url")
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND (supportsVTODO OR supportsVJOURNAL) AND sync ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE")
fun getSyncJtxCollections(serviceId: Long): List<Collection>
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVTODO AND sync ORDER BY displayName, url")
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVTODO AND sync ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE")
fun getSyncTaskLists(serviceId: Long): List<Collection>
@Insert(onConflict = OnConflictStrategy.IGNORE)
@ -72,6 +73,12 @@ interface CollectionDao {
@Update
fun update(collection: Collection)
@Query("UPDATE collection SET forceReadOnly=:forceReadOnly WHERE id=:id")
fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)
@Query("UPDATE collection SET sync=:sync WHERE id=:id")
fun updateSync(id: Long, sync: Boolean)
/**
* Tries to insert new row, but updates existing row if already present.
* This method preserves the primary key, as opposed to using "@Insert(onConflict = OnConflictStrategy.REPLACE)"

View file

@ -19,7 +19,7 @@ interface PrincipalDao {
fun get(id: Long): Principal
@Query("SELECT * FROM principal WHERE id=:id")
fun getLive(id: Long): LiveData<Principal>
fun getLive(id: Long): LiveData<Principal?>
@Query("SELECT * FROM principal WHERE serviceId=:serviceId")
fun getByService(serviceId: Long): List<Principal>

View file

@ -17,6 +17,9 @@ interface ServiceDao {
@Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type")
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>

View file

@ -5,7 +5,8 @@ package at.bitfire.davdroid.settings
import android.accounts.Account
import android.accounts.AccountManager
import android.content.*
import android.content.ContentResolver
import android.content.Context
import android.os.Bundle
import android.provider.CalendarContract
import androidx.annotation.WorkerThread
@ -459,6 +460,17 @@ class AccountSettings(
// UI settings
data class ShowOnlyPersonal(
val onlyPersonal: Boolean,
val locked: Boolean
)
fun getShowOnlyPersonal(): ShowOnlyPersonal {
@Suppress("DEPRECATION")
val pair = getShowOnlyPersonalPair()
return ShowOnlyPersonal(onlyPersonal = pair.first, locked = !pair.second)
}
/**
* Whether only personal collections should be shown.
*
@ -467,7 +479,8 @@ class AccountSettings(
* 1. (first) whether only personal collections should be shown
* 2. (second) whether the user shall be able to change the setting (= setting not locked)
*/
fun getShowOnlyPersonal(): Pair<Boolean, Boolean> =
@Deprecated("Use getShowOnlyPersonal() instead", replaceWith = ReplaceWith("getShowOnlyPersonal()"))
fun getShowOnlyPersonalPair(): Pair<Boolean, Boolean> =
when (settings.getIntOrNull(KEY_SHOW_ONLY_PERSONAL)) {
0 -> Pair(false, false)
1 -> Pair(true, false)

View file

@ -87,8 +87,7 @@ import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.syncadapter.SyncUtils
import at.bitfire.davdroid.syncadapter.SyncWorker
import at.bitfire.davdroid.ui.account.AccountActivity
import at.bitfire.davdroid.ui.account.AppWarningsModel
import at.bitfire.davdroid.ui.account.AccountActivity2
import at.bitfire.davdroid.ui.intro.IntroActivity
import at.bitfire.davdroid.ui.setup.LoginActivity
import at.bitfire.davdroid.ui.widget.ActionCard
@ -218,8 +217,8 @@ class AccountsActivity: AppCompatActivity() {
accounts = accounts ?: emptyList(),
onClickAccount = { account ->
val activity = this@AccountsActivity
val intent = Intent(activity, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
val intent = Intent(activity, AccountActivity2::class.java)
intent.putExtra(AccountActivity2.EXTRA_ACCOUNT, account)
activity.startActivity(intent)
},
modifier = Modifier
@ -561,7 +560,7 @@ fun SyncWarnings(
if (notificationsWarning)
ActionCard(
icon = Icons.Default.NotificationsOff,
actionText = stringResource(R.string.account_permissions_action),
actionText = stringResource(R.string.account_manage_permissions),
onAction = onClickPermissions
) {
Text(stringResource(R.string.account_list_no_notification_permission))

View file

@ -2,7 +2,7 @@
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.account
package at.bitfire.davdroid.ui
import android.app.Application
import android.content.BroadcastReceiver

View file

@ -12,6 +12,7 @@ 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
@ -28,6 +29,7 @@ import androidx.fragment.app.DialogFragment
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.davdroid.R
import com.google.accompanist.themeadapter.material.MdcTheme
import okhttp3.HttpUrl
import java.io.IOException
class ExceptionInfoFragment: DialogFragment() {
@ -57,7 +59,8 @@ class ExceptionInfoFragment: DialogFragment() {
setContent {
MdcTheme {
ExceptionInfoDialog(
account, exception
exception = exception,
account = account
) { dismiss() }
}
}
@ -73,8 +76,9 @@ class ExceptionInfoFragment: DialogFragment() {
@Composable
fun ExceptionInfoDialog(
account: Account?,
exception: Throwable,
account: Account? = null,
remoteResource: HttpUrl? = null,
onDismissRequest: () -> Unit
) {
val context = LocalContext.current
@ -99,15 +103,21 @@ fun ExceptionInfoDialog(
)
}
},
text = { Text(exception::class.java.name + "\n" + exception.localizedMessage) },
text = {
Text(
exception::class.java.name + "\n" + exception.localizedMessage,
style = MaterialTheme.typography.body1
)
},
dismissButton = {
TextButton(
onClick = {
val intent = DebugInfoActivity.IntentBuilder(context)
.withAccount(account)
.withCause(exception)
.build()
context.startActivity(intent)
val intent = DebugInfoActivity.IntentBuilder(context).withCause(exception)
if (account != null)
intent.withAccount(account)
if (remoteResource != null)
intent.withRemoteResource(remoteResource)
context.startActivity(intent.build())
}
) {
Text(stringResource(R.string.exception_show_details).uppercase())
@ -119,4 +129,4 @@ fun ExceptionInfoDialog(
}
}
)
}
}

View file

@ -0,0 +1,436 @@
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.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
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.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.resource.TaskUtils
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.syncadapter.AccountsCleanupWorker
import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker
import at.bitfire.davdroid.syncadapter.SyncWorker
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 java.util.Optional
import java.util.logging.Level
class AccountModel @AssistedInject constructor(
application: Application,
val db: AppDatabase,
@Assisted val account: Account
): AndroidViewModel(application), 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(application, 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 context = getApplication<Application>()
val accountManager: AccountManager = AccountManager.get(context)
val cardDavSvc = db.serviceDao().getLiveByAccountAndType(account.name, Service.TYPE_CARDDAV)
val canCreateAddressBook = cardDavSvc.switchMap { svc ->
if (svc != null)
db.homeSetDao().hasBindableByServiceLive(svc.id)
else
MutableLiveData(false)
}
val cardDavRefreshingActive = cardDavSvc.switchMap { svc ->
if (svc == null)
return@switchMap null
RefreshCollectionsWorker.exists(application, RefreshCollectionsWorker.workerName(svc.id))
}
val cardDavSyncPending = SyncWorker.exists(
getApplication(),
listOf(WorkInfo.State.ENQUEUED),
account,
listOf(context.getString(R.string.address_books_authority), ContactsContract.AUTHORITY)
)
val cardDavSyncActive = SyncWorker.exists(
getApplication(),
listOf(WorkInfo.State.RUNNING),
account,
listOf(context.getString(R.string.address_books_authority), ContactsContract.AUTHORITY)
)
val addressBooksPager = CollectionPager(db, cardDavSvc, Collection.TYPE_ADDRESSBOOK, showOnlyPersonal)
val calDavSvc = db.serviceDao().getLiveByAccountAndType(account.name, Service.TYPE_CALDAV)
val canCreateCalendar = calDavSvc.switchMap { svc ->
if (svc != null)
db.homeSetDao().hasBindableByServiceLive(svc.id)
else
MutableLiveData(false)
}
val calDavRefreshingActive = calDavSvc.switchMap { svc ->
if (svc == null)
return@switchMap null
RefreshCollectionsWorker.exists(application, RefreshCollectionsWorker.workerName(svc.id))
}
val calDavSyncPending = SyncWorker.exists(
getApplication(),
listOf(WorkInfo.State.ENQUEUED),
account,
listOf(CalendarContract.AUTHORITY)
)
val calDavSyncActive = SyncWorker.exists(
getApplication(),
listOf(WorkInfo.State.RUNNING),
account,
listOf(CalendarContract.AUTHORITY)
)
val calendarsPager = CollectionPager(db, calDavSvc, Collection.TYPE_CALENDAR, showOnlyPersonal)
val webcalPager = CollectionPager(db, calDavSvc, Collection.TYPE_WEBCAL, showOnlyPersonal)
val renameAccountError = MutableLiveData<String>()
val deleteCollectionResult = MutableLiveData<Optional<Exception>>()
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
)
TaskUtils.currentProvider(context)?.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")
val context: Application = getApplication()
// disable periodic workers of old account
syncIntervals.forEach { (authority, _) ->
PeriodicSyncWorker.disable(context, oldAccount, authority)
}
// cancel maybe running synchronization
SyncWorker.cancelSync(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
SyncWorker.enqueueAllAuthorities(context, newAccount)
}
/** 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)
}
/** Deletes the given collection from the database and the server. */
fun deleteCollection(collection: Collection) = viewModelScope.launch(Dispatchers.IO) {
HttpClient.Builder(getApplication(), AccountSettings(getApplication(), 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(getApplication())?.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 = getApplication<Application>().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?>,
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

@ -1,117 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.account
import android.content.Intent
import android.view.*
import androidx.fragment.app.FragmentManager
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.AccountCarddavItemBinding
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.util.PermissionUtils
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
class AddressBooksFragment: CollectionsFragment() {
override val noCollectionsStringId = R.string.account_no_address_books
private val menuProvider = object : CollectionsMenuProvider() {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.carddav_actions, menu)
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.create_address_book).isVisible = model.hasWriteableCollections.value ?: false
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
if (super.onMenuItemSelected(menuItem))
return true
if (menuItem.itemId == R.id.create_address_book) {
val intent = Intent(requireActivity(), CreateAddressBookActivity::class.java)
intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, accountModel.account)
startActivity(intent)
return true
}
return false
}
}
override fun onResume() {
super.onResume()
requireActivity().addMenuProvider(menuProvider)
}
override fun onPause() {
super.onPause()
requireActivity().removeMenuProvider(menuProvider)
}
override fun checkPermissions() {
if (PermissionUtils.havePermissions(requireActivity(), PermissionUtils.CONTACT_PERMISSIONS))
binding.permissionsCard.visibility = View.GONE
else {
binding.permissionsText.setText(R.string.account_carddav_missing_permissions)
binding.permissionsCard.visibility = View.VISIBLE
}
}
override fun createAdapter() = AddressBookAdapter(accountModel, parentFragmentManager)
class AddressBookViewHolder(
parent: ViewGroup,
accountModel: AccountActivity.Model,
val fragmentManager: FragmentManager,
): CollectionViewHolder<AccountCarddavItemBinding>(parent, AccountCarddavItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), accountModel) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AddressBookViewHolderEntryPoint {
fun settingsManager(): SettingsManager
}
private val settings = EntryPointAccessors.fromApplication(parent.context, AddressBookViewHolderEntryPoint::class.java).settingsManager()
private val forceReadOnlyAddressBooks = settings.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS) // managed restriction
override fun bindTo(item: Collection) {
binding.sync.isChecked = item.sync
binding.title.text = item.title()
if (item.description.isNullOrBlank())
binding.description.visibility = View.GONE
else {
binding.description.text = item.description
binding.description.visibility = View.VISIBLE
}
binding.readOnly.visibility = if (item.readOnly() || forceReadOnlyAddressBooks) View.VISIBLE else View.GONE
itemView.setOnClickListener {
accountModel.toggleSync(item)
}
binding.actionOverflow.setOnClickListener(CollectionPopupListener(accountModel, item, fragmentManager, forceReadOnlyAddressBooks))
}
}
class AddressBookAdapter(
accountModel: AccountActivity.Model,
val fragmentManager: FragmentManager
): CollectionAdapter(accountModel) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
AddressBookViewHolder(parent, accountModel, fragmentManager)
}
}

View file

@ -1,124 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.account
import android.content.Intent
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.AccountCaldavItemBinding
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.resource.TaskUtils
import at.bitfire.davdroid.util.PermissionUtils
class CalendarsFragment: CollectionsFragment() {
override val noCollectionsStringId = R.string.account_no_calendars
private val menuProvider = object : CollectionsMenuProvider() {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.caldav_actions, menu)
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.create_calendar).isVisible = model.hasWriteableCollections.value ?: false
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
if (super.onMenuItemSelected(menuItem)) {
return true
}
if (menuItem.itemId == R.id.create_calendar) {
val intent = Intent(requireActivity(), CreateCalendarActivity::class.java)
intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, accountModel.account)
startActivity(intent)
return true
}
return false
}
}
override fun onResume() {
super.onResume()
requireActivity().addMenuProvider(menuProvider)
}
override fun onPause() {
super.onPause()
requireActivity().removeMenuProvider(menuProvider)
}
override fun checkPermissions() {
val calendarPermissions = PermissionUtils.havePermissions(requireActivity(), PermissionUtils.CALENDAR_PERMISSIONS)
val taskProvider = TaskUtils.currentProvider(requireActivity())
val tasksPermissions = taskProvider == null || // no task provider OR
PermissionUtils.havePermissions(requireActivity(), taskProvider.permissions) // task permissions granted
if (calendarPermissions && tasksPermissions)
binding.permissionsCard.visibility = View.GONE
else {
binding.permissionsText.setText(when {
!calendarPermissions && tasksPermissions -> R.string.account_caldav_missing_calendar_permissions
calendarPermissions && !tasksPermissions -> R.string.account_caldav_missing_tasks_permissions
else -> R.string.account_caldav_missing_permissions
})
binding.permissionsCard.visibility = View.VISIBLE
}
}
override fun createAdapter(): CollectionAdapter = CalendarAdapter(accountModel, parentFragmentManager)
class CalendarViewHolder(
parent: ViewGroup,
accountModel: AccountActivity.Model,
val fragmentManager: FragmentManager
): CollectionViewHolder<AccountCaldavItemBinding>(parent, AccountCaldavItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), accountModel) {
override fun bindTo(item: Collection) {
binding.color.setBackgroundColor(item.color ?: Constants.DAVDROID_GREEN_RGBA)
binding.sync.isChecked = item.sync
binding.title.text = item.title()
if (item.description.isNullOrBlank())
binding.description.visibility = View.GONE
else {
binding.description.text = item.description
binding.description.visibility = View.VISIBLE
}
binding.readOnly.visibility = if (item.readOnly()) View.VISIBLE else View.GONE
binding.events.visibility = if (item.supportsVEVENT == true) View.VISIBLE else View.GONE
binding.tasks.visibility = if (item.supportsVTODO == true) View.VISIBLE else View.GONE
binding.journals.visibility = if (item.supportsVJOURNAL == true) View.VISIBLE else View.GONE
itemView.setOnClickListener {
accountModel.toggleSync(item)
}
binding.actionOverflow.setOnClickListener(CollectionPopupListener(accountModel, item, fragmentManager))
}
}
class CalendarAdapter(
accountModel: AccountActivity.Model,
val fragmentManager: FragmentManager
): CollectionAdapter(accountModel) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
CalendarViewHolder(parent, accountModel, fragmentManager)
}
}

View file

@ -0,0 +1,101 @@
package at.bitfire.davdroid.ui.account
import android.text.format.DateUtils
import androidx.compose.foundation.layout.Column
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(),
fontFamily = FontFamily.Monospace,
fontSize = MaterialTheme.typography.body2.fontSize,
modifier = Modifier.padding(bottom = 16.dp)
)
}
// Owner
if (owner != null) {
Text(stringResource(R.string.collection_properties_owner), style = MaterialTheme.typography.h5)
Text(owner, 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))
else
for ((app, timestamp) in lastSynced.entries) {
Text(app)
val timeStr = DateUtils.getRelativeDateTimeString(
context, timestamp, DateUtils.SECOND_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0
).toString()
Text(timeStr, Modifier.padding(bottom = 8.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"
),
owner = "Owner",
lastSynced = emptyMap()
)
}

View file

@ -1,180 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.account
import android.app.Application
import android.content.pm.PackageManager
import android.os.Bundle
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.map
import androidx.lifecycle.switchMap
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.TaskUtils
import com.google.accompanist.themeadapter.material.MdcTheme
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class CollectionInfoFragment: DialogFragment() {
companion object {
private const val ARGS_COLLECTION_ID = "collectionId"
fun newInstance(collectionId: Long): CollectionInfoFragment {
val frag = CollectionInfoFragment()
val args = Bundle(1)
args.putLong(ARGS_COLLECTION_ID, collectionId)
frag.arguments = args
return frag
}
}
@Inject lateinit var modelFactory: Model.Factory
val model by viewModels<Model> {
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>) =
modelFactory.create(requireArguments().getLong(ARGS_COLLECTION_ID)) as T
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return ComposeView(requireContext()).apply {
setContent {
MdcTheme {
CollectionInfoDialog()
}
}
}
}
@Composable
fun CollectionInfoDialog() {
Column(Modifier.padding(16.dp)) {
// URL
val collectionState = model.collection.observeAsState()
collectionState.value?.let { collection ->
Text(stringResource(R.string.collection_properties_url), style = MaterialTheme.typography.h5)
SelectionContainer {
Text(collection.url.toString(), modifier = Modifier.padding(bottom = 16.dp), fontFamily = FontFamily.Monospace)
}
}
// Owner
val owner = model.owner.observeAsState()
owner.value?.let { principal ->
Text(stringResource(R.string.collection_properties_owner), style = MaterialTheme.typography.h5)
Text(principal.displayName ?: principal.url.toString(), Modifier.padding(bottom = 16.dp))
}
// Last synced (for all applicable authorities)
val lastSyncedState = model.lastSynced.observeAsState()
lastSyncedState.value?.let { lastSynced ->
Text(stringResource(R.string.collection_properties_sync_time), style = MaterialTheme.typography.h5)
if (lastSynced.isEmpty())
Text(stringResource(R.string.collection_properties_sync_time_never))
else
for ((app, timestamp) in lastSynced.entries) {
Text(app)
val timeStr = DateUtils.getRelativeDateTimeString(requireContext(), timestamp,
DateUtils.SECOND_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString()
Text(timeStr, Modifier.padding(bottom = 8.dp))
}
}
}
}
class Model @AssistedInject constructor(
application: Application,
val db: AppDatabase,
@Assisted collectionId: Long
): AndroidViewModel(application) {
@AssistedFactory
interface Factory {
fun create(collectionId: Long): Model
}
val collection = db.collectionDao().getLive(collectionId)
val owner = collection.switchMap { collection ->
collection.ownerId?.let { ownerId ->
db.principalDao().getLive(ownerId)
}
}
val lastSynced: LiveData<Map<String, Long>> = // map: app name -> last sync timestamp
db.syncStatsDao().getLiveByCollectionId(collectionId).map { syncStatsList ->
// map: authority -> syncStats
val syncStatsMap = syncStatsList.associateBy { it.authority }
val interestingAuthorities = listOfNotNull(
ContactsContract.AUTHORITY,
CalendarContract.AUTHORITY,
TaskUtils.currentProvider(getApplication())?.authority
)
val result = mutableMapOf<String, Long>()
// map (authority name) -> (app name, last sync timestamp)
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 = getApplication<Application>().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

@ -1,361 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.account
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.app.Application
import android.content.Intent
import android.os.Bundle
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.view.Gravity
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.PopupMenu
import androidx.annotation.CallSuper
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.switchMap
import androidx.lifecycle.viewModelScope
import androidx.paging.LoadState
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.PagingDataAdapter
import androidx.paging.cachedIn
import androidx.paging.liveData
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import androidx.viewbinding.ViewBinding
import androidx.work.WorkInfo
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.AccountCollectionsBinding
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.TaskUtils
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.syncadapter.SyncWorker
import at.bitfire.davdroid.ui.PermissionsActivity
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshListener {
companion object {
const val EXTRA_SERVICE_ID = "serviceId"
const val EXTRA_COLLECTION_TYPE = "collectionType"
}
private var _binding: AccountCollectionsBinding? = null
protected val binding get() = _binding!!
val accountModel by activityViewModels<AccountActivity.Model>()
@Inject lateinit var modelFactory: Model.Factory
protected val model by viewModels<Model> {
object: ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T: ViewModel> create(modelClass: Class<T>): T =
modelFactory.create(
accountModel,
requireArguments().getLong(EXTRA_SERVICE_ID),
requireArguments().getString(EXTRA_COLLECTION_TYPE) ?: throw IllegalArgumentException("EXTRA_COLLECTION_TYPE required")
) as T
}
}
abstract val noCollectionsStringId: Int
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = AccountCollectionsBinding.inflate(inflater, container, false)
binding.permissionsBtn.setOnClickListener {
startActivity(Intent(requireActivity(), PermissionsActivity::class.java))
}
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
model.isRefreshing.observe(viewLifecycleOwner) { nowRefreshing ->
binding.swipeRefresh.isRefreshing = nowRefreshing
}
model.hasWriteableCollections.observe(viewLifecycleOwner) {
requireActivity().invalidateOptionsMenu()
}
model.collectionColors.observe(viewLifecycleOwner) { colors: List<Int?> ->
val realColors = colors.filterNotNull()
if (realColors.isNotEmpty())
binding.swipeRefresh.setColorSchemeColors(*realColors.toIntArray())
}
binding.swipeRefresh.setOnRefreshListener(this)
val updateProgress = Observer<Boolean> {
binding.progress.apply {
val isVisible = model.isSyncActive.value == true || model.isSyncPending.value == true
if (model.isSyncActive.value == true) {
isIndeterminate = true
} else if (model.isSyncPending.value == true) {
isIndeterminate = false
progress = 100
}
animate()
.alpha(if (isVisible) 1f else 0f)
// go to VISIBLE instantly, take 500 ms for INVISIBLE
.setDuration(if (isVisible) 0 else 500)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
visibility = if (isVisible) View.VISIBLE else View.INVISIBLE
}
})
}
}
model.isSyncPending.observe(viewLifecycleOwner, updateProgress)
model.isSyncActive.observe(viewLifecycleOwner, updateProgress)
val adapter = createAdapter()
binding.list.layoutManager = LinearLayoutManager(requireActivity())
binding.list.adapter = adapter
model.collections.observe(viewLifecycleOwner) { data ->
lifecycleScope.launch {
adapter.submitData(data)
}
}
adapter.addLoadStateListener { loadStates ->
if (loadStates.refresh is LoadState.NotLoading) {
if (adapter.itemCount > 0) {
binding.list.visibility = View.VISIBLE
binding.empty.visibility = View.GONE
} else {
binding.list.visibility = View.GONE
binding.empty.visibility = View.VISIBLE
}
}
}
binding.noCollections.setText(noCollectionsStringId)
}
override fun onRefresh() {
model.refresh()
}
override fun onResume() {
super.onResume()
checkPermissions()
(activity as? AccountActivity)?.updateRefreshCollectionsListAction(this)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
protected abstract fun checkPermissions()
protected abstract fun createAdapter(): CollectionAdapter
abstract class CollectionViewHolder<T: ViewBinding>(
parent: ViewGroup,
val binding: T,
protected val accountModel: AccountActivity.Model
): RecyclerView.ViewHolder(binding.root) {
abstract fun bindTo(item: Collection)
}
abstract class CollectionAdapter(
protected val accountModel: AccountActivity.Model
): PagingDataAdapter<Collection, CollectionViewHolder<*>>(DIFF_CALLBACK) {
companion object {
private val DIFF_CALLBACK = object: DiffUtil.ItemCallback<Collection>() {
override fun areItemsTheSame(oldItem: Collection, newItem: Collection) =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: Collection, newItem: Collection) =
oldItem == newItem
}
}
override fun onBindViewHolder(holder: CollectionViewHolder<*>, position: Int) {
getItem(position)?.let { item ->
holder.bindTo(item)
}
}
}
abstract inner class CollectionsMenuProvider : MenuProvider {
abstract override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater)
@CallSuper
override fun onPrepareMenu(menu: Menu) {
menu.findItem(R.id.showOnlyPersonal).let { showOnlyPersonal ->
accountModel.showOnlyPersonal.value?.let { value ->
showOnlyPersonal.isChecked = value
}
accountModel.showOnlyPersonalWritable.value?.let { writable ->
showOnlyPersonal.isEnabled = writable
}
}
}
@CallSuper
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.refresh -> {
onRefresh()
true
}
R.id.showOnlyPersonal -> {
accountModel.toggleShowOnlyPersonal()
true
}
else ->
false
}
}
}
class CollectionPopupListener(
private val accountModel: AccountActivity.Model,
private val item: Collection,
private val fragmentManager: FragmentManager,
private val forceReadOnly: Boolean = false
): View.OnClickListener {
override fun onClick(anchor: View) {
val popup = PopupMenu(anchor.context, anchor, Gravity.RIGHT)
popup.inflate(R.menu.account_collection_operations)
with(popup.menu.findItem(R.id.force_read_only)) {
if (item.type == Collection.TYPE_WEBCAL)
// Webcal collections are always read-only
isVisible = false
else {
// non-Webcal collection
if (item.privWriteContent)
isChecked = item.forceReadOnly
else
isVisible = false
}
if (item.type == Collection.TYPE_ADDRESSBOOK && forceReadOnly) {
// managed restriction "force read-only address books" is active
isChecked = true
isEnabled = false
}
}
popup.menu.findItem(R.id.delete_collection).isVisible = item.privUnbind
popup.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.force_read_only -> {
accountModel.toggleReadOnly(item)
}
R.id.properties ->
CollectionInfoFragment.newInstance(item.id).show(fragmentManager, null)
R.id.delete_collection ->
DeleteCollectionFragment.newInstance(accountModel.account, item.id).show(fragmentManager, null)
}
true
}
popup.show()
}
}
class Model @AssistedInject constructor(
application: Application,
val db: AppDatabase,
@Assisted val accountModel: AccountActivity.Model,
@Assisted val serviceId: Long,
@Assisted val collectionType: String
): AndroidViewModel(application) {
@AssistedFactory
interface Factory {
fun create(accountModel: AccountActivity.Model, serviceId: Long, collectionType: String): Model
}
// cache task provider
val taskProvider by lazy { TaskUtils.currentProvider(getApplication()) }
val hasWriteableCollections = db.homeSetDao().hasBindableByServiceLive(serviceId)
val collectionColors = db.collectionDao().colorsByServiceLive(serviceId)
val collections: LiveData<PagingData<Collection>> =
accountModel.showOnlyPersonal.switchMap { onlyPersonal ->
val pager = Pager(
PagingConfig(pageSize = 25),
pagingSourceFactory = {
Logger.log.info("Creating new pager onlyPersonal=$onlyPersonal")
if (onlyPersonal)
// show only personal collections
db.collectionDao().pagePersonalByServiceAndType(serviceId, collectionType)
else
// show all collections
db.collectionDao().pageByServiceAndType(serviceId, collectionType)
}
)
return@switchMap pager
.liveData
.cachedIn(viewModelScope)
}
// observe RefreshCollectionsWorker status
val isRefreshing = RefreshCollectionsWorker.exists(getApplication(), RefreshCollectionsWorker.workerName(serviceId))
// observe SyncWorker state
private val authorities =
if (collectionType == Collection.TYPE_ADDRESSBOOK)
listOf(getApplication<Application>().getString(R.string.address_books_authority), ContactsContract.AUTHORITY)
else
listOfNotNull(CalendarContract.AUTHORITY, taskProvider?.authority)
val isSyncActive = SyncWorker.exists(getApplication(),
listOf(WorkInfo.State.RUNNING),
accountModel.account,
authorities)
val isSyncPending = SyncWorker.exists(getApplication(),
listOf(WorkInfo.State.ENQUEUED),
accountModel.account,
authorities)
// actions
fun refresh() {
RefreshCollectionsWorker.enqueue(getApplication(), serviceId)
}
}
}

View file

@ -0,0 +1,294 @@
package at.bitfire.davdroid.ui.account
import androidx.compose.foundation.background
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.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.RemoveCircle
import androidx.compose.material.icons.filled.Today
import androidx.compose.material.icons.outlined.Task
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.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import okhttp3.HttpUrl.Companion.toHttpUrl
@Composable
fun CollectionsList(
collections: LazyPagingItems<Collection>,
onChangeSync: (collectionId: Long, sync: Boolean) -> Unit,
onChangeForceReadOnly: (collectionId: Long, forceReadOnly: Boolean) -> Unit,
modifier: Modifier = Modifier,
onSubscribe: (collection: Collection) -> Unit = {}
) {
LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Top),
modifier = modifier
) {
items(
count = collections.itemCount,
key = collections.itemKey { it.id }
) { index ->
collections[index]?.let { item ->
if (item.type == Collection.TYPE_WEBCAL)
CollectionList_Subscription(
item,
onSubscribe = {
onSubscribe(item)
}
)
else
CollectionList_Item(
item,
onChangeSync = { sync ->
onChangeSync(item.id, sync)
},
onChangeForceReadOnly = { forceReadOnly ->
onChangeForceReadOnly(item.id, forceReadOnly)
}
)
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CollectionList_Item(
collection: Collection,
onChangeSync: (sync: Boolean) -> Unit = {},
onChangeForceReadOnly: (forceReadOnly: Boolean) -> Unit = {}
) {
val context = LocalContext.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(IntrinsicSize.Min)
) {
if (collection.type == Collection.TYPE_CALENDAR) {
val color = collection.color?.let { Color(it) } ?: Color.Transparent
Box(
Modifier
.background(color)
.fillMaxHeight()
.width(4.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 = 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
}
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.collection_force_read_only))
Checkbox(
checked = collection.readOnly(),
onCheckedChange = { forceReadOnly ->
onChangeForceReadOnly(forceReadOnly)
showOverflow = false
}
)
}
}
// show properties
DropdownMenuItem(onClick = {
showPropertiesDialog = true
showOverflow = false
}) {
Text(stringResource(R.string.collection_properties))
}
// 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() {
CollectionList_Item(
Collection(
type = Collection.TYPE_CALENDAR,
url = "https://example.com/caldav/sample".toHttpUrl(),
displayName = "Sample Calendar",
description = "This Sample Calendar even has some lengthy description.",
color = 0xffff0000.toInt(),
forceReadOnly = true,
supportsVEVENT = true,
supportsVTODO = true,
supportsVJOURNAL = true
)
)
}
@Composable
fun CollectionList_Subscription(
item: Collection,
onSubscribe: () -> Unit = {}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(IntrinsicSize.Min)
) {
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)
) {
Text(
item.title(),
style = MaterialTheme.typography.body1
)
item.description?.let { description ->
Text(
description,
style = MaterialTheme.typography.body2
)
}
}
TextButton(onClick = onSubscribe) {
Text("Subscribe".uppercase())
}
}
}
@Composable
@Preview
fun CollectionList_Subscription_Preview() {
CollectionList_Subscription(
Collection(
type = Collection.TYPE_WEBCAL,
url = "https://example.com/caldav/sample".toHttpUrl(),
displayName = "Sample Subscription",
description = "This Sample Subscription even has some lengthy description.",
color = 0xffff0000.toInt()
)
)
}

View file

@ -107,7 +107,7 @@ class CreateAddressBookActivity: AppCompatActivity() {
override fun supportShouldUpRecreateTask(targetIntent: Intent) = true
override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) {
builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account)
builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity2.EXTRA_ACCOUNT, model.account)
}

View file

@ -120,8 +120,8 @@ class CreateCalendarActivity: AppCompatActivity(), ColorPickerDialogListener {
override fun onOptionsItemSelected(item: MenuItem) =
if (item.itemId == android.R.id.home) {
val intent = Intent(this, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account)
val intent = Intent(this, AccountActivity2::class.java)
intent.putExtra(AccountActivity2.EXTRA_ACCOUNT, model.account)
NavUtils.navigateUpTo(this, intent)
true
} else

View file

@ -0,0 +1,203 @@
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.ExceptionInfoDialog
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.util.Optional
@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,
onDeleteCollection = {
started = true
model.deleteCollection(collection)
},
onCancel = ::dismiss
)
}
}
}
@Composable
fun DeleteCollectionDialog_Content(
collection: Collection,
started: Boolean,
result: Optional<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 -> {
val exception = result.get()
ExceptionInfoDialog(
exception = exception,
remoteResource = collection.url,
onDismissRequest = 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 = Optional.of(Exception("Test error"))
)
}

View file

@ -1,147 +0,0 @@
/***************************************************************************************************
* 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.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import at.bitfire.dav4jvm.DavResource
import at.bitfire.davdroid.databinding.DeleteCollectionBinding
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.network.HttpClient
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.ui.ExceptionInfoFragment
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class DeleteCollectionFragment: DialogFragment() {
companion object {
const val ARG_ACCOUNT = "account"
const val ARG_COLLECTION_ID = "collectionId"
fun newInstance(account: Account, collectionId: Long): DialogFragment {
val frag = DeleteCollectionFragment()
val args = Bundle(2)
args.putParcelable(ARG_ACCOUNT, account)
args.putLong(ARG_COLLECTION_ID, collectionId)
frag.arguments = args
return frag
}
}
@Inject lateinit var modelFactory: Model.Factory
val model by viewModels<Model> {
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
modelFactory.create(
requireArguments().getParcelable(ARG_ACCOUNT) ?: throw IllegalArgumentException("ARG_ACCOUNT required"),
requireArguments().getLong(ARG_COLLECTION_ID)
) as T
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val binding = DeleteCollectionBinding.inflate(layoutInflater, null, false)
binding.lifecycleOwner = this
binding.model = model
binding.ok.setOnClickListener {
isCancelable = false
binding.progress.visibility = View.VISIBLE
binding.controls.visibility = View.GONE
model.deleteCollection().observe(viewLifecycleOwner, { exception ->
if (exception != null)
parentFragmentManager.beginTransaction()
.add(ExceptionInfoFragment.newInstance(exception, model.account), null)
.commit()
dismiss()
})
}
binding.cancel.setOnClickListener {
dismiss()
}
return binding.root
}
class Model @AssistedInject constructor(
application: Application,
val db: AppDatabase,
@Assisted var account: Account,
@Assisted val collectionId: Long
): AndroidViewModel(application) {
@AssistedFactory
interface Factory {
fun create(account: Account, collectionId: Long): Model
}
var collectionInfo: Collection? = null
val confirmation = MutableLiveData<Boolean>()
val result = MutableLiveData<Exception>()
init {
viewModelScope.launch(Dispatchers.IO) {
collectionInfo = db.collectionDao().get(collectionId)
}
}
fun deleteCollection(): LiveData<Exception> {
viewModelScope.launch(Dispatchers.IO + NonCancellable) {
val collectionInfo = collectionInfo ?: return@launch
HttpClient.Builder(getApplication(), AccountSettings(getApplication(), account))
.setForeground(true)
.build().use { httpClient ->
try {
val collection = DavResource(httpClient.okHttpClient, collectionInfo.url)
// delete collection from server
collection.delete(null) {}
// delete collection locally
db.collectionDao().delete(collectionInfo)
// return success
result.postValue(null)
} catch(e: Exception) {
// return error
result.postValue(e)
}
}
}
return result
}
}
}

View file

@ -0,0 +1,94 @@
package at.bitfire.davdroid.ui.account
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.AlertDialog
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TextField
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import at.bitfire.davdroid.R
@Composable
fun RenameAccountDialog(
oldName: String,
onRenameAccount: (newName: String) -> Unit = {},
onDismiss: () -> Unit = {}
) {
var accountName by remember { mutableStateOf(TextFieldValue(oldName, selection = TextRange(oldName.length))) }
val focusRequester = remember { FocusRequester() }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.account_rename)) },
text = { Column {
Text(
stringResource(R.string.account_rename_new_name_description),
modifier = Modifier.padding(bottom = 8.dp)
)
TextField(
value = accountName,
onValueChange = { accountName = it },
label = { Text(stringResource(R.string.account_rename_new_name)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Go
),
keyboardActions = KeyboardActions(
onGo = {
onRenameAccount(accountName.text)
}
),
modifier = Modifier.focusRequester(focusRequester)
)
}},
confirmButton = {
TextButton(
onClick = {
onRenameAccount(accountName.text)
},
enabled = oldName != accountName.text
) {
Text(stringResource(R.string.account_rename_rename).uppercase())
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel).uppercase())
}
},
)
// request focus on the first composition
var requestFocus = remember { true }
LaunchedEffect(requestFocus) {
if (requestFocus) {
focusRequester.requestFocus()
requestFocus = false
}
}
}
@Composable
@Preview
fun RenameAccountDialog_Preview() {
RenameAccountDialog("Account Name")
}

View file

@ -1,286 +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.annotation.SuppressLint
import android.app.Application
import android.content.ContentResolver
import android.content.pm.PackageManager
import android.os.Bundle
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.MainThread
import androidx.annotation.WorkerThread
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.AlertDialog
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TextField
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.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.InvalidAccountException
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.syncadapter.AccountsCleanupWorker
import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker
import at.bitfire.davdroid.syncadapter.SyncWorker
import at.bitfire.ical4android.TaskProvider
import com.google.accompanist.themeadapter.material.MdcTheme
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import java.util.logging.Level
import javax.inject.Inject
@AndroidEntryPoint
class RenameAccountFragment: DialogFragment() {
companion object {
private const val ARG_ACCOUNT = "account"
fun newInstance(account: Account): RenameAccountFragment {
val fragment = RenameAccountFragment()
val args = Bundle(1)
args.putParcelable(ARG_ACCOUNT, account)
fragment.arguments = args
return fragment
}
}
val model by viewModels<Model>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val oldAccount: Account = requireArguments().getParcelable(ARG_ACCOUNT)!!
model.errorMessage.observe(this) { msg ->
// we use a Toast to show the error message because a Snackbar is not usable for the input dialog fragment
Toast.makeText(requireActivity(), msg, Toast.LENGTH_LONG).show()
}
model.finishActivity.observe(this) {
requireActivity().finish()
}
return ComposeView(requireContext()).apply {
// Dispose of the Composition when the view's LifecycleOwner is destroyed
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MdcTheme {
var accountName by remember { mutableStateOf(oldAccount.name) }
AlertDialog(
onDismissRequest = { dismiss() },
title = { Text(stringResource(R.string.account_rename)) },
text = { Column {
Text(
stringResource(R.string.account_rename_new_name_description),
modifier = Modifier.padding(bottom = 8.dp)
)
TextField(
value = accountName,
onValueChange = { accountName = it },
label = { Text(stringResource(R.string.account_rename_new_name)) },
)
}},
confirmButton = {
TextButton(
onClick = { model.renameAccount(oldAccount, accountName) },
enabled = oldAccount.name != accountName
) {
Text(stringResource(R.string.account_rename_rename).uppercase())
}
},
dismissButton = {
TextButton(onClick = { dismiss() }) {
Text(stringResource(android.R.string.cancel).uppercase())
}
},
)
}
}
}
}
@HiltViewModel
class Model @Inject constructor(
application: Application,
val db: AppDatabase
): AndroidViewModel(application) {
val errorMessage = MutableLiveData<String>()
val finishActivity = MutableLiveData<Boolean>()
/**
* Will try to rename the given account to given string
*
* @param oldAccount the account to be renamed
* @param newName the new name
*/
fun renameAccount(oldAccount: Account, newName: String) {
val context: Application = getApplication()
// remember sync intervals
val oldSettings = try {
AccountSettings(context, oldAccount)
} catch (e: InvalidAccountException) {
errorMessage.postValue(context.getString(R.string.account_invalid))
finishActivity.value = true
return
}
val authorities = arrayOf(
context.getString(R.string.address_books_authority),
CalendarContract.AUTHORITY,
TaskProvider.ProviderName.OpenTasks.authority
)
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")
errorMessage.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
finishActivity.postValue(true)
}, null)
} catch (e: Exception) {
Logger.log.log(Level.WARNING, "Couldn't rename account", e)
errorMessage.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")
val context: Application = getApplication()
// disable periodic workers of old account
syncIntervals.forEach { (authority, _) ->
PeriodicSyncWorker.disable(context, oldAccount, authority)
}
// cancel maybe running synchronization
SyncWorker.cancelSync(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)
errorMessage.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
SyncWorker.enqueueAllAuthorities(context, newAccount)
}
}
}

View file

@ -76,7 +76,7 @@ class SettingsActivity: AppCompatActivity() {
override fun supportShouldUpRecreateTask(targetIntent: Intent) = true
override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) {
builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity2.EXTRA_ACCOUNT, account)
}

View file

@ -1,325 +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.app.Application
import android.content.ContentProviderClient
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.database.ContentObserver
import android.net.Uri
import android.os.Bundle
import android.provider.CalendarContract
import android.provider.CalendarContract.Calendars
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.WorkerThread
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.room.Transaction
import at.bitfire.dav4jvm.UrlUtils
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.AccountCaldavItemBinding
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.util.PermissionUtils
import com.google.android.material.snackbar.Snackbar
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.util.logging.Level
import javax.inject.Inject
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.collections.set
@AndroidEntryPoint
class WebcalFragment: CollectionsFragment() {
override val noCollectionsStringId = R.string.account_no_webcals
@Inject lateinit var webcalModelFactory: WebcalModel.Factory
private val webcalModel by viewModels<WebcalModel> {
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>) =
webcalModelFactory.create(
requireArguments().getLong(EXTRA_SERVICE_ID)
) as T
}
}
private val menuProvider = object : CollectionsMenuProvider() {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.caldav_actions, menu)
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.create_calendar).isVisible = false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
webcalModel.subscribedUrls.observe(this, { urls ->
Logger.log.log(Level.FINE, "Got Android calendar list", urls.keys)
})
}
override fun onResume() {
super.onResume()
requireActivity().addMenuProvider(menuProvider)
}
override fun onPause() {
super.onPause()
requireActivity().removeMenuProvider(menuProvider)
}
override fun checkPermissions() {
if (PermissionUtils.havePermissions(requireActivity(), PermissionUtils.CALENDAR_PERMISSIONS))
binding.permissionsCard.visibility = View.GONE
else {
binding.permissionsText.setText(R.string.account_webcal_missing_calendar_permissions)
binding.permissionsCard.visibility = View.VISIBLE
}
}
override fun createAdapter(): CollectionAdapter = WebcalAdapter(accountModel, webcalModel, this)
class CalendarViewHolder(
private val parent: ViewGroup,
accountModel: AccountActivity.Model,
private val webcalModel: WebcalModel,
private val webcalFragment: WebcalFragment
): CollectionViewHolder<AccountCaldavItemBinding>(parent, AccountCaldavItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), accountModel) {
override fun bindTo(item: Collection) {
binding.color.setBackgroundColor(item.color ?: Constants.DAVDROID_GREEN_RGBA)
binding.sync.isChecked = item.sync
binding.title.text = item.title()
if (item.description.isNullOrBlank())
binding.description.visibility = View.GONE
else {
binding.description.text = item.description
binding.description.visibility = View.VISIBLE
}
binding.readOnly.visibility = View.VISIBLE
binding.events.visibility = if (item.supportsVEVENT == true) View.VISIBLE else View.GONE
binding.tasks.visibility = if (item.supportsVTODO == true) View.VISIBLE else View.GONE
itemView.setOnClickListener {
if (item.sync)
webcalModel.unsubscribe(item)
else
subscribe(item)
}
binding.actionOverflow.setOnClickListener(CollectionPopupListener(accountModel, item, webcalFragment.parentFragmentManager))
}
private fun subscribe(item: Collection) {
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) }
Logger.log.info("Intent: ${intent.extras}")
val activity = webcalFragment.requireActivity()
if (activity.packageManager.resolveActivity(intent, 0) != null)
activity.startActivity(intent)
else {
val snack = Snackbar.make(parent, R.string.account_no_webcal_handler_found, Snackbar.LENGTH_LONG)
val installIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=at.bitfire.icsdroid"))
if (activity.packageManager.resolveActivity(installIntent, 0) != null)
snack.setAction(R.string.account_install_icsx5) {
activity.startActivityForResult(installIntent, 0)
}
snack.show()
}
}
}
class WebcalAdapter(
accountModel: AccountActivity.Model,
private val webcalModel: WebcalModel,
val webcalFragment: WebcalFragment
): CollectionAdapter(accountModel) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
CalendarViewHolder(parent, accountModel, webcalModel, webcalFragment)
}
class WebcalModel @AssistedInject constructor(
application: Application,
val db: AppDatabase,
@Assisted val serviceId: Long
): AndroidViewModel(application) {
@AssistedFactory
interface Factory {
fun create(serviceId: Long): WebcalModel
}
val context: Context get() = getApplication()
private val resolver = context.contentResolver
private var calendarPermission = false
private val calendarProvider = object: MediatorLiveData<ContentProviderClient>() {
init {
calendarPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) == PackageManager.PERMISSION_GRANTED
if (calendarPermission)
connect()
}
override fun onActive() {
super.onActive()
connect()
}
fun connect() {
if (calendarPermission && value == null)
value = resolver.acquireContentProviderClient(CalendarContract.AUTHORITY)
}
override fun onInactive() {
super.onInactive()
disconnect()
}
fun disconnect() {
value?.close()
value = null
}
}
val subscribedUrls = object: MediatorLiveData<MutableMap<Long, HttpUrl>>() {
var provider: ContentProviderClient? = null
var observer: ContentObserver? = null
init {
addSource(calendarProvider) { provider ->
this.provider = provider
if (provider != null) {
connect()
} else
unregisterObserver()
}
}
override fun onActive() {
super.onActive()
connect()
}
private fun connect() {
unregisterObserver()
provider?.let { provider ->
val newObserver = object: ContentObserver(null) {
override fun onChange(selfChange: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
queryCalendars(provider)
}
}
}
context.contentResolver.registerContentObserver(Calendars.CONTENT_URI, false, newObserver)
observer = newObserver
viewModelScope.launch(Dispatchers.IO) {
queryCalendars(provider)
}
}
}
override fun onInactive() {
super.onInactive()
unregisterObserver()
}
private fun unregisterObserver() {
observer?.let {
context.contentResolver.unregisterContentObserver(it)
observer = null
}
}
@WorkerThread
@Transaction
private fun queryCalendars(provider: ContentProviderClient) {
// query subscribed URLs from Android calendar list
val subscriptions = mutableMapOf<Long, HttpUrl>()
provider.query(Calendars.CONTENT_URI, arrayOf(Calendars._ID, Calendars.NAME),null, null, null)?.use { cursor ->
while (cursor.moveToNext())
cursor.getString(1)?.let { rawName ->
rawName.toHttpUrlOrNull()?.let { url ->
subscriptions[cursor.getLong(0)] = url
}
}
}
// update "sync" field in database accordingly (will update UI)
db.collectionDao().getByServiceAndType(serviceId, Collection.TYPE_WEBCAL).forEach { webcal ->
val newSync = subscriptions.values
.any { webcal.source?.let { source -> UrlUtils.equals(source, it) } ?: false }
if (newSync != webcal.sync)
db.collectionDao().update(webcal.copy(sync = newSync))
}
postValue(subscriptions)
}
}
fun unsubscribe(webcal: Collection) {
viewModelScope.launch(Dispatchers.IO) {
// find first matching source (Webcal) URL
subscribedUrls.value?.entries?.firstOrNull { (_, source) ->
UrlUtils.equals(source, webcal.source!!)
}?.key?.let { id ->
// delete first matching subscription from Android calendar list
calendarProvider.value?.delete(Calendars.CONTENT_URI,
"${Calendars._ID}=?", arrayOf(id.toString()))
}
}
}
}
}

View file

@ -39,7 +39,7 @@ 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.ui.account.AccountActivity
import at.bitfire.davdroid.ui.account.AccountActivity2
import at.bitfire.vcard4android.GroupMethod
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
@ -113,9 +113,9 @@ class AccountDetailsFragment : Fragment() {
// close Create account activity
requireActivity().finish()
// open Account activity for created account
val intent = Intent(requireActivity(), AccountActivity::class.java)
val intent = Intent(requireActivity(), AccountActivity2::class.java)
val account = Account(name, getString(R.string.account_type))
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
intent.putExtra(AccountActivity2.EXTRA_ACCOUNT, account)
startActivity(intent)
} else {
Snackbar.make(requireActivity().findViewById(android.R.id.content), R.string.login_account_not_created, Snackbar.LENGTH_LONG).show()

View file

@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
@Composable
fun ActionCard(
modifier: Modifier = Modifier,
icon: ImageVector? = null,
actionText: String? = null,
onAction: () -> Unit = {},
@ -32,6 +33,7 @@ fun ActionCard(
Card(Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxWidth()
.then(modifier)
) {
Column(Modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp)) {
if (icon != null)

View file

@ -1,100 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="0dp"
android:paddingEnd="8dp">
<View
android:id="@+id/color"
android:layout_width="8dp"
android:layout_height="match_parent"
android:layout_marginEnd="4dp"
tools:background="@color/primaryColor"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:orientation="horizontal">
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/sync"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:focusable="false"
android:focusableInTouchMode="false"
android:clickable="false"
android:contentDescription="@string/account_synchronize_this_collection"
android:layout_marginEnd="4dp"/>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
android:textAppearance="?android:attr/textAppearanceMedium"
tools:text="My Calendar" />
<TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
android:textAppearance="?android:attr/textAppearanceSmall"
tools:text="Calendar Description" />
</LinearLayout>
<ImageView
android:id="@+id/read_only"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/account_read_only"
app:srcCompat="@drawable/ic_remove_circle" />
<ImageView
android:id="@+id/events"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/account_calendar"
app:srcCompat="@drawable/ic_today"/>
<ImageView
android:id="@+id/tasks"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/account_task_list"
app:srcCompat="@drawable/ic_playlist_add_check"/>
<ImageView
android:id="@+id/journals"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/account_journal"
app:srcCompat="@drawable/ic_journals"/>
<ImageView
android:id="@+id/action_overflow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingLeft="0dp"
android:paddingRight="0dp"
android:paddingStart="0dp"
android:paddingEnd="0dp"
style="@style/Widget.AppCompat.ActionButton.Overflow"/>
</LinearLayout>
</LinearLayout>

View file

@ -1,59 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:gravity="center_vertical">
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/sync"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:focusable="false"
android:focusableInTouchMode="false"
android:clickable="false" />
<LinearLayout android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
android:textAppearance="?android:attr/textAppearanceMedium"
tools:text="My Address Book" />
<TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="viewStart"
android:textAppearance="?android:attr/textAppearanceSmall"
tools:text="Address Book Description" />
</LinearLayout>
<ImageView
android:id="@+id/read_only"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/account_read_only"
app:srcCompat="@drawable/ic_remove_circle"/>
<ImageView
android:id="@+id/action_overflow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingEnd="0dp"
style="@style/Widget.AppCompat.ActionButton.Overflow"/>
</LinearLayout>

View file

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/swipe_refresh"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ProgressBar
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="100"
android:indeterminate="true"
android:visibility="invisible"
style="@style/Widget.AppCompat.ProgressBar.Horizontal" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/permissionsCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/activity_margin"
android:layout_marginRight="@dimen/activity_margin"
android:layout_marginBottom="@dimen/card_padding"
android:visibility="gone">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/card_padding"
android:orientation="horizontal">
<TextView
android:id="@+id/permissionsText"
android:layout_height="wrap_content"
android:layout_width="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/permissionsBtn"
app:layout_constraintBottom_toBottomOf="parent"
style="@style/TextAppearance.MaterialComponents.Body1"
android:text="@string/account_caldav_missing_permissions" />
<Button
android:id="@+id/permissionsBtn"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toEndOf="@id/permissionsText"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:text="@string/account_permissions_action" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<!-- paddingBottom and clipToPadding are needed to make space for the FAB at the end -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:paddingBottom="148dp"
android:clipToPadding="false" />
<LinearLayout
android:id="@+id/empty"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="@dimen/activity_margin"
android:orientation="vertical"
android:visibility="gone"
android:gravity="center">
<TextView
android:id="@+id/no_collections"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textAppearance="@style/TextAppearance.AppCompat.Large" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_marginTop="8dp"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
android:text="@string/account_swipe_down"/>
</LinearLayout>
</LinearLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View file

@ -1,80 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="model"
type="at.bitfire.davdroid.ui.account.DeleteCollectionFragment.Model" />
</data>
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/activity_margin">
<TextView
style="@style/TextAppearance.MaterialComponents.Headline5"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/delete_collection_confirm_title"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:text="@{@string/delete_collection_confirm_warning(model.collectionInfo.title())}" />
<ProgressBar
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:layout_marginBottom="8dp"
android:visibility="gone"
tools:visibility="visible"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"/>
<LinearLayout
android:id="@+id/controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<CheckBox
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="@={model.confirmation}"
android:layout_marginBottom="8dp"
android:text="@string/delete_collection_data_shall_be_deleted"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="end"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/cancel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:text="@android:string/cancel"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/ok"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.MaterialComponents.Button.TextButton.Dialog"
android:enabled="@{model.confirmation ?? false}"
android:text="@android:string/ok"/>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</ScrollView>
</layout>

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/force_read_only"
android:checkable="true"
android:title="@string/collection_force_read_only"/>
<item android:id="@+id/properties"
android:title="@string/collection_properties"/>
<item android:id="@+id/delete_collection"
android:title="@string/delete_collection"/>
</menu>

View file

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<group android:orderInCategory="100">
<item android:id="@+id/settings"
android:icon="@drawable/ic_settings"
android:title="@string/account_settings"
app:showAsAction="ifRoom"/>
<item android:id="@+id/rename_account"
android:title="@string/account_rename"
app:showAsAction="never"/>
<item android:id="@+id/delete_account"
android:title="@string/account_delete"
app:showAsAction="never"/>
</group>
</menu>

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/showOnlyPersonal"
android:title="@string/account_only_personal"
android:checkable="true" />
<item android:id="@+id/refresh"
android:title="@string/account_refresh_calendar_list" />
<item android:id="@+id/create_calendar"
android:title="@string/account_create_new_calendar"/>
</menu>

View file

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:id="@+id/showOnlyPersonal"
android:title="@string/account_only_personal"
android:checkable="true" />
<item android:id="@+id/refresh"
android:title="@string/account_refresh_address_book_list"
app:showAsAction="never"/>
<item android:id="@+id/create_address_book"
android:title="@string/account_create_new_address_book"
app:showAsAction="never"/>
</menu>

View file

@ -17,6 +17,7 @@
<string name="manage_accounts">Manage accounts</string>
<string name="navigate_up">Navigate up</string>
<string name="no_internet_sync_scheduled">No Internet, scheduling sync</string>
<string name="options_menu">Options menu</string>
<string name="share">Share</string>
<string name="sync_started">Synchronization started</string>
@ -224,12 +225,8 @@
<string name="account_carddav">CardDAV</string>
<string name="account_caldav">CalDAV</string>
<string name="account_webcal">Webcal</string>
<string name="account_carddav_missing_permissions">No contacts sync (missing permissions)</string>
<string name="account_caldav_missing_calendar_permissions">No calendar sync (missing permissions)</string>
<string name="account_caldav_missing_tasks_permissions">No tasks sync (missing permissions)</string>
<string name="account_caldav_missing_permissions">No calendar and tasks sync (missing permissions)</string>
<string name="account_webcal_missing_calendar_permissions">Can\'t access calendars (missing permissions)</string>
<string name="account_permissions_action">Permissions</string>
<string name="account_missing_permissions">Additional permissions are required to synchronize these collections.</string>
<string name="account_manage_permissions">Manage permissions</string>
<string name="account_no_address_books">There are no address books (yet).</string>
<string name="account_no_calendars">There are no calendars (yet).</string>
<string name="account_no_webcals">There are no calendar subscriptions (yet).</string>
@ -251,10 +248,11 @@
<string name="account_task_list">task list</string>
<string name="account_journal">journal</string>
<string name="account_only_personal">Show only personal</string>
<string name="account_refresh_address_book_list">Refresh address book list</string>
<string name="account_refresh_collections">Refresh collections</string>
<string name="account_refreshing_collections">Refreshing collection list</string>
<string name="account_create_new_address_book">Create new address book</string>
<string name="account_refresh_calendar_list">Refresh calendar list</string>
<string name="account_create_new_calendar">Create new calendar</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>

View file

@ -75,6 +75,7 @@ androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androi
androidx-lifecycle-viewmodel-base = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-viewmodel" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-viewmodel" }
androidx-paging = { module = "androidx.paging:paging-runtime-ktx", version.ref = "androidx-paging" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" }
androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" }
androidx-security = { module = "androidx.security:security-crypto", version.ref = "androidx-security" }
androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "androidx-swiperefreshlayout" }