diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 771fd060fd2..d68d8be609b 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")
@@ -302,6 +297,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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c2f38f22d26..79f71e43b21 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 92125514beb..00000000000
--- 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/attachmentpreview/CameraCaptureActions.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/CameraCaptureActions.kt
new file mode 100644
index 00000000000..0b987af8cbd
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/CameraCaptureActions.kt
@@ -0,0 +1,94 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
+import com.nextcloud.talk.utils.FileUtils
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+private enum class CameraCaptureType { PHOTO, VIDEO }
+
+private const val CAMERA_FILE_DATE_PATTERN = "yyyy-MM-dd HH-mm-ss"
+
+internal data class CameraCaptureActions(val onTakePhoto: () -> Unit, val onTakeVideo: () -> Unit)
+
+@Composable
+internal 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) }
+ )
+}
+
+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)
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/CaptionInputBar.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/CaptionInputBar.kt
new file mode 100644
index 00000000000..1cbb9afed8e
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/CaptionInputBar.kt
@@ -0,0 +1,86 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+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.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.nextcloud.talk.R
+
+/** The bottom bar where the user types an optional caption and triggers sending. */
+@Composable
+internal fun CaptionInputBar(
+ caption: String,
+ onCaptionChange: (String) -> Unit,
+ sendEnabled: Boolean,
+ onSend: () -> Unit
+) {
+ val shape = RoundedCornerShape(dimensionResource(R.dimen.button_corner_radius))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .clip(shape)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ ) {
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .heightIn(min = dimensionResource(R.dimen.min_size_clickable_area))
+ .padding(start = 16.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ if (caption.isEmpty()) {
+ Text(
+ text = stringResource(R.string.nc_caption),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ BasicTextField(
+ value = caption,
+ onValueChange = onCaptionChange,
+ maxLines = 5,
+ textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ val sendTint = if (sendEnabled) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ IconButton(onClick = onSend, enabled = sendEnabled) {
+ Icon(
+ painter = painterResource(R.drawable.ic_send_24px),
+ contentDescription = stringResource(R.string.nc_description_send_message_button),
+ tint = sendTint
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt
new file mode 100644
index 00000000000..8d028242a0e
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt
@@ -0,0 +1,139 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2023 Julius Linus
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+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 androidx.lifecycle.ViewModelProvider
+import autodagger.AutoInjector
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.ui.theme.ViewThemeUtils
+import com.nextcloud.talk.utils.preferences.AppPreferences
+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
+
+ @Inject
+ lateinit var viewThemeUtils: ViewThemeUtils
+
+ @Inject
+ lateinit var appPreferences: AppPreferences
+
+ @Inject
+ lateinit var viewModelFactory: ViewModelProvider.Factory
+
+ private val viewModel: FileAttachmentPreviewViewModel by lazy {
+ ViewModelProvider(this, viewModelFactory)[FileAttachmentPreviewViewModel::class.java]
+ }
+
+ fun setListener(uploadFiles: (files: MutableList, caption: String, compressImages: Boolean) -> Unit) {
+ this.uploadFiles = uploadFiles
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ arguments?.let {
+ filesList = it.getStringArrayList(FILES_TO_UPLOAD_ARG)!!
+ conversationName = it.getString(CONVERSATION_NAME_ARG, "")
+ }
+
+ composeView = ComposeView(requireContext())
+ return MaterialAlertDialogBuilder(requireContext()).setView(composeView).create()
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
+ composeView
+
+ @Suppress("DEPRECATION")
+ override fun onStart() {
+ super.onStart()
+ 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)
+ clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
+ 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
+ }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
+
+ viewModel.setInitialFiles(filesList)
+
+ composeView?.apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent {
+ MaterialTheme(colorScheme = viewThemeUtils.getColorScheme(requireActivity())) {
+ FileAttachmentPreviewContent(
+ viewModel = viewModel,
+ conversationName = conversationName,
+ initialCompressImages = appPreferences.compressUploadImages,
+ onDismiss = { dismiss() },
+ onSend = { files, caption, compressImages ->
+ uploadFiles(files.toMutableList(), caption, compressImages)
+ dismiss()
+ }
+ )
+ }
+ }
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ composeView = null
+ }
+
+ 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, 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
+ }
+
+ val TAG: String = FileAttachmentPreviewFragment::class.java.simpleName
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt
new file mode 100644
index 00000000000..6424602b97a
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt
@@ -0,0 +1,269 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import android.content.res.Configuration
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+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.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import com.nextcloud.talk.R
+import com.nextcloud.talk.utils.FileUtils
+import kotlinx.coroutines.launch
+
+private const val MAX_ADD_MORE_FILES = 10
+
+/**
+ * Full-screen dialog content for reviewing, reordering and captioning files picked for upload,
+ * hosted by [FileAttachmentPreviewFragment]. [viewModel] owns the file list and its (IO-derived)
+ * descriptions so both survive configuration changes; everything else here is ephemeral UI state.
+ */
+@Suppress("LongMethod")
+@Composable
+internal fun FileAttachmentPreviewContent(
+ viewModel: FileAttachmentPreviewViewModel,
+ conversationName: String,
+ initialCompressImages: Boolean,
+ onDismiss: () -> Unit,
+ onSend: (files: List, caption: String, compressImages: Boolean) -> Unit
+) {
+ val context = LocalContext.current
+ val currentFiles = viewModel.files
+ val hasCompressibleMedia = currentFiles.any { isCompressible(FileUtils.resolveMimeType(context, it.toUri())) }
+ var caption by rememberSaveable { mutableStateOf("") }
+ var compressImages by rememberSaveable { mutableStateOf(hasCompressibleMedia && initialCompressImages) }
+
+ LaunchedEffect(currentFiles.toSet(), compressImages) {
+ viewModel.describeFiles(compressImages)
+ }
+ val fileDescriptions = currentFiles.mapNotNull { viewModel.descriptionsByUri[it] }
+
+ val pagerState = rememberPagerState(pageCount = { fileDescriptions.size })
+ val coroutineScope = rememberCoroutineScope()
+
+ val pickMoreMedia = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.PickMultipleVisualMedia(MAX_ADD_MORE_FILES)
+ ) { uris ->
+ viewModel.addFiles(uris.map { it.toString() })
+ }
+
+ val cameraCapture = rememberCameraCaptureActions(currentFiles)
+
+ LaunchedEffect(currentFiles.size) {
+ if (currentFiles.isEmpty()) {
+ onDismiss()
+ }
+ }
+
+ Surface(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .statusBarsPadding()
+ .navigationBarsPadding()
+ .imePadding()
+ ) {
+ PreviewTopBar(conversationName = conversationName, onDismiss = onDismiss)
+
+ HorizontalDivider()
+
+ if (fileDescriptions.isNotEmpty()) {
+ LargePreview(
+ descriptions = fileDescriptions,
+ pagerState = pagerState,
+ modifier = Modifier.weight(1f)
+ )
+
+ ThumbnailStrip(
+ descriptions = fileDescriptions,
+ selectedIndex = pagerState.currentPage,
+ onSelect = { index -> coroutineScope.launch { pagerState.scrollToPage(index) } },
+ onRemove = { uri -> viewModel.removeFile(uri) },
+ onReorder = { from, to -> viewModel.reorder(from, to) },
+ onAddMore = {
+ pickMoreMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo))
+ },
+ onTakePhoto = cameraCapture.onTakePhoto,
+ onTakeVideo = cameraCapture.onTakeVideo
+ )
+ }
+
+ if (hasCompressibleMedia) {
+ MediaQualitySegmentedButton(
+ highQuality = !compressImages,
+ onHighQualityChange = { highQuality -> compressImages = !highQuality },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+ }
+
+ CaptionInputBar(
+ caption = caption,
+ onCaptionChange = { caption = it },
+ sendEnabled = currentFiles.isNotEmpty(),
+ onSend = { onSend(currentFiles.toList(), caption, compressImages) }
+ )
+ }
+ }
+}
+
+@Composable
+private fun PreviewTopBar(conversationName: String, onDismiss: () -> Unit) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ IconButton(onClick = onDismiss) {
+ Icon(
+ imageVector = Icons.Filled.Close,
+ contentDescription = stringResource(R.string.nc_common_dismiss)
+ )
+ }
+
+ 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
+ )
+ }
+ }
+}
+
+@Composable
+private fun MediaQualitySegmentedButton(
+ highQuality: Boolean,
+ onHighQualityChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ SingleChoiceSegmentedButtonRow(modifier = modifier) {
+ SegmentedButton(
+ selected = highQuality,
+ onClick = { onHighQualityChange(true) },
+ shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
+ icon = {
+ Icon(
+ imageVector = HighQualityIcon,
+ contentDescription = null,
+ modifier = Modifier.size(SegmentedButtonDefaults.IconSize)
+ )
+ },
+ label = { Text(stringResource(R.string.nc_media_quality_original)) }
+ )
+ SegmentedButton(
+ selected = !highQuality,
+ onClick = { onHighQualityChange(false) },
+ shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
+ icon = {
+ Icon(
+ imageVector = HighQualityOffIcon,
+ contentDescription = null,
+ modifier = Modifier.size(SegmentedButtonDefaults.IconSize)
+ )
+ },
+ label = { Text(stringResource(R.string.nc_media_quality_reduced)) }
+ )
+ }
+}
+
+@Composable
+private fun rememberPreviewViewModel(files: List): FileAttachmentPreviewViewModel {
+ val context = LocalContext.current
+ return remember { FileAttachmentPreviewViewModel(context).apply { setInitialFiles(files) } }
+}
+
+@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(
+ viewModel = rememberPreviewViewModel(
+ listOf(
+ "file:///sdcard/DCIM/photo.jpg",
+ "file:///sdcard/DCIM/video.mp4",
+ "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(
+ viewModel = rememberPreviewViewModel(listOf("file:///sdcard/DCIM/photo.jpg")),
+ conversationName = "Team Chat",
+ initialCompressImages = true,
+ onDismiss = {},
+ onSend = { _, _, _ -> }
+ )
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt
new file mode 100644
index 00000000000..94790258ab3
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt
@@ -0,0 +1,65 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import android.content.Context
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * Holds the file list being reviewed for upload and each file's (IO-derived) [FileDescription],
+ * so both survive configuration changes (e.g. screen rotation) instead of resetting back to the
+ * dialog's original arguments. Everything else on the screen — caption text, drag/scroll
+ * position, HQ toggle animation state — is ephemeral UI state and stays in Compose's `remember`.
+ */
+internal class FileAttachmentPreviewViewModel @Inject constructor(private val context: Context) : ViewModel() {
+
+ val files = mutableStateListOf()
+
+ private val _descriptionsByUri = mutableStateMapOf()
+ val descriptionsByUri: Map get() = _descriptionsByUri
+
+ /** No-op after the first call, so re-entering (e.g. after rotation) doesn't wipe edits made since. */
+ fun setInitialFiles(initialFiles: List) {
+ if (files.isEmpty()) {
+ files.addAll(initialFiles)
+ }
+ }
+
+ fun addFiles(newFiles: List) {
+ files.addAll(newFiles.filterNot { it in files })
+ }
+
+ fun removeFile(uri: String) {
+ files.remove(uri)
+ }
+
+ fun reorder(from: Int, to: Int) {
+ if (from != to && from in files.indices && to in files.indices) {
+ val item = files.removeAt(from)
+ files.add(to, item)
+ }
+ }
+
+ /**
+ * Re-describes every current file. Callers should only invoke this when the *set* of files or
+ * [compress] changes — not on pure reordering — since it always redescribes the whole list.
+ */
+ fun describeFiles(compress: Boolean) {
+ val snapshot = files.toList()
+ viewModelScope.launch(Dispatchers.IO) {
+ val described = snapshot.associateWith { describeFile(context, it, compress) }
+ _descriptionsByUri.clear()
+ _descriptionsByUri.putAll(described)
+ }
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileDescriber.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileDescriber.kt
new file mode 100644
index 00000000000..d671b58b57e
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileDescriber.kt
@@ -0,0 +1,173 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.media.MediaMetadataRetriever
+import android.text.format.Formatter
+import androidx.core.net.toUri
+import androidx.exifinterface.media.ExifInterface
+import com.nextcloud.talk.utils.FileUtils
+import com.nextcloud.talk.utils.ImageCompressor
+import com.nextcloud.talk.utils.VideoCompressor
+import java.io.File
+import java.io.IOException
+import kotlin.math.roundToInt
+
+private const val VIDEO_ROTATION_DEGREES_90 = 90
+private const val VIDEO_ROTATION_DEGREES_270 = 270
+private const val VIDEO_FRAME_MAX_DIMENSION_PX = 720
+
+internal fun isCompressible(mimeType: String?): Boolean =
+ ImageCompressor.isCompressible(mimeType) || VideoCompressor.isCompressible(mimeType)
+
+/**
+ * [current] is the detail text for the file's active compress setting, [alternate] is what it
+ * would read as under the other setting. Both are computed regardless of [FileDescription]'s own
+ * `compress` flag, so the large preview's detail chip can reserve width for whichever is wider and
+ * never resize when the HQ toggle flips which one is actually shown.
+ */
+private data class DetailVariants(val current: String, val alternate: String)
+
+@Suppress("ReturnCount")
+internal fun describeFile(context: Context, uriString: String, compress: Boolean): FileDescription {
+ val uri = uriString.toUri()
+ val name = FileUtils.getFileName(uri, context)
+ val mimeType = FileUtils.resolveMimeType(context, uri)
+ val kind = mediaKind(mimeType)
+ val file = FileUtils.getFileFromUri(context, uri)
+ ?: return FileDescription(uriString, name, kind, mimeType, null)
+ val sizeOnly = formatSize(context, file.length())
+
+ val variants = when (kind) {
+ MediaKind.IMAGE -> describeImageDetail(context, file, compress, sizeOnly)
+ MediaKind.VIDEO -> describeVideoDetail(context, file, compress, sizeOnly)
+ MediaKind.OTHER -> "$name, $sizeOnly".let { DetailVariants(it, it) }
+ }
+ 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,
+ mimeType,
+ variants.current,
+ variants.alternate,
+ 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
+ val (width, height) = if (isSidewaysVideoRotation(file)) {
+ info.height to info.width
+ } else {
+ info.width to info.height
+ }
+ if (height <= 0) return null
+ return width.toFloat() / height.toFloat()
+}
+
+// MediaMetadataRetriever's width/height metadata reflect the raw, un-rotated encoded frame, so a
+// video recorded in the sensor's landscape orientation (with a 90/270 rotation flag for portrait
+// playback) reports swapped dimensions here compared to how it actually renders. getFrameAtTime()
+// (used for the thumbnail in extractVideoFrame) already applies this rotation, so swap back here
+// too, matching what's actually drawn.
+@Suppress("TooGenericExceptionCaught")
+private fun isSidewaysVideoRotation(file: File): Boolean {
+ val retriever = MediaMetadataRetriever()
+ return try {
+ retriever.setDataSource(file.absolutePath)
+ val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
+ rotation == VIDEO_ROTATION_DEGREES_90 || rotation == VIDEO_ROTATION_DEGREES_270
+ } catch (e: Exception) {
+ false
+ } finally {
+ retriever.release()
+ }
+}
+
+@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")
+private fun describeImageDetail(context: Context, file: File, compress: Boolean, fallback: String): DetailVariants {
+ val original = ImageCompressor.readImageInfo(file) ?: return DetailVariants(fallback, fallback)
+ val originalText = describeMedia(context, original.width, original.height, original.sizeBytes)
+ val compressed = ImageCompressor.estimateCompression(file)
+ val compressedText = compressed?.let { describeMedia(context, it.width, it.height, it.sizeBytes) } ?: originalText
+ return if (compress) DetailVariants(compressedText, originalText) else DetailVariants(originalText, compressedText)
+}
+
+@Suppress("ReturnCount")
+private fun describeVideoDetail(context: Context, file: File, compress: Boolean, fallback: String): DetailVariants {
+ val original = VideoCompressor.readVideoInfo(file) ?: return DetailVariants(fallback, fallback)
+ val originalText = describeMedia(context, original.width, original.height, original.sizeBytes)
+ val compressed = VideoCompressor.estimateCompression(file)
+ val compressedText = compressed?.let { describeMedia(context, it.width, it.height, it.sizeBytes) } ?: originalText
+ return if (compress) DetailVariants(compressedText, originalText) else DetailVariants(originalText, compressedText)
+}
+
+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)
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileDescription.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileDescription.kt
new file mode 100644
index 00000000000..247b89b13ce
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileDescription.kt
@@ -0,0 +1,32 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import android.graphics.Bitmap
+import com.nextcloud.talk.utils.Mimetype
+
+internal enum class MediaKind { IMAGE, VIDEO, OTHER }
+
+internal data class FileDescription(
+ val uri: String,
+ val name: String,
+ val kind: MediaKind,
+ val mimeType: String?,
+ val detail: String?,
+ // What `detail` would read as under the other compress setting — lets the large preview's
+ // detail chip reserve width for whichever variant is wider, so it never resizes on HQ toggle.
+ val alternateDetail: String? = null,
+ val aspectRatio: Float? = null,
+ val videoThumbnail: Bitmap? = null
+)
+
+internal 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
+ }
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileThumbnailImage.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileThumbnailImage.kt
new file mode 100644
index 00000000000..ddae262909f
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileThumbnailImage.kt
@@ -0,0 +1,90 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import com.nextcloud.talk.R
+import com.nextcloud.talk.utils.DrawableUtils
+
+internal const val THUMBNAIL_CORNER_RADIUS_DP = 16
+private const val THUMBNAIL_ICON_SIZE_DP = 48
+
+/**
+ * Renders [description] as an image, a video frame, or a mimetype icon, depending on [FileDescription.kind].
+ * Shared by the large preview pager and the thumbnail strip so both stay visually consistent.
+ */
+@Composable
+internal 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 = description.name,
+ contentScale = contentScale,
+ modifier = modifier.clip(shape).background(backgroundColor)
+ )
+
+ MediaKind.VIDEO ->
+ 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 ->
+ Box(
+ modifier = modifier.clip(shape).background(backgroundColor),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(DrawableUtils.getDrawableResourceIdForMimeType(description.mimeType)),
+ contentDescription = description.name,
+ tint = Color.Unspecified,
+ modifier = Modifier.size(iconSize)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/HighQualityIcons.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/HighQualityIcons.kt
new file mode 100644
index 00000000000..5ffbf3337e2
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/HighQualityIcons.kt
@@ -0,0 +1,194 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+@file:Suppress("MagicNumber") // vector path coordinates, not meaningful named constants
+
+package com.nextcloud.talk.attachmentpreview
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathFillType
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.unit.dp
+
+// Material Symbols "high_quality" / "high_quality_off" — not part of the material-icons-extended
+// artifact this project already depends on, so vendored here instead of via Icons.Filled.*.
+
+internal val HighQualityIcon: ImageVector by lazy {
+ ImageVector.Builder(
+ name = "high_quality",
+ defaultWidth = 24.dp,
+ defaultHeight = 24.dp,
+ viewportWidth = 24f,
+ viewportHeight = 24f
+ )
+ .apply {
+ path(
+ fill = SolidColor(Color.Black),
+ fillAlpha = 1f,
+ stroke = null,
+ strokeAlpha = 1f,
+ strokeLineWidth = 1f,
+ strokeLineCap = StrokeCap.Butt,
+ strokeLineJoin = StrokeJoin.Bevel,
+ strokeLineMiter = 1f,
+ pathFillType = PathFillType.NonZero
+ ) {
+ moveTo(14.75f, 16.5f)
+ horizontalLineToRelative(1.5f)
+ verticalLineTo(15f)
+ horizontalLineTo(17f)
+ quadToRelative(0.43f, 0f, 0.71f, -0.29f)
+ reflectiveQuadTo(18f, 14f)
+ verticalLineTo(10f)
+ quadTo(18f, 9.57f, 17.71f, 9.29f)
+ reflectiveQuadTo(17f, 9f)
+ horizontalLineTo(14f)
+ quadTo(13.58f, 9f, 13.29f, 9.29f)
+ reflectiveQuadTo(13f, 10f)
+ verticalLineToRelative(4f)
+ quadToRelative(0f, 0.42f, 0.29f, 0.71f)
+ reflectiveQuadTo(14f, 15f)
+ horizontalLineToRelative(0.75f)
+ verticalLineToRelative(1.5f)
+ close()
+ moveTo(6f, 15f)
+ horizontalLineTo(7.5f)
+ verticalLineTo(13f)
+ horizontalLineToRelative(2f)
+ verticalLineToRelative(2f)
+ horizontalLineTo(11f)
+ verticalLineTo(9f)
+ horizontalLineTo(9.5f)
+ verticalLineToRelative(2.5f)
+ horizontalLineToRelative(-2f)
+ verticalLineTo(9f)
+ horizontalLineTo(6f)
+ verticalLineToRelative(6f)
+ close()
+ moveToRelative(8.5f, -1.5f)
+ verticalLineToRelative(-3f)
+ horizontalLineToRelative(2f)
+ verticalLineToRelative(3f)
+ horizontalLineToRelative(-2f)
+ close()
+ moveTo(4f, 20f)
+ quadTo(3.18f, 20f, 2.59f, 19.41f)
+ reflectiveQuadTo(2f, 18f)
+ verticalLineTo(6f)
+ quadTo(2f, 5.18f, 2.59f, 4.59f)
+ reflectiveQuadTo(4f, 4f)
+ horizontalLineTo(20f)
+ quadToRelative(0.83f, 0f, 1.41f, 0.59f)
+ quadTo(22f, 5.18f, 22f, 6f)
+ verticalLineTo(18f)
+ quadToRelative(0f, 0.82f, -0.59f, 1.41f)
+ reflectiveQuadTo(20f, 20f)
+ horizontalLineTo(4f)
+ close()
+ moveTo(4f, 18f)
+ horizontalLineTo(20f)
+ verticalLineTo(6f)
+ horizontalLineTo(4f)
+ verticalLineTo(18f)
+ close()
+ moveToRelative(0f, 0f)
+ verticalLineTo(6f)
+ verticalLineTo(18f)
+ close()
+ }
+ }
+ .build()
+}
+
+internal val HighQualityOffIcon: ImageVector by lazy {
+ ImageVector.Builder(
+ name = "high_quality_off",
+ defaultWidth = 24.dp,
+ defaultHeight = 24.dp,
+ viewportWidth = 24f,
+ viewportHeight = 24f
+ )
+ .apply {
+ path(
+ fill = SolidColor(Color.Black),
+ fillAlpha = 1f,
+ stroke = null,
+ strokeAlpha = 1f,
+ strokeLineWidth = 1f,
+ strokeLineCap = StrokeCap.Butt,
+ strokeLineJoin = StrokeJoin.Bevel,
+ strokeLineMiter = 1f,
+ pathFillType = PathFillType.NonZero
+ ) {
+ moveTo(18f, 15.15f)
+ lineToRelative(-1.5f, -1.5f)
+ verticalLineTo(10.5f)
+ horizontalLineToRelative(-2f)
+ verticalLineToRelative(1.15f)
+ lineTo(13f, 10.15f)
+ verticalLineTo(9.92f)
+ quadToRelative(0f, -0.4f, 0.29f, -0.66f)
+ reflectiveQuadTo(14f, 9f)
+ horizontalLineToRelative(3f)
+ quadToRelative(0.43f, 0f, 0.71f, 0.29f)
+ reflectiveQuadTo(18f, 10f)
+ verticalLineToRelative(5.15f)
+ close()
+ moveTo(6f, 15f)
+ verticalLineTo(9f)
+ horizontalLineTo(7.5f)
+ verticalLineToRelative(2.5f)
+ horizontalLineToRelative(2f)
+ verticalLineToRelative(-2f)
+ lineTo(11f, 11f)
+ verticalLineToRelative(4f)
+ horizontalLineTo(9.5f)
+ verticalLineTo(13f)
+ horizontalLineToRelative(-2f)
+ verticalLineToRelative(2f)
+ horizontalLineTo(6f)
+ close()
+ moveTo(4f, 20f)
+ quadTo(3.18f, 20f, 2.59f, 19.41f)
+ reflectiveQuadTo(2f, 18f)
+ verticalLineTo(6f)
+ quadTo(2f, 5.18f, 2.59f, 4.59f)
+ reflectiveQuadTo(4f, 4f)
+ lineTo(6f, 6f)
+ horizontalLineTo(4f)
+ verticalLineTo(18f)
+ horizontalLineTo(15.15f)
+ lineTo(1.4f, 4.2f)
+ lineTo(2.8f, 2.8f)
+ lineTo(21.2f, 21.2f)
+ lineToRelative(-1.4f, 1.4f)
+ lineTo(17.15f, 20f)
+ horizontalLineTo(4f)
+ close()
+ moveTo(21.75f, 18.9f)
+ lineTo(20f, 17.15f)
+ verticalLineTo(6f)
+ horizontalLineTo(8.85f)
+ lineToRelative(-2f, -2f)
+ horizontalLineTo(20f)
+ quadToRelative(0.83f, 0f, 1.41f, 0.59f)
+ quadTo(22f, 5.18f, 22f, 6f)
+ verticalLineTo(17.9f)
+ quadToRelative(0f, 0.28f, -0.05f, 0.53f)
+ reflectiveQuadToRelative(-0.2f, 0.47f)
+ close()
+ moveTo(14.43f, 11.58f)
+ close()
+ moveTo(9.58f, 12.43f)
+ close()
+ }
+ }
+ .build()
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/LargePreview.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/LargePreview.kt
new file mode 100644
index 00000000000..cc5a244e405
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/LargePreview.kt
@@ -0,0 +1,336 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import androidx.annotation.OptIn
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.fillMaxSize
+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.layout.width
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.net.toUri
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.media3.common.MediaItem
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import com.nextcloud.talk.R
+
+private const val LARGE_MAX_HEIGHT_DP = 480
+private const val LARGE_VERTICAL_SPACING_DP = 12
+private const val LARGE_ICON_SIZE_DP = 96
+private const val LARGE_OTHER_BORDER_WIDTH_DP = 1
+private const val LARGE_OTHER_BORDER_PADDING_DP = 24
+private const val DETAIL_CHIP_CORNER_RADIUS_PERCENT = 50
+private const val DETAIL_CHIP_TEXT_FADE_DURATION_MS = 200
+private const val DETAIL_CHIP_PULSE_SCALE = 1.04f
+private const val DETAIL_CHIP_PULSE_DURATION_MS = 150
+private const val PLAY_BUTTON_SIZE_DP = 56
+private const val PLAY_ICON_SIZE_DP = 32
+private const val CLOSE_BUTTON_SIZE_DP = 40
+private const val CLOSE_ICON_SIZE_DP = 20
+private const val CLOSE_BUTTON_PADDING_DP = 8
+
+/** The large, swipeable preview shown above the thumbnail strip. */
+@Composable
+internal fun LargePreview(descriptions: List, pagerState: PagerState, modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(vertical = LARGE_VERTICAL_SPACING_DP.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ HorizontalPager(
+ state = pagerState,
+ key = { page -> descriptions.getOrNull(page)?.uri ?: page },
+ modifier = Modifier
+ .fillMaxSize()
+ .heightIn(max = LARGE_MAX_HEIGHT_DP.dp)
+ ) { page ->
+ descriptions.getOrNull(page)?.let { description -> LargePage(description) }
+ }
+ }
+}
+
+@Composable
+private fun LargePage(description: FileDescription) {
+ var isPlayingVideo by remember(description.uri, description.detail) { mutableStateOf(false) }
+
+ BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ var cardModifier = description.aspectRatio?.let { ratio ->
+ val (cardWidth, cardHeight) = fitWithinBounds(maxWidth, maxHeight, ratio)
+ Modifier.size(cardWidth, cardHeight)
+ } ?: Modifier.fillMaxSize()
+
+ // Non-media files have no image content of their own to visually delimit the card, so
+ // without an explicit outline the icon would appear to float directly on the dialog's
+ // background instead of reading as a bounded preview, unlike images/videos.
+ if (description.kind == MediaKind.OTHER) {
+ cardModifier = cardModifier
+ .padding(LARGE_OTHER_BORDER_PADDING_DP.dp)
+ .border(
+ width = LARGE_OTHER_BORDER_WIDTH_DP.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ shape = RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp)
+ )
+ }
+
+ Box(modifier = cardModifier) {
+ if (description.kind == MediaKind.VIDEO && isPlayingVideo) {
+ VideoPlayerCard(
+ uri = description.uri,
+ onStopped = { isPlayingVideo = false },
+ modifier = Modifier.fillMaxSize()
+ )
+ } else {
+ FileThumbnailImage(
+ description,
+ iconSize = LARGE_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 ->
+ LargeDetailOverlay(
+ detail,
+ description.alternateDetail,
+ 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, onStopped: () -> Unit, modifier: Modifier = Modifier) {
+ val context = LocalContext.current
+ val exoPlayer = remember(uri) { ExoPlayer.Builder(context).build() }
+
+ DisposableEffect(exoPlayer) {
+ onDispose { exoPlayer.release() }
+ }
+
+ // A still-active hardware video decoder competing with e.g. the system camera's own hardware
+ // encoder (launched on top of this dialog to take a photo/video, backgrounding it for as long
+ // as that takes) for the same limited codec resources can wedge the device's camera/GPU stack
+ // on some hardware. Stop decoding as soon as this screen isn't visible, not just on dispose —
+ // ON_STOP fires while backgrounded, well before the Composable would ever leave composition.
+ val lifecycleOwner = LocalLifecycleOwner.current
+ DisposableEffect(lifecycleOwner, exoPlayer) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_STOP) {
+ exoPlayer.stop()
+ onStopped()
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ // 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()
+ }
+
+ Box(modifier = modifier.clip(RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp))) {
+ AndroidView(
+ factory = { ctx ->
+ PlayerView(ctx).apply {
+ player = exoPlayer
+ useController = true
+ }
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+
+ CloseButtonOverlay(
+ onClick = {
+ exoPlayer.stop()
+ onStopped()
+ },
+ modifier = Modifier.align(Alignment.TopEnd)
+ )
+ }
+}
+
+@Composable
+private fun CloseButtonOverlay(onClick: () -> Unit, modifier: Modifier = Modifier) {
+ IconButton(
+ onClick = onClick,
+ modifier = modifier
+ .padding(CLOSE_BUTTON_PADDING_DP.dp)
+ .size(CLOSE_BUTTON_SIZE_DP.dp)
+ .clip(CircleShape)
+ .background(Color.Black.copy(alpha = 0.5f))
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Close,
+ contentDescription = stringResource(R.string.nc_close_video_playback),
+ tint = Color.White,
+ modifier = Modifier.size(CLOSE_ICON_SIZE_DP.dp)
+ )
+ }
+}
+
+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 LargeDetailOverlay(detail: String, alternateDetail: String?, modifier: Modifier = Modifier) {
+ val scale = remember { Animatable(1f) }
+ // The compressed-size estimate for `detail` is computed asynchronously (Dispatchers.IO), so it
+ // lands a moment after the HQ button is tapped — keying off `detail` itself (rather than e.g. a
+ // toggle counter bumped synchronously on tap) makes the pulse play exactly when the text is
+ // about to change, instead of firing early and having the text/size catch up unanimated later.
+ var hasComposedBefore by remember { mutableStateOf(false) }
+ LaunchedEffect(detail) {
+ if (hasComposedBefore) {
+ // A small, smooth up-and-back pulse (no spring/overshoot) — just enough to acknowledge
+ // the value changed without drawing much attention to itself.
+ scale.animateTo(DETAIL_CHIP_PULSE_SCALE, animationSpec = tween(DETAIL_CHIP_PULSE_DURATION_MS))
+ scale.animateTo(1f, animationSpec = tween(DETAIL_CHIP_PULSE_DURATION_MS))
+ }
+ hasComposedBefore = true
+ }
+
+ // Reserves width for whichever of the two variants is wider, measured directly rather than
+ // rendered as an extra invisible sibling — the chip then never resizes when toggling HQ swaps
+ // which variant is shown below.
+ val textMeasurer = rememberTextMeasurer()
+ val textStyle = MaterialTheme.typography.labelMedium
+ val density = LocalDensity.current
+ val contentWidth = remember(detail, alternateDetail, textStyle, density) {
+ val detailWidthPx = textMeasurer.measure(detail, textStyle).size.width
+ val alternateWidthPx = alternateDetail?.let { textMeasurer.measure(it, textStyle).size.width } ?: 0
+ with(density) { maxOf(detailWidthPx, alternateWidthPx).toDp() }
+ }
+
+ Box(
+ modifier = modifier
+ .graphicsLayer {
+ scaleX = scale.value
+ scaleY = scale.value
+ }
+ .clip(RoundedCornerShape(DETAIL_CHIP_CORNER_RADIUS_PERCENT))
+ .background(Color.Black.copy(alpha = 0.6f))
+ .padding(horizontal = 10.dp, vertical = 6.dp)
+ ) {
+ // AnimatedContent doesn't animate its very first composition, so no extra guard is needed
+ // here the way the pulse above needs `hasComposedBefore`.
+ AnimatedContent(
+ targetState = detail,
+ transitionSpec = {
+ fadeIn(tween(DETAIL_CHIP_TEXT_FADE_DURATION_MS))
+ .togetherWith(fadeOut(tween(DETAIL_CHIP_TEXT_FADE_DURATION_MS)))
+ },
+ label = "detailChipFade"
+ ) { animatedDetail ->
+ DetailText(animatedDetail, Modifier.width(contentWidth))
+ }
+ }
+}
+
+@Composable
+private fun DetailText(text: String, modifier: Modifier = Modifier) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelMedium,
+ color = Color.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.Center,
+ modifier = modifier
+ )
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStrip.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStrip.kt
new file mode 100644
index 00000000000..7d4feb80aff
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStrip.kt
@@ -0,0 +1,335 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+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.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+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.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import com.nextcloud.talk.R
+import kotlin.math.roundToInt
+
+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 INDICATOR_WIDTH_DP = 3
+private const val INDICATOR_GAP_OFFSET_DP = 5
+
+@Suppress("LongParameterList")
+@Composable
+internal 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() }
+
+ // With no file thumbnails shown (single-file case, handled below), the action tiles are the
+ // only content — center them instead of leaving them stuck to the start like a scrollable list.
+ val horizontalArrangement = if (descriptions.size > 1) {
+ Arrangement.spacedBy(STRIP_ITEM_SPACING_DP.dp)
+ } else {
+ Arrangement.spacedBy(STRIP_ITEM_SPACING_DP.dp, Alignment.CenterHorizontally)
+ }
+
+ LazyRow(
+ horizontalArrangement = horizontalArrangement,
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ // With a single file it's already shown in full in the large preview above, and the
+ // "selected" thumbnail styling (dark overlay + trash icon) would otherwise cover the
+ // only thumbnail permanently — looking like the file is already marked for deletion
+ // rather than just being the one file that was picked.
+ if (descriptions.size > 1) {
+ 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)
+}
+
+@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)
+ ) {
+ 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(
+ painter = painterResource(R.drawable.baseline_photo_library_24),
+ 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 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)
+ )
+ }
+}
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 2710b73b47f..f91014fcf95 100644
--- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
+++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
@@ -94,11 +94,11 @@ 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
import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.attachmentpreview.FileAttachmentPreviewFragment
import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.chat.data.model.FileParameters
import com.nextcloud.talk.chat.ui.ChatEmptyState
@@ -159,7 +159,6 @@ import com.nextcloud.talk.ui.chat.ChatView
import com.nextcloud.talk.ui.chat.ChatViewCallbacks
import com.nextcloud.talk.ui.chat.ChatViewState
import com.nextcloud.talk.ui.dialog.DateTimeCompose
-import com.nextcloud.talk.ui.dialog.FileAttachmentPreviewFragment
import com.nextcloud.talk.ui.dialog.GetPinnedOptionsDialog
import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment
import com.nextcloud.talk.ui.theme.LocalMessageUtils
@@ -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
@@ -2330,19 +2329,12 @@ 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()
+ filesToUpload.map { it.toString() }.toMutableList(),
+ currentConversation?.displayName ?: ""
)
- newFragment.setListener { files, caption ->
- uploadFiles(files, caption)
+ newFragment.setListener { files, caption, compressImages ->
+ uploadFiles(files, caption, compressImages)
}
newFragment.show(supportFragmentManager, FileAttachmentPreviewFragment.TAG)
} catch (e: IllegalStateException) {
@@ -2410,33 +2402,25 @@ 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")
}
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(
- filenamesWithLineBreaks.toString(),
- filesToUpload
+ filesToUpload,
+ currentConversation?.displayName ?: ""
)
- newFragment.setListener { files, caption -> uploadFiles(files, caption) }
+ newFragment.setListener { files, caption, compressImages ->
+ uploadFiles(files, caption, compressImages)
+ }
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
+ )
}
}
@@ -3824,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)
+ }
+ }
+ }
}
}
@@ -3851,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)
}
}
@@ -3944,13 +3942,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 +3958,8 @@ class ChatActivity :
caption,
roomToken,
replyToMessageId,
- displayName
+ displayName,
+ compressImages
)
cancelReply()
}
@@ -3996,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/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt
index 0eb664ba8f2..434b1160dcb 100644
--- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt
+++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt
@@ -1972,7 +1972,8 @@ class ChatViewModel @AssistedInject constructor(
caption: String = "",
roomToken: String = "",
replyToMessageId: Int? = null,
- displayName: String
+ displayName: String,
+ compressImages: Boolean = false
) {
val metaDataMap = mutableMapOf()
var room = ""
@@ -2004,7 +2005,8 @@ class ChatViewModel @AssistedInject constructor(
fileUri,
room,
displayName,
- metaData
+ metaData,
+ compressImages
)
} catch (e: IllegalArgumentException) {
Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt
index 9a7e2f0d181..21f36c889b3 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 b2a5195a497..43b93e9e5d0 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/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt
index c311a9e4cd9..9236b6bfd85 100644
--- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt
+++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt
@@ -11,6 +11,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.nextcloud.talk.account.viewmodels.BrowserLoginActivityViewModel
import com.nextcloud.talk.activities.CallViewModel
+import com.nextcloud.talk.attachmentpreview.FileAttachmentPreviewViewModel
import com.nextcloud.talk.chat.viewmodels.ScheduledMessagesViewModel
import com.nextcloud.talk.chooseaccount.viewmodel.StatusMessageViewModel
import com.nextcloud.talk.chooseaccount.viewmodel.StatusViewModel
@@ -215,6 +216,11 @@ abstract class ViewModelModule {
@IntoMap
@ViewModelKey(ChooseAccountShareToViewModel::class)
abstract fun chooseAccountShareToViewModel(viewModel: ChooseAccountShareToViewModel): ViewModel
+
+ @Binds
+ @IntoMap
+ @ViewModelKey(FileAttachmentPreviewViewModel::class)
+ internal abstract fun fileAttachmentPreviewViewModel(viewModel: FileAttachmentPreviewViewModel): ViewModel
}
// @Module
diff --git a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt
index e84edc05379..9dffb5cca7d 100644
--- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt
+++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt
@@ -43,8 +43,10 @@ import com.nextcloud.talk.users.UserManager
import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.CapabilitiesUtil
import com.nextcloud.talk.utils.FileUtils
+import com.nextcloud.talk.utils.ImageCompressor
import com.nextcloud.talk.utils.NotificationUtils
import com.nextcloud.talk.utils.RemoteFileUtils
+import com.nextcloud.talk.utils.VideoCompressor
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld
@@ -115,15 +117,21 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
require(sourceFile.isNotEmpty())
checkNotNull(roomToken)
- val sourceFileUri = sourceFile.toUri()
+ var sourceFileUri = sourceFile.toUri()
fileName = FileUtils.getFileName(sourceFileUri, context)
file = FileUtils.getFileFromUri(context, sourceFileUri)
+
+ initNotificationSetup()
+
+ if (inputData.getBoolean(COMPRESS_IMAGES, false)) {
+ sourceFileUri = compressMediaIfPossible(sourceFileUri)
+ }
+
val remotePath = getRemotePath(currentUser)
val useConversationSubfolders = CapabilitiesUtil.hasConversationSubfoldersForAttachments(
currentUser.capabilities!!.spreedCapability!!
)
- initNotificationSetup()
file?.let { isChunkedUploading = it.length() > CHUNK_UPLOAD_THRESHOLD_SIZE }
val uploadSuccess: Boolean = uploadFile(sourceFileUri, metaData, remotePath, useConversationSubfolders)
@@ -146,6 +154,35 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
}
}
+ /**
+ * Replaces [file] and [fileName] with a compressed copy if [sourceFileUri] points to a
+ * compressible image or video, returning the [Uri] that should be uploaded.
+ */
+ @Suppress("ReturnCount")
+ private fun compressMediaIfPossible(sourceFileUri: Uri): Uri {
+ val originalFile = file ?: return sourceFileUri
+ val mimeType = FileUtils.resolveMimeType(context, sourceFileUri)
+
+ val compressedFile = when {
+ ImageCompressor.isCompressible(mimeType) -> ImageCompressor.compress(context, originalFile)
+ VideoCompressor.isCompressible(mimeType) -> compressVideoWithProgress(originalFile)
+ else -> null
+ } ?: return sourceFileUri
+
+ file = compressedFile
+ fileName = compressedFile.name
+ return Uri.fromFile(compressedFile)
+ }
+
+ /**
+ * Video compression can take a while, so the upload notification is repurposed to show its
+ * progress before it transitions into the actual upload progress.
+ */
+ private fun compressVideoWithProgress(originalFile: File): File? {
+ showCompressionStartedNotification()
+ return VideoCompressor.compress(context, originalFile, onProgress = ::onCompressionProgress)
+ }
+
private fun uploadFile(
sourceFileUri: Uri,
metaData: String?,
@@ -285,6 +322,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
NotificationUtils.NotificationChannels
.NOTIFICATION_CHANNEL_UPLOADS.name
)
+ notificationId = SystemClock.uptimeMillis().toInt()
}
private fun initNotificationWithPercentage() {
@@ -304,13 +342,54 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
)
.build()
- notificationId = SystemClock.uptimeMillis().toInt()
mNotifyManager!!.notify(notificationId, initNotification)
// only need one summary notification but multiple upload worker can call it more than once but it is safe
// because of the same notification object config and id.
makeSummaryNotification()
}
+ /**
+ * Shows the same upload notification, but reflecting the compression phase that precedes the
+ * actual upload. Reuses [notificationId] so it later morphs into the upload progress notification
+ * instead of appearing as a separate entry.
+ */
+ private fun showCompressionStartedNotification() {
+ val compressionNotification = mBuilder!!
+ .setContentTitle(context.resources.getString(R.string.nc_compress_in_progress))
+ .setContentText(getCompressionNotificationContentText(ZERO_PERCENT))
+ .setSmallIcon(R.drawable.upload_white)
+ .setOngoing(true)
+ .setProgress(HUNDRED_PERCENT, ZERO_PERCENT, false)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setGroup(NotificationUtils.KEY_UPLOAD_GROUP)
+ .setContentIntent(getIntentToOpenConversation())
+ .addAction(
+ R.drawable.ic_cancel_white_24dp,
+ getResourceString(context, R.string.nc_cancel),
+ getCancelUploadIntent()
+ )
+ .build()
+
+ mNotifyManager!!.notify(notificationId, compressionNotification)
+ makeSummaryNotification()
+ }
+
+ private fun onCompressionProgress(percentage: Int) {
+ val progressUpdateNotification = mBuilder!!
+ .setProgress(HUNDRED_PERCENT, percentage, false)
+ .setContentText(getCompressionNotificationContentText(percentage))
+ .build()
+
+ mNotifyManager!!.notify(notificationId, progressUpdateNotification)
+ }
+
+ private fun getCompressionNotificationContentText(percentage: Int): String =
+ String.format(
+ getResourceString(context, R.string.nc_compress_notification_text),
+ getShortenedFileName(),
+ percentage
+ )
+
private fun makeSummaryNotification() {
// summary notification encapsulating the group of notifications
val summaryNotification = NotificationCompat.Builder(
@@ -374,11 +453,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
intent.putExtras(bundle)
val requestCode = System.currentTimeMillis().toInt()
- val intentFlag: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- PendingIntent.FLAG_MUTABLE
- } else {
- 0
- }
+ val intentFlag = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getActivity(context, requestCode, intent, intentFlag)
}
@@ -413,6 +488,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
private const val ROOM_TOKEN = "ROOM_TOKEN"
private const val CONVERSATION_NAME = "CONVERSATION_NAME"
private const val META_DATA = "META_DATA"
+ private const val COMPRESS_IMAGES = "COMPRESS_IMAGES"
private const val CHUNK_UPLOAD_THRESHOLD_SIZE: Long = 1024 * 1024
private const val NOTIFICATION_FILE_NAME_MAX_LENGTH = 20
private const val THREE_DOTS = "…"
@@ -465,12 +541,19 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
}
}
- fun upload(fileUri: String, roomToken: String, conversationName: String, metaData: String?) {
+ fun upload(
+ fileUri: String,
+ roomToken: String,
+ conversationName: String,
+ metaData: String?,
+ compressImages: Boolean = false
+ ) {
val data: Data = Data.Builder()
.putString(DEVICE_SOURCE_FILE, fileUri)
.putString(ROOM_TOKEN, roomToken)
.putString(CONVERSATION_NAME, conversationName)
.putString(META_DATA, metaData)
+ .putBoolean(COMPRESS_IMAGES, compressImages)
.build()
val uploadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(UploadAndShareFilesWorker::class.java)
.setInputData(data)
diff --git a/app/src/main/java/com/nextcloud/talk/models/TakePictureViewModel.java b/app/src/main/java/com/nextcloud/talk/models/TakePictureViewModel.java
deleted file mode 100644
index e4dcd5cc187..00000000000
--- 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 e743d326e13..df36d6bbf37 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/settings/SettingsActivity.kt b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt
index 1f25c1c6060..03252e00c40 100644
--- a/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt
+++ b/app/src/main/java/com/nextcloud/talk/settings/SettingsActivity.kt
@@ -1293,6 +1293,7 @@ class SettingsActivity :
appPreferences.setIncognitoKeyboard(!isChecked)
}
+ setupMediaQualitySetting()
setupPhoneBookIntegrationSetting()
binding.settingsScreenSecuritySwitch.isChecked = appPreferences.isScreenSecured
@@ -1318,6 +1319,90 @@ class SettingsActivity :
}
}
+ private fun setupMediaQualitySetting() {
+ updateMediaQualitySummary()
+ binding.settingsMediaQuality.setOnClickListener {
+ showMediaQualityDialog()
+ }
+ }
+
+ private fun updateMediaQualitySummary() {
+ binding.settingsMediaQualityDesc.text = if (appPreferences.compressUploadImages) {
+ getString(R.string.nc_media_quality_reduced)
+ } else {
+ getString(R.string.nc_media_quality_original)
+ }
+ }
+
+ private fun showMediaQualityDialog() {
+ var dialog: AlertDialog? = null
+ val view = buildMediaQualityDialogContentView { reducedQuality ->
+ appPreferences.setCompressUploadImages(reducedQuality)
+ updateMediaQualitySummary()
+ dialog?.dismiss()
+ }
+ val dialogBuilder = MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.nc_settings_media_quality_title)
+ .setView(view)
+ viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder)
+ dialog = dialogBuilder.show()
+ }
+
+ // AlertDialog/MaterialAlertDialogBuilder doesn't reliably render both setMessage(...) and
+ // setSingleChoiceItems(...) together (the list ends up empty), so the hint and the two
+ // options are laid out manually here instead, mirroring buildShareDialogContentView's style.
+ private fun buildMediaQualityDialogContentView(
+ onSelect: (reducedQuality: Boolean) -> Unit
+ ): android.widget.LinearLayout {
+ val density = resources.displayMetrics.density
+ fun dp(value: Int) = (value * density).toInt()
+ val selectableBackground = with(android.util.TypedValue()) {
+ theme.resolveAttribute(android.R.attr.selectableItemBackground, this, true)
+ resourceId
+ }
+
+ fun optionRow(label: String, reducedQuality: Boolean) =
+ android.widget.LinearLayout(this).apply {
+ orientation = android.widget.LinearLayout.HORIZONTAL
+ gravity = android.view.Gravity.CENTER_VERTICAL
+ val h = dp(DIALOG_PADDING_H_DP)
+ val v = dp(DIALOG_PADDING_V_DP)
+ setPadding(h, v, h, v)
+ setBackgroundResource(selectableBackground)
+ isClickable = true
+ isFocusable = true
+ addView(
+ android.widget.RadioButton(context).apply {
+ isChecked = appPreferences.compressUploadImages == reducedQuality
+ isClickable = false
+ }
+ )
+ addView(
+ android.widget.TextView(context).apply {
+ text = label
+ setPadding(dp(DIALOG_SPACING_DP), 0, 0, 0)
+ setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
+ }
+ )
+ setOnClickListener { onSelect(reducedQuality) }
+ }
+
+ return android.widget.LinearLayout(this).apply {
+ orientation = android.widget.LinearLayout.VERTICAL
+ addView(
+ android.widget.TextView(context).apply {
+ text = getString(R.string.nc_media_quality_original_hint)
+ val h = dp(DIALOG_PADDING_H_DP)
+ val v = dp(DIALOG_PADDING_V_DP)
+ setPadding(h, v, h, v)
+ setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
+ }
+ )
+ addView(optionRow(getString(R.string.nc_media_quality_original), reducedQuality = false))
+ addView(optionRow(getString(R.string.nc_media_quality_reduced), reducedQuality = true))
+ }
+ }
+
private fun setupPhoneBookIntegrationSetting() {
binding.settingsPhoneBookIntegrationSwitch.isChecked = appPreferences.isPhoneBookIntegrationEnabled
binding.settingsPhoneBookIntegration.setOnClickListener {
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
deleted file mode 100644
index c23bba00a35..00000000000
--- a/app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewFragment.kt
+++ /dev/null
@@ -1,95 +0,0 @@
-/*
- * Nextcloud Talk - Android Client
- *
- * SPDX-FileCopyrightText: 2023 Julius Linus
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-package com.nextcloud.talk.ui.dialog
-
-import android.app.Dialog
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-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 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
-
- @Inject
- lateinit var permissionUtil: PlatformPermissionUtil
-
- @Inject
- lateinit var viewThemeUtils: ViewThemeUtils
-
- fun setListener(uploadFiles: (files: MutableList, caption: String) -> 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()
- }
-
- 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)
- }
-
- 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)
- }
-
- private fun setUpListeners() {
- binding.buttonClose.setOnClickListener {
- dismiss()
- }
-
- binding.buttonSend.setOnClickListener {
- val caption: String = binding.dialogFileAttachmentPreviewCaption.text.toString()
- uploadFiles(filesList, caption)
- dismiss()
- }
- }
-
- 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 {
- 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
- }
-}
diff --git a/app/src/main/java/com/nextcloud/talk/utils/BitmapShrinker.kt b/app/src/main/java/com/nextcloud/talk/utils/BitmapShrinker.kt
index 1481b9a6f2b..410f98ce080 100644
--- a/app/src/main/java/com/nextcloud/talk/utils/BitmapShrinker.kt
+++ b/app/src/main/java/com/nextcloud/talk/utils/BitmapShrinker.kt
@@ -8,20 +8,70 @@ package com.nextcloud.talk.utils
import android.graphics.Bitmap
import android.graphics.BitmapFactory
+import android.graphics.ImageDecoder
import android.graphics.Matrix
+import android.os.Build
import android.util.Log
+import androidx.annotation.RequiresApi
import androidx.exifinterface.media.ExifInterface
+import java.io.File
import java.io.IOException
+import kotlin.math.roundToInt
object BitmapShrinker {
- private val TAG = "BitmapShrinker"
+ private const val TAG = "BitmapShrinker"
private const val DEGREES_90 = 90f
private const val DEGREES_180 = 180f
private const val DEGREES_270 = 270f
+ /**
+ * Decodes the image at [path], downscaled to fit within [reqWidth]x[reqHeight].
+ *
+ * On API 28+ this uses [ImageDecoder], which scales in a single pass and applies EXIF
+ * orientation for JPEG/HEIC automatically. Older platform versions fall back to the
+ * [BitmapFactory] two-pass sampling approach with manual EXIF rotation.
+ *
+ * [requireSoftwareBitmap] must be set when the result will be read or re-encoded (e.g. via
+ * [Bitmap.compress]), since [Bitmap.Config.HARDWARE] bitmaps do not support that.
+ */
@JvmStatic
- fun shrinkBitmap(path: String, reqWidth: Int, reqHeight: Int): Bitmap? {
+ @JvmOverloads
+ fun shrinkBitmap(path: String, reqWidth: Int, reqHeight: Int, requireSoftwareBitmap: Boolean = false): Bitmap? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ decodeWithImageDecoder(path, reqWidth, reqHeight, requireSoftwareBitmap)
+ } else {
+ decodeWithBitmapFactory(path, reqWidth, reqHeight)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.P)
+ private fun decodeWithImageDecoder(
+ path: String,
+ reqWidth: Int,
+ reqHeight: Int,
+ requireSoftwareBitmap: Boolean
+ ): Bitmap? =
+ try {
+ val source = ImageDecoder.createSource(File(path))
+ ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
+ val (width, height) = fitWithinBounds(info.size.width, info.size.height, reqWidth, reqHeight)
+ decoder.setTargetSize(width, height)
+ if (requireSoftwareBitmap) {
+ decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
+ }
+ }
+ } catch (e: IOException) {
+ Log.e(TAG, "Failed to decode bitmap from path: $path", e)
+ null
+ }
+
+ private fun fitWithinBounds(width: Int, height: Int, reqWidth: Int, reqHeight: Int): Pair {
+ if (width <= reqWidth && height <= reqHeight) return width to height
+ val scale = minOf(reqWidth.toDouble() / width, reqHeight.toDouble() / height)
+ return maxOf(1, (width * scale).roundToInt()) to maxOf(1, (height * scale).roundToInt())
+ }
+
+ private fun decodeWithBitmapFactory(path: String, reqWidth: Int, reqHeight: Int): Bitmap? {
val bitmap = decodeBitmap(path, reqWidth, reqHeight) ?: return null
return rotateBitmap(path, bitmap)
}
@@ -78,8 +128,8 @@ object BitmapShrinker {
bitmap,
0,
0,
- bitmap.getWidth(),
- bitmap.getHeight(),
+ bitmap.width,
+ bitmap.height,
matrix,
true
)
diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt
index ee9382e6b8d..fe6b4ea3af4 100644
--- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt
+++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt
@@ -14,6 +14,7 @@ import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
+import android.webkit.MimeTypeMap
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
@@ -201,6 +202,17 @@ object FileUtils {
return filename
}
+ /**
+ * Resolves the MIME type of [uri]. [ContentResolver.getType] only resolves `content://` URIs, so for
+ * `file://` URIs (e.g. the in-app camera writes plain files) this falls back to guessing from the
+ * file extension.
+ */
+ fun resolveMimeType(context: Context, uri: Uri): String? =
+ context.contentResolver.getType(uri)
+ ?: MimeTypeMap.getSingleton().getMimeTypeFromExtension(
+ MimeTypeMap.getFileExtensionFromUrl(uri.toString()).lowercase()
+ )
+
@JvmStatic
fun md5Sum(file: File): String {
val temp = file.name + file.lastModified() + file.length()
diff --git a/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt b/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt
new file mode 100644
index 00000000000..311985625f5
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt
@@ -0,0 +1,87 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2017-2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.utils
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.util.Log
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.IOException
+
+/**
+ * Downscales and re-encodes images as JPEG to reduce their upload size.
+ */
+object ImageCompressor {
+
+ private val TAG = ImageCompressor::class.java.simpleName
+ private const val MAX_DIMENSION = 2048
+ private const val JPEG_QUALITY = 80
+ private const val COMPRESSED_FILE_SUFFIX = "_compressed.jpg"
+
+ data class ImageInfo(val width: Int, val height: Int, val sizeBytes: Long)
+
+ fun isCompressible(mimeType: String?): Boolean =
+ mimeType != null && mimeType.startsWith(Mimetype.IMAGE_PREFIX) && mimeType != Mimetype.IMAGE_GIF
+
+ /**
+ * Reads the on-disk size and pixel dimensions of [sourceFile] without fully decoding it.
+ */
+ fun readImageInfo(sourceFile: File): ImageInfo? {
+ val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ BitmapFactory.decodeFile(sourceFile.absolutePath, options)
+ if (options.outWidth <= 0 || options.outHeight <= 0) return null
+ return ImageInfo(options.outWidth, options.outHeight, sourceFile.length())
+ }
+
+ /**
+ * Computes the dimensions and size [sourceFile] would have after [compress], without writing anything to disk.
+ */
+ fun estimateCompression(sourceFile: File): ImageInfo? {
+ val (bitmap, bytes) = encode(sourceFile) ?: return null
+ return try {
+ ImageInfo(bitmap.width, bitmap.height, bytes.size.toLong())
+ } finally {
+ bitmap.recycle()
+ }
+ }
+
+ /**
+ * Returns a downscaled, JPEG re-encoded copy of [sourceFile] in the app cache directory,
+ * or null if the image could not be decoded or written.
+ */
+ fun compress(context: Context, sourceFile: File): File? {
+ val (bitmap, bytes) = encode(sourceFile) ?: return null
+ return try {
+ val compressedFile = File(context.cacheDir, sourceFile.nameWithoutExtension + COMPRESSED_FILE_SUFFIX)
+ compressedFile.writeBytes(bytes)
+ compressedFile
+ } catch (e: IOException) {
+ Log.e(TAG, "failed to write compressed image for ${sourceFile.name}", e)
+ null
+ } finally {
+ bitmap.recycle()
+ }
+ }
+
+ private fun encode(sourceFile: File): Pair? {
+ val bitmap = BitmapShrinker.shrinkBitmap(
+ sourceFile.absolutePath,
+ MAX_DIMENSION,
+ MAX_DIMENSION,
+ requireSoftwareBitmap = true
+ )
+ if (bitmap == null) {
+ Log.w(TAG, "could not decode ${sourceFile.name} for compression")
+ return null
+ }
+ val output = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, output)
+ return bitmap to output.toByteArray()
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/utils/PickImage.kt b/app/src/main/java/com/nextcloud/talk/utils/PickImage.kt
index cfe44fa84b8..3d148e2f39d 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/java/com/nextcloud/talk/utils/VideoCompressor.kt b/app/src/main/java/com/nextcloud/talk/utils/VideoCompressor.kt
new file mode 100644
index 00000000000..428bc466aec
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/utils/VideoCompressor.kt
@@ -0,0 +1,221 @@
+/*
+ * 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_FRAME_RATE_FPS = 24
+ 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? {
+ Log.d(TAG, "reading video info for ${sourceFile.name}")
+ 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? {
+ Log.d(TAG, "estimating compression for ${sourceFile.name}")
+ 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? {
+ Log.d(TAG, "compressing ${sourceFile.name}")
+ 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 {
+ Log.d(TAG, "scaling dimensions for ${width}x$height")
+ 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 {
+ Log.d(TAG, "starting video compression for ${sourceFile.name}")
+ 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
+ Log.d(TAG, "video compression completed for ${sourceFile.name}")
+ 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)
+
+ // Capping the frame rate (frames are dropped, not sped up) noticeably cuts the total
+ // amount of GL/encoder work per second during transcoding. Uncapped, a 30/60fps source
+ // keeps its full rate through the whole effects pipeline, which on some devices pegs
+ // the GPU hard enough that the entire UI appears frozen for the transcode's duration
+ // even though it's all running on a background thread.
+ val editedMediaItem = EditedMediaItem.Builder(MediaItem.fromUri(Uri.fromFile(sourceFile)))
+ .setFrameRate(TARGET_FRAME_RATE_FPS)
+ .setEffects(Effects(emptyList(), listOf(Presentation.createForShortSide(TARGET_SHORT_SIDE))))
+ .build()
+
+ transformer.start(editedMediaItem, outputFile.absolutePath)
+ pollProgress(handler, transformerRef.get(), latch, onProgress)
+ }
+
+ val timeoutMs = max(MIN_TIMEOUT_MS, durationMs * TIMEOUT_MULTIPLIER)
+ val completedInTime = latch.await(timeoutMs, TimeUnit.MILLISECONDS)
+ if (!completedInTime) {
+ Log.w(TAG, "video compression timed out for ${sourceFile.name}")
+ handler.post { transformerRef.get()?.cancel() }
+ }
+ return completedInTime && success
+ } finally {
+ handlerThread.quitSafely()
+ }
+ }
+
+ /**
+ * Polls [Transformer.getProgress] on its own looper thread every [PROGRESS_POLL_INTERVAL_MS]
+ * until [latch] counts down (transcode completed, failed, or was cancelled).
+ */
+ @OptIn(UnstableApi::class)
+ private fun pollProgress(
+ handler: Handler,
+ transformer: Transformer?,
+ latch: CountDownLatch,
+ onProgress: (Int) -> Unit
+ ) {
+ if (transformer == null || latch.count == 0L) return
+
+ val progressHolder = ProgressHolder()
+ if (transformer.getProgress(progressHolder) == Transformer.PROGRESS_STATE_AVAILABLE) {
+ onProgress(progressHolder.progress)
+ }
+ handler.postDelayed({ pollProgress(handler, transformer, latch, onProgress) }, PROGRESS_POLL_INTERVAL_MS)
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java
index 9cbce99381f..a81dca8297a 100644
--- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java
+++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java
@@ -134,6 +134,10 @@ public interface AppPreferences {
void setPhoneBookIntegration(boolean value);
+ boolean getCompressUploadImages();
+
+ void setCompressUploadImages(boolean value);
+
// TODO Remove in 13.0.0
void removeLinkPreviews();
diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt
index 33605e7ca26..6c363934a2d 100644
--- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt
+++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt
@@ -353,6 +353,18 @@ class AppPreferencesImpl(val context: Context) : AppPreferences {
}
}
+ override fun getCompressUploadImages(): Boolean =
+ runBlocking {
+ async { readBoolean(COMPRESS_UPLOAD_IMAGES, defaultValue = true).first() }
+ }.getCompleted()
+
+ override fun setCompressUploadImages(value: Boolean) =
+ runBlocking {
+ async {
+ writeBoolean(COMPRESS_UPLOAD_IMAGES, value)
+ }
+ }
+
override fun removeLinkPreviews() =
runBlocking {
async {
@@ -675,6 +687,7 @@ class AppPreferencesImpl(val context: Context) : AppPreferences {
const val INCOGNITO_KEYBOARD = "incognito_keyboard"
const val SHOW_ECOSYSTEM = "SHOW_ECOSYSTEM"
const val PHONE_BOOK_INTEGRATION = "phone_book_integration"
+ const val COMPRESS_UPLOAD_IMAGES = "compress_upload_images"
const val LINK_PREVIEWS = "link_previews"
const val SCREEN_LOCK_TIMEOUT = "screen_lock_timeout"
const val LOCK_TIMESTAMP = "lock_timestamp"
diff --git a/app/src/main/res/drawable/ic_baseline_flash_off_24.xml b/app/src/main/res/drawable/ic_baseline_flash_off_24.xml
deleted file mode 100644
index 360917b9660..00000000000
--- 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 9e2d0322c45..00000000000
--- 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 e3d2b743aab..00000000000
--- 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 2c845435dd7..00000000000
--- 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 4417d5d04c4..00000000000
--- 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 48f2b6b8c24..00000000000
--- 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_settings.xml b/app/src/main/res/layout/activity_settings.xml
index 6f496a4e6a0..b632d716ab3 100644
--- a/app/src/main/res/layout/activity_settings.xml
+++ b/app/src/main/res/layout/activity_settings.xml
@@ -862,6 +862,27 @@
android:textSize="@dimen/headline_text_size"
android:textStyle="bold" />
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/dialog_file_attachment_preview.xml b/app/src/main/res/layout/dialog_file_attachment_preview.xml
deleted file mode 100644
index ac6ed18ee01..00000000000
--- a/app/src/main/res/layout/dialog_file_attachment_preview.xml
+++ /dev/null
@@ -1,95 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 21af10e0da6..f7e08c1d6b6 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -159,6 +159,11 @@ How to translate with transifex:
screen_security
Incognito keyboard
Instructs keyboard to disable personalized learning (without guarantees)
+ Sent media quality
+ Original quality
+ Reduced quality
+ Sending original quality uses more mobile data.
+ Close video playback
read_privacy
Tap to unlock
Locked
@@ -665,6 +670,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
@@ -672,6 +679,9 @@ How to translate with transifex:
Video recording from %1$s
+
+ Picture from %1$s
+
Share location
Search location
@@ -785,15 +795,9 @@ 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
Bluetooth
@@ -938,6 +942,11 @@ How to translate with transifex:
started a call
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
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 408e6ea7bbb..1bd3240fcb4 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -94,11 +94,6 @@
- true
-
-