mirror of
https://github.com/bitfireAT/davx5-ose
synced 2024-10-04 18:33:49 +00:00
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:
parent
2e669812b1
commit
962dab7cf2
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
|
@ -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(
|
|||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"))
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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" }
|
||||
|
|
Loading…
Reference in a new issue