diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6b390323aa3..d20f4af3c7e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -302,6 +302,8 @@ dependencies { implementation("androidx.media3:media3-exoplayer:$media3Version") implementation("androidx.media3:media3-ui:$media3Version") + implementation("androidx.media3:media3-transformer:$media3Version") + implementation("androidx.media3:media3-effect:$media3Version") implementation("com.github.chrisbanes:PhotoView:2.3.0") implementation("pl.droidsonroids.gif:android-gif-drawable:1.2.31") 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 fa07b43ab8f..3ad666ecb4e 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -2330,19 +2330,11 @@ class ChatActivity : try { require(filesToUpload.isNotEmpty()) - val filenamesWithLineBreaks = StringBuilder("\n") - - for (file in filesToUpload) { - val filename = FileUtils.getFileName(file, context) - filenamesWithLineBreaks.append(filename).append("\n") - } - val newFragment = FileAttachmentPreviewFragment.newInstance( - filenamesWithLineBreaks.toString(), filesToUpload.map { it.toString() }.toMutableList() ) - newFragment.setListener { files, caption -> - uploadFiles(files, caption) + newFragment.setListener { files, caption, compressImages -> + uploadFiles(files, caption, compressImages) } newFragment.show(supportFragmentManager, FileAttachmentPreviewFragment.TAG) } catch (e: IllegalStateException) { @@ -2426,18 +2418,10 @@ class ChatActivity : } if (permissionUtil.isFilesPermissionGranted()) { - val filenamesWithLineBreaks = StringBuilder("\n") - - for (file in filesToUpload) { - val filename = FileUtils.getFileName(file.toUri(), context) - filenamesWithLineBreaks.append(filename).append("\n") + val newFragment = FileAttachmentPreviewFragment.newInstance(filesToUpload) + newFragment.setListener { files, caption, compressImages -> + uploadFiles(files, caption, compressImages) } - - val newFragment = FileAttachmentPreviewFragment.newInstance( - filenamesWithLineBreaks.toString(), - filesToUpload - ) - newFragment.setListener { files, caption -> uploadFiles(files, caption) } newFragment.show(supportFragmentManager, FileAttachmentPreviewFragment.TAG) } else { UploadAndShareFilesWorker.requestStoragePermission(this) @@ -2545,27 +2529,17 @@ class ChatActivity : } } - private fun uploadFiles(files: MutableList, caption: String = "") { + private fun uploadFiles(files: MutableList, caption: String = "", compressImages: Boolean = false) { for (i in 0 until files.size) { - if (i == files.size - 1) { - uploadFile( - fileUri = files[i], - isVoiceMessage = false, - caption = caption, - roomToken = roomToken, - replyToMessageId = getReplyToMessageId(), - displayName = currentConversation?.displayName!! - ) - } else { - uploadFile( - fileUri = files[i], - isVoiceMessage = false, - caption = "", - roomToken = roomToken, - replyToMessageId = getReplyToMessageId(), - displayName = currentConversation?.displayName!! - ) - } + uploadFile( + fileUri = files[i], + isVoiceMessage = false, + caption = if (i == files.size - 1) caption else "", + roomToken = roomToken, + replyToMessageId = getReplyToMessageId(), + displayName = currentConversation?.displayName!!, + compressImages = compressImages + ) } } @@ -3945,13 +3919,15 @@ class ChatActivity : ) } + @Suppress("LongParameterList") fun uploadFile( fileUri: String, isVoiceMessage: Boolean, caption: String = "", roomToken: String = "", replyToMessageId: Int? = null, - displayName: String + displayName: String, + compressImages: Boolean = false ) { chatViewModel.uploadFile( fileUri, @@ -3959,7 +3935,8 @@ class ChatActivity : caption, roomToken, replyToMessageId, - displayName + displayName, + compressImages ) cancelReply() } diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt index b5a16950c0b..bb1baa27661 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -331,7 +331,6 @@ class MessageInputFragment : Fragment() { override fun onSuccess(result: Drawable) { binding.fragmentCallStarted.callAuthorChip.chipIcon = result - } }) .build() 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 0eb664ba8f2..434b1160dcb 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 @@ -1972,7 +1972,8 @@ class ChatViewModel @AssistedInject constructor( caption: String = "", roomToken: String = "", replyToMessageId: Int? = null, - displayName: String + displayName: String, + compressImages: Boolean = false ) { val metaDataMap = mutableMapOf() var room = "" @@ -2004,7 +2005,8 @@ class ChatViewModel @AssistedInject constructor( fileUri, room, displayName, - metaData + metaData, + compressImages ) } catch (e: IllegalArgumentException) { Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) 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 e84edc05379..9dffb5cca7d 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -43,8 +43,10 @@ import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.FileUtils +import com.nextcloud.talk.utils.ImageCompressor import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.RemoteFileUtils +import com.nextcloud.talk.utils.VideoCompressor import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld @@ -115,15 +117,21 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa require(sourceFile.isNotEmpty()) checkNotNull(roomToken) - val sourceFileUri = sourceFile.toUri() + var sourceFileUri = sourceFile.toUri() fileName = FileUtils.getFileName(sourceFileUri, context) file = FileUtils.getFileFromUri(context, sourceFileUri) + + initNotificationSetup() + + if (inputData.getBoolean(COMPRESS_IMAGES, false)) { + sourceFileUri = compressMediaIfPossible(sourceFileUri) + } + val remotePath = getRemotePath(currentUser) val useConversationSubfolders = CapabilitiesUtil.hasConversationSubfoldersForAttachments( currentUser.capabilities!!.spreedCapability!! ) - initNotificationSetup() file?.let { isChunkedUploading = it.length() > CHUNK_UPLOAD_THRESHOLD_SIZE } val uploadSuccess: Boolean = uploadFile(sourceFileUri, metaData, remotePath, useConversationSubfolders) @@ -146,6 +154,35 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } } + /** + * Replaces [file] and [fileName] with a compressed copy if [sourceFileUri] points to a + * compressible image or video, returning the [Uri] that should be uploaded. + */ + @Suppress("ReturnCount") + private fun compressMediaIfPossible(sourceFileUri: Uri): Uri { + val originalFile = file ?: return sourceFileUri + val mimeType = FileUtils.resolveMimeType(context, sourceFileUri) + + val compressedFile = when { + ImageCompressor.isCompressible(mimeType) -> ImageCompressor.compress(context, originalFile) + VideoCompressor.isCompressible(mimeType) -> compressVideoWithProgress(originalFile) + else -> null + } ?: return sourceFileUri + + file = compressedFile + fileName = compressedFile.name + return Uri.fromFile(compressedFile) + } + + /** + * Video compression can take a while, so the upload notification is repurposed to show its + * progress before it transitions into the actual upload progress. + */ + private fun compressVideoWithProgress(originalFile: File): File? { + showCompressionStartedNotification() + return VideoCompressor.compress(context, originalFile, onProgress = ::onCompressionProgress) + } + private fun uploadFile( sourceFileUri: Uri, metaData: String?, @@ -285,6 +322,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa NotificationUtils.NotificationChannels .NOTIFICATION_CHANNEL_UPLOADS.name ) + notificationId = SystemClock.uptimeMillis().toInt() } private fun initNotificationWithPercentage() { @@ -304,13 +342,54 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa ) .build() - notificationId = SystemClock.uptimeMillis().toInt() mNotifyManager!!.notify(notificationId, initNotification) // only need one summary notification but multiple upload worker can call it more than once but it is safe // because of the same notification object config and id. makeSummaryNotification() } + /** + * Shows the same upload notification, but reflecting the compression phase that precedes the + * actual upload. Reuses [notificationId] so it later morphs into the upload progress notification + * instead of appearing as a separate entry. + */ + private fun showCompressionStartedNotification() { + val compressionNotification = mBuilder!! + .setContentTitle(context.resources.getString(R.string.nc_compress_in_progress)) + .setContentText(getCompressionNotificationContentText(ZERO_PERCENT)) + .setSmallIcon(R.drawable.upload_white) + .setOngoing(true) + .setProgress(HUNDRED_PERCENT, ZERO_PERCENT, false) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setGroup(NotificationUtils.KEY_UPLOAD_GROUP) + .setContentIntent(getIntentToOpenConversation()) + .addAction( + R.drawable.ic_cancel_white_24dp, + getResourceString(context, R.string.nc_cancel), + getCancelUploadIntent() + ) + .build() + + mNotifyManager!!.notify(notificationId, compressionNotification) + makeSummaryNotification() + } + + private fun onCompressionProgress(percentage: Int) { + val progressUpdateNotification = mBuilder!! + .setProgress(HUNDRED_PERCENT, percentage, false) + .setContentText(getCompressionNotificationContentText(percentage)) + .build() + + mNotifyManager!!.notify(notificationId, progressUpdateNotification) + } + + private fun getCompressionNotificationContentText(percentage: Int): String = + String.format( + getResourceString(context, R.string.nc_compress_notification_text), + getShortenedFileName(), + percentage + ) + private fun makeSummaryNotification() { // summary notification encapsulating the group of notifications val summaryNotification = NotificationCompat.Builder( @@ -374,11 +453,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa intent.putExtras(bundle) val requestCode = System.currentTimeMillis().toInt() - val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE - } else { - 0 - } + val intentFlag = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE return PendingIntent.getActivity(context, requestCode, intent, intentFlag) } @@ -413,6 +488,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private const val ROOM_TOKEN = "ROOM_TOKEN" private const val CONVERSATION_NAME = "CONVERSATION_NAME" private const val META_DATA = "META_DATA" + private const val COMPRESS_IMAGES = "COMPRESS_IMAGES" private const val CHUNK_UPLOAD_THRESHOLD_SIZE: Long = 1024 * 1024 private const val NOTIFICATION_FILE_NAME_MAX_LENGTH = 20 private const val THREE_DOTS = "…" @@ -465,12 +541,19 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } } - fun upload(fileUri: String, roomToken: String, conversationName: String, metaData: String?) { + fun upload( + fileUri: String, + roomToken: String, + conversationName: String, + metaData: String?, + compressImages: Boolean = false + ) { val data: Data = Data.Builder() .putString(DEVICE_SOURCE_FILE, fileUri) .putString(ROOM_TOKEN, roomToken) .putString(CONVERSATION_NAME, conversationName) .putString(META_DATA, metaData) + .putBoolean(COMPRESS_IMAGES, compressImages) .build() val uploadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(UploadAndShareFilesWorker::class.java) .setInputData(data) diff --git a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt index 1f25c1c6060..0c62ef535f0 100644 --- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt @@ -1046,6 +1046,7 @@ class SettingsActivity : settingsScreenLockSwitch, settingsScreenSecuritySwitch, settingsIncognitoKeyboardSwitch, + settingsCompressUploadImagesSwitch, settingsPhoneBookIntegrationSwitch, settingsReadPrivacySwitch, settingsTypingStatusSwitch, @@ -1293,6 +1294,7 @@ class SettingsActivity : appPreferences.setIncognitoKeyboard(!isChecked) } + setupCompressUploadImagesSetting() setupPhoneBookIntegrationSetting() binding.settingsScreenSecuritySwitch.isChecked = appPreferences.isScreenSecured @@ -1318,6 +1320,15 @@ class SettingsActivity : } } + private fun setupCompressUploadImagesSetting() { + binding.settingsCompressUploadImagesSwitch.isChecked = appPreferences.compressUploadImages + binding.settingsCompressUploadImages.setOnClickListener { + val isChecked = binding.settingsCompressUploadImagesSwitch.isChecked + binding.settingsCompressUploadImagesSwitch.isChecked = !isChecked + appPreferences.setCompressUploadImages(!isChecked) + } + } + private fun setupPhoneBookIntegrationSetting() { binding.settingsPhoneBookIntegrationSwitch.isChecked = appPreferences.isPhoneBookIntegrationEnabled binding.settingsPhoneBookIntegration.setOnClickListener { diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewDialogCompose.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewDialogCompose.kt new file mode 100644 index 00000000000..9439d90d1ed --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewDialogCompose.kt @@ -0,0 +1,282 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.ui.dialog + +import android.content.Context +import android.content.res.Configuration +import android.text.format.Formatter +import androidx.compose.foundation.isSystemInDarkTheme +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +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.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import coil.compose.AsyncImage +import com.nextcloud.talk.R +import com.nextcloud.talk.utils.FileUtils +import com.nextcloud.talk.utils.ImageCompressor +import com.nextcloud.talk.utils.Mimetype +import com.nextcloud.talk.utils.VideoCompressor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +@Composable +fun FileAttachmentPreviewContent( + files: List, + initialCompressImages: Boolean, + onDismiss: () -> Unit, + onSend: (caption: String, compressImages: Boolean) -> Unit +) { + val context = LocalContext.current + val hasCompressibleMedia = remember(files) { + files.any { isCompressible(FileUtils.resolveMimeType(context, it.toUri())) } + } + var caption by rememberSaveable { mutableStateOf("") } + var compressImages by rememberSaveable { mutableStateOf(hasCompressibleMedia && initialCompressImages) } + + val fileDescriptions by produceState(initialValue = emptyList(), files, compressImages) { + value = withContext(Dispatchers.IO) { + files.map { describeFile(context, it, compressImages) } + } + } + + Surface(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.nc_add_file), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(16.dp) + ) + + HorizontalDivider() + + Column( + modifier = Modifier + .heightIn(max = FILE_LIST_MAX_HEIGHT_DP.dp) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + fileDescriptions.forEach { description -> + FileDescriptionRow(description) + } + } + + OutlinedTextField( + value = caption, + onValueChange = { caption = it }, + label = { Text(stringResource(R.string.nc_caption)) }, + maxLines = 3, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) + + if (hasCompressibleMedia) { + CompressImagesCheckbox( + checked = compressImages, + onCheckedChange = { compressImages = it } + ) + } + + HorizontalDivider() + + ActionButtons( + onDismiss = onDismiss, + onSend = { onSend(caption, compressImages) } + ) + } + } +} + +private fun isCompressible(mimeType: String?): Boolean = + ImageCompressor.isCompressible(mimeType) || VideoCompressor.isCompressible(mimeType) + +@Composable +private fun CompressImagesCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 8.dp, end = 16.dp, bottom = 8.dp) + ) { + Checkbox(checked = checked, onCheckedChange = onCheckedChange) + Text(stringResource(R.string.nc_compress_images)) + } +} + +@Composable +private fun ActionButtons(onDismiss: () -> Unit, onSend: () -> Unit) { + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.nc_no)) + } + TextButton(onClick = onSend) { + Text(stringResource(R.string.nc_yes)) + } + } +} + +private enum class MediaKind { IMAGE, VIDEO, OTHER } + +private data class FileDescription(val uri: String, val name: String, val kind: MediaKind, val detail: String?) + +private const val THUMBNAIL_SIZE_DP = 40 +private const val FILE_LIST_MAX_HEIGHT_DP = 240 + +@Composable +private fun FileDescriptionRow(description: FileDescription) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 4.dp) + ) { + FileThumbnail(description) + Column(modifier = Modifier.padding(start = 8.dp)) { + Text(text = description.name, style = MaterialTheme.typography.bodyLarge) + description.detail?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun FileThumbnail(description: FileDescription) { + when (description.kind) { + MediaKind.IMAGE -> + AsyncImage( + model = description.uri, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(THUMBNAIL_SIZE_DP.dp) + .clip(RoundedCornerShape(4.dp)) + ) + + MediaKind.VIDEO -> + Icon( + painter = painterResource(R.drawable.ic_mimetype_video), + contentDescription = null, + modifier = Modifier.size(THUMBNAIL_SIZE_DP.dp) + ) + + MediaKind.OTHER -> + Icon( + painter = painterResource(R.drawable.baseline_insert_drive_file_24), + contentDescription = null, + modifier = Modifier.size(THUMBNAIL_SIZE_DP.dp) + ) + } +} + +private fun mediaKind(mimeType: String?): MediaKind = + when { + mimeType?.startsWith(Mimetype.IMAGE_PREFIX) == true -> MediaKind.IMAGE + mimeType?.startsWith(Mimetype.VIDEO_PREFIX) == true -> MediaKind.VIDEO + else -> MediaKind.OTHER + } + +@Suppress("ReturnCount") +private fun describeFile(context: Context, uriString: String, compress: Boolean): FileDescription { + val uri = uriString.toUri() + val name = FileUtils.getFileName(uri, context) + val kind = mediaKind(FileUtils.resolveMimeType(context, uri)) + val file = FileUtils.getFileFromUri(context, uri) ?: return FileDescription(uriString, name, kind, null) + val sizeOnly = formatSize(context, file.length()) + + val detail = when (kind) { + MediaKind.IMAGE -> describeImageDetail(context, file, compress, sizeOnly) + MediaKind.VIDEO -> describeVideoDetail(context, file, compress, sizeOnly) + MediaKind.OTHER -> sizeOnly + } + return FileDescription(uriString, name, kind, detail) +} + +@Suppress("ReturnCount") +private fun describeImageDetail(context: Context, file: File, compress: Boolean, fallback: String): String { + val original = ImageCompressor.readImageInfo(file) ?: return fallback + if (!compress) return describeMedia(context, original.width, original.height, original.sizeBytes) + val compressed = ImageCompressor.estimateCompression(file) + ?: return describeMedia(context, original.width, original.height, original.sizeBytes) + return describeMedia(context, compressed.width, compressed.height, compressed.sizeBytes) +} + +@Suppress("ReturnCount") +private fun describeVideoDetail(context: Context, file: File, compress: Boolean, fallback: String): String { + val original = VideoCompressor.readVideoInfo(file) ?: return fallback + if (!compress) return describeMedia(context, original.width, original.height, original.sizeBytes) + val compressed = VideoCompressor.estimateCompression(file) + ?: return describeMedia(context, original.width, original.height, original.sizeBytes) + return describeMedia(context, compressed.width, compressed.height, compressed.sizeBytes) +} + +private fun describeMedia(context: Context, width: Int, height: Int, sizeBytes: Long): String = + "$width×$height, ${formatSize(context, sizeBytes)}" + +private fun formatSize(context: Context, sizeBytes: Long): String = Formatter.formatShortFileSize(context, sizeBytes) + +@Preview(name = "Light Mode", showBackground = true) +@Preview( + name = "Dark Mode", + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL +) +@Composable +private fun FileAttachmentPreviewContentPreview() { + val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + MaterialTheme(colorScheme = colorScheme) { + FileAttachmentPreviewContent( + files = listOf( + "file:///sdcard/DCIM/photo.jpg", + "file:///sdcard/DCIM/video.mp4", + "file:///sdcard/Documents/report.pdf" + ), + initialCompressImages = true, + onDismiss = {}, + onSend = { _, _ -> } + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewFragment.kt index c23bba00a35..43ae304b2ff 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewFragment.kt @@ -11,85 +11,99 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.WindowManager +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.DialogFragment import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.databinding.DialogFileAttachmentPreviewBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil +import com.nextcloud.talk.utils.preferences.AppPreferences import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) class FileAttachmentPreviewFragment : DialogFragment() { - private lateinit var files: String private lateinit var filesList: ArrayList - private var uploadFiles: (files: MutableList, caption: String) -> Unit = { _, _ -> } - lateinit var binding: DialogFileAttachmentPreviewBinding + private var uploadFiles: (files: MutableList, caption: String, compressImages: Boolean) -> Unit = + { _, _, _ -> } + private var composeView: ComposeView? = null @Inject - lateinit var permissionUtil: PlatformPermissionUtil + lateinit var viewThemeUtils: ViewThemeUtils @Inject - lateinit var viewThemeUtils: ViewThemeUtils + lateinit var appPreferences: AppPreferences - fun setListener(uploadFiles: (files: MutableList, caption: String) -> Unit) { + fun setListener(uploadFiles: (files: MutableList, caption: String, compressImages: Boolean) -> Unit) { this.uploadFiles = uploadFiles } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { arguments?.let { - files = it.getString(FILE_NAMES_ARG, "") filesList = it.getStringArrayList(FILES_TO_UPLOAD_ARG)!! } - binding = DialogFileAttachmentPreviewBinding.inflate(layoutInflater) - return MaterialAlertDialogBuilder(requireContext()).setView(binding.root).create() + composeView = ComposeView(requireContext()) + return MaterialAlertDialogBuilder(requireContext()).setView(composeView).create() } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - setUpViews() - setUpListeners() - return inflater.inflate(R.layout.dialog_file_attachment_preview, container, false) - } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = + composeView - private fun setUpViews() { - binding.dialogFileAttachmentPreviewFilenames.text = files - viewThemeUtils.material.colorMaterialButtonPrimaryBorderless(binding.buttonClose) - viewThemeUtils.material.colorMaterialButtonPrimaryBorderless(binding.buttonSend) - viewThemeUtils.platform.colorViewBackground(binding.root) - viewThemeUtils.material.colorTextInputLayout(binding.dialogFileAttachmentPreviewLayout) + @Suppress("DEPRECATION") + override fun onStart() { + super.onStart() + // Dialog.show() re-applies FLAG_ALT_FOCUSABLE_IM from the theme after onCreateDialog() + // runs, so clearing it must happen once the dialog window is actually up. Without this, + // the system never routes the keyboard to this window, even while it holds input focus. + dialog?.window?.apply { + setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) + } } - private fun setUpListeners() { - binding.buttonClose.setOnClickListener { - dismiss() - } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - binding.buttonSend.setOnClickListener { - val caption: String = binding.dialogFileAttachmentPreviewCaption.text.toString() - uploadFiles(filesList, caption) - dismiss() + composeView?.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + MaterialTheme(colorScheme = viewThemeUtils.getColorScheme(requireActivity())) { + FileAttachmentPreviewContent( + files = filesList, + initialCompressImages = appPreferences.compressUploadImages, + onDismiss = { dismiss() }, + onSend = { caption, compressImages -> + uploadFiles(filesList, caption, compressImages) + dismiss() + } + ) + } + } } } + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } + companion object { - private const val FILE_NAMES_ARG = "FILE_NAMES_ARG" private const val FILES_TO_UPLOAD_ARG = "FILES_TO_UPLOAD_ARG" @JvmStatic - fun newInstance(filenames: String, filesToUpload: MutableList): FileAttachmentPreviewFragment { + fun newInstance(filesToUpload: MutableList): FileAttachmentPreviewFragment { val fileAttachmentFragment = FileAttachmentPreviewFragment() val args = Bundle() - args.putString(FILE_NAMES_ARG, filenames) args.putStringArrayList(FILES_TO_UPLOAD_ARG, ArrayList(filesToUpload)) fileAttachmentFragment.arguments = args return fileAttachmentFragment } - val TAG: String = FilterConversationFragment::class.java.simpleName + val TAG: String = FileAttachmentPreviewFragment::class.java.simpleName } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/BitmapShrinker.kt b/app/src/main/java/com/nextcloud/talk/utils/BitmapShrinker.kt index 1481b9a6f2b..410f98ce080 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/BitmapShrinker.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/BitmapShrinker.kt @@ -8,20 +8,70 @@ package com.nextcloud.talk.utils import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.ImageDecoder import android.graphics.Matrix +import android.os.Build import android.util.Log +import androidx.annotation.RequiresApi import androidx.exifinterface.media.ExifInterface +import java.io.File import java.io.IOException +import kotlin.math.roundToInt object BitmapShrinker { - private val TAG = "BitmapShrinker" + private const val TAG = "BitmapShrinker" private const val DEGREES_90 = 90f private const val DEGREES_180 = 180f private const val DEGREES_270 = 270f + /** + * Decodes the image at [path], downscaled to fit within [reqWidth]x[reqHeight]. + * + * On API 28+ this uses [ImageDecoder], which scales in a single pass and applies EXIF + * orientation for JPEG/HEIC automatically. Older platform versions fall back to the + * [BitmapFactory] two-pass sampling approach with manual EXIF rotation. + * + * [requireSoftwareBitmap] must be set when the result will be read or re-encoded (e.g. via + * [Bitmap.compress]), since [Bitmap.Config.HARDWARE] bitmaps do not support that. + */ @JvmStatic - fun shrinkBitmap(path: String, reqWidth: Int, reqHeight: Int): Bitmap? { + @JvmOverloads + fun shrinkBitmap(path: String, reqWidth: Int, reqHeight: Int, requireSoftwareBitmap: Boolean = false): Bitmap? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + decodeWithImageDecoder(path, reqWidth, reqHeight, requireSoftwareBitmap) + } else { + decodeWithBitmapFactory(path, reqWidth, reqHeight) + } + + @RequiresApi(Build.VERSION_CODES.P) + private fun decodeWithImageDecoder( + path: String, + reqWidth: Int, + reqHeight: Int, + requireSoftwareBitmap: Boolean + ): Bitmap? = + try { + val source = ImageDecoder.createSource(File(path)) + ImageDecoder.decodeBitmap(source) { decoder, info, _ -> + val (width, height) = fitWithinBounds(info.size.width, info.size.height, reqWidth, reqHeight) + decoder.setTargetSize(width, height) + if (requireSoftwareBitmap) { + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + } + } + } catch (e: IOException) { + Log.e(TAG, "Failed to decode bitmap from path: $path", e) + null + } + + private fun fitWithinBounds(width: Int, height: Int, reqWidth: Int, reqHeight: Int): Pair { + if (width <= reqWidth && height <= reqHeight) return width to height + val scale = minOf(reqWidth.toDouble() / width, reqHeight.toDouble() / height) + return maxOf(1, (width * scale).roundToInt()) to maxOf(1, (height * scale).roundToInt()) + } + + private fun decodeWithBitmapFactory(path: String, reqWidth: Int, reqHeight: Int): Bitmap? { val bitmap = decodeBitmap(path, reqWidth, reqHeight) ?: return null return rotateBitmap(path, bitmap) } @@ -78,8 +128,8 @@ object BitmapShrinker { bitmap, 0, 0, - bitmap.getWidth(), - bitmap.getHeight(), + bitmap.width, + bitmap.height, matrix, true ) diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt index ee9382e6b8d..fe6b4ea3af4 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt @@ -14,6 +14,7 @@ import android.content.Context import android.net.Uri import android.provider.OpenableColumns import android.util.Log +import android.webkit.MimeTypeMap import java.io.File import java.io.FileNotFoundException import java.io.FileOutputStream @@ -201,6 +202,17 @@ object FileUtils { return filename } + /** + * Resolves the MIME type of [uri]. [ContentResolver.getType] only resolves `content://` URIs, so for + * `file://` URIs (e.g. the in-app camera writes plain files) this falls back to guessing from the + * file extension. + */ + fun resolveMimeType(context: Context, uri: Uri): String? = + context.contentResolver.getType(uri) + ?: MimeTypeMap.getSingleton().getMimeTypeFromExtension( + MimeTypeMap.getFileExtensionFromUrl(uri.toString()).lowercase() + ) + @JvmStatic fun md5Sum(file: File): String { val temp = file.name + file.lastModified() + file.length() diff --git a/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt b/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt new file mode 100644 index 00000000000..311985625f5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt @@ -0,0 +1,87 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2017-2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException + +/** + * Downscales and re-encodes images as JPEG to reduce their upload size. + */ +object ImageCompressor { + + private val TAG = ImageCompressor::class.java.simpleName + private const val MAX_DIMENSION = 2048 + private const val JPEG_QUALITY = 80 + private const val COMPRESSED_FILE_SUFFIX = "_compressed.jpg" + + data class ImageInfo(val width: Int, val height: Int, val sizeBytes: Long) + + fun isCompressible(mimeType: String?): Boolean = + mimeType != null && mimeType.startsWith(Mimetype.IMAGE_PREFIX) && mimeType != Mimetype.IMAGE_GIF + + /** + * Reads the on-disk size and pixel dimensions of [sourceFile] without fully decoding it. + */ + fun readImageInfo(sourceFile: File): ImageInfo? { + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(sourceFile.absolutePath, options) + if (options.outWidth <= 0 || options.outHeight <= 0) return null + return ImageInfo(options.outWidth, options.outHeight, sourceFile.length()) + } + + /** + * Computes the dimensions and size [sourceFile] would have after [compress], without writing anything to disk. + */ + fun estimateCompression(sourceFile: File): ImageInfo? { + val (bitmap, bytes) = encode(sourceFile) ?: return null + return try { + ImageInfo(bitmap.width, bitmap.height, bytes.size.toLong()) + } finally { + bitmap.recycle() + } + } + + /** + * Returns a downscaled, JPEG re-encoded copy of [sourceFile] in the app cache directory, + * or null if the image could not be decoded or written. + */ + fun compress(context: Context, sourceFile: File): File? { + val (bitmap, bytes) = encode(sourceFile) ?: return null + return try { + val compressedFile = File(context.cacheDir, sourceFile.nameWithoutExtension + COMPRESSED_FILE_SUFFIX) + compressedFile.writeBytes(bytes) + compressedFile + } catch (e: IOException) { + Log.e(TAG, "failed to write compressed image for ${sourceFile.name}", e) + null + } finally { + bitmap.recycle() + } + } + + private fun encode(sourceFile: File): Pair? { + val bitmap = BitmapShrinker.shrinkBitmap( + sourceFile.absolutePath, + MAX_DIMENSION, + MAX_DIMENSION, + requireSoftwareBitmap = true + ) + if (bitmap == null) { + Log.w(TAG, "could not decode ${sourceFile.name} for compression") + return null + } + val output = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, output) + return bitmap to output.toByteArray() + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/VideoCompressor.kt b/app/src/main/java/com/nextcloud/talk/utils/VideoCompressor.kt new file mode 100644 index 00000000000..b8e5f9dcd63 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/VideoCompressor.kt @@ -0,0 +1,208 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2017-2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.content.Context +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.os.Handler +import android.os.HandlerThread +import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.effect.Presentation +import androidx.media3.transformer.AudioEncoderSettings +import androidx.media3.transformer.Composition +import androidx.media3.transformer.DefaultEncoderFactory +import androidx.media3.transformer.EditedMediaItem +import androidx.media3.transformer.Effects +import androidx.media3.transformer.ExportException +import androidx.media3.transformer.ExportResult +import androidx.media3.transformer.ProgressHolder +import androidx.media3.transformer.Transformer +import androidx.media3.transformer.VideoEncoderSettings +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * Downscales videos to [TARGET_SHORT_SIDE]p and re-encodes them to reduce their upload size. + */ +object VideoCompressor { + + private val TAG = VideoCompressor::class.java.simpleName + private const val TARGET_SHORT_SIDE = 540 + private const val TARGET_VIDEO_BITRATE_BPS = 600_000L + private const val TARGET_AUDIO_BITRATE_BPS = 64_000L + private const val BITS_PER_BYTE = 8L + private const val MILLIS_PER_SECOND = 1000L + private const val MIN_TIMEOUT_MS = 30_000L + private const val TIMEOUT_MULTIPLIER = 3L + private const val COMPRESSED_FILE_SUFFIX = "_compressed.mp4" + private const val PROGRESS_POLL_INTERVAL_MS = 300L + + data class VideoInfo(val width: Int, val height: Int, val durationMs: Long, val sizeBytes: Long) + + fun isCompressible(mimeType: String?): Boolean = mimeType != null && mimeType.startsWith(Mimetype.VIDEO_PREFIX) + + /** + * Reads the on-disk size, pixel dimensions and duration of [sourceFile] via metadata only. + */ + @Suppress("TooGenericExceptionCaught") + fun readVideoInfo(sourceFile: File): VideoInfo? { + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(sourceFile.absolutePath) + val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() + val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() + val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() + if (width == null || height == null || durationMs == null) { + null + } else { + VideoInfo(width, height, durationMs, sourceFile.length()) + } + } catch (e: Exception) { + Log.w(TAG, "could not read video info for ${sourceFile.name}", e) + null + } finally { + retriever.release() + } + } + + /** + * Estimates the dimensions and size [sourceFile] would have after [compress], based on a fixed + * target bitrate rather than a real (expensive) transcode. + */ + fun estimateCompression(sourceFile: File): VideoInfo? { + val original = readVideoInfo(sourceFile) ?: return null + val (targetWidth, targetHeight) = scaledDimensions(original.width, original.height) + val estimatedBytes = (TARGET_VIDEO_BITRATE_BPS + TARGET_AUDIO_BITRATE_BPS) / BITS_PER_BYTE * + (original.durationMs / MILLIS_PER_SECOND) + return VideoInfo(targetWidth, targetHeight, original.durationMs, minOf(estimatedBytes, original.sizeBytes)) + } + + /** + * Returns a downscaled, re-encoded copy of [sourceFile] in the app cache directory, or null if + * the video was already small enough, could not be read, or transcoding failed/timed out. + * + * [onProgress] is invoked with a 0-100 value from a background thread while transcoding runs. + */ + @Suppress("ReturnCount") + fun compress(context: Context, sourceFile: File, onProgress: (Int) -> Unit = {}): File? { + val original = readVideoInfo(sourceFile) ?: return null + if (minOf(original.width, original.height) <= TARGET_SHORT_SIDE) return null + + val outputFile = File(context.cacheDir, sourceFile.nameWithoutExtension + COMPRESSED_FILE_SUFFIX) + outputFile.delete() + + val success = runTransform(context, sourceFile, outputFile, original.durationMs, onProgress) + return if (success && outputFile.exists()) outputFile else null + } + + private fun scaledDimensions(width: Int, height: Int): Pair { + val shortSide = minOf(width, height) + if (shortSide <= TARGET_SHORT_SIDE) return width to height + val scale = TARGET_SHORT_SIDE.toDouble() / shortSide + return (width * scale).roundToInt() to (height * scale).roundToInt() + } + + /** + * Media3's [Transformer] must be created and driven from a thread with a prepared [Looper][android.os.Looper]. + * Using a dedicated, short-lived [HandlerThread] here (instead of the app's main thread) keeps any number of + * concurrent/queued compressions from ever delaying UI rendering. + */ + @OptIn(UnstableApi::class) + @Suppress("ReturnCount") + private fun runTransform( + context: Context, + sourceFile: File, + outputFile: File, + durationMs: Long, + onProgress: (Int) -> Unit + ): Boolean { + val handlerThread = HandlerThread("VideoCompressor-${sourceFile.name}").apply { start() } + try { + val latch = CountDownLatch(1) + var success = false + val transformerRef = AtomicReference() + val handler = Handler(handlerThread.looper) + + handler.post { + val encoderFactory = DefaultEncoderFactory.Builder(context) + .setRequestedVideoEncoderSettings( + VideoEncoderSettings.Builder().setBitrate(TARGET_VIDEO_BITRATE_BPS.toInt()).build() + ) + .setRequestedAudioEncoderSettings( + AudioEncoderSettings.Builder().setBitrate(TARGET_AUDIO_BITRATE_BPS.toInt()).build() + ) + .setEnableFallback(true) + .build() + + val transformer = Transformer.Builder(context) + .setEncoderFactory(encoderFactory) + .addListener(object : Transformer.Listener { + override fun onCompleted(composition: Composition, exportResult: ExportResult) { + success = true + latch.countDown() + } + + override fun onError( + composition: Composition, + exportResult: ExportResult, + exportException: ExportException + ) { + Log.e(TAG, "video compression failed for ${sourceFile.name}", exportException) + latch.countDown() + } + }) + .build() + transformerRef.set(transformer) + + val editedMediaItem = EditedMediaItem.Builder(MediaItem.fromUri(Uri.fromFile(sourceFile))) + .setEffects(Effects(emptyList(), listOf(Presentation.createForShortSide(TARGET_SHORT_SIDE)))) + .build() + + transformer.start(editedMediaItem, outputFile.absolutePath) + pollProgress(handler, transformerRef.get(), latch, onProgress) + } + + val timeoutMs = max(MIN_TIMEOUT_MS, durationMs * TIMEOUT_MULTIPLIER) + val completedInTime = latch.await(timeoutMs, TimeUnit.MILLISECONDS) + if (!completedInTime) { + Log.w(TAG, "video compression timed out for ${sourceFile.name}") + handler.post { transformerRef.get()?.cancel() } + } + return completedInTime && success + } finally { + handlerThread.quitSafely() + } + } + + /** + * Polls [Transformer.getProgress] on its own looper thread every [PROGRESS_POLL_INTERVAL_MS] + * until [latch] counts down (transcode completed, failed, or was cancelled). + */ + @OptIn(UnstableApi::class) + private fun pollProgress( + handler: Handler, + transformer: Transformer?, + latch: CountDownLatch, + onProgress: (Int) -> Unit + ) { + if (transformer == null || latch.count == 0L) return + + val progressHolder = ProgressHolder() + if (transformer.getProgress(progressHolder) == Transformer.PROGRESS_STATE_AVAILABLE) { + onProgress(progressHolder.progress) + } + handler.postDelayed({ pollProgress(handler, transformer, latch, onProgress) }, PROGRESS_POLL_INTERVAL_MS) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java index 9cbce99381f..a81dca8297a 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java @@ -134,6 +134,10 @@ public interface AppPreferences { void setPhoneBookIntegration(boolean value); + boolean getCompressUploadImages(); + + void setCompressUploadImages(boolean value); + // TODO Remove in 13.0.0 void removeLinkPreviews(); diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt index 33605e7ca26..6c363934a2d 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt @@ -353,6 +353,18 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { } } + override fun getCompressUploadImages(): Boolean = + runBlocking { + async { readBoolean(COMPRESS_UPLOAD_IMAGES, defaultValue = true).first() } + }.getCompleted() + + override fun setCompressUploadImages(value: Boolean) = + runBlocking { + async { + writeBoolean(COMPRESS_UPLOAD_IMAGES, value) + } + } + override fun removeLinkPreviews() = runBlocking { async { @@ -675,6 +687,7 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { const val INCOGNITO_KEYBOARD = "incognito_keyboard" const val SHOW_ECOSYSTEM = "SHOW_ECOSYSTEM" const val PHONE_BOOK_INTEGRATION = "phone_book_integration" + const val COMPRESS_UPLOAD_IMAGES = "compress_upload_images" const val LINK_PREVIEWS = "link_previews" const val SCREEN_LOCK_TIMEOUT = "screen_lock_timeout" const val LOCK_TIMESTAMP = "lock_timestamp" diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 6f496a4e6a0..5efd7a1522e 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -862,6 +862,40 @@ android:textSize="@dimen/headline_text_size" android:textStyle="bold" /> + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21af10e0da6..bb123907519 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -159,6 +159,8 @@ How to translate with transifex: screen_security Incognito keyboard Instructs keyboard to disable personalized learning (without guarantees) + Compress images and videos before upload + Reduce file size of shared images and videos by default read_privacy Tap to unlock Locked @@ -665,6 +667,8 @@ How to translate with transifex: Gallery %1$s to %2$s - %3$s\%% + Compressing + %1$s - %2$s\%% Failure Failed to upload %1$s Permission for file access is required @@ -938,6 +942,7 @@ How to translate with transifex: started a call Error 429 Too Many Requests Caption + Compress images and videos Retrieval failed Languages could not be retrieved Edit