From 35dabbd5196a90c67c45e0f8842e371fff7ec600 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 27 Aug 2026 18:00:23 +0200 Subject: [PATCH 1/5] feat(upload): Allow setting file permissions when uploading attachments Lets the sender choose between view-only and editable when uploading a file into a conversation, mirroring the option added on iOS (nextcloud/talk-ios#2669). Only shown/honoured when the server has conversation subfolders enabled, since that's the only upload path the server can apply the permission to. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../FileAttachmentPreviewFragment.kt | 29 +++++++-- .../FileAttachmentPreviewScreen.kt | 59 +++++++++++++++++-- .../com/nextcloud/talk/chat/ChatActivity.kt | 30 ++++++---- .../talk/chat/viewmodels/ChatViewModel.kt | 6 +- .../talk/jobs/UploadAndShareFilesWorker.kt | 22 +++++-- .../PostConversationAttachmentRequest.kt | 3 + .../ProbeConversationAttachmentRequest.kt | 3 + app/src/main/res/drawable/edit_24px.xml | 17 ++++++ app/src/main/res/drawable/lock_24px.xml | 17 ++++++ app/src/main/res/values/strings.xml | 2 + 10 files changed, 160 insertions(+), 28 deletions(-) create mode 100644 app/src/main/res/drawable/edit_24px.xml create mode 100644 app/src/main/res/drawable/lock_24px.xml diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt index 8d028242a0e..dbcfaadb533 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt @@ -32,8 +32,10 @@ import javax.inject.Inject class FileAttachmentPreviewFragment : DialogFragment() { private lateinit var filesList: ArrayList private var conversationName: String = "" - private var uploadFiles: (files: MutableList, caption: String, compressImages: Boolean) -> Unit = - { _, _, _ -> } + private var showFilePermissionsOption: Boolean = false + private var uploadFiles: + (files: MutableList, caption: String, compressImages: Boolean, allowUpdate: Boolean) -> Unit = + { _, _, _, _ -> } private var composeView: ComposeView? = null @Inject @@ -49,7 +51,14 @@ class FileAttachmentPreviewFragment : DialogFragment() { ViewModelProvider(this, viewModelFactory)[FileAttachmentPreviewViewModel::class.java] } - fun setListener(uploadFiles: (files: MutableList, caption: String, compressImages: Boolean) -> Unit) { + fun setListener( + uploadFiles: ( + files: MutableList, + caption: String, + compressImages: Boolean, + allowUpdate: Boolean + ) -> Unit + ) { this.uploadFiles = uploadFiles } @@ -57,6 +66,7 @@ class FileAttachmentPreviewFragment : DialogFragment() { arguments?.let { filesList = it.getStringArrayList(FILES_TO_UPLOAD_ARG)!! conversationName = it.getString(CONVERSATION_NAME_ARG, "") + showFilePermissionsOption = it.getBoolean(FILE_PERMISSIONS_OPTION_ARG, false) } composeView = ComposeView(requireContext()) @@ -102,9 +112,10 @@ class FileAttachmentPreviewFragment : DialogFragment() { viewModel = viewModel, conversationName = conversationName, initialCompressImages = appPreferences.compressUploadImages, + showFilePermissionsOption = showFilePermissionsOption, onDismiss = { dismiss() }, - onSend = { files, caption, compressImages -> - uploadFiles(files.toMutableList(), caption, compressImages) + onSend = { files, caption, compressImages, allowUpdate -> + uploadFiles(files.toMutableList(), caption, compressImages, allowUpdate) dismiss() } ) @@ -123,13 +134,19 @@ class FileAttachmentPreviewFragment : DialogFragment() { private const val LIGHT_LUMINANCE_THRESHOLD = 0.5f private const val FILES_TO_UPLOAD_ARG = "FILES_TO_UPLOAD_ARG" private const val CONVERSATION_NAME_ARG = "CONVERSATION_NAME_ARG" + private const val FILE_PERMISSIONS_OPTION_ARG = "FILE_PERMISSIONS_OPTION_ARG" @JvmStatic - fun newInstance(filesToUpload: MutableList, conversationName: String): FileAttachmentPreviewFragment { + fun newInstance( + filesToUpload: MutableList, + conversationName: String, + showFilePermissionsOption: Boolean = false + ): FileAttachmentPreviewFragment { val fileAttachmentFragment = FileAttachmentPreviewFragment() val args = Bundle() args.putStringArrayList(FILES_TO_UPLOAD_ARG, ArrayList(filesToUpload)) args.putString(CONVERSATION_NAME_ARG, conversationName) + args.putBoolean(FILE_PERMISSIONS_OPTION_ARG, showFilePermissionsOption) fileAttachmentFragment.arguments = args return fileAttachmentFragment } diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt index ada48869f6f..1b6e9ade9c4 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt @@ -67,20 +67,23 @@ private const val APP_BAR_HORIZONTAL_PADDING_DP = 4 * hosted by [FileAttachmentPreviewFragment]. [viewModel] owns the file list and its (IO-derived) * descriptions so both survive configuration changes; everything else here is ephemeral UI state. */ -@Suppress("LongMethod") +@Suppress("LongMethod", "LongParameterList") @Composable internal fun FileAttachmentPreviewContent( viewModel: FileAttachmentPreviewViewModel, conversationName: String, initialCompressImages: Boolean, + showFilePermissionsOption: Boolean = false, onDismiss: () -> Unit, - onSend: (files: List, caption: String, compressImages: Boolean) -> Unit + onSend: (files: List, caption: String, compressImages: Boolean, allowUpdate: Boolean) -> Unit ) { val context = LocalContext.current val currentFiles = viewModel.files val hasCompressibleMedia = currentFiles.any { isCompressible(FileUtils.resolveMimeType(context, it.toUri())) } var caption by rememberSaveable { mutableStateOf("") } var compressImages by rememberSaveable { mutableStateOf(hasCompressibleMedia && initialCompressImages) } + // Deliberately not remembered between uploads, so an exception stays an exception. + var allowUpdate by rememberSaveable { mutableStateOf(false) } LaunchedEffect(currentFiles.toSet(), compressImages) { viewModel.describeFiles(compressImages) @@ -149,11 +152,21 @@ internal fun FileAttachmentPreviewContent( ) } + if (showFilePermissionsOption) { + FilePermissionSegmentedButton( + allowUpdate = allowUpdate, + onAllowUpdateChange = { allowUpdate = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + CaptionInputBar( caption = caption, onCaptionChange = { caption = it }, sendEnabled = currentFiles.isNotEmpty(), - onSend = { onSend(currentFiles.toList(), caption, compressImages) } + onSend = { onSend(currentFiles.toList(), caption, compressImages, allowUpdate) } ) } } @@ -234,6 +247,42 @@ private fun MediaQualitySegmentedButton( } } +@Composable +private fun FilePermissionSegmentedButton( + allowUpdate: Boolean, + onAllowUpdateChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + SingleChoiceSegmentedButtonRow(modifier = modifier) { + SegmentedButton( + selected = !allowUpdate, + onClick = { onAllowUpdateChange(false) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + icon = { + Icon( + painter = painterResource(R.drawable.lock_24px), + contentDescription = null, + modifier = Modifier.size(SegmentedButtonDefaults.IconSize) + ) + }, + label = { Text(stringResource(R.string.nc_file_permission_view_only)) } + ) + SegmentedButton( + selected = allowUpdate, + onClick = { onAllowUpdateChange(true) }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + icon = { + Icon( + painter = painterResource(R.drawable.edit_24px), + contentDescription = null, + modifier = Modifier.size(SegmentedButtonDefaults.IconSize) + ) + }, + label = { Text(stringResource(R.string.nc_file_permission_editable)) } + ) + } +} + @Composable private fun rememberPreviewViewModel(files: List): FileAttachmentPreviewViewModel { val context = LocalContext.current @@ -263,7 +312,7 @@ private fun FileAttachmentPreviewContentPreview() { conversationName = "Team Chat", initialCompressImages = true, onDismiss = {}, - onSend = { _, _, _ -> } + onSend = { _, _, _, _ -> } ) } } @@ -277,7 +326,7 @@ private fun FileAttachmentPreviewContentSingleFilePreview() { conversationName = "Team Chat", initialCompressImages = true, onDismiss = {}, - onSend = { _, _, _ -> } + onSend = { _, _, _, _ -> } ) } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 2cad07cae7a..19578d31f25 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -2578,10 +2578,11 @@ class ChatActivity : val newFragment = FileAttachmentPreviewFragment.newInstance( filesToUpload.map { it.toString() }.toMutableList(), - currentConversation?.displayName ?: "" + currentConversation?.displayName ?: "", + CapabilitiesUtil.hasConversationSubfoldersForAttachments(spreedCapabilities) ) - newFragment.setListener { files, caption, compressImages -> - uploadFiles(files, caption, compressImages) + newFragment.setListener { files, caption, compressImages, allowUpdate -> + uploadFiles(files, caption, compressImages, allowUpdate) } newFragment.show(supportFragmentManager, FileAttachmentPreviewFragment.TAG) } catch (e: IllegalStateException) { @@ -2663,10 +2664,11 @@ class ChatActivity : if (permissionUtil.isFilesPermissionGranted()) { val newFragment = FileAttachmentPreviewFragment.newInstance( filesToUpload, - currentConversation?.displayName ?: "" + currentConversation?.displayName ?: "", + CapabilitiesUtil.hasConversationSubfoldersForAttachments(spreedCapabilities) ) - newFragment.setListener { files, caption, compressImages -> - uploadFiles(files, caption, compressImages) + newFragment.setListener { files, caption, compressImages, allowUpdate -> + uploadFiles(files, caption, compressImages, allowUpdate) } newFragment.show(supportFragmentManager, FileAttachmentPreviewFragment.TAG) } else { @@ -2775,7 +2777,12 @@ class ChatActivity : } } - private fun uploadFiles(files: MutableList, caption: String = "", compressImages: Boolean = false) { + private fun uploadFiles( + files: MutableList, + caption: String = "", + compressImages: Boolean = false, + allowUpdate: Boolean = false + ) { val uploadId = UUID.randomUUID().toString() for (i in 0 until files.size) { uploadFile( @@ -2787,7 +2794,8 @@ class ChatActivity : displayName = currentConversation?.displayName!!, compressImages = compressImages, uploadId = uploadId, - order = i + 1 + order = i + 1, + allowUpdate = allowUpdate ) } } @@ -4180,7 +4188,8 @@ class ChatActivity : displayName: String, compressImages: Boolean = false, uploadId: String? = null, - order: Int = 1 + order: Int = 1, + allowUpdate: Boolean = false ) { chatViewModel.uploadFile( fileUri, @@ -4191,7 +4200,8 @@ class ChatActivity : displayName, compressImages, uploadId, - order + order, + allowUpdate ) cancelReply() } diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index d78fa8b8a22..88d2fa78ad5 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -2235,7 +2235,8 @@ class ChatViewModel @AssistedInject constructor( displayName: String, compressImages: Boolean = false, uploadId: String? = null, - order: Int = 1 + order: Int = 1, + allowUpdate: Boolean = false ) { val metaDataMap = mutableMapOf() var room = "" @@ -2295,7 +2296,8 @@ class ChatViewModel @AssistedInject constructor( metaData = metaData, referenceId = referenceId, internalConversationId = internalConversationId, - compressImages = compressImages + compressImages = compressImages, + allowUpdate = allowUpdate ) if (!isVoiceMessage) { diff --git a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt index e3c2929f954..3096c5138ca 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -170,12 +170,14 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa val useConversationSubfolders = CapabilitiesUtil.hasConversationSubfoldersForAttachments( currentUser.capabilities!!.spreedCapability!! ) + val allowUpdate = inputData.getBoolean(ALLOW_UPDATE, false) file?.let { isChunkedUploading = it.length() > CHUNK_UPLOAD_THRESHOLD_SIZE } val uploadSuccess: Boolean = uploadFile( sourceFileUri = sourceFileUri, metaData = metaData, remotePath = remotePath, - useConversationSubfolders = useConversationSubfolders + useConversationSubfolders = useConversationSubfolders, + allowUpdate = allowUpdate ) if (uploadSuccess && (isStopped || isCancelled())) { @@ -253,12 +255,13 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa sourceFileUri: Uri, metaData: String?, remotePath: String, - useConversationSubfolders: Boolean + useConversationSubfolders: Boolean, + allowUpdate: Boolean ): Boolean = if (file == null) { false } else if (useConversationSubfolders) { - uploadUsingConversationSubfolders(sourceFileUri, metaData) + uploadUsingConversationSubfolders(sourceFileUri, metaData, allowUpdate) } else if (isChunkedUploading) { Log.d(TAG, "starting chunked upload because size is " + file!!.length()) val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull() @@ -312,7 +315,11 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa return result } - private fun uploadUsingConversationSubfolders(sourceFileUri: Uri, metaData: String?): Boolean = + private fun uploadUsingConversationSubfolders( + sourceFileUri: Uri, + metaData: String?, + allowUpdate: Boolean + ): Boolean = runBlocking { val credentials = ApiUtils.getCredentials( currentUser.username, @@ -321,6 +328,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa val uploadId = UUID.randomUUID().toString() val fileNames = ProbeConversationAttachmentRequest().apply { fileNames = listOf(fileName) + this.allowUpdate = allowUpdate } val probeResponse = ncApiCoroutines.probeConversationAttachmentFolder( @@ -360,6 +368,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa referenceId = this@UploadAndShareFilesWorker.referenceId.orEmpty() talkMetaData = metaData fileName = predictedName + this.allowUpdate = allowUpdate } runCatching { @@ -458,6 +467,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa const val KEY_INTERNAL_CONVERSATION_ID = "INTERNAL_CONVERSATION_ID" const val PROGRESS_KEY = "UPLOAD_PROGRESS" private const val COMPRESS_IMAGES = "COMPRESS_IMAGES" + private const val ALLOW_UPDATE = "ALLOW_UPDATE" private const val CHUNK_UPLOAD_THRESHOLD_SIZE: Long = 1024 * 1024 // Total attempts allowed for a single upload (1 initial run + retries) before giving up on a @@ -523,7 +533,8 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa metaData: String?, referenceId: String = "", internalConversationId: String = "", - compressImages: Boolean = false + compressImages: Boolean = false, + allowUpdate: Boolean = false ): UUID { val data: Data = Data.Builder() .putString(DEVICE_SOURCE_FILE, fileUri) @@ -533,6 +544,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa .putString(KEY_REFERENCE_ID, referenceId) .putString(KEY_INTERNAL_CONVERSATION_ID, internalConversationId) .putBoolean(COMPRESS_IMAGES, compressImages) + .putBoolean(ALLOW_UPDATE, allowUpdate) .build() val uploadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(UploadAndShareFilesWorker::class.java) .setInputData(data) diff --git a/app/src/main/java/com/nextcloud/talk/models/json/chatpostattachment/PostConversationAttachmentRequest.kt b/app/src/main/java/com/nextcloud/talk/models/json/chatpostattachment/PostConversationAttachmentRequest.kt index 6f49c5a7559..3184b48c83d 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/chatpostattachment/PostConversationAttachmentRequest.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/chatpostattachment/PostConversationAttachmentRequest.kt @@ -24,4 +24,7 @@ class PostConversationAttachmentRequest { @JsonField(name = ["fileName"]) var fileName: String? = null + + @JsonField(name = ["allowUpdate"]) + var allowUpdate: Boolean? = false } diff --git a/app/src/main/java/com/nextcloud/talk/models/json/chatprobeattachmentfolder/ProbeConversationAttachmentRequest.kt b/app/src/main/java/com/nextcloud/talk/models/json/chatprobeattachmentfolder/ProbeConversationAttachmentRequest.kt index d6f873240da..a23bc3c9eae 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/chatprobeattachmentfolder/ProbeConversationAttachmentRequest.kt +++ b/app/src/main/java/com/nextcloud/talk/models/json/chatprobeattachmentfolder/ProbeConversationAttachmentRequest.kt @@ -15,4 +15,7 @@ class ProbeConversationAttachmentRequest { @JsonField(name = ["fileNames"]) var fileNames: List? = null + + @JsonField(name = ["allowUpdate"]) + var allowUpdate: Boolean? = false } diff --git a/app/src/main/res/drawable/edit_24px.xml b/app/src/main/res/drawable/edit_24px.xml new file mode 100644 index 00000000000..beb1683d89d --- /dev/null +++ b/app/src/main/res/drawable/edit_24px.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable/lock_24px.xml b/app/src/main/res/drawable/lock_24px.xml new file mode 100644 index 00000000000..ac57d90f4c4 --- /dev/null +++ b/app/src/main/res/drawable/lock_24px.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 359b1887b85..8c4831830f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -169,6 +169,8 @@ How to translate with transifex: Original quality Reduced quality Sending original quality uses more mobile data. + View-only + Editable Close video playback read_privacy Tap to unlock From ef5c1cb33e8b9765f8e6f98a6c78fd6948ef8aa3 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 27 Aug 2026 18:12:04 +0200 Subject: [PATCH 2/5] feat(upload): Use iOS-style option buttons for quality and permissions Replaces the two-way segmented buttons with compact pill buttons that show the current selection and open a dropdown menu with the alternative, matching the upload option buttons of the iOS share sheet (nextcloud/talk-ios#2669): gray while the option is the default one an upload starts with, filled with the theme color once it isn't. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../FileAttachmentPreviewScreen.kt | 222 ++++++++++++------ 1 file changed, 144 insertions(+), 78 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt index 1b6e9ade9c4..60bbae9698d 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt @@ -12,7 +12,10 @@ import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -23,15 +26,19 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme @@ -46,6 +53,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -142,24 +150,30 @@ internal fun FileAttachmentPreviewContent( } } - if (hasCompressibleMedia) { - MediaQualitySegmentedButton( - highQuality = !compressImages, - onHighQualityChange = { highQuality -> compressImages = !highQuality }, + if (showFilePermissionsOption || hasCompressibleMedia) { + // Both options are filled in from the same state they control on purpose: letting a + // button show its selection independently of what is uploaded would leave the two + // free to drift apart. + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - ) - } + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (showFilePermissionsOption) { + FilePermissionOptionButton( + allowUpdate = allowUpdate, + onAllowUpdateChange = { allowUpdate = it } + ) + } - if (showFilePermissionsOption) { - FilePermissionSegmentedButton( - allowUpdate = allowUpdate, - onAllowUpdateChange = { allowUpdate = it }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - ) + if (hasCompressibleMedia) { + MediaQualityOptionButton( + highQuality = !compressImages, + onHighQualityChange = { highQuality -> compressImages = !highQuality } + ) + } + } } CaptionInputBar( @@ -211,75 +225,127 @@ private fun PreviewTopBar(conversationName: String, onDismiss: () -> Unit) { } } +private const val OPTION_BUTTON_ICON_SIZE_DP = 16 +private const val OPTION_BUTTON_HORIZONTAL_PADDING_DP = 12 +private const val OPTION_BUTTON_VERTICAL_PADDING_DP = 6 +private const val OPTION_BUTTON_LABEL_PADDING_DP = 4 + +/** + * A button that shows the option it currently has selected and offers the alternatives in a + * dropdown menu: gray as long as it holds the option a share starts with (the default), and + * filled with the theme color once it does not - the same pair of colors and the same + * gray-unless-an-exception idea the upload option buttons of the iOS app use. + */ @Composable -private fun MediaQualitySegmentedButton( - highQuality: Boolean, - onHighQualityChange: (Boolean) -> Unit, - modifier: Modifier = Modifier -) { - SingleChoiceSegmentedButtonRow(modifier = modifier) { - SegmentedButton( - selected = highQuality, - onClick = { onHighQualityChange(true) }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), - icon = { - Icon( - painter = painterResource(R.drawable.high_quality_24px), - contentDescription = null, - modifier = Modifier.size(SegmentedButtonDefaults.IconSize) - ) - }, - label = { Text(stringResource(R.string.nc_media_quality_original)) } +private fun OptionButton(label: String, icon: Painter, isDefault: Boolean, onClick: () -> Unit) { + val colors = if (isDefault) { + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant ) - SegmentedButton( - selected = !highQuality, - onClick = { onHighQualityChange(false) }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), - icon = { - Icon( - painter = painterResource(R.drawable.high_quality_off_24px), - contentDescription = null, - modifier = Modifier.size(SegmentedButtonDefaults.IconSize) - ) - }, - label = { Text(stringResource(R.string.nc_media_quality_reduced)) } + } else { + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + } + + Button( + onClick = onClick, + shape = CircleShape, + colors = colors, + contentPadding = PaddingValues( + horizontal = OPTION_BUTTON_HORIZONTAL_PADDING_DP.dp, + vertical = OPTION_BUTTON_VERTICAL_PADDING_DP.dp + ) + ) { + Icon(painter = icon, contentDescription = null, modifier = Modifier.size(OPTION_BUTTON_ICON_SIZE_DP.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = OPTION_BUTTON_LABEL_PADDING_DP.dp) + ) + Icon( + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = null, + modifier = Modifier.size(OPTION_BUTTON_ICON_SIZE_DP.dp) ) } } @Composable -private fun FilePermissionSegmentedButton( - allowUpdate: Boolean, - onAllowUpdateChange: (Boolean) -> Unit, - modifier: Modifier = Modifier -) { - SingleChoiceSegmentedButtonRow(modifier = modifier) { - SegmentedButton( - selected = !allowUpdate, - onClick = { onAllowUpdateChange(false) }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), - icon = { - Icon( - painter = painterResource(R.drawable.lock_24px), - contentDescription = null, - modifier = Modifier.size(SegmentedButtonDefaults.IconSize) - ) - }, - label = { Text(stringResource(R.string.nc_file_permission_view_only)) } +private fun MediaQualityOptionButton(highQuality: Boolean, onHighQualityChange: (Boolean) -> Unit) { + var expanded by remember { mutableStateOf(false) } + + Box { + OptionButton( + label = stringResource( + if (highQuality) R.string.nc_media_quality_original else R.string.nc_media_quality_reduced + ), + icon = painterResource( + if (highQuality) R.drawable.high_quality_24px else R.drawable.high_quality_off_24px + ), + isDefault = !highQuality, + onClick = { expanded = true } ) - SegmentedButton( - selected = allowUpdate, - onClick = { onAllowUpdateChange(true) }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), - icon = { - Icon( - painter = painterResource(R.drawable.edit_24px), - contentDescription = null, - modifier = Modifier.size(SegmentedButtonDefaults.IconSize) - ) - }, - label = { Text(stringResource(R.string.nc_file_permission_editable)) } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.nc_media_quality_original)) }, + leadingIcon = { Icon(painterResource(R.drawable.high_quality_24px), contentDescription = null) }, + trailingIcon = { if (highQuality) Icon(Icons.Filled.Check, contentDescription = null) }, + onClick = { + onHighQualityChange(true) + expanded = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.nc_media_quality_reduced)) }, + leadingIcon = { Icon(painterResource(R.drawable.high_quality_off_24px), contentDescription = null) }, + trailingIcon = { if (!highQuality) Icon(Icons.Filled.Check, contentDescription = null) }, + onClick = { + onHighQualityChange(false) + expanded = false + } + ) + } + } +} + +@Composable +private fun FilePermissionOptionButton(allowUpdate: Boolean, onAllowUpdateChange: (Boolean) -> Unit) { + var expanded by remember { mutableStateOf(false) } + + Box { + OptionButton( + label = stringResource( + if (allowUpdate) R.string.nc_file_permission_editable else R.string.nc_file_permission_view_only + ), + icon = painterResource(if (allowUpdate) R.drawable.edit_24px else R.drawable.lock_24px), + isDefault = !allowUpdate, + onClick = { expanded = true } ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.nc_file_permission_view_only)) }, + leadingIcon = { Icon(painterResource(R.drawable.lock_24px), contentDescription = null) }, + trailingIcon = { if (!allowUpdate) Icon(Icons.Filled.Check, contentDescription = null) }, + onClick = { + onAllowUpdateChange(false) + expanded = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.nc_file_permission_editable)) }, + leadingIcon = { Icon(painterResource(R.drawable.edit_24px), contentDescription = null) }, + trailingIcon = { if (allowUpdate) Icon(Icons.Filled.Check, contentDescription = null) }, + onClick = { + onAllowUpdateChange(true) + expanded = false + } + ) + } } } From 0876bf592c59edb888150e65d1ec6d80792ca6c2 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 27 Aug 2026 18:44:29 +0200 Subject: [PATCH 3/5] fix(upload): Center the upload option buttons horizontally Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../talk/attachmentpreview/FileAttachmentPreviewScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt index 60bbae9698d..db31397216d 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt @@ -158,7 +158,7 @@ internal fun FileAttachmentPreviewContent( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) ) { if (showFilePermissionsOption) { FilePermissionOptionButton( From 0dc15e56e8bd2eeca8d29cb0d4d16f8ba6be4b65 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 27 Aug 2026 18:48:57 +0200 Subject: [PATCH 4/5] fix(upload): Use the same icons as iOS for the upload options Media quality: SF Symbols has no SD/HD badges, so the iOS app draws them as text in a bordered box; do the same instead of the unrelated Material "high quality" icons. File permissions: switch from a lock icon to Edit/EditOff, matching the pencil/pencil.slash icons iOS uses. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../FileAttachmentPreviewScreen.kt | 60 ++++++++++++++----- app/src/main/res/drawable/edit_24px.xml | 17 ------ .../main/res/drawable/high_quality_24px.xml | 17 ------ .../res/drawable/high_quality_off_24px.xml | 17 ------ app/src/main/res/drawable/lock_24px.xml | 17 ------ 5 files changed, 46 insertions(+), 82 deletions(-) delete mode 100644 app/src/main/res/drawable/edit_24px.xml delete mode 100644 app/src/main/res/drawable/high_quality_24px.xml delete mode 100644 app/src/main/res/drawable/high_quality_off_24px.xml delete mode 100644 app/src/main/res/drawable/lock_24px.xml diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt index db31397216d..0c6331552c2 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt @@ -11,6 +11,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.compose.foundation.border import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -27,10 +28,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.EditOff import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenu @@ -38,6 +42,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -53,10 +58,9 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -229,6 +233,9 @@ private const val OPTION_BUTTON_ICON_SIZE_DP = 16 private const val OPTION_BUTTON_HORIZONTAL_PADDING_DP = 12 private const val OPTION_BUTTON_VERTICAL_PADDING_DP = 6 private const val OPTION_BUTTON_LABEL_PADDING_DP = 4 +private const val TEXT_BADGE_BORDER_DP = 1 +private const val TEXT_BADGE_CORNER_RADIUS_DP = 4 +private const val TEXT_BADGE_HORIZONTAL_PADDING_DP = 3 /** * A button that shows the option it currently has selected and offers the alternatives in a @@ -237,7 +244,7 @@ private const val OPTION_BUTTON_LABEL_PADDING_DP = 4 * gray-unless-an-exception idea the upload option buttons of the iOS app use. */ @Composable -private fun OptionButton(label: String, icon: Painter, isDefault: Boolean, onClick: () -> Unit) { +private fun OptionButton(label: String, isDefault: Boolean, onClick: () -> Unit, icon: @Composable () -> Unit) { val colors = if (isDefault) { ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, @@ -259,7 +266,7 @@ private fun OptionButton(label: String, icon: Painter, isDefault: Boolean, onCli vertical = OPTION_BUTTON_VERTICAL_PADDING_DP.dp ) ) { - Icon(painter = icon, contentDescription = null, modifier = Modifier.size(OPTION_BUTTON_ICON_SIZE_DP.dp)) + icon() Text( text = label, style = MaterialTheme.typography.labelMedium, @@ -275,6 +282,27 @@ private fun OptionButton(label: String, icon: Painter, isDefault: Boolean, onCli } } +/** + * A short text in a bordered box, standing in for the SD/HD badges iOS draws next to its quality + * option - SF Symbols has no equivalent, so the iOS app renders them the same way. + */ +@Composable +private fun TextBadge(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = LocalContentColor.current, + modifier = Modifier + .border( + width = TEXT_BADGE_BORDER_DP.dp, + color = LocalContentColor.current, + shape = RoundedCornerShape(TEXT_BADGE_CORNER_RADIUS_DP.dp) + ) + .padding(horizontal = TEXT_BADGE_HORIZONTAL_PADDING_DP.dp) + ) +} + @Composable private fun MediaQualityOptionButton(highQuality: Boolean, onHighQualityChange: (Boolean) -> Unit) { var expanded by remember { mutableStateOf(false) } @@ -284,16 +312,14 @@ private fun MediaQualityOptionButton(highQuality: Boolean, onHighQualityChange: label = stringResource( if (highQuality) R.string.nc_media_quality_original else R.string.nc_media_quality_reduced ), - icon = painterResource( - if (highQuality) R.drawable.high_quality_24px else R.drawable.high_quality_off_24px - ), isDefault = !highQuality, - onClick = { expanded = true } + onClick = { expanded = true }, + icon = { TextBadge(if (highQuality) "HD" else "SD") } ) DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DropdownMenuItem( text = { Text(stringResource(R.string.nc_media_quality_original)) }, - leadingIcon = { Icon(painterResource(R.drawable.high_quality_24px), contentDescription = null) }, + leadingIcon = { TextBadge("HD") }, trailingIcon = { if (highQuality) Icon(Icons.Filled.Check, contentDescription = null) }, onClick = { onHighQualityChange(true) @@ -302,7 +328,7 @@ private fun MediaQualityOptionButton(highQuality: Boolean, onHighQualityChange: ) DropdownMenuItem( text = { Text(stringResource(R.string.nc_media_quality_reduced)) }, - leadingIcon = { Icon(painterResource(R.drawable.high_quality_off_24px), contentDescription = null) }, + leadingIcon = { TextBadge("SD") }, trailingIcon = { if (!highQuality) Icon(Icons.Filled.Check, contentDescription = null) }, onClick = { onHighQualityChange(false) @@ -322,14 +348,20 @@ private fun FilePermissionOptionButton(allowUpdate: Boolean, onAllowUpdateChange label = stringResource( if (allowUpdate) R.string.nc_file_permission_editable else R.string.nc_file_permission_view_only ), - icon = painterResource(if (allowUpdate) R.drawable.edit_24px else R.drawable.lock_24px), isDefault = !allowUpdate, - onClick = { expanded = true } + onClick = { expanded = true }, + icon = { + Icon( + imageVector = if (allowUpdate) Icons.Filled.Edit else Icons.Filled.EditOff, + contentDescription = null, + modifier = Modifier.size(OPTION_BUTTON_ICON_SIZE_DP.dp) + ) + } ) DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { DropdownMenuItem( text = { Text(stringResource(R.string.nc_file_permission_view_only)) }, - leadingIcon = { Icon(painterResource(R.drawable.lock_24px), contentDescription = null) }, + leadingIcon = { Icon(Icons.Filled.EditOff, contentDescription = null) }, trailingIcon = { if (!allowUpdate) Icon(Icons.Filled.Check, contentDescription = null) }, onClick = { onAllowUpdateChange(false) @@ -338,7 +370,7 @@ private fun FilePermissionOptionButton(allowUpdate: Boolean, onAllowUpdateChange ) DropdownMenuItem( text = { Text(stringResource(R.string.nc_file_permission_editable)) }, - leadingIcon = { Icon(painterResource(R.drawable.edit_24px), contentDescription = null) }, + leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) }, trailingIcon = { if (allowUpdate) Icon(Icons.Filled.Check, contentDescription = null) }, onClick = { onAllowUpdateChange(true) diff --git a/app/src/main/res/drawable/edit_24px.xml b/app/src/main/res/drawable/edit_24px.xml deleted file mode 100644 index beb1683d89d..00000000000 --- a/app/src/main/res/drawable/edit_24px.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/high_quality_24px.xml b/app/src/main/res/drawable/high_quality_24px.xml deleted file mode 100644 index d22e94581c6..00000000000 --- a/app/src/main/res/drawable/high_quality_24px.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/high_quality_off_24px.xml b/app/src/main/res/drawable/high_quality_off_24px.xml deleted file mode 100644 index a56255f6da2..00000000000 --- a/app/src/main/res/drawable/high_quality_off_24px.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/lock_24px.xml b/app/src/main/res/drawable/lock_24px.xml deleted file mode 100644 index ac57d90f4c4..00000000000 --- a/app/src/main/res/drawable/lock_24px.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - From 1381103cbab63c01167ba5b1d31765f8d0ee50c2 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 27 Aug 2026 19:33:04 +0200 Subject: [PATCH 5/5] fix(upload): Use the same button style for both upload options Editable/original quality no longer switch the button to a filled highlight - it stays the same gray pill as view-only/reduced quality, so only the label and icon change with the selection. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../FileAttachmentPreviewScreen.kt | 35 +++---------------- 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt index 0c6331552c2..97d06c56313 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt @@ -94,7 +94,6 @@ internal fun FileAttachmentPreviewContent( val hasCompressibleMedia = currentFiles.any { isCompressible(FileUtils.resolveMimeType(context, it.toUri())) } var caption by rememberSaveable { mutableStateOf("") } var compressImages by rememberSaveable { mutableStateOf(hasCompressibleMedia && initialCompressImages) } - // Deliberately not remembered between uploads, so an exception stays an exception. var allowUpdate by rememberSaveable { mutableStateOf(false) } LaunchedEffect(currentFiles.toSet(), compressImages) { @@ -155,9 +154,6 @@ internal fun FileAttachmentPreviewContent( } if (showFilePermissionsOption || hasCompressibleMedia) { - // Both options are filled in from the same state they control on purpose: letting a - // button show its selection independently of what is uploaded would leave the two - // free to drift apart. Row( modifier = Modifier .fillMaxWidth() @@ -237,30 +233,15 @@ private const val TEXT_BADGE_BORDER_DP = 1 private const val TEXT_BADGE_CORNER_RADIUS_DP = 4 private const val TEXT_BADGE_HORIZONTAL_PADDING_DP = 3 -/** - * A button that shows the option it currently has selected and offers the alternatives in a - * dropdown menu: gray as long as it holds the option a share starts with (the default), and - * filled with the theme color once it does not - the same pair of colors and the same - * gray-unless-an-exception idea the upload option buttons of the iOS app use. - */ @Composable -private fun OptionButton(label: String, isDefault: Boolean, onClick: () -> Unit, icon: @Composable () -> Unit) { - val colors = if (isDefault) { - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - } else { - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) - } - +private fun OptionButton(label: String, onClick: () -> Unit, icon: @Composable () -> Unit) { Button( onClick = onClick, shape = CircleShape, - colors = colors, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), contentPadding = PaddingValues( horizontal = OPTION_BUTTON_HORIZONTAL_PADDING_DP.dp, vertical = OPTION_BUTTON_VERTICAL_PADDING_DP.dp @@ -282,10 +263,6 @@ private fun OptionButton(label: String, isDefault: Boolean, onClick: () -> Unit, } } -/** - * A short text in a bordered box, standing in for the SD/HD badges iOS draws next to its quality - * option - SF Symbols has no equivalent, so the iOS app renders them the same way. - */ @Composable private fun TextBadge(text: String) { Text( @@ -312,7 +289,6 @@ private fun MediaQualityOptionButton(highQuality: Boolean, onHighQualityChange: label = stringResource( if (highQuality) R.string.nc_media_quality_original else R.string.nc_media_quality_reduced ), - isDefault = !highQuality, onClick = { expanded = true }, icon = { TextBadge(if (highQuality) "HD" else "SD") } ) @@ -348,7 +324,6 @@ private fun FilePermissionOptionButton(allowUpdate: Boolean, onAllowUpdateChange label = stringResource( if (allowUpdate) R.string.nc_file_permission_editable else R.string.nc_file_permission_view_only ), - isDefault = !allowUpdate, onClick = { expanded = true }, icon = { Icon(