Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
575 changes: 575 additions & 0 deletions core/schemas/at.bitfire.davdroid.db.AppDatabase/19.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion core/src/main/kotlin/at/bitfire/davdroid/db/AppDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ import javax.inject.Singleton
SyncStats::class,
WebDavDocument::class,
WebDavMount::class
], exportSchema = true, version = 18, autoMigrations = [
], exportSchema = true, version = 19, autoMigrations = [
AutoMigration(from = 18, to = 19), // collection: add localDisplayName
AutoMigration(from = 17, to = 18, spec = AutoMigration18::class),
AutoMigration(from = 16, to = 17), // collection: add VAPID key
AutoMigration(from = 15, to = 16, spec = AutoMigration16::class),
Expand Down
12 changes: 10 additions & 2 deletions core/src/main/kotlin/at/bitfire/davdroid/db/Collection.kt
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ data class Collection(
* Human-readable name of the collection
*/
val displayName: String? = null,
/**
* User-defined local name that overrides [displayName] when shown in the app and, for
* calendar collections, when synced to the Android CalendarProvider. The server-side
* name is never changed.
*
* `null` means no local override (use the server's [displayName]).
*/
val localDisplayName: String? = null,
/**
* Human-readable description of the collection
*/
Expand Down Expand Up @@ -262,7 +270,7 @@ data class Collection(

// calculated properties

fun title() = displayName ?: url.lastSegment
fun title() = localDisplayName ?: displayName ?: url.lastSegment
fun readOnly() = forceReadOnly || !privWriteContent

}
}
15 changes: 9 additions & 6 deletions core/src/main/kotlin/at/bitfire/davdroid/db/CollectionDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,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 COLLATE NOCASE, url COLLATE NOCASE")
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY COALESCE(localDisplayName, displayName) COLLATE NOCASE, url COLLATE NOCASE")
fun getByServiceAndType(serviceId: Long, @CollectionType type: String): List<Collection>

@Query("SELECT * FROM collection WHERE pushTopic=:topic AND sync")
Expand All @@ -57,25 +57,25 @@ 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 COLLATE NOCASE, URL COLLATE NOCASE")
"AND (supportsVTODO OR supportsVEVENT OR supportsVJOURNAL OR (supportsVEVENT IS NULL AND supportsVTODO IS NULL AND supportsVJOURNAL IS NULL)) ORDER BY COALESCE(localDisplayName, displayName) COLLATE NOCASE, URL COLLATE NOCASE")
fun pageByServiceAndType(serviceId: Long, @CollectionType 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 COLLATE NOCASE, collection.url COLLATE NOCASE")
@Query("SELECT collection.* FROM collection, homeset WHERE collection.serviceId=:serviceId AND type=:type AND homeSetId=homeset.id AND homeset.personal ORDER BY COALESCE(collection.localDisplayName, collection.displayName) COLLATE NOCASE, collection.url COLLATE NOCASE")
fun pagePersonalByServiceAndType(serviceId: Long, @CollectionType type: String): PagingSource<Int, Collection>

@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 COLLATE NOCASE, url COLLATE NOCASE")
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVEVENT AND sync ORDER BY COALESCE(localDisplayName, 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 COLLATE NOCASE, url COLLATE NOCASE")
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND (supportsVTODO OR supportsVJOURNAL) AND sync ORDER BY COALESCE(localDisplayName, 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 COLLATE NOCASE, url COLLATE NOCASE")
@Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type='${Collection.TYPE_CALENDAR}' AND supportsVTODO AND sync ORDER BY COALESCE(localDisplayName, displayName) COLLATE NOCASE, url COLLATE NOCASE")
fun getSyncTaskLists(serviceId: Long): List<Collection>

/**
Expand Down Expand Up @@ -103,6 +103,9 @@ interface CollectionDao {
@Query("UPDATE collection SET forceReadOnly=:forceReadOnly WHERE id=:id")
suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean)

@Query("UPDATE collection SET localDisplayName=:localDisplayName WHERE id=:id")
suspend fun updateLocalDisplayName(id: Long, localDisplayName: String?)

@Query("UPDATE collection SET pushSubscription=:pushSubscription, pushSubscriptionExpires=:pushSubscriptionExpires, pushSubscriptionCreated=:updatedAt WHERE id=:id")
suspend fun updatePushSubscription(id: Long, pushSubscription: String?, pushSubscriptionExpires: Long?, updatedAt: Long = System.currentTimeMillis()/1000)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import at.bitfire.davdroid.di.qualifier.IoDispatcher
import at.bitfire.davdroid.network.HttpClientBuilder
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.util.trimToNull
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
Expand Down Expand Up @@ -215,9 +216,10 @@ class DavCollectionRepository @Inject constructor(
/**
* Inserts or updates the collection.
*
* On update, it will _not_ update the flags
* - [Collection.sync] and
* - [Collection.forceReadOnly],
* On update, it will _not_ update the user-controlled fields
* - [Collection.sync],
* - [Collection.forceReadOnly] and
* - [Collection.localDisplayName],
* but use the values of the already existing collection.
*
* @param newCollection Collection to be inserted or updated
Expand All @@ -228,7 +230,11 @@ class DavCollectionRepository @Inject constructor(
val oldCollection = dao.getByServiceAndUrl(newCollection.serviceId, newCollection.url.toString())
val newCollectionWithFlags =
if (oldCollection != null)
newCollection.copy(sync = oldCollection.sync, forceReadOnly = oldCollection.forceReadOnly)
newCollection.copy(
sync = oldCollection.sync,
forceReadOnly = oldCollection.forceReadOnly,
localDisplayName = oldCollection.localDisplayName
)
else
newCollection

Expand Down Expand Up @@ -261,6 +267,14 @@ class DavCollectionRepository @Inject constructor(
dao.updateForceReadOnly(id, forceReadOnly)
}

/**
* Sets the local display name override (or clears it when [localDisplayName] is null/blank).
* Does not change the collection on the server.
*/
suspend fun setLocalDisplayName(id: Long, localDisplayName: String?) {
dao.updateLocalDisplayName(id, localDisplayName.trimToNull())
}

/**
* Whether or not the local collection should be synced with the server
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,10 @@ class LocalCalendarStore @Inject constructor(
}

private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
val serverDisplayName = if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName
val values = contentValuesOf(
Calendars._SYNC_ID to info.id,
Calendars.CALENDAR_DISPLAY_NAME to
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName,
Calendars.CALENDAR_DISPLAY_NAME to (info.localDisplayName ?: serverDisplayName),

Calendars.ALLOWED_AVAILABILITY to arrayOf(
Events.AVAILABILITY_BUSY,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package at.bitfire.davdroid.ui.account

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
Expand All @@ -15,6 +16,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
Expand All @@ -24,6 +27,7 @@ import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.CloudSync
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.DoNotDisturbOn
import androidx.compose.material.icons.filled.DriveFileRenameOutline
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
Expand All @@ -37,24 +41,33 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.repository.DavSyncStatsRepository
import at.bitfire.davdroid.sync.SyncDataType
import at.bitfire.davdroid.ui.composable.AppTheme
Expand Down Expand Up @@ -96,6 +109,9 @@ fun CollectionScreen(
onSetForceReadOnly = model::setForceReadOnly,
title = collection.title(),
displayName = collection.displayName,
localDisplayName = collection.localDisplayName,
onSetLocalDisplayName = model::setLocalDisplayName,
supportsLocalRename = collection.type == Collection.TYPE_CALENDAR || collection.type == Collection.TYPE_WEBCAL,
description = collection.description,
owner = model.owner.collectAsStateWithLifecycle(null).value,
localItemCounts = model.localItemCounts.collectAsStateWithLifecycle(initialValue = emptyList()).value,
Expand Down Expand Up @@ -123,6 +139,9 @@ fun CollectionScreen(
onSetForceReadOnly: (Boolean) -> Unit = {},
title: String,
displayName: String? = null,
localDisplayName: String? = null,
onSetLocalDisplayName: (String?) -> Unit = {},
supportsLocalRename: Boolean = false,
description: String? = null,
owner: String? = null,
lastSynced: List<DavSyncStatsRepository.LastSynced> = emptyList(),
Expand Down Expand Up @@ -238,10 +257,40 @@ fun CollectionScreen(
}
)

if (supportsLocalRename) {
var showRenameDialog by remember { mutableStateOf(false) }
CollectionScreen_Entry(
icon = Icons.Default.DriveFileRenameOutline,
title = stringResource(R.string.collection_local_rename),
text = localDisplayName ?: stringResource(R.string.collection_local_rename_off),
onClick = { showRenameDialog = true },
control = {
// Reflect "pending on" while the dialog is open so the switch state
// matches the user's tap immediately (flips back if they cancel).
Switch(
checked = localDisplayName != null || showRenameDialog,
onCheckedChange = { enabled ->
if (enabled) showRenameDialog = true
else onSetLocalDisplayName(null)
}
)
}
)
if (showRenameDialog)
LocalRenameDialog(
initialName = localDisplayName ?: displayName ?: title,
onDismiss = { showRenameDialog = false },
onConfirm = { newName ->
onSetLocalDisplayName(newName)
showRenameDialog = false
}
)
}

if (displayName != null)
CollectionScreen_Entry(
title = stringResource(R.string.collection_title),
text = title
text = displayName
)

if (description != null)
Expand Down Expand Up @@ -354,11 +403,15 @@ fun CollectionScreen_Entry(
title: String? = null,
text: String? = null,
isLast: Boolean = false,
onClick: (() -> Unit)? = null,
control: @Composable (() -> Unit)? = null,
content: @Composable (() -> Unit)? = null
) {
Row(
verticalAlignment = if (content != null) Alignment.Top else Alignment.CenterVertically
verticalAlignment = if (content != null) Alignment.Top else Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.let { if (onClick != null) it.clickable(role = Role.Button, onClick = onClick) else it }
) {
if (icon != null)
Icon(
Expand Down Expand Up @@ -409,6 +462,8 @@ fun CollectionScreen_Preview() {
url = "https://example.com/calendar",
title = "Some Calendar, with some additional text to make it wrap around and stuff.",
displayName = "Some Calendar, with some additional text to make it wrap around and stuff.",
localDisplayName = "My Local Name",
supportsLocalRename = true,
description = "This is some description of the calendar. It can be long and wrap around.",
owner = "Some One",
lastSynced = listOf(
Expand Down Expand Up @@ -475,4 +530,65 @@ fun DeleteCollectionDialog_Preview() {
DeleteCollectionDialog(
displayName = "Some Calendar"
)
}
}


@Composable
fun LocalRenameDialog(
initialName: String,
onDismiss: () -> Unit = {},
onConfirm: (newName: String) -> Unit = {}
) {
var name by remember {
mutableStateOf(TextFieldValue(initialName, selection = TextRange(initialName.length)))
}

AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.DriveFileRenameOutline, contentDescription = null) },
title = { Text(stringResource(R.string.collection_local_rename)) },
text = {
Column {
Text(
stringResource(R.string.collection_local_rename_description),
modifier = Modifier.padding(bottom = 8.dp)
)

val focusRequester = remember { FocusRequester() }
TextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.collection_local_rename_label)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = { if (name.text.isNotBlank()) onConfirm(name.text) }
),
modifier = Modifier.focusRequester(focusRequester)
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
},
confirmButton = {
Button(
onClick = { onConfirm(name.text) },
enabled = name.text.isNotBlank()
) {
Text(stringResource(R.string.dialog_save))
}
},
dismissButton = {
OutlinedButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
}
)
}

@Composable
@Preview
fun LocalRenameDialog_Preview() {
LocalRenameDialog(initialName = "My Calendar")
}
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,13 @@ class CollectionScreenModel @AssistedInject constructor(
}
}

fun setLocalDisplayName(localDisplayName: String?) {
viewModelScope.launch {
collectionRepository.setLocalDisplayName(collectionId, localDisplayName)
collectionSelectedUseCase.get().handleWithDelay(collectionId)
}
}

fun setSync(sync: Boolean) {
viewModelScope.launch {
collectionRepository.setSync(collectionId, sync)
Expand Down
5 changes: 5 additions & 0 deletions core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<string name="dialog_remove">Remove</string>
<string name="dialog_deny">Cancel</string>
<string name="dialog_enable">Enable</string>
<string name="dialog_save">Save</string>
<string name="field_required">This field is required</string>
<string name="help">Help</string>
<string name="navigate_up">Navigate up</string>
Expand Down Expand Up @@ -458,6 +459,10 @@
<string name="collection_read_only_by_setting">Read-only (by policy)</string>
<string name="collection_read_only_forced">Read-only (only locally)</string>
<string name="collection_read_write">Read/write</string>
<string name="collection_local_rename">Local rename</string>
<string name="collection_local_rename_off">Use server name</string>
<string name="collection_local_rename_label">Local name</string>
<string name="collection_local_rename_description">Show this collection under a different name on this device. The name on the server is not changed.</string>
<string name="collection_title">Title</string>
<string name="collection_description">Description</string>
<string name="collection_owner">Owner</string>
Expand Down
Loading