From 576747ed7b44839e74a5bc81fe7d5c7f9d539651 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 10 Jul 2026 11:29:07 +0200 Subject: [PATCH 1/4] Compress images and videos before upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional compression step to the chat attachment upload flow, covering both gallery-picked and camera-captured photos/videos. - Add ImageCompressor/VideoCompressor utilities: images are downscaled and re-encoded as JPEG; videos are downscaled to 540p short side and transcoded via Media3 Transformer on a dedicated HandlerThread so the main thread is never blocked. - Migrate the file attachment preview dialog to Jetpack Compose, adding thumbnails, per-file dimensions/size preview, and a "Compress images and videos" checkbox that updates the preview live. - Persist the compression choice as a user preference (default on), with a toggle in Settings. - Extend the existing upload notification to also show live progress ("Compressing… X%") while a video is being transcoded, by polling Transformer.getProgress(), before it turns into the upload progress notification. - Modernize BitmapShrinker to use ImageDecoder on API 28+ (single-pass decode+scale, automatic EXIF rotation for JPEG/HEIC), falling back to the previous BitmapFactory-based path on API 26/27. - Fix MIME-type resolution for file:// URIs (e.g. camera-captured photos/videos), which previously failed dialog/thumbnail detection since ContentResolver.getType() only handles content:// URIs. Assisted-by: Claude Code 2.1.199:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- app/build.gradle.kts | 2 + .../com/nextcloud/talk/chat/ChatActivity.kt | 63 ++-- .../talk/chat/viewmodels/ChatViewModel.kt | 6 +- .../talk/jobs/UploadAndShareFilesWorker.kt | 101 ++++++- .../talk/settings/SettingsActivity.kt | 11 + .../FileAttachmentPreviewDialogCompose.kt | 282 ++++++++++++++++++ .../dialog/FileAttachmentPreviewFragment.kt | 86 +++--- .../nextcloud/talk/utils/BitmapShrinker.kt | 58 +++- .../com/nextcloud/talk/utils/FileUtils.kt | 12 + .../nextcloud/talk/utils/ImageCompressor.kt | 87 ++++++ .../nextcloud/talk/utils/VideoCompressor.kt | 208 +++++++++++++ .../utils/preferences/AppPreferences.java | 4 + .../utils/preferences/AppPreferencesImpl.kt | 13 + app/src/main/res/layout/activity_settings.xml | 34 +++ .../layout/dialog_file_attachment_preview.xml | 95 ------ app/src/main/res/values/strings.xml | 5 + 16 files changed, 878 insertions(+), 189 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewDialogCompose.kt create mode 100644 app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt create mode 100644 app/src/main/java/com/nextcloud/talk/utils/VideoCompressor.kt delete mode 100644 app/src/main/res/layout/dialog_file_attachment_preview.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a48bf9c3a7..2097b6fb00 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.32") 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 2710b73b47..4028ddba63 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) { @@ -2425,18 +2417,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) @@ -2544,27 +2528,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 + ) } } @@ -3944,13 +3918,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, @@ -3958,7 +3934,8 @@ class ChatActivity : caption, roomToken, replyToMessageId, - displayName + displayName, + compressImages ) 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 0eb664ba8f..434b1160dc 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 e84edc0537..9dffb5cca7 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 1f25c1c606..0c62ef535f 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 0000000000..9439d90d1e --- /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 c23bba00a3..43ae304b2f 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 1481b9a6f2..410f98ce08 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 ee9382e6b8..fe6b4ea3af 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 0000000000..311985625f --- /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 0000000000..b8e5f9dcd6 --- /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 9cbce99381..a81dca8297 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 33605e7ca2..6c363934a2 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 6f496a4e6a..5efd7a1522 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 21af10e0da..bb12390751 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 From 5b696c3ddff1a8807dbeebf6ff99c21135931007 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 30 Jul 2026 17:14:24 +0200 Subject: [PATCH 2/4] add new upload dialog with previews Assisted-by: Claude Code 2.1.199:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 8 +- .../FileAttachmentPreviewDialogCompose.kt | 959 ++++++++++++++++-- .../dialog/FileAttachmentPreviewFragment.kt | 35 +- app/src/main/res/values/strings.xml | 5 + 4 files changed, 912 insertions(+), 95 deletions(-) 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 4028ddba63..47bec383ad 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -2331,7 +2331,8 @@ class ChatActivity : require(filesToUpload.isNotEmpty()) val newFragment = FileAttachmentPreviewFragment.newInstance( - filesToUpload.map { it.toString() }.toMutableList() + filesToUpload.map { it.toString() }.toMutableList(), + currentConversation?.displayName ?: "" ) newFragment.setListener { files, caption, compressImages -> uploadFiles(files, caption, compressImages) @@ -2417,7 +2418,10 @@ class ChatActivity : } if (permissionUtil.isFilesPermissionGranted()) { - val newFragment = FileAttachmentPreviewFragment.newInstance(filesToUpload) + val newFragment = FileAttachmentPreviewFragment.newInstance( + filesToUpload, + currentConversation?.displayName ?: "" + ) newFragment.setListener { files, caption, compressImages -> uploadFiles(files, caption, compressImages) } 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 index 9439d90d1e..ce2ef42925 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewDialogCompose.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewDialogCompose.kt @@ -6,47 +6,115 @@ */ package com.nextcloud.talk.ui.dialog +import android.Manifest import android.content.Context +import android.content.pm.PackageManager import android.content.res.Configuration +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.net.Uri import android.text.format.Formatter +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.annotation.OptIn +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints 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 +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Checkbox +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton 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.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState 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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.zIndex +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider import androidx.core.net.toUri +import androidx.exifinterface.media.ExifInterface +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView import coil.compose.AsyncImage import com.nextcloud.talk.R import com.nextcloud.talk.utils.FileUtils @@ -54,72 +122,155 @@ 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.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.roundToInt +@Suppress("LongMethod", "LongParameterList") @Composable fun FileAttachmentPreviewContent( files: List, + conversationName: String, initialCompressImages: Boolean, onDismiss: () -> Unit, - onSend: (caption: String, compressImages: Boolean) -> Unit + onSend: (files: List, caption: String, compressImages: Boolean) -> Unit ) { val context = LocalContext.current - val hasCompressibleMedia = remember(files) { - files.any { isCompressible(FileUtils.resolveMimeType(context, it.toUri())) } - } + val currentFiles = remember(files) { mutableStateListOf().apply { addAll(files) } } + val hasCompressibleMedia = currentFiles.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) { + // Keyed by the set of files (not their order), so dragging to reorder doesn't trigger a re-describe + // round trip through Dispatchers.IO — that async gap was causing a brief mismatch between the + // still-old-ordered list and the already-updated drag state. Reordering just re-maps synchronously below. + val descriptionsByUri by produceState( + initialValue = emptyMap(), + currentFiles.toSet(), + compressImages + ) { + val filesSnapshot = currentFiles.toList() value = withContext(Dispatchers.IO) { - files.map { describeFile(context, it, compressImages) } + filesSnapshot.associateWith { describeFile(context, it, compressImages) } } } + val fileDescriptions = currentFiles.mapNotNull { descriptionsByUri[it] } - 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) - ) + val pagerState = rememberPagerState(pageCount = { fileDescriptions.size }) + val coroutineScope = rememberCoroutineScope() - HorizontalDivider() + var previousFileUris by remember { mutableStateOf(fileDescriptions.map { it.uri }) } + LaunchedEffect(fileDescriptions.map { it.uri }) { + val uris = fileDescriptions.map { it.uri } + if (uris != previousFileUris) { + val activeUri = previousFileUris.getOrNull(pagerState.currentPage) + val newIndex = uris.indexOf(activeUri) + if (newIndex >= 0 && newIndex != pagerState.currentPage) { + pagerState.scrollToPage(newIndex) + } + previousFileUris = uris + } + } - Column( + val pickMoreMedia = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia(MAX_ADD_MORE_FILES) + ) { uris -> + val newFiles = uris.map { it.toString() }.filterNot { it in currentFiles } + currentFiles.addAll(newFiles) + } + + val cameraCapture = rememberCameraCaptureActions(currentFiles) + + LaunchedEffect(currentFiles.size) { + if (currentFiles.isEmpty()) { + onDismiss() + } + } + + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + .imePadding() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .heightIn(max = FILE_LIST_MAX_HEIGHT_DP.dp) - .verticalScroll(rememberScrollState()) + .fillMaxWidth() .padding(16.dp) ) { - fileDescriptions.forEach { description -> - FileDescriptionRow(description) + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(R.string.nc_common_dismiss) + ) } - } - OutlinedTextField( - value = caption, - onValueChange = { caption = it }, - label = { Text(stringResource(R.string.nc_caption)) }, - maxLines = 3, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = conversationName, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = stringResource(R.string.nc_add_file), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } - if (hasCompressibleMedia) { - CompressImagesCheckbox( - checked = compressImages, - onCheckedChange = { compressImages = it } - ) + if (hasCompressibleMedia) { + HighQualityToggle( + checked = !compressImages, + onCheckedChange = { highQuality -> compressImages = !highQuality } + ) + } } HorizontalDivider() - ActionButtons( - onDismiss = onDismiss, - onSend = { onSend(caption, compressImages) } + if (fileDescriptions.isNotEmpty()) { + HeroPreview( + descriptions = fileDescriptions, + pagerState = pagerState, + modifier = Modifier.weight(1f) + ) + + ThumbnailStrip( + descriptions = fileDescriptions, + selectedIndex = pagerState.currentPage, + onSelect = { index -> coroutineScope.launch { pagerState.scrollToPage(index) } }, + onRemove = { uri -> currentFiles.remove(uri) }, + onReorder = { from, to -> + if (from != to && from in currentFiles.indices && to in currentFiles.indices) { + val item = currentFiles.removeAt(from) + currentFiles.add(to, item) + } + }, + onAddMore = { + pickMoreMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo)) + }, + onTakePhoto = cameraCapture.onTakePhoto, + onTakeVideo = cameraCapture.onTakeVideo + ) + } + + CaptionInputBar( + caption = caption, + onCaptionChange = { caption = it }, + sendEnabled = currentFiles.isNotEmpty(), + onSend = { onSend(currentFiles.toList(), caption, compressImages) } ) } } @@ -128,87 +279,636 @@ fun FileAttachmentPreviewContent( private fun isCompressible(mimeType: String?): Boolean = ImageCompressor.isCompressible(mimeType) || VideoCompressor.isCompressible(mimeType) +private enum class CameraCaptureType { PHOTO, VIDEO } + +private const val CAMERA_FILE_DATE_PATTERN = "yyyy-MM-dd HH-mm-ss" + +private fun createCameraOutputUri(context: Context, type: CameraCaptureType): Uri { + val outputDir = FileUtils.getSharedAttachmentsDirectory(context.cacheDir) ?: context.cacheDir + val timestamp = SimpleDateFormat(CAMERA_FILE_DATE_PATTERN, Locale.ROOT).format(Date()) + val extension = if (type == CameraCaptureType.PHOTO) "jpg" else "mp4" + val file = File(outputDir, "$timestamp.$extension") + return FileProvider.getUriForFile(context, context.packageName, file) +} + +private data class CameraCaptureActions(val onTakePhoto: () -> Unit, val onTakeVideo: () -> Unit) + @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)) +private fun rememberCameraCaptureActions(currentFiles: MutableList): CameraCaptureActions { + val context = LocalContext.current + var pendingCameraUri by remember { mutableStateOf(null) } + var pendingCameraPermissionType by remember { mutableStateOf(null) } + + val onCaptureResult: (Boolean) -> Unit = { success -> + val uri = pendingCameraUri + if (success && uri != null) { + currentFiles.add(uri.toString()) + } + pendingCameraUri = null } + + val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture(), onCaptureResult) + val captureVideo = rememberLauncherForActivityResult(ActivityResultContracts.CaptureVideo(), onCaptureResult) + + fun launchCameraCapture(type: CameraCaptureType) { + val uri = createCameraOutputUri(context, type) + pendingCameraUri = uri + when (type) { + CameraCaptureType.PHOTO -> takePicture.launch(uri) + CameraCaptureType.VIDEO -> captureVideo.launch(uri) + } + } + + val cameraPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + val type = pendingCameraPermissionType + pendingCameraPermissionType = null + if (granted && type != null) { + launchCameraCapture(type) + } + } + + fun requestCameraCapture(type: CameraCaptureType) { + val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + if (hasPermission) { + launchCameraCapture(type) + } else { + pendingCameraPermissionType = type + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + return CameraCaptureActions( + onTakePhoto = { requestCameraCapture(CameraCaptureType.PHOTO) }, + onTakeVideo = { requestCameraCapture(CameraCaptureType.VIDEO) } + ) +} + +@Composable +private fun HighQualityToggle(checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + val description = stringResource(R.string.nc_hq_toggle_description) + FilterChip( + selected = checked, + onClick = { onCheckedChange(!checked) }, + label = { Text(stringResource(R.string.nc_hq_toggle)) }, + leadingIcon = if (checked) { + { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(FilterChipDefaults.IconSize) + ) + } + } else { + null + }, + modifier = Modifier.semantics { contentDescription = description } + ) } @Composable -private fun ActionButtons(onDismiss: () -> Unit, onSend: () -> Unit) { +private fun CaptionInputBar( + caption: String, + onCaptionChange: (String) -> Unit, + sendEnabled: Boolean, + onSend: () -> Unit +) { + val shape = RoundedCornerShape(dimensionResource(R.dimen.button_corner_radius)) Row( - horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp) + .padding(16.dp) + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant) ) { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.nc_no)) + TextField( + value = caption, + onValueChange = onCaptionChange, + placeholder = { Text(stringResource(R.string.nc_caption)) }, + maxLines = 3, + colors = TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent + ), + modifier = Modifier + .weight(1f) + .heightIn(min = dimensionResource(R.dimen.min_size_clickable_area)) + ) + + val sendTint = if (sendEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant } - TextButton(onClick = onSend) { - Text(stringResource(R.string.nc_yes)) + IconButton(onClick = onSend, enabled = sendEnabled) { + Icon( + painter = painterResource(R.drawable.ic_send_24px), + contentDescription = stringResource(R.string.nc_description_send_message_button), + tint = sendTint + ) } } } private enum class MediaKind { IMAGE, VIDEO, OTHER } -private data class FileDescription(val uri: String, val name: String, val kind: MediaKind, val detail: String?) +private data class FileDescription( + val uri: String, + val name: String, + val kind: MediaKind, + val detail: String?, + val aspectRatio: Float? = null, + val videoThumbnail: Bitmap? = null +) -private const val THUMBNAIL_SIZE_DP = 40 -private const val FILE_LIST_MAX_HEIGHT_DP = 240 +private const val MAX_ADD_MORE_FILES = 10 +private const val HERO_MAX_HEIGHT_DP = 480 +private const val HERO_VERTICAL_SPACING_DP = 12 +private const val HERO_ICON_SIZE_DP = 96 +private const val DETAIL_CHIP_CORNER_RADIUS_PERCENT = 50 +private const val DETAIL_CHIP_RIPPLE_DURATION_MS = 300L +private const val PLAY_BUTTON_SIZE_DP = 56 +private const val PLAY_ICON_SIZE_DP = 32 +private const val STRIP_THUMBNAIL_SIZE_DP = 64 +private const val STRIP_ICON_SIZE_DP = 28 +private const val STRIP_PLAY_ICON_SIZE_DP = 28 +private const val STRIP_ITEM_SPACING_DP = 8 +private const val SELECTED_BORDER_WIDTH_DP = 2 +private const val THUMBNAIL_CORNER_RADIUS_DP = 16 +private const val THUMBNAIL_ICON_SIZE_DP = 48 +private const val VIDEO_FRAME_MAX_DIMENSION_PX = 720 @Composable -private fun FileDescriptionRow(description: FileDescription) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 4.dp) +private fun HeroPreview(descriptions: List, pagerState: PagerState, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(vertical = HERO_VERTICAL_SPACING_DP.dp), + contentAlignment = Alignment.Center + ) { + HorizontalPager( + state = pagerState, + key = { page -> descriptions.getOrNull(page)?.uri ?: page }, + modifier = Modifier + .fillMaxSize() + .heightIn(max = HERO_MAX_HEIGHT_DP.dp) + ) { page -> + descriptions.getOrNull(page)?.let { description -> HeroPage(description) } + } + } +} + +@Composable +private fun HeroPage(description: FileDescription) { + var isPlayingVideo by remember(description.uri) { mutableStateOf(false) } + + BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + val cardModifier = description.aspectRatio?.let { ratio -> + val (cardWidth, cardHeight) = fitWithinBounds(maxWidth, maxHeight, ratio) + Modifier.size(cardWidth, cardHeight) + } ?: Modifier.fillMaxSize() + + Box(modifier = cardModifier) { + if (description.kind == MediaKind.VIDEO && isPlayingVideo) { + VideoPlayerCard(uri = description.uri, modifier = Modifier.fillMaxSize()) + } else { + FileThumbnailImage( + description, + iconSize = HERO_ICON_SIZE_DP.dp, + contentScale = ContentScale.Fit, + backgroundColor = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxSize() + ) + + if (description.kind == MediaKind.VIDEO) { + PlayButtonOverlay( + onClick = { isPlayingVideo = true }, + modifier = Modifier.align(Alignment.Center) + ) + } + + description.detail?.let { detail -> + HeroDetailOverlay( + detail, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(12.dp) + ) + } + } + } + } +} + +@Composable +private fun PlayButtonOverlay(onClick: () -> Unit, modifier: Modifier = Modifier) { + IconButton( + onClick = onClick, + modifier = modifier + .size(PLAY_BUTTON_SIZE_DP.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.5f)) + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_play_arrow_voice_message_24), + contentDescription = stringResource(R.string.media_message_content_play), + tint = Color.White, + modifier = Modifier.size(PLAY_ICON_SIZE_DP.dp) + ) + } +} + +@OptIn(UnstableApi::class) +@Composable +private fun VideoPlayerCard(uri: String, modifier: Modifier = Modifier) { + val context = LocalContext.current + val exoPlayer = remember(uri) { ExoPlayer.Builder(context).build() } + + DisposableEffect(exoPlayer) { + onDispose { exoPlayer.release() } + } + + // Preparing/starting playback here (instead of alongside player creation above) ensures the + // PlayerView below has already been created and bound via `player = exoPlayer` first. Doing + // both synchronously in the same step let the player start decoding before its SurfaceView + // existed, silently dropping the first playback's frames — a black screen until the *next* + // play attempt, once the surface had already been created once. + LaunchedEffect(exoPlayer, uri) { + exoPlayer.setMediaItem(MediaItem.fromUri(uri.toUri())) + exoPlayer.playWhenReady = true + exoPlayer.prepare() + } + + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + player = exoPlayer + useController = true + } + }, + modifier = modifier + ) +} + +private fun fitWithinBounds(maxWidth: Dp, maxHeight: Dp, ratio: Float): Pair { + val boundsRatio = maxWidth / maxHeight + return if (boundsRatio > ratio) { + (maxHeight * ratio) to maxHeight + } else { + maxWidth to (maxWidth / ratio) + } +} + +@Composable +private fun HeroDetailOverlay(detail: String, modifier: Modifier = Modifier) { + val interactionSource = remember { MutableInteractionSource() } + + LaunchedEffect(detail) { + val press = PressInteraction.Press(Offset.Zero) + interactionSource.emit(press) + delay(DETAIL_CHIP_RIPPLE_DURATION_MS) + interactionSource.emit(PressInteraction.Release(press)) + } + + Box( + modifier = modifier + .clip(RoundedCornerShape(DETAIL_CHIP_CORNER_RADIUS_PERCENT)) + .background(Color.Black.copy(alpha = 0.6f)) + .indication(interactionSource, ripple(color = MaterialTheme.colorScheme.primary)) + .padding(horizontal = 10.dp, vertical = 6.dp) + ) { + Text( + text = detail, + style = MaterialTheme.typography.labelMedium, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun ThumbnailStrip( + descriptions: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + onRemove: (String) -> Unit, + onReorder: (from: Int, to: Int) -> Unit, + onAddMore: () -> Unit, + onTakePhoto: () -> Unit, + onTakeVideo: () -> Unit +) { + val density = LocalDensity.current + val itemExtentPx = remember(density) { + with(density) { (STRIP_THUMBNAIL_SIZE_DP + STRIP_ITEM_SPACING_DP).dp.toPx() } + } + val dragState = remember { DragReorderState() } + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(STRIP_ITEM_SPACING_DP.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + modifier = Modifier.fillMaxWidth() + ) { + itemsIndexed(descriptions, key = { _, description -> description.uri }) { index, description -> + DraggableStripItem( + description = description, + index = index, + selected = index == selectedIndex, + itemExtentPx = itemExtentPx, + itemCount = descriptions.size, + dragState = dragState, + onSelect = onSelect, + onRemove = onRemove, + onReorder = onReorder + ) + } + item { AddMoreTile(onClick = onAddMore) } + item { TakePhotoTile(onClick = onTakePhoto) } + item { TakeVideoTile(onClick = onTakeVideo) } + } +} + +/** + * No live reordering happens while dragging — only [draggedIndex] (fixed for the whole gesture) + * and [insertionIndex] (where a drop would currently land) update. The actual list mutation is a + * single [onReorder] call on release. [insertionIndex] ranges over `0..itemCount` (not just + * `0..lastIndex`): `itemCount` itself is the distinct "after the very last item" position, needed + * so the drop-line can be drawn after the last thumbnail, not just before it. + */ +private class DragReorderState { + val draggedIndex = mutableStateOf(-1) + val dragOffsetX = mutableStateOf(0f) + val insertionIndex = mutableStateOf(-1) +} + +private const val INDICATOR_WIDTH_DP = 3 +private const val INDICATOR_GAP_OFFSET_DP = 5 + +@Suppress("LongParameterList") +@Composable +private fun DraggableStripItem( + description: FileDescription, + index: Int, + selected: Boolean, + itemExtentPx: Float, + itemCount: Int, + dragState: DragReorderState, + onSelect: (Int) -> Unit, + onRemove: (String) -> Unit, + onReorder: (Int, Int) -> Unit +) { + val isDragged = index == dragState.draggedIndex.value + val currentIndex by rememberUpdatedState(index) + + Box { + StripThumbnail( + description = description, + selected = selected, + onClick = { onSelect(index) }, + onRemove = { onRemove(description.uri) }, + modifier = Modifier + .graphicsLayer { translationX = if (isDragged) dragState.dragOffsetX.value else 0f } + .zIndex(if (isDragged) 1f else 0f) + .pointerInput(description.uri) { + detectDragGesturesAfterLongPress( + onDragStart = { + dragState.draggedIndex.value = currentIndex + dragState.insertionIndex.value = currentIndex + dragState.dragOffsetX.value = 0f + }, + onDragEnd = { + val from = dragState.draggedIndex.value + val insertion = dragState.insertionIndex.value + if (from >= 0 && insertion >= 0) { + val to = if (insertion > from) insertion - 1 else insertion + if (to != from) onReorder(from, to) + } + dragState.draggedIndex.value = -1 + dragState.insertionIndex.value = -1 + dragState.dragOffsetX.value = 0f + }, + onDragCancel = { + dragState.draggedIndex.value = -1 + dragState.insertionIndex.value = -1 + dragState.dragOffsetX.value = 0f + }, + onDrag = { change, dragAmount -> + change.consume() + dragState.dragOffsetX.value += dragAmount.x + val slotsMoved = (dragState.dragOffsetX.value / itemExtentPx).roundToInt() + dragState.insertionIndex.value = (currentIndex + slotsMoved).coerceIn(0, itemCount) + } + ) + } + ) + + DropIndicators(index, itemCount, dragState) + } +} + +@Composable +private fun BoxScope.DropIndicators(index: Int, itemCount: Int, dragState: DragReorderState) { + if (dragState.draggedIndex.value < 0) return + + if (dragState.insertionIndex.value == index) { + DropIndicatorLine( + modifier = Modifier + .align(Alignment.CenterStart) + .offset(x = (-INDICATOR_GAP_OFFSET_DP).dp) + ) + } + if (index == itemCount - 1 && dragState.insertionIndex.value == itemCount) { + DropIndicatorLine( + modifier = Modifier + .align(Alignment.CenterEnd) + .offset(x = INDICATOR_GAP_OFFSET_DP.dp) + ) + } +} + +@Composable +private fun DropIndicatorLine(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .width(INDICATOR_WIDTH_DP.dp) + .height(STRIP_THUMBNAIL_SIZE_DP.dp) + .background( + MaterialTheme.colorScheme.primary, + RoundedCornerShape(INDICATOR_WIDTH_DP.dp / 2) + ) + ) +} + +@Composable +private fun StripThumbnail( + description: FileDescription, + selected: Boolean, + onClick: () -> Unit, + onRemove: () -> Unit, + modifier: Modifier = Modifier +) { + val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant + val shape = RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp) + Box( + modifier = modifier + .size(STRIP_THUMBNAIL_SIZE_DP.dp) + .clip(shape) + .border(SELECTED_BORDER_WIDTH_DP.dp, borderColor, shape) + .clickable(onClick = if (selected) onRemove else onClick) ) { - 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 + FileThumbnailImage( + description, + iconSize = STRIP_ICON_SIZE_DP.dp, + modifier = Modifier.fillMaxSize() + ) + + if (description.kind == MediaKind.VIDEO && !selected) { + Box( + modifier = Modifier + .size(STRIP_PLAY_ICON_SIZE_DP.dp) + .align(Alignment.Center) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_play_arrow_voice_message_24), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size((STRIP_PLAY_ICON_SIZE_DP / 2).dp) ) } } + + if (selected) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(R.string.nc_remove_file), + tint = Color.White, + modifier = Modifier.size(STRIP_ICON_SIZE_DP.dp) + ) + } + } + } +} + +@Composable +private fun AddMoreTile(onClick: () -> Unit) { + Box( + modifier = Modifier + .size(STRIP_THUMBNAIL_SIZE_DP.dp) + .clip(RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = stringResource(R.string.nc_add_more_files), + modifier = Modifier.size(STRIP_ICON_SIZE_DP.dp) + ) + } +} + +@Composable +private fun TakePhotoTile(onClick: () -> Unit) { + Box( + modifier = Modifier + .size(STRIP_THUMBNAIL_SIZE_DP.dp) + .clip(RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.PhotoCamera, + contentDescription = stringResource(R.string.take_photo), + modifier = Modifier.size(STRIP_ICON_SIZE_DP.dp) + ) } } @Composable -private fun FileThumbnail(description: FileDescription) { +private fun TakeVideoTile(onClick: () -> Unit) { + Box( + modifier = Modifier + .size(STRIP_THUMBNAIL_SIZE_DP.dp) + .clip(RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Videocam, + contentDescription = stringResource(R.string.nc_take_video), + modifier = Modifier.size(STRIP_ICON_SIZE_DP.dp) + ) + } +} + +@Composable +private fun FileThumbnailImage( + description: FileDescription, + modifier: Modifier = Modifier, + iconSize: Dp = THUMBNAIL_ICON_SIZE_DP.dp, + contentScale: ContentScale = ContentScale.Crop, + backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant +) { + val shape = RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp) + 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)) + contentDescription = description.name, + contentScale = contentScale, + modifier = modifier.clip(shape).background(backgroundColor) ) MediaKind.VIDEO -> - Icon( - painter = painterResource(R.drawable.ic_mimetype_video), - contentDescription = null, - modifier = Modifier.size(THUMBNAIL_SIZE_DP.dp) - ) + if (description.videoThumbnail != null) { + Image( + bitmap = description.videoThumbnail.asImageBitmap(), + contentDescription = description.name, + contentScale = contentScale, + modifier = modifier.clip(shape).background(backgroundColor) + ) + } else { + Box( + modifier = modifier.clip(shape).background(backgroundColor), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_mimetype_video), + contentDescription = description.name, + modifier = Modifier.size(iconSize) + ) + } + } MediaKind.OTHER -> - Icon( - painter = painterResource(R.drawable.baseline_insert_drive_file_24), - contentDescription = null, - modifier = Modifier.size(THUMBNAIL_SIZE_DP.dp) - ) + Box( + modifier = modifier.clip(shape).background(backgroundColor), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.baseline_insert_drive_file_24), + contentDescription = description.name, + modifier = Modifier.size(iconSize) + ) + } } } @@ -232,7 +932,70 @@ private fun describeFile(context: Context, uriString: String, compress: Boolean) MediaKind.VIDEO -> describeVideoDetail(context, file, compress, sizeOnly) MediaKind.OTHER -> sizeOnly } - return FileDescription(uriString, name, kind, detail) + val aspectRatio = when (kind) { + MediaKind.IMAGE -> imageAspectRatio(file) + MediaKind.VIDEO -> videoAspectRatio(file) + MediaKind.OTHER -> null + } + val videoThumbnail = if (kind == MediaKind.VIDEO) extractVideoFrame(file) else null + return FileDescription(uriString, name, kind, detail, aspectRatio, videoThumbnail) +} + +@Suppress("ReturnCount") +private fun imageAspectRatio(file: File): Float? { + val info = ImageCompressor.readImageInfo(file) ?: return null + if (info.height <= 0) return null + val (width, height) = if (isSidewaysExifOrientation(file)) { + info.height to info.width + } else { + info.width to info.height + } + if (height <= 0) return null + return width.toFloat() / height.toFloat() +} + +// BitmapFactory's decode bounds are the raw, un-rotated pixel grid, so a photo whose sensor-native +// orientation is landscape (with an EXIF tag marking it as rotated for portrait display) reports +// swapped width/height here compared to how it actually renders. Swap them back so the aspect ratio +// used to size the preview matches what Coil (which does honor EXIF) actually draws. +private fun isSidewaysExifOrientation(file: File): Boolean = + try { + val orientation = ExifInterface(file.absolutePath).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270 + } catch (e: IOException) { + false + } + +@Suppress("ReturnCount") +private fun videoAspectRatio(file: File): Float? { + val info = VideoCompressor.readVideoInfo(file) ?: return null + if (info.height <= 0) return null + return info.width.toFloat() / info.height.toFloat() +} + +@Suppress("TooGenericExceptionCaught") +private fun extractVideoFrame(file: File): Bitmap? { + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(file.absolutePath) + retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)?.let(::downscaleIfNeeded) + } catch (e: Exception) { + null + } finally { + retriever.release() + } +} + +private fun downscaleIfNeeded(bitmap: Bitmap): Bitmap { + val largestDimension = maxOf(bitmap.width, bitmap.height) + if (largestDimension <= VIDEO_FRAME_MAX_DIMENSION_PX) return bitmap + val scale = VIDEO_FRAME_MAX_DIMENSION_PX.toFloat() / largestDimension + val targetWidth = (bitmap.width * scale).roundToInt().coerceAtLeast(1) + val targetHeight = (bitmap.height * scale).roundToInt().coerceAtLeast(1) + return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true) } @Suppress("ReturnCount") @@ -272,11 +1035,27 @@ private fun FileAttachmentPreviewContentPreview() { files = listOf( "file:///sdcard/DCIM/photo.jpg", "file:///sdcard/DCIM/video.mp4", - "file:///sdcard/Documents/report.pdf" + "file:///sdcard/Documents/report.pdf", + "file:///sdcard/DCIM/photo2.jpg" ), + conversationName = "Team Chat", + initialCompressImages = true, + onDismiss = {}, + onSend = { _, _, _ -> } + ) + } +} + +@Preview(name = "Single File", showBackground = true) +@Composable +private fun FileAttachmentPreviewContentSingleFilePreview() { + MaterialTheme(colorScheme = lightColorScheme()) { + FileAttachmentPreviewContent( + files = listOf("file:///sdcard/DCIM/photo.jpg"), + conversationName = "Team Chat", initialCompressImages = true, onDismiss = {}, - onSend = { _, _ -> } + 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 43ae304b2f..252822703c 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 @@ -7,14 +7,18 @@ package com.nextcloud.talk.ui.dialog import android.app.Dialog +import android.graphics.Color 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.graphics.luminance import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.DialogFragment import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -26,6 +30,7 @@ import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) class FileAttachmentPreviewFragment : DialogFragment() { private lateinit var filesList: ArrayList + private var conversationName: String = "" private var uploadFiles: (files: MutableList, caption: String, compressImages: Boolean) -> Unit = { _, _, _ -> } private var composeView: ComposeView? = null @@ -43,6 +48,7 @@ class FileAttachmentPreviewFragment : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { arguments?.let { filesList = it.getStringArrayList(FILES_TO_UPLOAD_ARG)!! + conversationName = it.getString(CONVERSATION_NAME_ARG, "") } composeView = ComposeView(requireContext()) @@ -59,8 +65,27 @@ class FileAttachmentPreviewFragment : DialogFragment() { // 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 { + setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT) + setBackgroundDrawableResource(android.R.color.transparent) setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) + // Dialog windows dim whatever is behind them by default; at fullscreen size that + // scrim covers the whole window, including the status bar area, making it look dark + // regardless of the Compose content's own color. + clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + // targetSdk 36 enforces edge-to-edge; Window.setStatusBarColor() is a no-op there. + // Instead, let the dialog draw behind the status bar so the Compose Surface's own + // background shows through it, and inset the content with statusBarsPadding(). + WindowCompat.setDecorFitsSystemWindows(this, false) + statusBarColor = Color.TRANSPARENT + navigationBarColor = Color.TRANSPARENT + + val surfaceColor = viewThemeUtils.getColorScheme(requireActivity()).surface + val isLightSurface = surfaceColor.luminance() > LIGHT_LUMINANCE_THRESHOLD + WindowInsetsControllerCompat(this, decorView).apply { + isAppearanceLightStatusBars = isLightSurface + isAppearanceLightNavigationBars = isLightSurface + } } } @@ -74,10 +99,11 @@ class FileAttachmentPreviewFragment : DialogFragment() { MaterialTheme(colorScheme = viewThemeUtils.getColorScheme(requireActivity())) { FileAttachmentPreviewContent( files = filesList, + conversationName = conversationName, initialCompressImages = appPreferences.compressUploadImages, onDismiss = { dismiss() }, - onSend = { caption, compressImages -> - uploadFiles(filesList, caption, compressImages) + onSend = { files, caption, compressImages -> + uploadFiles(files.toMutableList(), caption, compressImages) dismiss() } ) @@ -93,13 +119,16 @@ class FileAttachmentPreviewFragment : DialogFragment() { companion object { + 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" @JvmStatic - fun newInstance(filesToUpload: MutableList): FileAttachmentPreviewFragment { + fun newInstance(filesToUpload: MutableList, conversationName: String): FileAttachmentPreviewFragment { val fileAttachmentFragment = FileAttachmentPreviewFragment() val args = Bundle() args.putStringArrayList(FILES_TO_UPLOAD_ARG, ArrayList(filesToUpload)) + args.putString(CONVERSATION_NAME_ARG, conversationName) fileAttachmentFragment.arguments = args return fileAttachmentFragment } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bb12390751..9d48eaf9db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -798,6 +798,7 @@ How to translate with transifex: Error taking picture Taking a photo is not possible without permissions Camera permission granted. Please choose camera again. + Take a video Bluetooth @@ -943,6 +944,10 @@ How to translate with transifex: Error 429 Too Many Requests Caption Compress images and videos + HQ + High quality, no compression + Remove file + Add more files Retrieval failed Languages could not be retrieved Edit From 7729c5e5d1fab468b0d57375b6b081a6725b90f9 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Fri, 31 Jul 2026 14:00:01 +0200 Subject: [PATCH 3/4] remove built in camera Assisted-by: Claude Code 2.1.199:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- app/build.gradle.kts | 5 - app/src/main/AndroidManifest.xml | 5 - .../talk/activities/TakePhotoActivity.java | 427 ------------------ .../com/nextcloud/talk/chat/ChatActivity.kt | 51 ++- .../ConversationCreationActivity.kt | 2 +- .../ConversationInfoEditActivity.kt | 4 +- .../talk/models/TakePictureViewModel.java | 102 ----- .../nextcloud/talk/profile/ProfileActivity.kt | 4 +- .../com/nextcloud/talk/utils/PickImage.kt | 33 +- .../res/drawable/ic_baseline_flash_off_24.xml | 16 - .../res/drawable/ic_baseline_flash_on_24.xml | 16 - app/src/main/res/drawable/ic_crop_16_9.xml | 16 - app/src/main/res/drawable/ic_crop_4_3.xml | 16 - app/src/main/res/drawable/ic_high_quality.xml | 16 - app/src/main/res/drawable/ic_low_quality.xml | 16 - .../main/res/layout/activity_take_picture.xml | 223 --------- app/src/main/res/values/strings.xml | 10 +- app/src/main/res/values/styles.xml | 5 - 18 files changed, 71 insertions(+), 896 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/talk/activities/TakePhotoActivity.java delete mode 100644 app/src/main/java/com/nextcloud/talk/models/TakePictureViewModel.java delete mode 100644 app/src/main/res/drawable/ic_baseline_flash_off_24.xml delete mode 100644 app/src/main/res/drawable/ic_baseline_flash_on_24.xml delete mode 100644 app/src/main/res/drawable/ic_crop_16_9.xml delete mode 100644 app/src/main/res/drawable/ic_crop_4_3.xml delete mode 100644 app/src/main/res/drawable/ic_high_quality.xml delete mode 100644 app/src/main/res/drawable/ic_low_quality.xml delete mode 100644 app/src/main/res/layout/activity_take_picture.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2097b6fb00..264683e9a7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,7 +24,6 @@ plugins { val kotlinVersion: String by rootProject.extra -val androidxCameraVersion = "1.6.1" val coilKtVersion = "2.7.0" val daggerVersion = "2.59.2" val emojiVersion = "1.6.0" @@ -232,10 +231,6 @@ dependencies { implementation("org.conscrypt:conscrypt-android:2.5.3") implementation("com.github.nextcloud-deps:qrcodescanner:0.1.2.4") // "com.github.blikoon:QRCodeScanner:0.1.2" - implementation("androidx.camera:camera-core:$androidxCameraVersion") - implementation("androidx.camera:camera-camera2:$androidxCameraVersion") - implementation("androidx.camera:camera-lifecycle:$androidxCameraVersion") - implementation("androidx.camera:camera-view:$androidxCameraVersion") implementation("androidx.exifinterface:exifinterface:1.4.2") implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c2f38f22d2..79f71e43b2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -225,11 +225,6 @@ android:configChanges="orientation|keyboardHidden|screenSize" android:theme="@style/FullScreenTextTheme" /> - - diff --git a/app/src/main/java/com/nextcloud/talk/activities/TakePhotoActivity.java b/app/src/main/java/com/nextcloud/talk/activities/TakePhotoActivity.java deleted file mode 100644 index 92125514be..0000000000 --- a/app/src/main/java/com/nextcloud/talk/activities/TakePhotoActivity.java +++ /dev/null @@ -1,427 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2021 Andy Scherzinger - * SPDX-FileCopyrightText: 2021 Stefan Niedermann - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.activities; - -import android.content.Context; -import android.content.Intent; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.hardware.camera2.CameraMetadata; -import android.hardware.camera2.CaptureRequest; -import android.net.Uri; -import android.os.Bundle; -import android.util.DisplayMetrics; -import android.util.Log; -import android.util.Size; -import android.view.OrientationEventListener; -import android.view.ScaleGestureDetector; -import android.view.Surface; -import android.view.View; -import com.google.android.material.snackbar.Snackbar; -import com.google.common.util.concurrent.ListenableFuture; -import com.nextcloud.talk.R; -import com.nextcloud.talk.application.NextcloudTalkApplication; -import com.nextcloud.talk.databinding.ActivityTakePictureBinding; -import com.nextcloud.talk.models.TakePictureViewModel; -import com.nextcloud.talk.ui.theme.ViewThemeUtils; -import com.nextcloud.talk.utils.BitmapShrinker; -import com.nextcloud.talk.utils.FileUtils; - -import java.io.File; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; -import java.util.concurrent.ExecutionException; - -import javax.inject.Inject; - -import androidx.activity.OnBackPressedCallback; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.OptIn; -import androidx.appcompat.app.AppCompatActivity; -import androidx.camera.camera2.interop.Camera2Interop; -import androidx.camera.core.AspectRatio; -import androidx.camera.core.Camera; -import androidx.camera.core.ImageCapture; -import androidx.camera.core.ImageCaptureException; -import androidx.camera.core.Preview; -import androidx.camera.lifecycle.ProcessCameraProvider; -import androidx.core.content.ContextCompat; -import androidx.exifinterface.media.ExifInterface; -import androidx.lifecycle.ViewModelProvider; -import autodagger.AutoInjector; - -import static com.nextcloud.talk.utils.Mimetype.IMAGE_JPEG; - -@AutoInjector(NextcloudTalkApplication.class) -public class TakePhotoActivity extends AppCompatActivity { - private static final String TAG = TakePhotoActivity.class.getSimpleName(); - - private static final float MAX_SCALE = 6.0f; - private static final float MEDIUM_SCALE = 2.45f; - - private ActivityTakePictureBinding binding; - private TakePictureViewModel viewModel; - - private ListenableFuture cameraProviderFuture; - private OrientationEventListener orientationEventListener; - - private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH-mm-ss", Locale.ROOT); - - private Camera camera; - - @Inject - ViewThemeUtils viewThemeUtils; - - private OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) { - @Override - public void handleOnBackPressed() { - Uri uri = (Uri) binding.photoPreview.getTag(); - - if (uri != null) { - File photoFile = new File(uri.getPath()); - if (!photoFile.delete()) { - Log.w(TAG, "Error deleting temp camera image"); - } - binding.photoPreview.setTag(null); - } - - finish(); - } - }; - - @Override - protected void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this); - - binding = ActivityTakePictureBinding.inflate(getLayoutInflater()); - viewModel = new ViewModelProvider(this).get(TakePictureViewModel.class); - - setContentView(binding.getRoot()); - - viewThemeUtils.material.themeFAB(binding.takePhoto); - viewThemeUtils.material.colorMaterialButtonPrimaryFilled(binding.send); - - cameraProviderFuture = ProcessCameraProvider.getInstance(this); - cameraProviderFuture.addListener(() -> { - try { - final ProcessCameraProvider cameraProvider = cameraProviderFuture.get(); - - camera = cameraProvider.bindToLifecycle( - this, - viewModel.getCameraSelector(), - getImageCapture( - viewModel.isCropEnabled().getValue(), viewModel.isLowResolutionEnabled().getValue()), - getPreview(viewModel.isCropEnabled().getValue())); - - viewModel.getTorchToggleButtonImageResource() - .observe( - this, - res -> binding.toggleTorch.setIcon(ContextCompat.getDrawable(this, res))); - viewModel.isTorchEnabled() - .observe( - this, - enabled -> camera.getCameraControl().enableTorch(viewModel.isTorchEnabled().getValue())); - binding.toggleTorch.setOnClickListener((v) -> viewModel.toggleTorchEnabled()); - - viewModel.getCropToggleButtonImageResource() - .observe( - this, - res -> binding.toggleCrop.setIcon(ContextCompat.getDrawable(this, res))); - viewModel.isCropEnabled() - .observe( - this, - enabled -> { - cameraProvider.unbindAll(); - camera = cameraProvider.bindToLifecycle( - this, - viewModel.getCameraSelector(), - getImageCapture( - viewModel.isCropEnabled().getValue(), viewModel.isLowResolutionEnabled().getValue()), - getPreview(viewModel.isCropEnabled().getValue())); - camera.getCameraControl().enableTorch(viewModel.isTorchEnabled().getValue()); - }); - binding.toggleCrop.setOnClickListener((v) -> viewModel.toggleCropEnabled()); - - viewModel.getLowResolutionToggleButtonImageResource() - .observe( - this, - res -> binding.toggleLowres.setIcon(ContextCompat.getDrawable(this, res))); - viewModel.isLowResolutionEnabled() - .observe( - this, - enabled -> { - cameraProvider.unbindAll(); - camera = cameraProvider.bindToLifecycle( - this, - viewModel.getCameraSelector(), - getImageCapture( - viewModel.isCropEnabled().getValue(), viewModel.isLowResolutionEnabled().getValue()), - getPreview(viewModel.isCropEnabled().getValue())); - camera.getCameraControl().enableTorch(viewModel.isTorchEnabled().getValue()); - }); - binding.toggleLowres.setOnClickListener((v) -> viewModel.toggleLowResolutionEnabled()); - - binding.switchCamera.setOnClickListener((v) -> { - viewModel.toggleCameraSelector(); - cameraProvider.unbindAll(); - camera = cameraProvider.bindToLifecycle( - this, - viewModel.getCameraSelector(), - getImageCapture( - viewModel.isCropEnabled().getValue(), viewModel.isLowResolutionEnabled().getValue()), - getPreview(viewModel.isCropEnabled().getValue())); - }); - binding.retake.setOnClickListener((v) -> { - Uri uri = (Uri) binding.photoPreview.getTag(); - File photoFile = new File(uri.getPath()); - if (!photoFile.delete()) { - Log.w(TAG, "Error deleting temp camera image"); - } - binding.takePhoto.setEnabled(true); - binding.photoPreview.setTag(null); - showCameraElements(); - }); - binding.send.setOnClickListener((v) -> { - Uri uri = (Uri) binding.photoPreview.getTag(); - setResult(RESULT_OK, new Intent().setDataAndType(uri, IMAGE_JPEG)); - binding.photoPreview.setTag(null); - finish(); - }); - - ScaleGestureDetector mDetector = - new ScaleGestureDetector(this, new ScaleGestureDetector.SimpleOnScaleGestureListener(){ - @Override - public boolean onScale(ScaleGestureDetector detector){ - float ratio = camera.getCameraInfo().getZoomState().getValue().getZoomRatio(); - float delta = detector.getScaleFactor(); - camera.getCameraControl().setZoomRatio(ratio * delta); - return true; - } - }); - binding.preview.setOnTouchListener((v, event) -> { - v.performClick(); - mDetector.onTouchEvent(event); - return true; - }); - - // Enable enlarging the image more than default 3x maximumScale. - // Medium scale adapted to make double-tap behaviour more consistent. - binding.photoPreview.setMaximumScale(MAX_SCALE); - binding.photoPreview.setMediumScale(MEDIUM_SCALE); - } catch (IllegalArgumentException | ExecutionException | InterruptedException e) { - Log.e(TAG, "Error taking picture", e); - Snackbar.make(binding.getRoot(), e.getMessage(), Snackbar.LENGTH_LONG).show(); - finish(); - } - }, ContextCompat.getMainExecutor(this)); - - getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback); - } - - private void showCameraElements() { - binding.send.setVisibility(View.GONE); - binding.retake.setVisibility(View.GONE); - binding.photoPreview.setVisibility(View.INVISIBLE); - - binding.preview.setVisibility(View.VISIBLE); - binding.takePhoto.setVisibility(View.VISIBLE); - binding.switchCamera.setVisibility(View.VISIBLE); - binding.toggleTorch.setVisibility(View.VISIBLE); - binding.toggleCrop.setVisibility(View.VISIBLE); - binding.toggleLowres.setVisibility(View.VISIBLE); - } - - private void showPictureProcessingElements() { - binding.preview.setVisibility(View.INVISIBLE); - binding.takePhoto.setVisibility(View.GONE); - binding.switchCamera.setVisibility(View.GONE); - binding.toggleTorch.setVisibility(View.GONE); - binding.toggleCrop.setVisibility(View.GONE); - binding.toggleLowres.setVisibility(View.GONE); - - binding.send.setVisibility(View.VISIBLE); - binding.retake.setVisibility(View.VISIBLE); - binding.photoPreview.setVisibility(View.VISIBLE); - } - - private ImageCapture getImageCapture(Boolean crop, Boolean lowres) { - final ImageCapture imageCapture; - if (lowres) imageCapture = new ImageCapture.Builder() - .setTargetResolution(new Size(crop ? 1080 : 1440, 1920)).build(); - else imageCapture = new ImageCapture.Builder() - .setTargetAspectRatio(crop ? AspectRatio.RATIO_16_9 : AspectRatio.RATIO_4_3).build(); - - orientationEventListener = new OrientationEventListener(this) { - @Override - public void onOrientationChanged(int orientation) { - int rotation; - - // Monitors orientation values to determine the target rotation value - if (orientation >= 45 && orientation < 135) { - rotation = Surface.ROTATION_270; - } else if (orientation >= 135 && orientation < 225) { - rotation = Surface.ROTATION_180; - } else if (orientation >= 225 && orientation < 315) { - rotation = Surface.ROTATION_90; - } else { - rotation = Surface.ROTATION_0; - } - - imageCapture.setTargetRotation(rotation); - } - }; - orientationEventListener.enable(); - - binding.takePhoto.setOnClickListener((v) -> { - binding.takePhoto.setEnabled(false); - final String photoFileName = dateFormat.format(new Date()) + ".jpg"; - try { - final File photoFile = FileUtils.getTempCacheFile(this, "photos/" + photoFileName); - final ImageCapture.OutputFileOptions options = - new ImageCapture.OutputFileOptions.Builder(photoFile).build(); - imageCapture.takePicture( - options, - ContextCompat.getMainExecutor(this), - new ImageCapture.OnImageSavedCallback() { - - @Override - public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) { - setPreviewImage(photoFile); - showPictureProcessingElements(); - } - - @Override - public void onError(@NonNull ImageCaptureException e) { - Log.e(TAG, "Error", e); - - if (!photoFile.delete()) { - Log.w(TAG, "Deleting picture failed"); - } - binding.takePhoto.setEnabled(true); - } - }); - } catch (Exception e) { - Log.e(TAG, "error while taking picture", e); - Snackbar.make(binding.getRoot(), R.string.take_photo_error_deleting_picture, Snackbar.LENGTH_SHORT).show(); - } - }); - - return imageCapture; - } - - private void setPreviewImage(File photoFile) { - final Uri savedUri = Uri.fromFile(photoFile); - BitmapFactory.Options options = new BitmapFactory.Options(); - options.inPreferredConfig = Bitmap.Config.ARGB_8888; - DisplayMetrics displayMetrics = getResources().getDisplayMetrics(); - int doubleScreenWidth = displayMetrics.widthPixels * 2; - int doubleScreenHeight = displayMetrics.heightPixels * 2; - - Bitmap bitmap = BitmapShrinker.shrinkBitmap(photoFile.getAbsolutePath(), - doubleScreenWidth, - doubleScreenHeight); - if (bitmap == null) { - Log.e(TAG, "Preview bitmap could not be decoded from path: " + photoFile.getAbsolutePath()); - Snackbar.make(binding.getRoot(), R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show(); - return; - } - binding.photoPreview.setImageBitmap(bitmap); - binding.photoPreview.setTag(savedUri); - viewModel.disableTorchIfEnabled(); - } - - public int getImageOrientation(File imageFile) { - int rotate = 0; - try { - ExifInterface exif = new ExifInterface(imageFile.getAbsolutePath()); - int orientation = exif.getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL); - - switch (orientation) { - case ExifInterface.ORIENTATION_ROTATE_270: - rotate = 270; - break; - case ExifInterface.ORIENTATION_ROTATE_180: - rotate = 180; - break; - case ExifInterface.ORIENTATION_ROTATE_90: - rotate = 90; - break; - default: - rotate = 0; - break; - } - - Log.i(TAG, "ImageOrientation - Exif orientation: " + orientation + " - " + "Rotate value: " + rotate); - } catch (Exception e) { - Log.w(TAG, "Error calculation rotation value"); - } - return rotate; - } - - @OptIn(markerClass = androidx.camera.camera2.interop.ExperimentalCamera2Interop.class) - private Preview getPreview(boolean crop) { - Preview.Builder previewBuilder = new Preview.Builder() - .setTargetAspectRatio(crop ? AspectRatio.RATIO_16_9 : AspectRatio.RATIO_4_3); - new Camera2Interop.Extender<>(previewBuilder) - .setCaptureRequestOption(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, - CameraMetadata.CONTROL_VIDEO_STABILIZATION_MODE_OFF - ); - - Preview preview = previewBuilder.build(); - preview.setSurfaceProvider(binding.preview.getSurfaceProvider()); - - return preview; - } - - @Override - protected void onPause() { - if (this.orientationEventListener != null) { - this.orientationEventListener.disable(); - } - super.onPause(); - } - - @Override - protected void onResume() { - super.onResume(); - if (this.orientationEventListener != null) { - this.orientationEventListener.enable(); - } - } - - @Override - public void onSaveInstanceState(Bundle savedInstanceState) { - if (binding.photoPreview.getTag() != null) { - savedInstanceState.putString("Uri", ((Uri) binding.photoPreview.getTag()).getPath()); - } - - super.onSaveInstanceState(savedInstanceState); - } - - @Override - public void onRestoreInstanceState(Bundle savedInstanceState) { - super.onRestoreInstanceState(savedInstanceState); - - String uri = savedInstanceState.getString("Uri", null); - - if (uri != null) { - File photoFile = new File(uri); - setPreviewImage(photoFile); - showPictureProcessingElements(); - } - } - - public static Intent createIntent(@NonNull Context context) { - return new Intent(context, TakePhotoActivity.class).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); - } -} 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 47bec383ad..e1760aad27 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -94,7 +94,6 @@ import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.activities.CallActivity -import com.nextcloud.talk.activities.TakePhotoActivity import com.nextcloud.talk.adapters.messages.CallStartedMessageInterface import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.api.NcApiCoroutines @@ -390,7 +389,7 @@ class ChatActivity : private val _participantPermissionsFlow = MutableStateFlow(null) val participantPermissionsFlow: StateFlow = _participantPermissionsFlow.asStateFlow() - private var videoURI: Uri? = null + private var pendingCameraUri: Uri? = null private var pendingTargetMessageId: Long? = null private var pendingTargetThreadId: Long? = null private var pendingTargetSearchQuery: String? = null @@ -2403,16 +2402,13 @@ class ChatActivity : try { filesToUpload.clear() - if (intent != null && intent.data != null) { - run { - intent.data.let { - filesToUpload.add(intent.data.toString()) - } - } - require(filesToUpload.isNotEmpty()) - } else if (videoURI != null) { - filesToUpload.add(videoURI.toString()) - videoURI = null + // The system camera app is only guaranteed to write to the URI passed via EXTRA_OUTPUT; + // whether it also populates the result intent's data is device/vendor-dependent, so the + // URI we supplied up front is the one source of truth here. + val uri = pendingCameraUri ?: intent?.data + pendingCameraUri = null + if (uri != null) { + filesToUpload.add(uri.toString()) } else { error("Failed to get data from intent and uri") } @@ -3802,7 +3798,31 @@ class ChatActivity : if (!permissionUtil.isCameraPermissionGranted()) { requestCameraPermissions() } else { - startPickCameraIntentForResult.launch(TakePhotoActivity.createIntent(context)) + Intent(MediaStore.ACTION_IMAGE_CAPTURE).also { takePictureIntent -> + takePictureIntent.resolveActivity(packageManager)?.also { + val photoFile: File? = try { + val outputDir = FileUtils.getSharedAttachmentsDirectory(context.cacheDir) + ?: throw IOException("Could not create shared attachments directory") + val dateFormat = SimpleDateFormat(FILE_DATE_PATTERN, Locale.ROOT) + val date = dateFormat.format(Date()) + val photoName = String.format( + context.resources.getString(R.string.nc_picture_filename), + date + ) + File(outputDir, "$photoName$PICTURE_SUFFIX") + } catch (e: IOException) { + Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + Log.e(TAG, "error while creating photo file", e) + null + } + + photoFile?.also { + pendingCameraUri = FileProvider.getUriForFile(context, context.packageName, it) + takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, pendingCameraUri) + startPickCameraIntentForResult.launch(takePictureIntent) + } + } + } } } @@ -3829,8 +3849,8 @@ class ChatActivity : } videoFile?.also { - videoURI = FileProvider.getUriForFile(context, context.packageName, it) - takeVideoIntent.putExtra(MediaStore.EXTRA_OUTPUT, videoURI) + pendingCameraUri = FileProvider.getUriForFile(context, context.packageName, it) + takeVideoIntent.putExtra(MediaStore.EXTRA_OUTPUT, pendingCameraUri) startPickCameraIntentForResult.launch(takeVideoIntent) } } @@ -3977,6 +3997,7 @@ class ChatActivity : private const val REQUEST_CAMERA_PERMISSION = 223 private const val FILE_DATE_PATTERN = "yyyy-MM-dd HH-mm-ss" private const val VIDEO_SUFFIX = ".mp4" + private const val PICTURE_SUFFIX = ".jpg" private const val VOICE_MESSAGE_SEEKBAR_BASE = 1000 private const val HTTP_BAD_REQUEST = 400 private const val HTTP_FORBIDDEN = 403 diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt index 9a7e2f0d18..21f36c889b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -164,7 +164,7 @@ fun ConversationCreationScreen( contract = ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == Activity.RESULT_OK) { - pickImage?.onTakePictureResult(imagePickerLauncher, result.data) + pickImage?.onTakePictureResult(imagePickerLauncher) } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt index b2a5195a49..43b93e9e5d 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt @@ -67,8 +67,8 @@ class ConversationInfoEditActivity : BaseActivity() { private val startTakePictureIntentForResult = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { - handleResult(it) { result -> - pickImage?.onTakePictureResult(startImagePickerForResult, result.data) + handleResult(it) { + pickImage?.onTakePictureResult(startImagePickerForResult) } } diff --git a/app/src/main/java/com/nextcloud/talk/models/TakePictureViewModel.java b/app/src/main/java/com/nextcloud/talk/models/TakePictureViewModel.java deleted file mode 100644 index e4dcd5cc18..0000000000 --- a/app/src/main/java/com/nextcloud/talk/models/TakePictureViewModel.java +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2021 Andy Scherzinger - * SPDX-FileCopyrightText: 2021 Stefan Niedermann - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.models; - -import com.nextcloud.talk.R; - -import androidx.annotation.NonNull; -import androidx.camera.core.CameraSelector; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; -import androidx.lifecycle.ViewModel; - -import static androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA; -import static androidx.camera.core.CameraSelector.DEFAULT_FRONT_CAMERA; - -public class TakePictureViewModel extends ViewModel { - - @NonNull - private CameraSelector cameraSelector = DEFAULT_BACK_CAMERA; - - @NonNull - private final MutableLiveData torchEnabled = new MutableLiveData<>(Boolean.FALSE); - - @NonNull - private final MutableLiveData lowResolutionEnabled = new MutableLiveData<>(Boolean.FALSE); - - @NonNull - private final MutableLiveData cropEnabled = new MutableLiveData<>(Boolean.FALSE); - - @NonNull - public CameraSelector getCameraSelector() { - return this.cameraSelector; - } - - public void toggleCameraSelector() { - if (this.cameraSelector == DEFAULT_BACK_CAMERA) { - this.cameraSelector = DEFAULT_FRONT_CAMERA; - if (this.torchEnabled.getValue()) { - toggleTorchEnabled(); - } - } else { - this.cameraSelector = DEFAULT_BACK_CAMERA; - } - } - - public void disableTorchIfEnabled() { - if (this.torchEnabled.getValue()) { - toggleTorchEnabled(); - } - } - - public void toggleTorchEnabled() { - //noinspection ConstantConditions - this.torchEnabled.postValue(!this.torchEnabled.getValue()); - } - - public void toggleLowResolutionEnabled() { - //noinspection ConstantConditions - this.lowResolutionEnabled.postValue(!this.lowResolutionEnabled.getValue()); - } - - public void toggleCropEnabled() { - //noinspection ConstantConditions - this.cropEnabled.postValue(!this.cropEnabled.getValue()); - } - - public LiveData isTorchEnabled() { - return this.torchEnabled; - } - - public LiveData isLowResolutionEnabled() { - return this.lowResolutionEnabled; - } - - public LiveData isCropEnabled() { - return this.cropEnabled; - } - - public LiveData getTorchToggleButtonImageResource() { - return Transformations.map(isTorchEnabled(), enabled -> enabled - ? R.drawable.ic_baseline_flash_on_24 - : R.drawable.ic_baseline_flash_off_24); - } - - public LiveData getLowResolutionToggleButtonImageResource() { - return Transformations.map(isLowResolutionEnabled(), enabled -> enabled - ? R.drawable.ic_low_quality - : R.drawable.ic_high_quality); - } - - public LiveData getCropToggleButtonImageResource() { - return Transformations.map(isCropEnabled(), enabled -> enabled - ? R.drawable.ic_crop_16_9 - : R.drawable.ic_crop_4_3); - } -} diff --git a/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt b/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt index e743d326e1..df36d6bbf3 100644 --- a/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/profile/ProfileActivity.kt @@ -97,8 +97,8 @@ class ProfileActivity : BaseActivity() { private val startTakePictureIntentForResult = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { - handleResult(it) { result -> - pickImage.onTakePictureResult(startImagePickerForResult, result.data) + handleResult(it) { + pickImage.onTakePictureResult(startImagePickerForResult) } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/PickImage.kt b/app/src/main/java/com/nextcloud/talk/utils/PickImage.kt index cfe44fa84b..3d148e2f39 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/PickImage.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/PickImage.kt @@ -14,12 +14,13 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri import android.os.Bundle +import android.provider.MediaStore import android.util.Log import androidx.activity.result.ActivityResultLauncher +import androidx.core.content.FileProvider import autodagger.AutoInjector import com.github.dhaval2404.imagepicker.ImagePicker import com.github.dhaval2404.imagepicker.constant.ImageProvider -import com.nextcloud.talk.activities.TakePhotoActivity import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.data.user.model.User @@ -44,6 +45,8 @@ class PickImage(private val activity: Activity, private var currentUser: User?) @Inject lateinit var permissionUtil: PlatformPermissionUtil + private var pendingCameraFile: File? = null + init { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) } @@ -84,7 +87,13 @@ class PickImage(private val activity: Activity, private var currentUser: User?) fun takePicture(startTakePictureIntentForResult: ActivityResultLauncher) { if (permissionUtil.isCameraPermissionGranted()) { - startTakePictureIntentForResult.launch(TakePhotoActivity.createIntent(activity)) + val photoFile = createTempFileForCameraCapture() + pendingCameraFile = photoFile + val uri = FileProvider.getUriForFile(activity, activity.packageName, photoFile) + val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { + putExtra(MediaStore.EXTRA_OUTPUT, uri) + } + startTakePictureIntentForResult.launch(takePictureIntent) } else { activity.requestPermissions( arrayOf(android.Manifest.permission.CAMERA), @@ -151,6 +160,17 @@ class PickImage(private val activity: Activity, private var currentUser: User?) ) } + private fun createTempFileForCameraCapture(): File { + FileUtils.removeTempCacheFile( + activity, + CAMERA_CAPTURE_PATH + ) + return FileUtils.getTempCacheFile( + activity, + CAMERA_CAPTURE_PATH + ) + } + fun onImagePickerResult(data: Intent?, handleImage: (uri: Uri) -> Unit) { val uri: Uri = data?.data!! handleImage(uri) @@ -163,16 +183,17 @@ class PickImage(private val activity: Activity, private var currentUser: User?) } } - fun onTakePictureResult(startImagePickerForResult: ActivityResultLauncher, data: Intent?) { - data?.data?.path?.let { - selectLocal(startImagePickerForResult, File(it)) - } + fun onTakePictureResult(startImagePickerForResult: ActivityResultLauncher) { + val file = pendingCameraFile ?: return + pendingCameraFile = null + selectLocal(startImagePickerForResult, file) } companion object { private const val TAG: String = "PickImage" private const val MAX_SIZE: Int = 1024 private const val AVATAR_PATH = "photos/avatar.png" + private const val CAMERA_CAPTURE_PATH = "photos/camera_capture.jpg" private const val FULL_QUALITY: Int = 100 const val REQUEST_PERMISSION_CAMERA: Int = 1 } diff --git a/app/src/main/res/drawable/ic_baseline_flash_off_24.xml b/app/src/main/res/drawable/ic_baseline_flash_off_24.xml deleted file mode 100644 index 360917b966..0000000000 --- a/app/src/main/res/drawable/ic_baseline_flash_off_24.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_baseline_flash_on_24.xml b/app/src/main/res/drawable/ic_baseline_flash_on_24.xml deleted file mode 100644 index 9e2d0322c4..0000000000 --- a/app/src/main/res/drawable/ic_baseline_flash_on_24.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_crop_16_9.xml b/app/src/main/res/drawable/ic_crop_16_9.xml deleted file mode 100644 index e3d2b743aa..0000000000 --- a/app/src/main/res/drawable/ic_crop_16_9.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_crop_4_3.xml b/app/src/main/res/drawable/ic_crop_4_3.xml deleted file mode 100644 index 2c845435dd..0000000000 --- a/app/src/main/res/drawable/ic_crop_4_3.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_high_quality.xml b/app/src/main/res/drawable/ic_high_quality.xml deleted file mode 100644 index 4417d5d04c..0000000000 --- a/app/src/main/res/drawable/ic_high_quality.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_low_quality.xml b/app/src/main/res/drawable/ic_low_quality.xml deleted file mode 100644 index 48f2b6b8c2..0000000000 --- a/app/src/main/res/drawable/ic_low_quality.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/app/src/main/res/layout/activity_take_picture.xml b/app/src/main/res/layout/activity_take_picture.xml deleted file mode 100644 index 963136e336..0000000000 --- a/app/src/main/res/layout/activity_take_picture.xml +++ /dev/null @@ -1,223 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9d48eaf9db..f8b5e1a1ec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -676,6 +676,9 @@ How to translate with transifex: Video recording from %1$s + + Picture from %1$s + Share location Search location @@ -789,13 +792,6 @@ How to translate with transifex: Take a photo - Switch camera - Re-take photo - Toggle torch - Crop photo - Reduce image size - Send - Error taking picture Taking a photo is not possible without permissions Camera permission granted. Please choose camera again. Take a video diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 408e6ea7bb..1bd3240fcb 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -94,11 +94,6 @@ true - -