diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a48bf9c3a71..264683e9a72 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/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
index 2710b73b47f..e1760aad270 100644
--- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
+++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
@@ -94,7 +94,6 @@ import com.nextcloud.talk.BuildConfig
import com.nextcloud.talk.R
import com.nextcloud.talk.activities.BaseActivity
import com.nextcloud.talk.activities.CallActivity
-import com.nextcloud.talk.activities.TakePhotoActivity
import com.nextcloud.talk.adapters.messages.CallStartedMessageInterface
import com.nextcloud.talk.api.NcApi
import com.nextcloud.talk.api.NcApiCoroutines
@@ -390,7 +389,7 @@ class ChatActivity :
private val _participantPermissionsFlow = MutableStateFlow(null)
val participantPermissionsFlow: StateFlow = _participantPermissionsFlow.asStateFlow()
- private var videoURI: Uri? = null
+ private var pendingCameraUri: Uri? = null
private var pendingTargetMessageId: Long? = null
private var pendingTargetThreadId: Long? = null
private var pendingTargetSearchQuery: String? = null
@@ -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/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/FileAttachmentPreviewDialogCompose.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewDialogCompose.kt
new file mode 100644
index 00000000000..0fd32176c46
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewDialogCompose.kt
@@ -0,0 +1,1122 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.ui.dialog
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.content.res.Configuration
+import android.graphics.Bitmap
+import android.media.MediaMetadataRetriever
+import android.net.Uri
+import android.text.format.Formatter
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia
+import androidx.annotation.OptIn
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.PhotoCamera
+import androidx.compose.material.icons.filled.Videocam
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.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.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.compose.ui.zIndex
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
+import androidx.core.net.toUri
+import androidx.exifinterface.media.ExifInterface
+import androidx.media3.common.MediaItem
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import coil.compose.AsyncImage
+import com.nextcloud.talk.R
+import com.nextcloud.talk.utils.DrawableUtils
+import com.nextcloud.talk.utils.FileUtils
+import com.nextcloud.talk.utils.ImageCompressor
+import com.nextcloud.talk.utils.Mimetype
+import com.nextcloud.talk.utils.VideoCompressor
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.IOException
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import kotlin.math.roundToInt
+
+@Suppress("LongMethod", "LongParameterList")
+@Composable
+fun FileAttachmentPreviewContent(
+ files: List,
+ conversationName: String,
+ initialCompressImages: Boolean,
+ onDismiss: () -> Unit,
+ onSend: (files: List, caption: String, compressImages: Boolean) -> Unit
+) {
+ val context = LocalContext.current
+ val currentFiles = remember(files) { mutableStateListOf().apply { addAll(files) } }
+ val hasCompressibleMedia = currentFiles.any { isCompressible(FileUtils.resolveMimeType(context, it.toUri())) }
+ var caption by rememberSaveable { mutableStateOf("") }
+ var compressImages by rememberSaveable { mutableStateOf(hasCompressibleMedia && initialCompressImages) }
+ var hqToggleVersion by rememberSaveable { mutableStateOf(0) }
+
+ // Keyed by the set of files (not their order), so dragging to reorder doesn't trigger a re-describe
+ // round trip through Dispatchers.IO — that async gap was causing a brief mismatch between the
+ // still-old-ordered list and the already-updated drag state. Reordering just re-maps synchronously below.
+ val descriptionsByUri by produceState(
+ initialValue = emptyMap(),
+ currentFiles.toSet(),
+ compressImages
+ ) {
+ val filesSnapshot = currentFiles.toList()
+ value = withContext(Dispatchers.IO) {
+ filesSnapshot.associateWith { describeFile(context, it, compressImages) }
+ }
+ }
+ val fileDescriptions = currentFiles.mapNotNull { descriptionsByUri[it] }
+
+ val pagerState = rememberPagerState(pageCount = { fileDescriptions.size })
+ val coroutineScope = rememberCoroutineScope()
+
+ val pickMoreMedia = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.PickMultipleVisualMedia(MAX_ADD_MORE_FILES)
+ ) { uris ->
+ val newFiles = uris.map { it.toString() }.filterNot { it in currentFiles }
+ currentFiles.addAll(newFiles)
+ }
+
+ val cameraCapture = rememberCameraCaptureActions(currentFiles)
+
+ LaunchedEffect(currentFiles.size) {
+ if (currentFiles.isEmpty()) {
+ onDismiss()
+ }
+ }
+
+ Surface(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .statusBarsPadding()
+ .navigationBarsPadding()
+ .imePadding()
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .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
+ )
+ }
+
+ if (hasCompressibleMedia) {
+ HighQualityToggle(
+ checked = !compressImages,
+ onCheckedChange = { highQuality ->
+ compressImages = !highQuality
+ hqToggleVersion++
+ }
+ )
+ }
+ }
+
+ HorizontalDivider()
+
+ if (fileDescriptions.isNotEmpty()) {
+ HeroPreview(
+ descriptions = fileDescriptions,
+ pagerState = pagerState,
+ hqToggleVersion = hqToggleVersion,
+ modifier = Modifier.weight(1f)
+ )
+
+ ThumbnailStrip(
+ descriptions = fileDescriptions,
+ selectedIndex = pagerState.currentPage,
+ onSelect = { index -> coroutineScope.launch { pagerState.scrollToPage(index) } },
+ onRemove = { uri -> currentFiles.remove(uri) },
+ onReorder = { from, to ->
+ if (from != to && from in currentFiles.indices && to in currentFiles.indices) {
+ val item = currentFiles.removeAt(from)
+ currentFiles.add(to, item)
+ }
+ },
+ onAddMore = {
+ pickMoreMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo))
+ },
+ onTakePhoto = cameraCapture.onTakePhoto,
+ onTakeVideo = cameraCapture.onTakeVideo
+ )
+ }
+
+ CaptionInputBar(
+ caption = caption,
+ onCaptionChange = { caption = it },
+ sendEnabled = currentFiles.isNotEmpty(),
+ onSend = { onSend(currentFiles.toList(), caption, compressImages) }
+ )
+ }
+ }
+}
+
+private fun isCompressible(mimeType: String?): Boolean =
+ ImageCompressor.isCompressible(mimeType) || VideoCompressor.isCompressible(mimeType)
+
+private enum class CameraCaptureType { PHOTO, VIDEO }
+
+private const val CAMERA_FILE_DATE_PATTERN = "yyyy-MM-dd HH-mm-ss"
+
+private fun createCameraOutputUri(context: Context, type: CameraCaptureType): Uri {
+ val outputDir = FileUtils.getSharedAttachmentsDirectory(context.cacheDir) ?: context.cacheDir
+ val timestamp = SimpleDateFormat(CAMERA_FILE_DATE_PATTERN, Locale.ROOT).format(Date())
+ val extension = if (type == CameraCaptureType.PHOTO) "jpg" else "mp4"
+ val file = File(outputDir, "$timestamp.$extension")
+ return FileProvider.getUriForFile(context, context.packageName, file)
+}
+
+private data class CameraCaptureActions(val onTakePhoto: () -> Unit, val onTakeVideo: () -> Unit)
+
+@Composable
+private fun rememberCameraCaptureActions(currentFiles: MutableList): CameraCaptureActions {
+ val context = LocalContext.current
+ var pendingCameraUri by remember { mutableStateOf(null) }
+ var pendingCameraPermissionType by remember { mutableStateOf(null) }
+
+ val onCaptureResult: (Boolean) -> Unit = { success ->
+ val uri = pendingCameraUri
+ if (success && uri != null) {
+ currentFiles.add(uri.toString())
+ }
+ pendingCameraUri = null
+ }
+
+ val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture(), onCaptureResult)
+ val captureVideo = rememberLauncherForActivityResult(ActivityResultContracts.CaptureVideo(), onCaptureResult)
+
+ fun launchCameraCapture(type: CameraCaptureType) {
+ val uri = createCameraOutputUri(context, type)
+ pendingCameraUri = uri
+ when (type) {
+ CameraCaptureType.PHOTO -> takePicture.launch(uri)
+ CameraCaptureType.VIDEO -> captureVideo.launch(uri)
+ }
+ }
+
+ val cameraPermissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { granted ->
+ val type = pendingCameraPermissionType
+ pendingCameraPermissionType = null
+ if (granted && type != null) {
+ launchCameraCapture(type)
+ }
+ }
+
+ fun requestCameraCapture(type: CameraCaptureType) {
+ val hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
+ PackageManager.PERMISSION_GRANTED
+ if (hasPermission) {
+ launchCameraCapture(type)
+ } else {
+ pendingCameraPermissionType = type
+ cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+
+ return CameraCaptureActions(
+ onTakePhoto = { requestCameraCapture(CameraCaptureType.PHOTO) },
+ onTakeVideo = { requestCameraCapture(CameraCaptureType.VIDEO) }
+ )
+}
+
+@Composable
+private fun HighQualityToggle(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
+ val description = stringResource(R.string.nc_hq_toggle_description)
+ FilterChip(
+ selected = checked,
+ onClick = { onCheckedChange(!checked) },
+ label = { Text(stringResource(R.string.nc_hq_toggle)) },
+ leadingIcon = if (checked) {
+ {
+ Icon(
+ imageVector = Icons.Filled.Check,
+ contentDescription = null,
+ modifier = Modifier.size(FilterChipDefaults.IconSize)
+ )
+ }
+ } else {
+ null
+ },
+ modifier = Modifier.semantics { contentDescription = description }
+ )
+}
+
+@Composable
+private fun 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
+ )
+ }
+ }
+}
+
+private enum class MediaKind { IMAGE, VIDEO, OTHER }
+
+private data class FileDescription(
+ val uri: String,
+ val name: String,
+ val kind: MediaKind,
+ val mimeType: String?,
+ val detail: String?,
+ val aspectRatio: Float? = null,
+ val videoThumbnail: Bitmap? = null
+)
+
+private const val MAX_ADD_MORE_FILES = 10
+private const val HERO_MAX_HEIGHT_DP = 480
+private const val HERO_VERTICAL_SPACING_DP = 12
+private const val HERO_ICON_SIZE_DP = 96
+private const val DETAIL_CHIP_CORNER_RADIUS_PERCENT = 50
+private const val DETAIL_CHIP_POP_DURATION_MS = 520
+private const val DETAIL_CHIP_POP_INITIAL_SCALE = 0.85f
+private const val PLAY_BUTTON_SIZE_DP = 56
+private const val PLAY_ICON_SIZE_DP = 32
+private const val STRIP_THUMBNAIL_SIZE_DP = 64
+private const val STRIP_ICON_SIZE_DP = 28
+private const val STRIP_PLAY_ICON_SIZE_DP = 28
+private const val STRIP_ITEM_SPACING_DP = 8
+private const val SELECTED_BORDER_WIDTH_DP = 2
+private const val HERO_OTHER_BORDER_WIDTH_DP = 1
+private const val HERO_OTHER_BORDER_PADDING_DP = 24
+private const val THUMBNAIL_CORNER_RADIUS_DP = 16
+private const val THUMBNAIL_ICON_SIZE_DP = 48
+private const val VIDEO_FRAME_MAX_DIMENSION_PX = 720
+
+@Composable
+private fun HeroPreview(
+ descriptions: List,
+ pagerState: PagerState,
+ hqToggleVersion: Int,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(vertical = HERO_VERTICAL_SPACING_DP.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ HorizontalPager(
+ state = pagerState,
+ key = { page -> descriptions.getOrNull(page)?.uri ?: page },
+ modifier = Modifier
+ .fillMaxSize()
+ .heightIn(max = HERO_MAX_HEIGHT_DP.dp)
+ ) { page ->
+ descriptions.getOrNull(page)?.let { description -> HeroPage(description, hqToggleVersion) }
+ }
+ }
+}
+
+@Composable
+private fun HeroPage(description: FileDescription, hqToggleVersion: Int) {
+ var isPlayingVideo by remember(description.uri) { 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(HERO_OTHER_BORDER_PADDING_DP.dp)
+ .border(
+ width = HERO_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, modifier = Modifier.fillMaxSize())
+ } else {
+ FileThumbnailImage(
+ description,
+ iconSize = HERO_ICON_SIZE_DP.dp,
+ contentScale = ContentScale.Fit,
+ backgroundColor = MaterialTheme.colorScheme.surface,
+ modifier = Modifier.fillMaxSize()
+ )
+
+ if (description.kind == MediaKind.VIDEO) {
+ PlayButtonOverlay(
+ onClick = { isPlayingVideo = true },
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+
+ description.detail?.let { detail ->
+ HeroDetailOverlay(
+ detail,
+ hqToggleVersion,
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(12.dp)
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PlayButtonOverlay(onClick: () -> Unit, modifier: Modifier = Modifier) {
+ IconButton(
+ onClick = onClick,
+ modifier = modifier
+ .size(PLAY_BUTTON_SIZE_DP.dp)
+ .clip(CircleShape)
+ .background(Color.Black.copy(alpha = 0.5f))
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_baseline_play_arrow_voice_message_24),
+ contentDescription = stringResource(R.string.media_message_content_play),
+ tint = Color.White,
+ modifier = Modifier.size(PLAY_ICON_SIZE_DP.dp)
+ )
+ }
+}
+
+@OptIn(UnstableApi::class)
+@Composable
+private fun VideoPlayerCard(uri: String, modifier: Modifier = Modifier) {
+ val context = LocalContext.current
+ val exoPlayer = remember(uri) { ExoPlayer.Builder(context).build() }
+
+ DisposableEffect(exoPlayer) {
+ onDispose { exoPlayer.release() }
+ }
+
+ // Preparing/starting playback here (instead of alongside player creation above) ensures the
+ // PlayerView below has already been created and bound via `player = exoPlayer` first. Doing
+ // both synchronously in the same step let the player start decoding before its SurfaceView
+ // existed, silently dropping the first playback's frames — a black screen until the *next*
+ // play attempt, once the surface had already been created once.
+ LaunchedEffect(exoPlayer, uri) {
+ exoPlayer.setMediaItem(MediaItem.fromUri(uri.toUri()))
+ exoPlayer.playWhenReady = true
+ exoPlayer.prepare()
+ }
+
+ AndroidView(
+ factory = { ctx ->
+ PlayerView(ctx).apply {
+ player = exoPlayer
+ useController = true
+ }
+ },
+ modifier = modifier
+ )
+}
+
+private fun fitWithinBounds(maxWidth: Dp, maxHeight: Dp, ratio: Float): Pair {
+ val boundsRatio = maxWidth / maxHeight
+ return if (boundsRatio > ratio) {
+ (maxHeight * ratio) to maxHeight
+ } else {
+ maxWidth to (maxWidth / ratio)
+ }
+}
+
+@Composable
+private fun HeroDetailOverlay(detail: String, hqToggleVersion: Int, modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier
+ .clip(RoundedCornerShape(DETAIL_CHIP_CORNER_RADIUS_PERCENT))
+ .background(Color.Black.copy(alpha = 0.6f))
+ .padding(horizontal = 10.dp, vertical = 6.dp)
+ ) {
+ AnimatedContent(
+ targetState = hqToggleVersion,
+ transitionSpec = {
+ (fadeIn(tween(DETAIL_CHIP_POP_DURATION_MS)) + scaleIn(initialScale = DETAIL_CHIP_POP_INITIAL_SCALE))
+ .togetherWith(fadeOut(tween(DETAIL_CHIP_POP_DURATION_MS)))
+ },
+ label = "detailChipPop"
+ ) {
+ Text(
+ text = detail,
+ style = MaterialTheme.typography.labelMedium,
+ color = Color.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+}
+
+@Suppress("LongParameterList")
+@Composable
+private fun ThumbnailStrip(
+ descriptions: List,
+ selectedIndex: Int,
+ onSelect: (Int) -> Unit,
+ onRemove: (String) -> Unit,
+ onReorder: (from: Int, to: Int) -> Unit,
+ onAddMore: () -> Unit,
+ onTakePhoto: () -> Unit,
+ onTakeVideo: () -> Unit
+) {
+ val density = LocalDensity.current
+ val itemExtentPx = remember(density) {
+ with(density) { (STRIP_THUMBNAIL_SIZE_DP + STRIP_ITEM_SPACING_DP).dp.toPx() }
+ }
+ val dragState = remember { DragReorderState() }
+
+ 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()
+ ) {
+ 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)
+}
+
+private const val INDICATOR_WIDTH_DP = 3
+private const val INDICATOR_GAP_OFFSET_DP = 5
+
+@Suppress("LongParameterList")
+@Composable
+private fun DraggableStripItem(
+ description: FileDescription,
+ index: Int,
+ selected: Boolean,
+ itemExtentPx: Float,
+ itemCount: Int,
+ dragState: DragReorderState,
+ onSelect: (Int) -> Unit,
+ onRemove: (String) -> Unit,
+ onReorder: (Int, Int) -> Unit
+) {
+ val isDragged = index == dragState.draggedIndex.value
+ val currentIndex by rememberUpdatedState(index)
+
+ Box {
+ StripThumbnail(
+ description = description,
+ selected = selected,
+ onClick = { onSelect(index) },
+ onRemove = { onRemove(description.uri) },
+ modifier = Modifier
+ .graphicsLayer { translationX = if (isDragged) dragState.dragOffsetX.value else 0f }
+ .zIndex(if (isDragged) 1f else 0f)
+ .pointerInput(description.uri) {
+ detectDragGesturesAfterLongPress(
+ onDragStart = {
+ dragState.draggedIndex.value = currentIndex
+ dragState.insertionIndex.value = currentIndex
+ dragState.dragOffsetX.value = 0f
+ },
+ onDragEnd = {
+ val from = dragState.draggedIndex.value
+ val insertion = dragState.insertionIndex.value
+ if (from >= 0 && insertion >= 0) {
+ val to = if (insertion > from) insertion - 1 else insertion
+ if (to != from) onReorder(from, to)
+ }
+ dragState.draggedIndex.value = -1
+ dragState.insertionIndex.value = -1
+ dragState.dragOffsetX.value = 0f
+ },
+ onDragCancel = {
+ dragState.draggedIndex.value = -1
+ dragState.insertionIndex.value = -1
+ dragState.dragOffsetX.value = 0f
+ },
+ onDrag = { change, dragAmount ->
+ change.consume()
+ dragState.dragOffsetX.value += dragAmount.x
+ val slotsMoved = (dragState.dragOffsetX.value / itemExtentPx).roundToInt()
+ dragState.insertionIndex.value = (currentIndex + slotsMoved).coerceIn(0, itemCount)
+ }
+ )
+ }
+ )
+
+ DropIndicators(index, itemCount, dragState)
+ }
+}
+
+@Composable
+private fun BoxScope.DropIndicators(index: Int, itemCount: Int, dragState: DragReorderState) {
+ if (dragState.draggedIndex.value < 0) return
+
+ if (dragState.insertionIndex.value == index) {
+ DropIndicatorLine(
+ modifier = Modifier
+ .align(Alignment.CenterStart)
+ .offset(x = (-INDICATOR_GAP_OFFSET_DP).dp)
+ )
+ }
+ if (index == itemCount - 1 && dragState.insertionIndex.value == itemCount) {
+ DropIndicatorLine(
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .offset(x = INDICATOR_GAP_OFFSET_DP.dp)
+ )
+ }
+}
+
+@Composable
+private fun DropIndicatorLine(modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier
+ .width(INDICATOR_WIDTH_DP.dp)
+ .height(STRIP_THUMBNAIL_SIZE_DP.dp)
+ .background(
+ MaterialTheme.colorScheme.primary,
+ RoundedCornerShape(INDICATOR_WIDTH_DP.dp / 2)
+ )
+ )
+}
+
+@Composable
+private fun StripThumbnail(
+ description: FileDescription,
+ selected: Boolean,
+ onClick: () -> Unit,
+ onRemove: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant
+ val shape = RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp)
+ Box(
+ modifier = modifier
+ .size(STRIP_THUMBNAIL_SIZE_DP.dp)
+ .clip(shape)
+ .border(SELECTED_BORDER_WIDTH_DP.dp, borderColor, shape)
+ .clickable(onClick = if (selected) onRemove else onClick)
+ ) {
+ 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)
+ )
+ }
+}
+
+@Composable
+private fun FileThumbnailImage(
+ description: FileDescription,
+ modifier: Modifier = Modifier,
+ iconSize: Dp = THUMBNAIL_ICON_SIZE_DP.dp,
+ contentScale: ContentScale = ContentScale.Crop,
+ backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant
+) {
+ val shape = RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp)
+
+ when (description.kind) {
+ MediaKind.IMAGE ->
+ AsyncImage(
+ model = description.uri,
+ contentDescription = 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)
+ )
+ }
+ }
+}
+
+private fun mediaKind(mimeType: String?): MediaKind =
+ when {
+ mimeType?.startsWith(Mimetype.IMAGE_PREFIX) == true -> MediaKind.IMAGE
+ mimeType?.startsWith(Mimetype.VIDEO_PREFIX) == true -> MediaKind.VIDEO
+ else -> MediaKind.OTHER
+ }
+
+@Suppress("ReturnCount")
+private fun describeFile(context: Context, uriString: String, compress: Boolean): FileDescription {
+ val uri = uriString.toUri()
+ val name = FileUtils.getFileName(uri, context)
+ val 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 detail = when (kind) {
+ MediaKind.IMAGE -> describeImageDetail(context, file, compress, sizeOnly)
+ MediaKind.VIDEO -> describeVideoDetail(context, file, compress, sizeOnly)
+ MediaKind.OTHER -> "$name, $sizeOnly"
+ }
+ 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, detail, aspectRatio, videoThumbnail)
+}
+
+@Suppress("ReturnCount")
+private fun imageAspectRatio(file: File): Float? {
+ val info = ImageCompressor.readImageInfo(file) ?: return null
+ if (info.height <= 0) return null
+ val (width, height) = if (isSidewaysExifOrientation(file)) {
+ info.height to info.width
+ } else {
+ info.width to info.height
+ }
+ if (height <= 0) return null
+ return width.toFloat() / height.toFloat()
+}
+
+// BitmapFactory's decode bounds are the raw, un-rotated pixel grid, so a photo whose sensor-native
+// orientation is landscape (with an EXIF tag marking it as rotated for portrait display) reports
+// swapped width/height here compared to how it actually renders. Swap them back so the aspect ratio
+// used to size the preview matches what Coil (which does honor EXIF) actually draws.
+private fun isSidewaysExifOrientation(file: File): Boolean =
+ try {
+ val orientation = ExifInterface(file.absolutePath).getAttributeInt(
+ ExifInterface.TAG_ORIENTATION,
+ ExifInterface.ORIENTATION_NORMAL
+ )
+ orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270
+ } catch (e: IOException) {
+ false
+ }
+
+private const val VIDEO_ROTATION_DEGREES_90 = 90
+private const val VIDEO_ROTATION_DEGREES_270 = 270
+
+@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): String {
+ val original = ImageCompressor.readImageInfo(file) ?: return fallback
+ if (!compress) return describeMedia(context, original.width, original.height, original.sizeBytes)
+ val compressed = ImageCompressor.estimateCompression(file)
+ ?: return describeMedia(context, original.width, original.height, original.sizeBytes)
+ return describeMedia(context, compressed.width, compressed.height, compressed.sizeBytes)
+}
+
+@Suppress("ReturnCount")
+private fun describeVideoDetail(context: Context, file: File, compress: Boolean, fallback: String): String {
+ val original = VideoCompressor.readVideoInfo(file) ?: return fallback
+ if (!compress) return describeMedia(context, original.width, original.height, original.sizeBytes)
+ val compressed = VideoCompressor.estimateCompression(file)
+ ?: return describeMedia(context, original.width, original.height, original.sizeBytes)
+ return describeMedia(context, compressed.width, compressed.height, compressed.sizeBytes)
+}
+
+private fun describeMedia(context: Context, width: Int, height: Int, sizeBytes: Long): String =
+ "$width×$height, ${formatSize(context, sizeBytes)}"
+
+private fun formatSize(context: Context, sizeBytes: Long): String = Formatter.formatShortFileSize(context, sizeBytes)
+
+@Preview(name = "Light Mode", showBackground = true)
+@Preview(
+ name = "Dark Mode",
+ showBackground = true,
+ uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL
+)
+@Composable
+private fun FileAttachmentPreviewContentPreview() {
+ val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
+ MaterialTheme(colorScheme = colorScheme) {
+ FileAttachmentPreviewContent(
+ files = listOf(
+ "file:///sdcard/DCIM/photo.jpg",
+ "file:///sdcard/DCIM/video.mp4",
+ "file:///sdcard/Documents/report.pdf",
+ "file:///sdcard/DCIM/photo2.jpg"
+ ),
+ conversationName = "Team Chat",
+ initialCompressImages = true,
+ onDismiss = {},
+ onSend = { _, _, _ -> }
+ )
+ }
+}
+
+@Preview(name = "Single File", showBackground = true)
+@Composable
+private fun FileAttachmentPreviewContentSingleFilePreview() {
+ MaterialTheme(colorScheme = lightColorScheme()) {
+ FileAttachmentPreviewContent(
+ files = listOf("file:///sdcard/DCIM/photo.jpg"),
+ conversationName = "Team Chat",
+ initialCompressImages = true,
+ onDismiss = {},
+ onSend = { _, _, _ -> }
+ )
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewFragment.kt
index c23bba00a35..252822703c6 100644
--- a/app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewFragment.kt
+++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/FileAttachmentPreviewFragment.kt
@@ -7,89 +7,132 @@
package com.nextcloud.talk.ui.dialog
import android.app.Dialog
+import android.graphics.Color
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.view.WindowManager
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsControllerCompat
import androidx.fragment.app.DialogFragment
import autodagger.AutoInjector
import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import com.nextcloud.talk.R
import com.nextcloud.talk.application.NextcloudTalkApplication
-import com.nextcloud.talk.databinding.DialogFileAttachmentPreviewBinding
import com.nextcloud.talk.ui.theme.ViewThemeUtils
-import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil
+import com.nextcloud.talk.utils.preferences.AppPreferences
import javax.inject.Inject
@AutoInjector(NextcloudTalkApplication::class)
class FileAttachmentPreviewFragment : DialogFragment() {
- private lateinit var files: String
private lateinit var filesList: ArrayList
- private var uploadFiles: (files: MutableList, caption: String) -> Unit = { _, _ -> }
- lateinit var binding: DialogFileAttachmentPreviewBinding
+ private var conversationName: String = ""
+ private var uploadFiles: (files: MutableList, caption: String, compressImages: Boolean) -> Unit =
+ { _, _, _ -> }
+ private var composeView: ComposeView? = null
@Inject
- lateinit var permissionUtil: PlatformPermissionUtil
+ lateinit var viewThemeUtils: ViewThemeUtils
@Inject
- lateinit var viewThemeUtils: ViewThemeUtils
+ lateinit var appPreferences: AppPreferences
- fun setListener(uploadFiles: (files: MutableList, caption: String) -> Unit) {
+ fun setListener(uploadFiles: (files: MutableList, caption: String, compressImages: Boolean) -> Unit) {
this.uploadFiles = uploadFiles
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
arguments?.let {
- files = it.getString(FILE_NAMES_ARG, "")
filesList = it.getStringArrayList(FILES_TO_UPLOAD_ARG)!!
+ conversationName = it.getString(CONVERSATION_NAME_ARG, "")
}
- binding = DialogFileAttachmentPreviewBinding.inflate(layoutInflater)
- return MaterialAlertDialogBuilder(requireContext()).setView(binding.root).create()
+ composeView = ComposeView(requireContext())
+ return MaterialAlertDialogBuilder(requireContext()).setView(composeView).create()
}
- override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
- NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
- setUpViews()
- setUpListeners()
- return inflater.inflate(R.layout.dialog_file_attachment_preview, container, false)
- }
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
+ composeView
- private fun setUpViews() {
- binding.dialogFileAttachmentPreviewFilenames.text = files
- viewThemeUtils.material.colorMaterialButtonPrimaryBorderless(binding.buttonClose)
- viewThemeUtils.material.colorMaterialButtonPrimaryBorderless(binding.buttonSend)
- viewThemeUtils.platform.colorViewBackground(binding.root)
- viewThemeUtils.material.colorTextInputLayout(binding.dialogFileAttachmentPreviewLayout)
- }
+ @Suppress("DEPRECATION")
+ override fun onStart() {
+ super.onStart()
+ // Dialog.show() re-applies FLAG_ALT_FOCUSABLE_IM from the theme after onCreateDialog()
+ // runs, so clearing it must happen once the dialog window is actually up. Without this,
+ // the system never routes the keyboard to this window, even while it holds input focus.
+ dialog?.window?.apply {
+ setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT)
+ setBackgroundDrawableResource(android.R.color.transparent)
+ setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
+ clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
+ // Dialog windows dim whatever is behind them by default; at fullscreen size that
+ // scrim covers the whole window, including the status bar area, making it look dark
+ // regardless of the Compose content's own color.
+ clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
+ // targetSdk 36 enforces edge-to-edge; Window.setStatusBarColor() is a no-op there.
+ // Instead, let the dialog draw behind the status bar so the Compose Surface's own
+ // background shows through it, and inset the content with statusBarsPadding().
+ WindowCompat.setDecorFitsSystemWindows(this, false)
+ statusBarColor = Color.TRANSPARENT
+ navigationBarColor = Color.TRANSPARENT
- private fun setUpListeners() {
- binding.buttonClose.setOnClickListener {
- dismiss()
+ 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)
- binding.buttonSend.setOnClickListener {
- val caption: String = binding.dialogFileAttachmentPreviewCaption.text.toString()
- uploadFiles(filesList, caption)
- dismiss()
+ composeView?.apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent {
+ MaterialTheme(colorScheme = viewThemeUtils.getColorScheme(requireActivity())) {
+ FileAttachmentPreviewContent(
+ files = filesList,
+ 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 FILE_NAMES_ARG = "FILE_NAMES_ARG"
+ 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(filenames: String, filesToUpload: MutableList): FileAttachmentPreviewFragment {
+ fun newInstance(filesToUpload: MutableList, conversationName: String): FileAttachmentPreviewFragment {
val fileAttachmentFragment = FileAttachmentPreviewFragment()
val args = Bundle()
- args.putString(FILE_NAMES_ARG, filenames)
args.putStringArrayList(FILES_TO_UPLOAD_ARG, ArrayList(filesToUpload))
+ args.putString(CONVERSATION_NAME_ARG, conversationName)
fileAttachmentFragment.arguments = args
return fileAttachmentFragment
}
- val TAG: String = FilterConversationFragment::class.java.simpleName
+ val TAG: String = FileAttachmentPreviewFragment::class.java.simpleName
}
}
diff --git a/app/src/main/java/com/nextcloud/talk/utils/BitmapShrinker.kt b/app/src/main/java/com/nextcloud/talk/utils/BitmapShrinker.kt
index 1481b9a6f2b..410f98ce080 100644
--- a/app/src/main/java/com/nextcloud/talk/utils/BitmapShrinker.kt
+++ b/app/src/main/java/com/nextcloud/talk/utils/BitmapShrinker.kt
@@ -8,20 +8,70 @@ package com.nextcloud.talk.utils
import android.graphics.Bitmap
import android.graphics.BitmapFactory
+import android.graphics.ImageDecoder
import android.graphics.Matrix
+import android.os.Build
import android.util.Log
+import androidx.annotation.RequiresApi
import androidx.exifinterface.media.ExifInterface
+import java.io.File
import java.io.IOException
+import kotlin.math.roundToInt
object BitmapShrinker {
- private val TAG = "BitmapShrinker"
+ private const val TAG = "BitmapShrinker"
private const val DEGREES_90 = 90f
private const val DEGREES_180 = 180f
private const val DEGREES_270 = 270f
+ /**
+ * Decodes the image at [path], downscaled to fit within [reqWidth]x[reqHeight].
+ *
+ * On API 28+ this uses [ImageDecoder], which scales in a single pass and applies EXIF
+ * orientation for JPEG/HEIC automatically. Older platform versions fall back to the
+ * [BitmapFactory] two-pass sampling approach with manual EXIF rotation.
+ *
+ * [requireSoftwareBitmap] must be set when the result will be read or re-encoded (e.g. via
+ * [Bitmap.compress]), since [Bitmap.Config.HARDWARE] bitmaps do not support that.
+ */
@JvmStatic
- fun shrinkBitmap(path: String, reqWidth: Int, reqHeight: Int): Bitmap? {
+ @JvmOverloads
+ fun shrinkBitmap(path: String, reqWidth: Int, reqHeight: Int, requireSoftwareBitmap: Boolean = false): Bitmap? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ decodeWithImageDecoder(path, reqWidth, reqHeight, requireSoftwareBitmap)
+ } else {
+ decodeWithBitmapFactory(path, reqWidth, reqHeight)
+ }
+
+ @RequiresApi(Build.VERSION_CODES.P)
+ private fun decodeWithImageDecoder(
+ path: String,
+ reqWidth: Int,
+ reqHeight: Int,
+ requireSoftwareBitmap: Boolean
+ ): Bitmap? =
+ try {
+ val source = ImageDecoder.createSource(File(path))
+ ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
+ val (width, height) = fitWithinBounds(info.size.width, info.size.height, reqWidth, reqHeight)
+ decoder.setTargetSize(width, height)
+ if (requireSoftwareBitmap) {
+ decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
+ }
+ }
+ } catch (e: IOException) {
+ Log.e(TAG, "Failed to decode bitmap from path: $path", e)
+ null
+ }
+
+ private fun fitWithinBounds(width: Int, height: Int, reqWidth: Int, reqHeight: Int): Pair {
+ if (width <= reqWidth && height <= reqHeight) return width to height
+ val scale = minOf(reqWidth.toDouble() / width, reqHeight.toDouble() / height)
+ return maxOf(1, (width * scale).roundToInt()) to maxOf(1, (height * scale).roundToInt())
+ }
+
+ private fun decodeWithBitmapFactory(path: String, reqWidth: Int, reqHeight: Int): Bitmap? {
val bitmap = decodeBitmap(path, reqWidth, reqHeight) ?: return null
return rotateBitmap(path, bitmap)
}
@@ -78,8 +128,8 @@ object BitmapShrinker {
bitmap,
0,
0,
- bitmap.getWidth(),
- bitmap.getHeight(),
+ bitmap.width,
+ bitmap.height,
matrix,
true
)
diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt
index ee9382e6b8d..fe6b4ea3af4 100644
--- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt
+++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt
@@ -14,6 +14,7 @@ import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
+import android.webkit.MimeTypeMap
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
@@ -201,6 +202,17 @@ object FileUtils {
return filename
}
+ /**
+ * Resolves the MIME type of [uri]. [ContentResolver.getType] only resolves `content://` URIs, so for
+ * `file://` URIs (e.g. the in-app camera writes plain files) this falls back to guessing from the
+ * file extension.
+ */
+ fun resolveMimeType(context: Context, uri: Uri): String? =
+ context.contentResolver.getType(uri)
+ ?: MimeTypeMap.getSingleton().getMimeTypeFromExtension(
+ MimeTypeMap.getFileExtensionFromUrl(uri.toString()).lowercase()
+ )
+
@JvmStatic
fun md5Sum(file: File): String {
val temp = file.name + file.lastModified() + file.length()
diff --git a/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt b/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt
new file mode 100644
index 00000000000..311985625f5
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt
@@ -0,0 +1,87 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2017-2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.utils
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.util.Log
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.IOException
+
+/**
+ * Downscales and re-encodes images as JPEG to reduce their upload size.
+ */
+object ImageCompressor {
+
+ private val TAG = ImageCompressor::class.java.simpleName
+ private const val MAX_DIMENSION = 2048
+ private const val JPEG_QUALITY = 80
+ private const val COMPRESSED_FILE_SUFFIX = "_compressed.jpg"
+
+ data class ImageInfo(val width: Int, val height: Int, val sizeBytes: Long)
+
+ fun isCompressible(mimeType: String?): Boolean =
+ mimeType != null && mimeType.startsWith(Mimetype.IMAGE_PREFIX) && mimeType != Mimetype.IMAGE_GIF
+
+ /**
+ * Reads the on-disk size and pixel dimensions of [sourceFile] without fully decoding it.
+ */
+ fun readImageInfo(sourceFile: File): ImageInfo? {
+ val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ BitmapFactory.decodeFile(sourceFile.absolutePath, options)
+ if (options.outWidth <= 0 || options.outHeight <= 0) return null
+ return ImageInfo(options.outWidth, options.outHeight, sourceFile.length())
+ }
+
+ /**
+ * Computes the dimensions and size [sourceFile] would have after [compress], without writing anything to disk.
+ */
+ fun estimateCompression(sourceFile: File): ImageInfo? {
+ val (bitmap, bytes) = encode(sourceFile) ?: return null
+ return try {
+ ImageInfo(bitmap.width, bitmap.height, bytes.size.toLong())
+ } finally {
+ bitmap.recycle()
+ }
+ }
+
+ /**
+ * Returns a downscaled, JPEG re-encoded copy of [sourceFile] in the app cache directory,
+ * or null if the image could not be decoded or written.
+ */
+ fun compress(context: Context, sourceFile: File): File? {
+ val (bitmap, bytes) = encode(sourceFile) ?: return null
+ return try {
+ val compressedFile = File(context.cacheDir, sourceFile.nameWithoutExtension + COMPRESSED_FILE_SUFFIX)
+ compressedFile.writeBytes(bytes)
+ compressedFile
+ } catch (e: IOException) {
+ Log.e(TAG, "failed to write compressed image for ${sourceFile.name}", e)
+ null
+ } finally {
+ bitmap.recycle()
+ }
+ }
+
+ private fun encode(sourceFile: File): Pair? {
+ val bitmap = BitmapShrinker.shrinkBitmap(
+ sourceFile.absolutePath,
+ MAX_DIMENSION,
+ MAX_DIMENSION,
+ requireSoftwareBitmap = true
+ )
+ if (bitmap == null) {
+ Log.w(TAG, "could not decode ${sourceFile.name} for compression")
+ return null
+ }
+ val output = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, output)
+ return bitmap to output.toByteArray()
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/utils/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..b8e5f9dcd63
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/utils/VideoCompressor.kt
@@ -0,0 +1,208 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2017-2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.utils
+
+import android.content.Context
+import android.media.MediaMetadataRetriever
+import android.net.Uri
+import android.os.Handler
+import android.os.HandlerThread
+import android.util.Log
+import androidx.annotation.OptIn
+import androidx.media3.common.MediaItem
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.effect.Presentation
+import androidx.media3.transformer.AudioEncoderSettings
+import androidx.media3.transformer.Composition
+import androidx.media3.transformer.DefaultEncoderFactory
+import androidx.media3.transformer.EditedMediaItem
+import androidx.media3.transformer.Effects
+import androidx.media3.transformer.ExportException
+import androidx.media3.transformer.ExportResult
+import androidx.media3.transformer.ProgressHolder
+import androidx.media3.transformer.Transformer
+import androidx.media3.transformer.VideoEncoderSettings
+import java.io.File
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
+import kotlin.math.max
+import kotlin.math.roundToInt
+
+/**
+ * Downscales videos to [TARGET_SHORT_SIDE]p and re-encodes them to reduce their upload size.
+ */
+object VideoCompressor {
+
+ private val TAG = VideoCompressor::class.java.simpleName
+ private const val TARGET_SHORT_SIDE = 540
+ private const val TARGET_VIDEO_BITRATE_BPS = 600_000L
+ private const val TARGET_AUDIO_BITRATE_BPS = 64_000L
+ private const val BITS_PER_BYTE = 8L
+ private const val MILLIS_PER_SECOND = 1000L
+ private const val MIN_TIMEOUT_MS = 30_000L
+ private const val TIMEOUT_MULTIPLIER = 3L
+ private const val COMPRESSED_FILE_SUFFIX = "_compressed.mp4"
+ private const val PROGRESS_POLL_INTERVAL_MS = 300L
+
+ data class VideoInfo(val width: Int, val height: Int, val durationMs: Long, val sizeBytes: Long)
+
+ fun isCompressible(mimeType: String?): Boolean = mimeType != null && mimeType.startsWith(Mimetype.VIDEO_PREFIX)
+
+ /**
+ * Reads the on-disk size, pixel dimensions and duration of [sourceFile] via metadata only.
+ */
+ @Suppress("TooGenericExceptionCaught")
+ fun readVideoInfo(sourceFile: File): VideoInfo? {
+ val retriever = MediaMetadataRetriever()
+ return try {
+ retriever.setDataSource(sourceFile.absolutePath)
+ val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
+ val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
+ val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
+ if (width == null || height == null || durationMs == null) {
+ null
+ } else {
+ VideoInfo(width, height, durationMs, sourceFile.length())
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "could not read video info for ${sourceFile.name}", e)
+ null
+ } finally {
+ retriever.release()
+ }
+ }
+
+ /**
+ * Estimates the dimensions and size [sourceFile] would have after [compress], based on a fixed
+ * target bitrate rather than a real (expensive) transcode.
+ */
+ fun estimateCompression(sourceFile: File): VideoInfo? {
+ val original = readVideoInfo(sourceFile) ?: return null
+ val (targetWidth, targetHeight) = scaledDimensions(original.width, original.height)
+ val estimatedBytes = (TARGET_VIDEO_BITRATE_BPS + TARGET_AUDIO_BITRATE_BPS) / BITS_PER_BYTE *
+ (original.durationMs / MILLIS_PER_SECOND)
+ return VideoInfo(targetWidth, targetHeight, original.durationMs, minOf(estimatedBytes, original.sizeBytes))
+ }
+
+ /**
+ * Returns a downscaled, re-encoded copy of [sourceFile] in the app cache directory, or null if
+ * the video was already small enough, could not be read, or transcoding failed/timed out.
+ *
+ * [onProgress] is invoked with a 0-100 value from a background thread while transcoding runs.
+ */
+ @Suppress("ReturnCount")
+ fun compress(context: Context, sourceFile: File, onProgress: (Int) -> Unit = {}): File? {
+ val original = readVideoInfo(sourceFile) ?: return null
+ if (minOf(original.width, original.height) <= TARGET_SHORT_SIDE) return null
+
+ val outputFile = File(context.cacheDir, sourceFile.nameWithoutExtension + COMPRESSED_FILE_SUFFIX)
+ outputFile.delete()
+
+ val success = runTransform(context, sourceFile, outputFile, original.durationMs, onProgress)
+ return if (success && outputFile.exists()) outputFile else null
+ }
+
+ private fun scaledDimensions(width: Int, height: Int): Pair {
+ val shortSide = minOf(width, height)
+ if (shortSide <= TARGET_SHORT_SIDE) return width to height
+ val scale = TARGET_SHORT_SIDE.toDouble() / shortSide
+ return (width * scale).roundToInt() to (height * scale).roundToInt()
+ }
+
+ /**
+ * Media3's [Transformer] must be created and driven from a thread with a prepared [Looper][android.os.Looper].
+ * Using a dedicated, short-lived [HandlerThread] here (instead of the app's main thread) keeps any number of
+ * concurrent/queued compressions from ever delaying UI rendering.
+ */
+ @OptIn(UnstableApi::class)
+ @Suppress("ReturnCount")
+ private fun runTransform(
+ context: Context,
+ sourceFile: File,
+ outputFile: File,
+ durationMs: Long,
+ onProgress: (Int) -> Unit
+ ): Boolean {
+ val handlerThread = HandlerThread("VideoCompressor-${sourceFile.name}").apply { start() }
+ try {
+ val latch = CountDownLatch(1)
+ var success = false
+ val transformerRef = AtomicReference()
+ val handler = Handler(handlerThread.looper)
+
+ handler.post {
+ val encoderFactory = DefaultEncoderFactory.Builder(context)
+ .setRequestedVideoEncoderSettings(
+ VideoEncoderSettings.Builder().setBitrate(TARGET_VIDEO_BITRATE_BPS.toInt()).build()
+ )
+ .setRequestedAudioEncoderSettings(
+ AudioEncoderSettings.Builder().setBitrate(TARGET_AUDIO_BITRATE_BPS.toInt()).build()
+ )
+ .setEnableFallback(true)
+ .build()
+
+ val transformer = Transformer.Builder(context)
+ .setEncoderFactory(encoderFactory)
+ .addListener(object : Transformer.Listener {
+ override fun onCompleted(composition: Composition, exportResult: ExportResult) {
+ success = true
+ latch.countDown()
+ }
+
+ override fun onError(
+ composition: Composition,
+ exportResult: ExportResult,
+ exportException: ExportException
+ ) {
+ Log.e(TAG, "video compression failed for ${sourceFile.name}", exportException)
+ latch.countDown()
+ }
+ })
+ .build()
+ transformerRef.set(transformer)
+
+ val editedMediaItem = EditedMediaItem.Builder(MediaItem.fromUri(Uri.fromFile(sourceFile)))
+ .setEffects(Effects(emptyList(), listOf(Presentation.createForShortSide(TARGET_SHORT_SIDE))))
+ .build()
+
+ transformer.start(editedMediaItem, outputFile.absolutePath)
+ pollProgress(handler, transformerRef.get(), latch, onProgress)
+ }
+
+ val timeoutMs = max(MIN_TIMEOUT_MS, durationMs * TIMEOUT_MULTIPLIER)
+ val completedInTime = latch.await(timeoutMs, TimeUnit.MILLISECONDS)
+ if (!completedInTime) {
+ Log.w(TAG, "video compression timed out for ${sourceFile.name}")
+ handler.post { transformerRef.get()?.cancel() }
+ }
+ return completedInTime && success
+ } finally {
+ handlerThread.quitSafely()
+ }
+ }
+
+ /**
+ * Polls [Transformer.getProgress] on its own looper thread every [PROGRESS_POLL_INTERVAL_MS]
+ * until [latch] counts down (transcode completed, failed, or was cancelled).
+ */
+ @OptIn(UnstableApi::class)
+ private fun pollProgress(
+ handler: Handler,
+ transformer: Transformer?,
+ latch: CountDownLatch,
+ onProgress: (Int) -> Unit
+ ) {
+ if (transformer == null || latch.count == 0L) return
+
+ val progressHolder = ProgressHolder()
+ if (transformer.getProgress(progressHolder) == Transformer.PROGRESS_STATE_AVAILABLE) {
+ onProgress(progressHolder.progress)
+ }
+ handler.postDelayed({ pollProgress(handler, transformer, latch, onProgress) }, PROGRESS_POLL_INTERVAL_MS)
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java
index 9cbce99381f..a81dca8297a 100644
--- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java
+++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java
@@ -134,6 +134,10 @@ public interface AppPreferences {
void setPhoneBookIntegration(boolean value);
+ boolean getCompressUploadImages();
+
+ void setCompressUploadImages(boolean value);
+
// TODO Remove in 13.0.0
void removeLinkPreviews();
diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt
index 33605e7ca26..6c363934a2d 100644
--- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt
+++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt
@@ -353,6 +353,18 @@ class AppPreferencesImpl(val context: Context) : AppPreferences {
}
}
+ override fun getCompressUploadImages(): Boolean =
+ runBlocking {
+ async { readBoolean(COMPRESS_UPLOAD_IMAGES, defaultValue = true).first() }
+ }.getCompleted()
+
+ override fun setCompressUploadImages(value: Boolean) =
+ runBlocking {
+ async {
+ writeBoolean(COMPRESS_UPLOAD_IMAGES, value)
+ }
+ }
+
override fun removeLinkPreviews() =
runBlocking {
async {
@@ -675,6 +687,7 @@ class AppPreferencesImpl(val context: Context) : AppPreferences {
const val INCOGNITO_KEYBOARD = "incognito_keyboard"
const val SHOW_ECOSYSTEM = "SHOW_ECOSYSTEM"
const val PHONE_BOOK_INTEGRATION = "phone_book_integration"
+ const val COMPRESS_UPLOAD_IMAGES = "compress_upload_images"
const val LINK_PREVIEWS = "link_previews"
const val SCREEN_LOCK_TIMEOUT = "screen_lock_timeout"
const val LOCK_TIMESTAMP = "lock_timestamp"
diff --git a/app/src/main/res/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..d7bbdd9ca2e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -159,6 +159,10 @@ 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.
read_privacy
Tap to unlock
Locked
@@ -665,6 +669,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 +678,9 @@ How to translate with transifex:
Video recording from %1$s
+
+ Picture from %1$s
+
Share location
Search location
@@ -785,15 +794,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 +941,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
-
-