diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 1d9b04b0e2b..13f1a99bb0a 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -42,6 +42,7 @@ val androidxTestVersion = "1.5.0"
val media3Version = "1.11.1"
val coroutinesVersion = "1.11.0"
val mockitoKotlinVersion = "6.4.0"
+val cameraXVersion = "1.6.1"
android {
compileSdk = 37
@@ -244,6 +245,12 @@ dependencies {
implementation("androidx.lifecycle:lifecycle-common:$lifecycleVersion")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion")
+ implementation("androidx.camera:camera-core:$cameraXVersion")
+ implementation("androidx.camera:camera-camera2:$cameraXVersion")
+ implementation("androidx.camera:camera-lifecycle:$cameraXVersion")
+ implementation("androidx.camera:camera-video:$cameraXVersion")
+ implementation("androidx.camera:camera-view:$cameraXVersion")
+
implementation("androidx.biometric:biometric:1.1.0")
implementation("io.reactivex.rxjava2:rxandroid:2.1.1")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4c27ccc2c81..5cbb67ee910 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -65,6 +65,7 @@
android:maxSdkVersion="32" />
+
@@ -191,6 +192,13 @@
+
+
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditLogic.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditLogic.kt
new file mode 100644
index 00000000000..77570719037
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditLogic.kt
@@ -0,0 +1,136 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import kotlin.math.ceil
+
+/*
+ * Pure, Android-free helpers behind the attachment preview editing features, kept apart from the
+ * UI so they can be covered by plain JVM unit tests.
+ */
+
+private const val EDITED_SUFFIX = "_edited_"
+private const val SHARED_URI_SEGMENT = "shared_attachments"
+private val editedSuffixRegex = Regex("${EDITED_SUFFIX}[0-9-]+$")
+private const val MAX_DECODE_PIXELS = 20_000_000L
+
+/** A point on the displayed image, in 0..1 of the image's width/height (resolution independent). */
+internal data class NormalizedPoint(val x: Float, val y: Float)
+
+/** One finger-down..finger-up brush stroke. [widthFraction] is the brush width relative to the image width. */
+internal data class DrawStroke(val colorArgb: Int, val widthFraction: Float, val points: List)
+
+/** Where an image of a given aspect ratio lands when fitted (letterboxed) into a view, in view pixels. */
+internal data class FitRect(val left: Float, val top: Float, val width: Float, val height: Float)
+
+/** Replaces [oldUri] by [newUri] keeping its position; returns [files] unchanged when [oldUri] is absent. */
+internal fun replaceUri(files: List, oldUri: String, newUri: String): List {
+ val index = files.indexOf(oldUri)
+ return if (index < 0 || oldUri == newUri) {
+ files
+ } else {
+ files.toMutableList().also { it[index] = newUri }
+ }
+}
+
+/**
+ * Toggles [uri] in the set of files excluded from sending. At least one file always stays selected,
+ * so excluding the last selected file is refused (the set is returned unchanged).
+ */
+internal fun toggleSelection(files: List, unselected: Set, uri: String): Set =
+ when {
+ uri !in files -> unselected
+ uri in unselected -> unselected - uri
+ files.count { it !in unselected } <= 1 -> unselected
+ else -> unselected + uri
+ }
+
+/** The files that will actually be sent, in list order. */
+internal fun selectedFiles(files: List, unselected: Set): List =
+ files.filter { it !in unselected }
+
+/**
+ * Name for the file an edit produces: the original base name without extension and without an
+ * earlier edit suffix (so repeated edits don't pile suffixes up), plus a unique [stamp].
+ */
+internal fun editedFileName(originalName: String, stamp: String, extension: String): String {
+ val base = originalName.substringBeforeLast('.', originalName).replace(editedSuffixRegex, "")
+ return "${base.ifBlank { "image" }}$EDITED_SUFFIX$stamp.$extension"
+}
+
+/** True for names produced by [editedFileName], i.e. intermediates of an earlier edit. */
+internal fun isEditedFileName(fileName: String): Boolean =
+ editedSuffixRegex.containsMatchIn(fileName.substringBeforeLast('.', fileName))
+
+/**
+ * The edited file's name when [pathSegments] of a URI under [authority] address exactly
+ * `shared_attachments/` in our own FileProvider; null for anything else
+ * (foreign authority, an original/camera file, a nested path). Only those are ours to delete.
+ */
+internal fun ownEditedName(authority: String?, ownAuthority: String, pathSegments: List): String? {
+ val name = pathSegments.getOrNull(1)
+ val matches = authority == ownAuthority &&
+ pathSegments.size == 2 &&
+ pathSegments.first() == SHARED_URI_SEGMENT &&
+ name != null &&
+ isEditedFileName(name)
+ return if (matches) name else null
+}
+
+/**
+ * Whether drawing must turn the pixels upright instead of carrying the EXIF orientation tag over.
+ * Mirrored orientations (2/4/5/7) are not understood downstream, and PNG is not rotated by EXIF at
+ * all; only JPEG with the plain rotations 3/6/8 keeps the tag.
+ */
+internal fun rotatesPixels(png: Boolean, rotationDegrees: Int, flipped: Boolean): Boolean =
+ flipped || (png && rotationDegrees != 0)
+
+/** Output extension and whether it is PNG (lossless, keeps transparency) for a source MIME type. */
+internal fun editOutputIsPng(sourceMimeType: String?): Boolean = sourceMimeType == "image/png"
+
+/** Fits an image of [aspectRatio] (width / height) into the view like `ContentScale.Fit`, centered. */
+internal fun fitRect(viewWidth: Float, viewHeight: Float, aspectRatio: Float): FitRect {
+ val viewRatio = viewWidth / viewHeight
+ val (width, height) = if (viewRatio > aspectRatio) {
+ (viewHeight * aspectRatio) to viewHeight
+ } else {
+ viewWidth to (viewWidth / aspectRatio)
+ }
+ return FitRect((viewWidth - width) / 2f, (viewHeight - height) / 2f, width, height)
+}
+
+/** Maps a view-space touch position to the image, clamped so strokes can't leave the picture. */
+internal fun toNormalized(x: Float, y: Float, rect: FitRect): NormalizedPoint =
+ NormalizedPoint(
+ ((x - rect.left) / rect.width).coerceIn(0f, 1f),
+ ((y - rect.top) / rect.height).coerceIn(0f, 1f)
+ )
+
+/** Maps a normalized point to pixels of a bitmap of the given size. */
+internal fun toPixels(point: NormalizedPoint, bitmapWidth: Int, bitmapHeight: Int): Pair =
+ (point.x * bitmapWidth) to (point.y * bitmapHeight)
+
+/** Brush width in bitmap pixels; never thinner than one pixel. */
+internal fun strokeWidthPixels(stroke: DrawStroke, bitmapWidth: Int): Float =
+ (stroke.widthFraction * bitmapWidth).coerceAtLeast(1f)
+
+/** Drops the most recent stroke ("undo"). */
+internal fun undoLast(strokes: List): List = strokes.dropLast(1)
+
+/**
+ * Power-of-two `inSampleSize` that keeps a decoded bitmap under [maxPixels]; 1 means full resolution.
+ * Only very large sources (beyond ~20 MP) are downsampled, to avoid running out of memory.
+ */
+internal fun decodeSampleSize(width: Int, height: Int, maxPixels: Long = MAX_DECODE_PIXELS): Int {
+ var sample = 1
+ var pixels = width.toLong() * height.toLong()
+ while (pixels > maxPixels) {
+ sample *= 2
+ pixels = ceil(width / sample.toDouble()).toLong() * ceil(height / sample.toDouble()).toLong()
+ }
+ return sample
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditing.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditing.kt
new file mode 100644
index 00000000000..3673adb2a66
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditing.kt
@@ -0,0 +1,242 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Matrix
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.RectF
+import android.net.Uri
+import androidx.core.content.FileProvider
+import androidx.core.graphics.createBitmap
+import androidx.exifinterface.media.ExifInterface
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.utils.FileUtils
+import com.yalantis.ucrop.UCrop
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import kotlin.math.roundToInt
+
+private const val TAG = "AttachmentEditing"
+private const val EDIT_FILE_STAMP_PATTERN = "yyyyMMdd-HHmmss-SSS"
+private const val EDIT_JPEG_QUALITY = 95
+private const val EDIT_PNG_QUALITY = 100
+
+// uCrop crops the bitmap it decoded for display, not the original file, and decodes it no larger
+// than this on either side (by default roughly the screen diagonal, ~2600 px) - so without an
+// explicit limit an HD photo would silently come out downscaled. 4096 keeps a typical 12 MP photo
+// at full size, stays inside every GPU's texture limit and bounds the decoded bitmap to ~64 MB.
+private const val CROP_MAX_BITMAP_PX = 4096
+
+/**
+ * Reserves the (empty) output file of an edit in the cache dir shared through the app's FileProvider. The name is
+ * built from the current millisecond; when it is taken (two edits in the same millisecond), a numeric suffix is
+ * added, so an output never overwrites a file of another edit. The caller owns the file and deletes it on failure.
+ * Null when the directory is unavailable (anything else would be unshareable) or the file can't be created.
+ */
+internal fun createEditOutputFile(context: Context, sourceName: String, sourceMimeType: String?): File? {
+ val directory = FileUtils.getSharedAttachmentsDirectory(context.cacheDir) ?: return null
+ val stamp = SimpleDateFormat(EDIT_FILE_STAMP_PATTERN, Locale.ROOT).format(Date())
+ val extension = if (editOutputIsPng(sourceMimeType)) "png" else "jpg"
+ return try {
+ claimFirstFree(
+ maxAttempts = EDIT_NAME_MAX_ATTEMPTS,
+ candidate = { attempt ->
+ val unique = if (attempt == 0) stamp else "$stamp-$attempt"
+ File(directory, editedFileName(sourceName, unique, extension))
+ },
+ claim = { it.createNewFile() }
+ )
+ } catch (e: IOException) {
+ logEditFileFailure(e)
+ } catch (e: SecurityException) {
+ logEditFileFailure(e)
+ }
+}
+
+private fun logEditFileFailure(error: Exception): File? {
+ NextcloudTalkApplication.sharedApplication?.logger?.w(TAG, "Failed to reserve an edit output file", error)
+ return null
+}
+
+/** The same URI form the camera capture uses for files in the shared attachments cache; null if not shareable. */
+internal fun editedFileUri(context: Context, file: File): Uri? =
+ try {
+ FileProvider.getUriForFile(context, context.packageName, file)
+ } catch (e: IllegalArgumentException) {
+ NextcloudTalkApplication.sharedApplication?.logger?.w(TAG, "Edited file is outside the shared paths", e)
+ null
+ }
+
+/**
+ * The file behind [uriString] if it is an intermediate produced by an earlier edit (our FileProvider,
+ * shared attachments cache, edit-suffixed name) - safe to delete once superseded. Null for anything
+ * else, e.g. the user's original photo or a camera shot.
+ */
+internal fun ownEditedFile(context: Context, uriString: String): File? {
+ val uri = Uri.parse(uriString)
+ val name = ownEditedName(uri.authority, context.packageName, uri.pathSegments)
+ return name?.let { FileUtils.resolveSharedAttachmentFile(context.cacheDir, it) }
+}
+
+/** Intent of uCrop's crop-and-rotate screen, writing the result to [destination]. */
+internal fun createCropIntent(context: Context, source: Uri, destination: File, sourceMimeType: String?): Intent {
+ val png = editOutputIsPng(sourceMimeType)
+ val options = UCrop.Options().apply {
+ setCompressionFormat(if (png) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG)
+ setCompressionQuality(if (png) EDIT_PNG_QUALITY else EDIT_JPEG_QUALITY)
+ setFreeStyleCropEnabled(true)
+ setMaxBitmapSize(CROP_MAX_BITMAP_PX)
+ }
+ return UCrop.of(source, Uri.fromFile(destination)).withOptions(options).getIntent(context)
+}
+
+/**
+ * Burns [strokes] into a copy of [source] at (at most ~20 MP of) its own resolution and writes it
+ * to [destination]. Only one bitmap is ever held: the strokes are drawn straight onto the decoded,
+ * un-rotated pixels through the inverse of the EXIF transform, and the orientation tag is carried
+ * over to the output instead of rotating the pixels. Returns false on any failure.
+ */
+internal fun renderDrawing(
+ context: Context,
+ source: Uri,
+ strokes: List,
+ destination: File,
+ png: Boolean
+): Boolean =
+ try {
+ drawOnDecodedCopy(context, source, strokes, destination, png)
+ } catch (e: IOException) {
+ logRenderFailure(e)
+ } catch (e: SecurityException) {
+ // e.g. a Photo Picker uri whose read grant was revoked
+ logRenderFailure(e)
+ } catch (e: OutOfMemoryError) {
+ logRenderFailure(e)
+ }.also { if (!it) destination.delete() }
+
+private fun logRenderFailure(error: Throwable): Boolean {
+ NextcloudTalkApplication.sharedApplication?.logger?.w(TAG, "Failed to save drawing", error)
+ return false
+}
+
+private fun drawOnDecodedCopy(
+ context: Context,
+ source: Uri,
+ strokes: List,
+ destination: File,
+ png: Boolean
+): Boolean {
+ val resolver = context.contentResolver
+ val exif = resolver.openInputStream(source)?.use { ExifInterface(it) }
+ val bitmap = decodeRaw(context, source)
+ val written = bitmap != null && paintAndWrite(bitmap, exif, strokes, destination, png)
+ bitmap?.recycle()
+ return written
+}
+
+private fun decodeRaw(context: Context, source: Uri): Bitmap? {
+ val resolver = context.contentResolver
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ resolver.openInputStream(source)?.use { BitmapFactory.decodeStream(it, null, bounds) }
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
+
+ val options = BitmapFactory.Options().apply {
+ inSampleSize = decodeSampleSize(bounds.outWidth, bounds.outHeight)
+ inMutable = true
+ }
+ return resolver.openInputStream(source)?.use { BitmapFactory.decodeStream(it, null, options) }
+}
+
+private fun paintAndWrite(
+ bitmap: Bitmap,
+ exif: ExifInterface?,
+ strokes: List,
+ destination: File,
+ png: Boolean
+): Boolean {
+ val degrees = exif?.rotationDegrees ?: 0
+ val flipped = exif?.isFlipped == true
+ val toUpright = uprightMatrix(degrees, flipped, bitmap.width, bitmap.height)
+ val bounds = RectF(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat()).also { toUpright.mapRect(it) }
+ drawStrokes(bitmap, strokes, toUpright, bounds.width().roundToInt(), bounds.height().roundToInt())
+
+ val turnPixels = rotatesPixels(png, degrees, flipped)
+ val output = if (turnPixels) uprightCopy(bitmap, toUpright, bounds) else bitmap
+ val format = if (png) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG
+ val quality = if (png) EDIT_PNG_QUALITY else EDIT_JPEG_QUALITY
+ val written = FileOutputStream(destination).use { output.compress(format, quality, it) }
+ if (output !== bitmap) output.recycle()
+ if (written && !turnPixels && degrees != 0) {
+ ExifInterface(destination.absolutePath).apply {
+ setAttribute(ExifInterface.TAG_ORIENTATION, exif?.getAttribute(ExifInterface.TAG_ORIENTATION))
+ saveAttributes()
+ }
+ }
+ return written
+}
+
+/** The pixels turned/mirrored upright; the raw [bitmap] is released first to keep the memory peak low. */
+private fun uprightCopy(bitmap: Bitmap, toUpright: Matrix, bounds: RectF): Bitmap {
+ val upright = createBitmap(bounds.width().roundToInt(), bounds.height().roundToInt())
+ Canvas(upright).drawBitmap(bitmap, toUpright, Paint(Paint.FILTER_BITMAP_FLAG))
+ bitmap.recycle()
+ return upright
+}
+
+/** Raw-pixel to upright-display transform for an EXIF orientation, translated to start at 0,0. */
+internal fun uprightMatrix(rotationDegrees: Int, flipped: Boolean, width: Int, height: Int): Matrix {
+ val matrix = Matrix().apply {
+ if (flipped) postScale(-1f, 1f)
+ postRotate(rotationDegrees.toFloat())
+ }
+ val mapped = RectF(0f, 0f, width.toFloat(), height.toFloat()).also { matrix.mapRect(it) }
+ matrix.postTranslate(-mapped.left, -mapped.top)
+ return matrix
+}
+
+/** Draws [strokes] (positioned on the upright [uprightWidth]x[uprightHeight] picture) onto the raw [bitmap]. */
+private fun drawStrokes(
+ bitmap: Bitmap,
+ strokes: List,
+ toUpright: Matrix,
+ uprightWidth: Int,
+ uprightHeight: Int
+) {
+ val canvas = Canvas(bitmap)
+ canvas.concat(Matrix().also { toUpright.invert(it) })
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeCap = Paint.Cap.ROUND
+ strokeJoin = Paint.Join.ROUND
+ }
+ strokes.filter { it.points.isNotEmpty() }.forEach { stroke ->
+ paint.color = stroke.colorArgb
+ paint.strokeWidth = strokeWidthPixels(stroke, uprightWidth)
+ val pixels = stroke.points.map { toPixels(it, uprightWidth, uprightHeight) }
+ if (pixels.size == 1) {
+ canvas.drawPoint(pixels.first().first, pixels.first().second, paint)
+ } else {
+ canvas.drawPath(strokePath(pixels), paint)
+ }
+ }
+}
+
+private fun strokePath(pixels: List>): Path =
+ Path().apply {
+ moveTo(pixels.first().first, pixels.first().second)
+ pixels.drop(1).forEach { lineTo(it.first, it.second) }
+ }
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBar.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBar.kt
new file mode 100644
index 00000000000..06ec0b89498
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBar.kt
@@ -0,0 +1,231 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.selection.toggleable
+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.Edit
+import androidx.compose.material.icons.filled.EditOff
+import androidx.compose.material.icons.outlined.Brush
+import androidx.compose.material.icons.outlined.CropRotate
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.SubcomposeLayout
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.dp
+import com.nextcloud.talk.R
+
+private const val BADGE_BORDER_DP = 1
+private const val BADGE_CORNER_RADIUS_DP = 4
+private const val BADGE_HORIZONTAL_PADDING_DP = 3
+private const val QUALITY_TOGGLE_SIZE_DP = 48
+private const val PERMISSION_ICON_SIZE_DP = 18
+private const val PERMISSION_COMPACT_PADDING_DP = 15
+
+/** Where more files can come from: the "add" button next to the caption offers these. */
+internal data class AddMoreActions(
+ val onPickFromGallery: () -> Unit,
+ val onTakePhoto: () -> Unit,
+ val onTakeVideo: () -> Unit
+)
+
+/** What the compact panel under the caption shows. */
+internal data class ToolBarState(
+ val showQuality: Boolean,
+ val highQuality: Boolean,
+ val showPermission: Boolean,
+ val allowUpdate: Boolean
+)
+
+/** [onCrop]/[onDraw] are null when the current file isn't an image (no edit tools for video/documents). */
+internal data class ToolBarActions(
+ val onCrop: (() -> Unit)?,
+ val onDraw: (() -> Unit)?,
+ val onHighQualityChange: (Boolean) -> Unit,
+ val onAllowUpdateChange: (Boolean) -> Unit
+)
+
+/**
+ * Telegram-style pill with edit tools (images only), the SD/HD switch and the View-only/Editable choice.
+ * Buttons keep their size. When the full pill is wider than its slot, the permission choice shrinks to its icon;
+ * should the pill still be too wide (large font), its content scrolls.
+ */
+@Composable
+internal fun AttachmentToolBar(state: ToolBarState, actions: ToolBarActions, modifier: Modifier = Modifier) {
+ SubcomposeLayout(
+ modifier = modifier
+ .clip(RoundedCornerShape(percent = 50))
+ .background(ScrimColor)
+ ) { constraints ->
+ val full = subcompose(PermissionStyle.LABELED) { ToolBarContent(state, actions, compactPermission = false) }
+ val fullWidth = full.maxOfOrNull { it.maxIntrinsicWidth(Constraints.Infinity) } ?: 0
+ val measurables = if (state.showPermission && shouldCompactPermission(fullWidth, constraints.maxWidth)) {
+ subcompose(PermissionStyle.ICON_ONLY) { ToolBarContent(state, actions, compactPermission = true) }
+ } else {
+ full
+ }
+ val placeables = measurables.map { it.measure(constraints.copy(minWidth = 0)) }
+ layout(placeables.maxOfOrNull { it.width } ?: 0, placeables.maxOfOrNull { it.height } ?: 0) {
+ placeables.forEach { it.place(0, 0) }
+ }
+ }
+}
+
+private enum class PermissionStyle { LABELED, ICON_ONLY }
+
+@Composable
+private fun ToolBarContent(state: ToolBarState, actions: ToolBarActions, compactPermission: Boolean) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ modifier = Modifier
+ .horizontalScroll(rememberScrollState())
+ .padding(horizontal = 4.dp)
+ ) {
+ actions.onCrop?.let { onCrop ->
+ ToolIconButton(Icons.Outlined.CropRotate, R.string.nc_attachment_crop, onCrop)
+ }
+ actions.onDraw?.let { onDraw ->
+ ToolIconButton(Icons.Outlined.Brush, R.string.nc_attachment_draw, onDraw)
+ }
+ if (state.showQuality) {
+ QualityToggle(state.highQuality, actions.onHighQualityChange)
+ }
+ if (state.showPermission) {
+ PermissionChoice(state.allowUpdate, compactPermission, actions.onAllowUpdateChange)
+ }
+ }
+}
+
+@Composable
+private fun ToolIconButton(icon: ImageVector, @StringRes description: Int, onClick: () -> Unit) {
+ IconButton(onClick = onClick) {
+ Icon(imageVector = icon, contentDescription = stringResource(description), tint = Color.White)
+ }
+}
+
+@Composable
+private fun TextBadge(text: String, color: Color) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Bold,
+ color = color,
+ modifier = Modifier
+ .border(BADGE_BORDER_DP.dp, color, RoundedCornerShape(BADGE_CORNER_RADIUS_DP.dp))
+ .padding(horizontal = BADGE_HORIZONTAL_PADDING_DP.dp)
+ )
+}
+
+/** SD = compressed, HD = original; one tap flips it, the chip on the photo shows the resulting size. */
+@Composable
+private fun QualityToggle(highQuality: Boolean, onHighQualityChange: (Boolean) -> Unit) {
+ val description = stringResource(R.string.nc_media_quality_original)
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(QUALITY_TOGGLE_SIZE_DP.dp)
+ .clip(CircleShape)
+ .toggleable(value = highQuality, role = Role.Switch, onValueChange = onHighQualityChange)
+ .semantics { contentDescription = description }
+ ) {
+ TextBadge(if (highQuality) "HD" else "SD", Color.White)
+ }
+}
+
+@Composable
+private fun PermissionChoice(allowUpdate: Boolean, compact: Boolean, onAllowUpdateChange: (Boolean) -> Unit) {
+ var expanded by remember { mutableStateOf(false) }
+ val label = stringResource(
+ if (allowUpdate) R.string.nc_file_permission_editable else R.string.nc_file_permission_view_only
+ )
+
+ Box {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .clip(RoundedCornerShape(percent = 50))
+ .clickable(role = Role.Button, onClickLabel = label) { expanded = true }
+ .padding(
+ horizontal = if (compact) PERMISSION_COMPACT_PADDING_DP.dp else 10.dp,
+ vertical = if (compact) PERMISSION_COMPACT_PADDING_DP.dp else 12.dp
+ )
+ ) {
+ Icon(
+ imageVector = if (allowUpdate) Icons.Filled.Edit else Icons.Filled.EditOff,
+ contentDescription = if (compact) label else null,
+ tint = Color.White,
+ modifier = Modifier.size(PERMISSION_ICON_SIZE_DP.dp)
+ )
+ if (!compact) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelMedium,
+ color = Color.White,
+ maxLines = 1,
+ modifier = Modifier.padding(start = 6.dp)
+ )
+ }
+ }
+ DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.nc_file_permission_view_only)) },
+ leadingIcon = { Icon(Icons.Filled.EditOff, contentDescription = null) },
+ trailingIcon = { if (!allowUpdate) Icon(Icons.Filled.Check, contentDescription = null) },
+ onClick = {
+ onAllowUpdateChange(false)
+ expanded = false
+ }
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.nc_file_permission_editable)) },
+ leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) },
+ trailingIcon = { if (allowUpdate) Icon(Icons.Filled.Check, contentDescription = null) },
+ onClick = {
+ onAllowUpdateChange(true)
+ expanded = false
+ }
+ )
+ }
+ }
+}
+
+/** The labeled View-only/Editable choice is used while the full pill fits the slot; otherwise only its icon. */
+internal fun shouldCompactPermission(fullWidthPx: Int, availableWidthPx: Int): Boolean = fullWidthPx > availableWidthPx
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBarPreviews.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBarPreviews.kt
new file mode 100644
index 00000000000..ef42bba6a9d
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBarPreviews.kt
@@ -0,0 +1,63 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+
+private val previewImage = FileDescription(
+ uri = "file:///sdcard/DCIM/photo.jpg",
+ name = "photo.jpg",
+ kind = MediaKind.IMAGE,
+ mimeType = "image/jpeg",
+ detail = "4000×3000, 3 MB",
+ aspectRatio = 1.33f
+)
+
+@Composable
+private fun BottomToolRowPreviewContainer(allowUpdate: Boolean = false) {
+ MaterialTheme {
+ BottomToolRow(
+ options = ToolBarState(
+ showQuality = true,
+ highQuality = false,
+ showPermission = true,
+ allowUpdate = allowUpdate
+ ),
+ current = previewImage,
+ onCrop = {},
+ onDraw = {},
+ onHighQualityChange = {},
+ onAllowUpdateChange = {},
+ sendEnabled = true,
+ onSend = {}
+ )
+ }
+}
+
+@Preview(name = "Narrow 360dp", widthDp = 360, showBackground = true, backgroundColor = 0xFF000000)
+@Preview(name = "Phone 393dp", widthDp = 393, showBackground = true, backgroundColor = 0xFF000000)
+@Preview(name = "Landscape 850dp", widthDp = 850, showBackground = true, backgroundColor = 0xFF000000)
+@Preview(name = "Side pane 500dp", widthDp = 500, showBackground = true, backgroundColor = 0xFF000000)
+@Composable
+private fun BottomToolRowWidthsPreview() {
+ BottomToolRowPreviewContainer()
+}
+
+@Preview(
+ name = "Huawei 348dp ru, font 1.3",
+ widthDp = 348,
+ fontScale = 1.3f,
+ locale = "ru",
+ showBackground = true,
+ backgroundColor = 0xFF000000
+)
+@Composable
+private fun BottomToolRowOwnerDevicePreview() {
+ BottomToolRowPreviewContainer(allowUpdate = true)
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/CameraCaptureActions.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/CameraCaptureActions.kt
index 0b987af8cbd..762f458bdc9 100644
--- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/CameraCaptureActions.kt
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/CameraCaptureActions.kt
@@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
+import com.nextcloud.talk.camera.TakePhotoInApp
import com.nextcloud.talk.utils.FileUtils
import java.io.File
import java.text.SimpleDateFormat
@@ -38,7 +39,10 @@ internal fun rememberCameraCaptureActions(currentFiles: MutableList): Ca
var pendingCameraUri by remember { mutableStateOf(null) }
var pendingCameraPermissionType by remember { mutableStateOf(null) }
- val onCaptureResult: (Boolean) -> Unit = { success ->
+ val takePhoto = rememberLauncherForActivityResult(TakePhotoInApp()) { uri ->
+ uri?.let { currentFiles.add(it.toString()) }
+ }
+ val captureVideo = rememberLauncherForActivityResult(ActivityResultContracts.CaptureVideo()) { success ->
val uri = pendingCameraUri
if (success && uri != null) {
currentFiles.add(uri.toString())
@@ -46,15 +50,14 @@ internal fun rememberCameraCaptureActions(currentFiles: MutableList): Ca
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)
+ CameraCaptureType.PHOTO -> takePhoto.launch(Unit)
+ CameraCaptureType.VIDEO -> {
+ val uri = createVideoOutputUri(context)
+ pendingCameraUri = uri
+ captureVideo.launch(uri)
+ }
}
}
@@ -85,10 +88,9 @@ internal fun rememberCameraCaptureActions(currentFiles: MutableList): Ca
)
}
-private fun createCameraOutputUri(context: Context, type: CameraCaptureType): Uri {
+private fun createVideoOutputUri(context: Context): 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")
+ val file = File(outputDir, "$timestamp.mp4")
return FileProvider.getUriForFile(context, context.packageName, file)
}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/CaptionInputBar.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/CaptionInputBar.kt
index 1cbb9afed8e..d76b28b3906 100644
--- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/CaptionInputBar.kt
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/CaptionInputBar.kt
@@ -12,16 +12,27 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.AddPhotoAlternate
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
@@ -29,58 +40,107 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.nextcloud.talk.R
-/** The bottom bar where the user types an optional caption and triggers sending. */
+internal val ScrimColor = Color.Black.copy(alpha = 0.5f)
+private const val SEND_BUTTON_SIZE_DP = 56
+
+/** The caption field floating over the photo, with the "add more files" button at its start. */
@Composable
internal fun CaptionInputBar(
caption: String,
onCaptionChange: (String) -> Unit,
- sendEnabled: Boolean,
- onSend: () -> Unit
+ addMoreActions: AddMoreActions,
+ modifier: Modifier = Modifier
) {
- val shape = RoundedCornerShape(dimensionResource(R.dimen.button_corner_radius))
Row(
verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
+ modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
- .clip(shape)
- .background(MaterialTheme.colorScheme.surfaceVariant)
+ .clip(RoundedCornerShape(dimensionResource(R.dimen.button_corner_radius)))
+ .background(ScrimColor)
) {
+ AddMoreButton(addMoreActions)
+
Box(
modifier = Modifier
.weight(1f)
.heightIn(min = dimensionResource(R.dimen.min_size_clickable_area))
- .padding(start = 16.dp),
+ .padding(start = 4.dp, end = 16.dp),
contentAlignment = Alignment.CenterStart
) {
if (caption.isEmpty()) {
Text(
text = stringResource(R.string.nc_caption),
style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ color = Color.White.copy(alpha = 0.7f)
)
}
BasicTextField(
value = caption,
onValueChange = onCaptionChange,
maxLines = 5,
- textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
- cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ textStyle = MaterialTheme.typography.bodyLarge.copy(color = Color.White),
+ cursorBrush = SolidColor(Color.White),
modifier = Modifier.fillMaxWidth()
)
}
+ }
+}
- val sendTint = if (sendEnabled) {
- MaterialTheme.colorScheme.primary
- } else {
- MaterialTheme.colorScheme.onSurfaceVariant
- }
- IconButton(onClick = onSend, enabled = sendEnabled) {
+@Composable
+private fun AddMoreButton(actions: AddMoreActions) {
+ var expanded by remember { mutableStateOf(false) }
+
+ Box {
+ IconButton(onClick = { expanded = true }) {
Icon(
- painter = painterResource(R.drawable.ic_send_24px),
- contentDescription = stringResource(R.string.nc_description_send_message_button),
- tint = sendTint
+ imageVector = Icons.Outlined.AddPhotoAlternate,
+ contentDescription = stringResource(R.string.nc_attachment_add_menu),
+ tint = Color.White
+ )
+ }
+ DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.nc_add_more_files)) },
+ onClick = {
+ expanded = false
+ actions.onPickFromGallery()
+ }
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.take_photo)) },
+ onClick = {
+ expanded = false
+ actions.onTakePhoto()
+ }
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.nc_take_video)) },
+ onClick = {
+ expanded = false
+ actions.onTakeVideo()
+ }
)
}
}
}
+
+/** The round "send" button. */
+@Composable
+internal fun SendButton(enabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
+ val container = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
+ IconButton(
+ onClick = onClick,
+ enabled = enabled,
+ modifier = modifier
+ .size(SEND_BUTTON_SIZE_DP.dp)
+ .clip(CircleShape)
+ .background(container)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_send_24px),
+ contentDescription = stringResource(R.string.nc_description_send_message_button),
+ tint = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/DetailVariants.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/DetailVariants.kt
new file mode 100644
index 00000000000..df2fdf444c7
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/DetailVariants.kt
@@ -0,0 +1,15 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+/**
+ * [current] is the detail text for the file's active compress setting, [alternate] is what it
+ * would read as under the other setting. Both are computed regardless of [FileDescription]'s own
+ * `compress` flag, so the large preview's detail chip can reserve width for whichever is wider and
+ * never resize when the HQ toggle flips which one is actually shown.
+ */
+internal data class DetailVariants(val current: String, val alternate: String)
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/DrawingEditor.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/DrawingEditor.kt
new file mode 100644
index 00000000000..3ff25912e2e
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/DrawingEditor.kt
@@ -0,0 +1,234 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import androidx.activity.compose.BackHandler
+import androidx.annotation.StringRes
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Undo
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import com.nextcloud.talk.R
+
+private const val BRUSH_WIDTH_FRACTION = 0.012f
+private const val SWATCH_SIZE_DP = 32
+private const val SWATCH_SELECTED_BORDER_DP = 3
+private const val COLOR_RED = 0xFFE53935
+private const val COLOR_YELLOW = 0xFFFDD835
+private const val COLOR_GREEN = 0xFF43A047
+private const val COLOR_BLUE = 0xFF1E88E5
+private val brushSwatches = listOf(
+ Swatch(Color.White, R.string.nc_attachment_color_white),
+ Swatch(Color.Black, R.string.nc_attachment_color_black),
+ Swatch(Color(COLOR_RED), R.string.nc_attachment_color_red),
+ Swatch(Color(COLOR_YELLOW), R.string.nc_attachment_color_yellow),
+ Swatch(Color(COLOR_GREEN), R.string.nc_attachment_color_green),
+ Swatch(Color(COLOR_BLUE), R.string.nc_attachment_color_blue)
+)
+
+private data class Swatch(val color: Color, @StringRes val name: Int)
+
+/**
+ * Full-screen brush editor over an image. Strokes are kept in image-relative coordinates, so the
+ * result doesn't depend on the screen size; they live in [session], owned by the view model.
+ * [aspectRatio] is the image's displayed width / height.
+ */
+@Composable
+internal fun DrawingEditor(
+ imageUri: String,
+ aspectRatio: Float,
+ session: DrawingSession,
+ onCancel: () -> Unit,
+ onDone: () -> Unit
+) {
+ BackHandler(onBack = onCancel)
+
+ Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
+ BoxWithConstraints(modifier = Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) {
+ val widthPx = with(LocalDensity.current) { maxWidth.toPx() }
+ val heightPx = with(LocalDensity.current) { maxHeight.toPx() }
+ val rect = remember(widthPx, heightPx, aspectRatio) { fitRect(widthPx, heightPx, aspectRatio) }
+
+ AsyncImage(
+ model = imageUri,
+ contentDescription = null,
+ contentScale = ContentScale.Fit,
+ modifier = Modifier.fillMaxSize()
+ )
+ DrawingSurface(rect = rect, session = session)
+ }
+
+ EditorTopBar(
+ canUndo = session.strokes.isNotEmpty(),
+ onCancel = onCancel,
+ onUndo = session::undo,
+ onDone = onDone,
+ modifier = Modifier.align(Alignment.TopCenter)
+ )
+ ColorRow(
+ selectedArgb = session.brushColorArgb,
+ onSelect = { session.brushColorArgb = it.toArgb() },
+ modifier = Modifier.align(Alignment.BottomCenter)
+ )
+ }
+}
+
+@Composable
+private fun DrawingSurface(rect: FitRect, session: DrawingSession) {
+ val live = remember { mutableStateListOf() }
+
+ fun commit() {
+ if (live.isNotEmpty()) {
+ session.add(DrawStroke(session.brushColorArgb, BRUSH_WIDTH_FRACTION, live.toList()))
+ live.clear()
+ }
+ }
+
+ Canvas(
+ modifier = Modifier
+ .fillMaxSize()
+ .pointerInput(rect) {
+ detectDragGestures(
+ onDragStart = { start ->
+ live.clear()
+ live.add(toNormalized(start.x, start.y, rect))
+ },
+ onDrag = { change, _ ->
+ change.consume()
+ live.add(toNormalized(change.position.x, change.position.y, rect))
+ },
+ onDragEnd = ::commit,
+ onDragCancel = ::commit
+ )
+ }
+ ) {
+ session.strokes.forEach { drawStroke(it.colorArgb, it.widthFraction, it.points, rect) }
+ drawStroke(session.brushColorArgb, BRUSH_WIDTH_FRACTION, live.toList(), rect)
+ }
+}
+
+private fun DrawScope.drawStroke(colorArgb: Int, widthFraction: Float, points: List, rect: FitRect) {
+ if (points.isEmpty()) return
+ val color = Color(colorArgb)
+ val width = (widthFraction * rect.width).coerceAtLeast(1f)
+ fun NormalizedPoint.toOffset() = Offset(rect.left + x * rect.width, rect.top + y * rect.height)
+ if (points.size == 1) {
+ drawCircle(color, radius = width / 2f, center = points.first().toOffset())
+ return
+ }
+ val path = Path().apply {
+ val first = points.first().toOffset()
+ moveTo(first.x, first.y)
+ points.drop(1).forEach { point -> point.toOffset().let { lineTo(it.x, it.y) } }
+ }
+ drawPath(path, color, style = Stroke(width = width, cap = StrokeCap.Round, join = StrokeJoin.Round))
+}
+
+@Composable
+private fun EditorTopBar(
+ canUndo: Boolean,
+ onCancel: () -> Unit,
+ onUndo: () -> Unit,
+ onDone: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = modifier
+ .fillMaxWidth()
+ .background(ScrimColor)
+ .statusBarsPadding()
+ .padding(horizontal = 4.dp)
+ ) {
+ IconButton(onClick = onCancel) {
+ Icon(Icons.Filled.Close, stringResource(R.string.nc_common_dismiss), tint = Color.White)
+ }
+ Row {
+ IconButton(onClick = onUndo, enabled = canUndo) {
+ Icon(
+ Icons.AutoMirrored.Filled.Undo,
+ stringResource(R.string.nc_attachment_undo),
+ tint = Color.White.copy(alpha = if (canUndo) 1f else 0.4f)
+ )
+ }
+ IconButton(onClick = onDone) {
+ Icon(Icons.Filled.Check, stringResource(R.string.save), tint = Color.White)
+ }
+ }
+ }
+}
+
+@Composable
+private fun ColorRow(selectedArgb: Int, onSelect: (Color) -> Unit, modifier: Modifier = Modifier) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier
+ .fillMaxWidth()
+ .background(ScrimColor)
+ .navigationBarsPadding()
+ .padding(vertical = 12.dp)
+ ) {
+ brushSwatches.forEach { swatch ->
+ val isSelected = swatch.color.toArgb() == selectedArgb
+ val borderColor = if (isSelected) Color.White else Color.Gray
+ val borderWidth = if (isSelected) SWATCH_SELECTED_BORDER_DP else 1
+ val name = stringResource(swatch.name)
+ Box(
+ modifier = Modifier
+ .size(SWATCH_SIZE_DP.dp)
+ .clip(CircleShape)
+ .background(swatch.color)
+ .border(borderWidth.dp, borderColor, CircleShape)
+ .selectable(selected = isSelected, role = Role.RadioButton) { onSelect(swatch.color) }
+ .semantics { contentDescription = name }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/DrawingSession.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/DrawingSession.kt
new file mode 100644
index 00000000000..57b289a6c44
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/DrawingSession.kt
@@ -0,0 +1,33 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+
+private const val DEFAULT_BRUSH_COLOR_ARGB = 0xFFFFFFFF
+
+/** Strokes and brush colour of the drawing being made, held by the view model across rotation. */
+internal class DrawingSession {
+ var strokes by mutableStateOf>(emptyList())
+ private set
+
+ var brushColorArgb by mutableStateOf(DEFAULT_BRUSH_COLOR_ARGB.toInt())
+
+ fun add(stroke: DrawStroke) {
+ strokes = strokes + stroke
+ }
+
+ fun undo() {
+ strokes = undoLast(strokes)
+ }
+
+ fun clear() {
+ strokes = emptyList()
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/EditNameClaim.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/EditNameClaim.kt
new file mode 100644
index 00000000000..6a36a2e361a
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/EditNameClaim.kt
@@ -0,0 +1,18 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+/** How many names (the plain one plus numbered ones) are tried when an edit output name is already taken. */
+internal const val EDIT_NAME_MAX_ATTEMPTS = 100
+
+/**
+ * Claims the first free name: tries [candidate] for attempt 0 (the plain name), then 1, 2, ... up to
+ * [maxAttempts] attempts in total. [claim] must reserve the candidate atomically and return false when it is taken.
+ * Null when every attempt was taken.
+ */
+internal fun claimFirstFree(maxAttempts: Int, candidate: (Int) -> T, claim: (T) -> Boolean): T? =
+ (0 until maxAttempts).asSequence().map(candidate).firstOrNull(claim)
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt
index 42677896a8c..80e6c8b765c 100644
--- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt
@@ -14,7 +14,6 @@ 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
@@ -34,9 +33,7 @@ class FileAttachmentPreviewFragment : DialogFragment() {
private lateinit var filesList: ArrayList
private var conversationName: String = ""
private var showFilePermissionsOption: Boolean = false
- private var uploadFiles:
- (files: MutableList, caption: String, compressImages: Boolean, allowUpdate: Boolean) -> Unit =
- { _, _, _, _ -> }
+ private var startDrawing: Boolean = false
private var composeView: ComposeView? = null
@Inject
@@ -52,22 +49,12 @@ class FileAttachmentPreviewFragment : DialogFragment() {
ViewModelProvider(this, viewModelFactory)[FileAttachmentPreviewViewModel::class.java]
}
- fun setListener(
- uploadFiles: (
- files: MutableList,
- caption: String,
- compressImages: Boolean,
- allowUpdate: Boolean
- ) -> Unit
- ) {
- this.uploadFiles = uploadFiles
- }
-
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
arguments?.let {
filesList = it.getStringArrayList(FILES_TO_UPLOAD_ARG)!!
conversationName = it.getString(CONVERSATION_NAME_ARG, "")
showFilePermissionsOption = it.getBoolean(FILE_PERMISSIONS_OPTION_ARG, false)
+ startDrawing = it.getBoolean(START_DRAWING_ARG, false)
}
composeView = ComposeView(requireContext())
@@ -90,11 +77,10 @@ class FileAttachmentPreviewFragment : DialogFragment() {
statusBarColor = Color.TRANSPARENT
navigationBarColor = Color.TRANSPARENT
- val surfaceColor = viewThemeUtils.getColorScheme(requireActivity()).surface
- val isLightSurface = surfaceColor.luminance() > LIGHT_LUMINANCE_THRESHOLD
+ // The screen is always dark (photo on black), so system bar icons are always light.
WindowInsetsControllerCompat(this, decorView).apply {
- isAppearanceLightStatusBars = isLightSurface
- isAppearanceLightNavigationBars = isLightSurface
+ isAppearanceLightStatusBars = false
+ isAppearanceLightNavigationBars = false
}
}
}
@@ -104,7 +90,7 @@ class FileAttachmentPreviewFragment : DialogFragment() {
NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils)
- viewModel.setInitialFiles(filesList)
+ viewModel.setInitialFiles(filesList, startDrawing)
composeView?.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
@@ -117,7 +103,10 @@ class FileAttachmentPreviewFragment : DialogFragment() {
showFilePermissionsOption = showFilePermissionsOption,
onDismiss = { dismiss() },
onSend = { files, caption, compressImages, allowUpdate ->
- uploadFiles(files.toMutableList(), caption, compressImages, allowUpdate)
+ parentFragmentManager.setFragmentResult(
+ RESULT_KEY,
+ packResult(files, caption, compressImages, allowUpdate)
+ )
dismiss()
}
)
@@ -133,22 +122,41 @@ class FileAttachmentPreviewFragment : DialogFragment() {
companion object {
- private const val LIGHT_LUMINANCE_THRESHOLD = 0.5f
private const val FILES_TO_UPLOAD_ARG = "FILES_TO_UPLOAD_ARG"
private const val CONVERSATION_NAME_ARG = "CONVERSATION_NAME_ARG"
private const val FILE_PERMISSIONS_OPTION_ARG = "FILE_PERMISSIONS_OPTION_ARG"
+ private const val START_DRAWING_ARG = "START_DRAWING_ARG"
+
+ const val RESULT_KEY = "FILE_ATTACHMENT_PREVIEW_RESULT"
+ const val RESULT_FILES = "RESULT_FILES"
+ const val RESULT_CAPTION = "RESULT_CAPTION"
+ const val RESULT_COMPRESS_IMAGES = "RESULT_COMPRESS_IMAGES"
+ const val RESULT_ALLOW_UPDATE = "RESULT_ALLOW_UPDATE"
+
+ /**
+ * The result goes through the fragment manager, so it still arrives after the activity was recreated.
+ */
+ fun packResult(files: List, caption: String, compressImages: Boolean, allowUpdate: Boolean): Bundle =
+ Bundle().apply {
+ putStringArrayList(RESULT_FILES, ArrayList(files))
+ putString(RESULT_CAPTION, caption)
+ putBoolean(RESULT_COMPRESS_IMAGES, compressImages)
+ putBoolean(RESULT_ALLOW_UPDATE, allowUpdate)
+ }
@JvmStatic
fun newInstance(
filesToUpload: MutableList,
conversationName: String,
- showFilePermissionsOption: Boolean = false
+ showFilePermissionsOption: Boolean = false,
+ startDrawing: Boolean = false
): FileAttachmentPreviewFragment {
val fileAttachmentFragment = FileAttachmentPreviewFragment()
val args = Bundle()
args.putStringArrayList(FILES_TO_UPLOAD_ARG, ArrayList(filesToUpload))
args.putString(CONVERSATION_NAME_ARG, conversationName)
args.putBoolean(FILE_PERMISSIONS_OPTION_ARG, showFilePermissionsOption)
+ args.putBoolean(START_DRAWING_ARG, startDrawing)
fileAttachmentFragment.arguments = args
return fileAttachmentFragment
}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt
index 5fd88b9df1f..a6db4972af1 100644
--- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt
@@ -6,18 +6,24 @@
*/
package com.nextcloud.talk.attachmentpreview
+import android.app.Activity
import android.content.res.Configuration
+import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia
+import androidx.compose.foundation.background
import androidx.compose.foundation.border
+import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -25,26 +31,16 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
-import androidx.compose.material.icons.filled.Close
-import androidx.compose.material.icons.filled.Edit
-import androidx.compose.material.icons.filled.EditOff
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
-import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
@@ -58,24 +54,39 @@ 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.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.semantics.Role
+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.core.net.toUri
import com.nextcloud.talk.R
+import com.yalantis.ucrop.UCrop
import kotlinx.coroutines.launch
+import java.io.File
private const val MAX_ADD_MORE_FILES = 10
private const val APP_BAR_HEIGHT_DP = 64
private const val APP_BAR_HORIZONTAL_PADDING_DP = 4
+private const val SELECTION_MARK_SIZE_DP = 32
+private const val SELECTION_MARK_BORDER_DP = 2
+private const val SCRIM_ALPHA = 0.6f
+private const val TOOL_ROW_SPACING_DP = 12
/**
- * Full-screen dialog content for reviewing, reordering and captioning files picked for upload,
- * hosted by [FileAttachmentPreviewFragment]. [viewModel] owns the file list and its (IO-derived)
- * descriptions so both survive configuration changes; everything else here is ephemeral UI state.
+ * Full-screen dialog content for reviewing, selecting, editing and captioning files picked for
+ * upload, hosted by [FileAttachmentPreviewFragment]. [viewModel] owns the file list, the selection
+ * and its (IO-derived) descriptions so they survive configuration changes; everything else here is
+ * ephemeral UI state.
*/
@Suppress("LongMethod", "LongParameterList")
@Composable
@@ -87,10 +98,12 @@ internal fun FileAttachmentPreviewContent(
onDismiss: () -> Unit,
onSend: (files: List, caption: String, compressImages: Boolean, allowUpdate: Boolean) -> Unit
) {
+ val context = LocalContext.current
val currentFiles = viewModel.files
var caption by rememberSaveable { mutableStateOf("") }
var compressImages by rememberSaveable { mutableStateOf(initialCompressImages) }
var allowUpdate by rememberSaveable { mutableStateOf(false) }
+ var drawingUri by rememberSaveable { mutableStateOf(null) }
LaunchedEffect(currentFiles.toSet(), compressImages) {
viewModel.describeFiles(compressImages)
@@ -104,16 +117,29 @@ internal fun FileAttachmentPreviewContent(
// while waiting for the descriptions would hold Send back until large files are copied.
val hasCompressibleMedia = currentFiles.any { isCompressible(viewModel.mimeTypesByUri[it]) }
+ AutoStartDrawing(viewModel, fileDescriptions) {
+ viewModel.drawing.clear()
+ drawingUri = it
+ }
+
val pagerState = rememberPagerState(pageCount = { fileDescriptions.size })
val coroutineScope = rememberCoroutineScope()
+ val currentDescription = fileDescriptions.getOrNull(pagerState.currentPage)
val pickMoreMedia = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(MAX_ADD_MORE_FILES)
) { uris ->
viewModel.addFiles(uris.map { it.toString() })
}
-
val cameraCapture = rememberCameraCaptureActions(currentFiles)
+ val startCrop = rememberCropLauncher(viewModel)
+
+ LaunchedEffect(viewModel.editFailed) {
+ if (viewModel.editFailed) {
+ Toast.makeText(context, R.string.nc_attachment_edit_failed, Toast.LENGTH_LONG).show()
+ viewModel.editFailureShown()
+ }
+ }
LaunchedEffect(currentFiles.size) {
if (currentFiles.isEmpty()) {
@@ -121,89 +147,224 @@ internal fun FileAttachmentPreviewContent(
}
}
- Surface(modifier = Modifier.fillMaxSize()) {
+ val density = LocalDensity.current
+ var bottomPanelHeight by remember { mutableStateOf(0.dp) }
+ val topBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + APP_BAR_HEIGHT_DP.dp
+
+ Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
+ if (fileDescriptions.isNotEmpty()) {
+ LargePreview(
+ descriptions = fileDescriptions,
+ pagerState = pagerState,
+ videoPadding = PaddingValues(top = topBarHeight, bottom = bottomPanelHeight),
+ detailTopPadding = topBarHeight
+ )
+ }
+
+ PreviewTopBar(
+ conversationName = conversationName,
+ position = pagerState.currentPage to fileDescriptions.size,
+ selectedCount = viewModel.selectedFiles().size,
+ currentSelected = currentDescription?.let { it.uri !in viewModel.unselected } ?: false,
+ onToggleSelected = { currentDescription?.let { viewModel.toggleSelected(it.uri) } },
+ onDismiss = onDismiss,
+ modifier = Modifier.align(Alignment.TopCenter)
+ )
+
Column(
modifier = Modifier
- .fillMaxSize()
- .statusBarsPadding()
+ .align(Alignment.BottomCenter)
+ .onSizeChanged { bottomPanelHeight = with(density) { it.height.toDp() } }
+ .background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = SCRIM_ALPHA))))
.navigationBarsPadding()
.imePadding()
) {
- PreviewTopBar(conversationName = conversationName, onDismiss = onDismiss)
+ if (fileDescriptions.size > 1) {
+ ThumbnailStrip(
+ descriptions = fileDescriptions,
+ selectedIndex = pagerState.currentPage,
+ unselected = viewModel.unselected.toSet(),
+ onSelect = { index -> coroutineScope.launch { pagerState.scrollToPage(index) } },
+ onReorder = { from, to -> viewModel.reorder(from, to) }
+ )
+ }
- HorizontalDivider()
+ CaptionInputBar(
+ caption = caption,
+ onCaptionChange = { caption = it },
+ addMoreActions = AddMoreActions(
+ onPickFromGallery = {
+ pickMoreMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo))
+ },
+ onTakePhoto = cameraCapture.onTakePhoto,
+ onTakeVideo = cameraCapture.onTakeVideo
+ )
+ )
- Column(modifier = Modifier.weight(1f)) {
- if (fileDescriptions.isNotEmpty()) {
- LargePreview(
- descriptions = fileDescriptions,
- pagerState = pagerState,
- modifier = Modifier.weight(1f)
- )
+ BottomToolRow(
+ options = ToolBarState(
+ showQuality = hasCompressibleMedia,
+ highQuality = !compressImages,
+ showPermission = showFilePermissionsOption,
+ allowUpdate = allowUpdate
+ ),
+ current = currentDescription?.takeUnless { viewModel.isEditing },
+ onCrop = startCrop,
+ onDraw = {
+ viewModel.drawing.clear()
+ drawingUri = it.uri
+ },
+ onHighQualityChange = { highQuality -> compressImages = !highQuality },
+ onAllowUpdateChange = { allowUpdate = it },
+ sendEnabled = viewModel.selectedFiles().isNotEmpty() && !viewModel.isEditing,
+ onSend = { onSend(viewModel.selectedFiles(), caption, compressImages, allowUpdate) }
+ )
+ }
- ThumbnailStrip(
- descriptions = fileDescriptions,
- selectedIndex = pagerState.currentPage,
- onSelect = { index -> coroutineScope.launch { pagerState.scrollToPage(index) } },
- onRemove = { uri -> viewModel.removeFile(uri) },
- onReorder = { from, to -> viewModel.reorder(from, to) },
- onAddMore = {
- pickMoreMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo))
- },
- onTakePhoto = cameraCapture.onTakePhoto,
- onTakeVideo = cameraCapture.onTakeVideo
- )
- }
+ DrawingOverlay(
+ target = fileDescriptions.firstOrNull { it.uri == drawingUri },
+ session = viewModel.drawing,
+ onCancel = {
+ viewModel.drawing.clear()
+ drawingUri = null
+ },
+ onDone = { target ->
+ viewModel.saveDrawing(target)
+ drawingUri = null
}
+ )
+ }
+}
- if (showFilePermissionsOption || hasCompressibleMedia) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 8.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
- ) {
- if (showFilePermissionsOption) {
- FilePermissionOptionButton(
- allowUpdate = allowUpdate,
- onAllowUpdateChange = { allowUpdate = it }
- )
- }
-
- if (hasCompressibleMedia) {
- MediaQualityOptionButton(
- highQuality = !compressImages,
- onHighQualityChange = { highQuality -> compressImages = !highQuality }
- )
- }
- }
+/** Opened from the media viewer's draw button: go straight into the drawing editor once the photo is described. */
+@Composable
+private fun AutoStartDrawing(
+ viewModel: FileAttachmentPreviewViewModel,
+ descriptions: List,
+ onStart: (String) -> Unit
+) {
+ val target = viewModel.autoDrawUri?.let { uri -> descriptions.firstOrNull { it.uri == uri } }
+ LaunchedEffect(target) {
+ if (target != null) {
+ if (target.kind == MediaKind.IMAGE && target.aspectRatio != null) {
+ onStart(target.uri)
}
+ viewModel.autoDrawUri = null
+ }
+ }
+}
- CaptionInputBar(
- caption = caption,
- onCaptionChange = { caption = it },
- sendEnabled = currentFiles.isNotEmpty(),
- onSend = {
- onSend(currentFiles.toList(), caption, compressImages, allowUpdate)
- }
+/** The edit tools / quality / permission pill on the left, the round send button on the right. */
+@Suppress("LongParameterList")
+@Composable
+internal fun BottomToolRow(
+ options: ToolBarState,
+ current: FileDescription?,
+ onCrop: (FileDescription) -> Unit,
+ onDraw: (FileDescription) -> Unit,
+ onHighQualityChange: (Boolean) -> Unit,
+ onAllowUpdateChange: (Boolean) -> Unit,
+ sendEnabled: Boolean,
+ onSend: () -> Unit
+) {
+ // Edit tools exist for images only; drawing additionally needs a known aspect ratio to map touches.
+ val image = current?.takeIf { it.kind == MediaKind.IMAGE }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(TOOL_ROW_SPACING_DP.dp),
+ modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 12.dp)
+ ) {
+ // The pill gets all the width the send button leaves; it scrolls instead of squeezing its buttons.
+ Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
+ AttachmentToolBar(
+ state = options,
+ actions = ToolBarActions(
+ onCrop = image?.let { { onCrop(it) } },
+ onDraw = image?.takeIf { it.aspectRatio != null }?.let { { onDraw(it) } },
+ onHighQualityChange = onHighQualityChange,
+ onAllowUpdateChange = onAllowUpdateChange
+ )
)
}
+ SendButton(enabled = sendEnabled, onClick = onSend)
}
}
@Composable
-private fun PreviewTopBar(conversationName: String, onDismiss: () -> Unit) {
+private fun DrawingOverlay(
+ target: FileDescription?,
+ session: DrawingSession,
+ onCancel: () -> Unit,
+ onDone: (FileDescription) -> Unit
+) {
+ val ratio = target?.aspectRatio ?: return
+ DrawingEditor(
+ imageUri = target.uri,
+ aspectRatio = ratio,
+ session = session,
+ onCancel = onCancel,
+ onDone = { onDone(target) }
+ )
+}
+
+/** Launches uCrop on a file and swaps the cropped/rotated result in at the original's position. */
+@Composable
+private fun rememberCropLauncher(viewModel: FileAttachmentPreviewViewModel): (FileDescription) -> Unit {
+ val context = LocalContext.current
+ var sourceUri by rememberSaveable { mutableStateOf(null) }
+ var destinationPath by rememberSaveable { mutableStateOf(null) }
+
+ val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ val source = sourceUri
+ val destination = destinationPath?.let(::File)
+ val cropped = result.resultCode == Activity.RESULT_OK && result.data?.let { UCrop.getOutput(it) } != null
+ if (cropped && source != null && destination != null && destination.length() > 0) {
+ viewModel.applyCrop(source, destination)
+ } else {
+ // cancelled or failed: don't leave uCrop's empty/partial output behind
+ destination?.delete()
+ }
+ sourceUri = null
+ destinationPath = null
+ }
+
+ return { description ->
+ val destination = createEditOutputFile(context, description.name, description.mimeType)
+ if (destination == null) {
+ Toast.makeText(context, R.string.nc_attachment_edit_failed, Toast.LENGTH_LONG).show()
+ } else {
+ sourceUri = description.uri
+ destinationPath = destination.absolutePath
+ launcher.launch(createCropIntent(context, description.uri.toUri(), destination, description.mimeType))
+ }
+ }
+}
+
+@Suppress("LongParameterList")
+@Composable
+private fun PreviewTopBar(
+ conversationName: String,
+ position: Pair,
+ selectedCount: Int,
+ currentSelected: Boolean,
+ onToggleSelected: () -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier
+) {
Row(
verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
+ modifier = modifier
.fillMaxWidth()
+ .background(Brush.verticalGradient(listOf(Color.Black.copy(alpha = SCRIM_ALPHA), Color.Transparent)))
+ .statusBarsPadding()
.height(APP_BAR_HEIGHT_DP.dp)
.padding(horizontal = APP_BAR_HORIZONTAL_PADDING_DP.dp)
) {
IconButton(onClick = onDismiss) {
Icon(
- imageVector = Icons.Filled.Close,
- contentDescription = stringResource(R.string.nc_common_dismiss)
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.nc_common_dismiss),
+ tint = Color.White
)
}
@@ -214,148 +375,61 @@ private fun PreviewTopBar(conversationName: String, onDismiss: () -> Unit) {
) {
Text(
text = conversationName,
- style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.onSurface,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- Text(
- text = stringResource(R.string.nc_add_file),
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.titleMedium,
+ color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
+ if (position.second > 1) {
+ Text(
+ text = stringResource(R.string.nc_attachment_position, position.first + 1, position.second),
+ style = MaterialTheme.typography.labelMedium,
+ color = Color.White.copy(alpha = 0.8f)
+ )
+ }
}
- }
-}
-private const val OPTION_BUTTON_ICON_SIZE_DP = 16
-private const val OPTION_BUTTON_HORIZONTAL_PADDING_DP = 12
-private const val OPTION_BUTTON_VERTICAL_PADDING_DP = 6
-private const val OPTION_BUTTON_LABEL_PADDING_DP = 4
-private const val TEXT_BADGE_BORDER_DP = 1
-private const val TEXT_BADGE_CORNER_RADIUS_DP = 4
-private const val TEXT_BADGE_HORIZONTAL_PADDING_DP = 3
-
-@Composable
-private fun OptionButton(label: String, onClick: () -> Unit, icon: @Composable () -> Unit) {
- Button(
- onClick = onClick,
- shape = CircleShape,
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.surfaceVariant,
- contentColor = MaterialTheme.colorScheme.onSurfaceVariant
- ),
- contentPadding = PaddingValues(
- horizontal = OPTION_BUTTON_HORIZONTAL_PADDING_DP.dp,
- vertical = OPTION_BUTTON_VERTICAL_PADDING_DP.dp
- )
- ) {
- icon()
- Text(
- text = label,
- style = MaterialTheme.typography.labelMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.padding(horizontal = OPTION_BUTTON_LABEL_PADDING_DP.dp)
- )
- Icon(
- imageVector = Icons.Filled.ArrowDropDown,
- contentDescription = null,
- modifier = Modifier.size(OPTION_BUTTON_ICON_SIZE_DP.dp)
+ SelectionMark(
+ selected = currentSelected,
+ onClick = onToggleSelected,
+ modifier = Modifier.padding(end = 4.dp)
)
+ SelectedCount(selectedCount)
}
}
@Composable
-private fun TextBadge(text: String) {
- Text(
- text = text,
- style = MaterialTheme.typography.labelSmall,
- fontWeight = FontWeight.Bold,
- color = LocalContentColor.current,
+private fun SelectedCount(count: Int) {
+ Box(
+ contentAlignment = Alignment.Center,
modifier = Modifier
- .border(
- width = TEXT_BADGE_BORDER_DP.dp,
- color = LocalContentColor.current,
- shape = RoundedCornerShape(TEXT_BADGE_CORNER_RADIUS_DP.dp)
- )
- .padding(horizontal = TEXT_BADGE_HORIZONTAL_PADDING_DP.dp)
- )
-}
-
-@Composable
-private fun MediaQualityOptionButton(highQuality: Boolean, onHighQualityChange: (Boolean) -> Unit) {
- var expanded by remember { mutableStateOf(false) }
-
- Box {
- OptionButton(
- label = stringResource(
- if (highQuality) R.string.nc_media_quality_original else R.string.nc_media_quality_reduced
- ),
- onClick = { expanded = true },
- icon = { TextBadge(if (highQuality) "HD" else "SD") }
- )
- DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.nc_media_quality_original)) },
- leadingIcon = { TextBadge("HD") },
- trailingIcon = { if (highQuality) Icon(Icons.Filled.Check, contentDescription = null) },
- onClick = {
- onHighQualityChange(true)
- expanded = false
- }
- )
- DropdownMenuItem(
- text = { Text(stringResource(R.string.nc_media_quality_reduced)) },
- leadingIcon = { TextBadge("SD") },
- trailingIcon = { if (!highQuality) Icon(Icons.Filled.Check, contentDescription = null) },
- onClick = {
- onHighQualityChange(false)
- expanded = false
- }
- )
- }
+ .padding(horizontal = 8.dp)
+ .size(SELECTION_MARK_SIZE_DP.dp)
+ .border(SELECTION_MARK_BORDER_DP.dp, Color.White, CircleShape)
+ ) {
+ Text(text = count.toString(), style = MaterialTheme.typography.labelLarge, color = Color.White)
}
}
+/** The round tick that includes/excludes the current file; the last selected file can't be un-ticked. */
@Composable
-private fun FilePermissionOptionButton(allowUpdate: Boolean, onAllowUpdateChange: (Boolean) -> Unit) {
- var expanded by remember { mutableStateOf(false) }
-
- Box {
- OptionButton(
- label = stringResource(
- if (allowUpdate) R.string.nc_file_permission_editable else R.string.nc_file_permission_view_only
- ),
- onClick = { expanded = true },
- icon = {
- Icon(
- imageVector = if (allowUpdate) Icons.Filled.Edit else Icons.Filled.EditOff,
- contentDescription = null,
- modifier = Modifier.size(OPTION_BUTTON_ICON_SIZE_DP.dp)
- )
- }
- )
- DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
- DropdownMenuItem(
- text = { Text(stringResource(R.string.nc_file_permission_view_only)) },
- leadingIcon = { Icon(Icons.Filled.EditOff, contentDescription = null) },
- trailingIcon = { if (!allowUpdate) Icon(Icons.Filled.Check, contentDescription = null) },
- onClick = {
- onAllowUpdateChange(false)
- expanded = false
- }
- )
- DropdownMenuItem(
- text = { Text(stringResource(R.string.nc_file_permission_editable)) },
- leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) },
- trailingIcon = { if (allowUpdate) Icon(Icons.Filled.Check, contentDescription = null) },
- onClick = {
- onAllowUpdateChange(true)
- expanded = false
- }
+private fun SelectionMark(selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
+ val description = stringResource(R.string.nc_attachment_include)
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier
+ .size(SELECTION_MARK_SIZE_DP.dp)
+ .clip(CircleShape)
+ .background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
+ .border(SELECTION_MARK_BORDER_DP.dp, if (selected) Color.Transparent else Color.White, CircleShape)
+ .toggleable(value = selected, role = Role.Checkbox, onValueChange = { onClick() })
+ .semantics { contentDescription = description }
+ ) {
+ if (selected) {
+ Icon(
+ imageVector = Icons.Filled.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimary
)
}
}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt
index f365753a920..6afd99cff17 100644
--- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt
@@ -7,24 +7,31 @@
package com.nextcloud.talk.attachmentpreview
import android.content.Context
+import android.net.Uri
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import androidx.core.net.toUri
import com.nextcloud.talk.utils.FileUtils
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
+import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import java.io.File
import javax.inject.Inject
/**
- * Holds the file list being reviewed for upload and each file's (IO-derived) [FileDescription],
- * so both survive configuration changes (e.g. screen rotation) instead of resetting back to the
- * dialog's original arguments. Everything else on the screen — caption text, drag/scroll
- * position, HQ toggle animation state — is ephemeral UI state and stays in Compose's `remember`.
+ * Holds the file list being reviewed for upload, which of them are excluded from sending, and
+ * each file's (IO-derived) [FileDescription], so all survive configuration changes (e.g. screen
+ * rotation) instead of resetting back to the dialog's original arguments. Everything else on the
+ * screen — caption text, drag/scroll position, HQ toggle animation state — is ephemeral UI state
+ * and stays in Compose's `remember`.
*/
internal class FileAttachmentPreviewViewModel(
private val context: Context,
@@ -53,10 +60,22 @@ internal class FileAttachmentPreviewViewModel(
private var describeJob: Job? = null
private var mimeTypeJob: Job? = null
- /** No-op after the first call, so re-entering (e.g. after rotation) doesn't wipe edits made since. */
- fun setInitialFiles(initialFiles: List) {
+ /**
+ * The file whose drawing editor is to be opened as soon as it is described, set once by
+ * [setInitialFiles] and cleared by the screen once the editor is open.
+ */
+ var autoDrawUri by mutableStateOf(null)
+
+ /**
+ * No-op after the first call, so re-entering (e.g. after rotation) doesn't wipe edits made since.
+ * With [startDrawing] the first file opens straight in the drawing editor.
+ */
+ fun setInitialFiles(initialFiles: List, startDrawing: Boolean = false) {
if (files.isEmpty()) {
files.addAll(initialFiles)
+ if (startDrawing) {
+ autoDrawUri = initialFiles.firstOrNull()
+ }
}
}
@@ -64,8 +83,92 @@ internal class FileAttachmentPreviewViewModel(
files.addAll(newFiles.filterNot { it in files })
}
- fun removeFile(uri: String) {
- files.remove(uri)
+ /** Files the user un-ticked; they stay in the list (and can be re-ticked) but are not sent. */
+ val unselected = mutableStateListOf()
+
+ /** True while an edit (e.g. a drawing) is being written, so sending can't race it with the old file. */
+ var isEditing by mutableStateOf(false)
+ private set
+
+ /** Set when an edit could not be saved; the screen reports it once and calls [editFailureShown]. */
+ var editFailed by mutableStateOf(false)
+ private set
+
+ /** The drawing in progress; here (not in `remember`) so it survives rotation. */
+ val drawing = DrawingSession()
+
+ fun editFailureShown() {
+ editFailed = false
+ }
+
+ fun toggleSelected(uri: String) {
+ val updated = toggleSelection(files, unselected.toSet(), uri)
+ unselected.clear()
+ unselected.addAll(updated)
+ }
+
+ fun selectedFiles(): List = selectedFiles(files, unselected.toSet())
+
+ /**
+ * Swaps an edited file in at the position of the original. The old description is carried over
+ * right away (re-pointed at [newUri]) so the pager doesn't drop the page while the fresh
+ * description is computed; [describeFiles] then refreshes size/resolution.
+ */
+ fun replaceFile(oldUri: String, newUri: String) {
+ val updated = replaceUri(files, oldUri, newUri)
+ if (updated === files) return
+ files.clear()
+ files.addAll(updated)
+ if (unselected.remove(oldUri)) {
+ unselected.add(newUri)
+ }
+ _descriptionsByUri[oldUri]?.let { _descriptionsByUri[newUri] = it.copy(uri = newUri) }
+ _descriptionsByUri.remove(oldUri)
+ }
+
+ /** Burns the current [drawing] strokes into a copy of [description]'s image; keeps the original on failure. */
+ fun saveDrawing(description: FileDescription) {
+ val drawn = drawing.strokes
+ drawing.clear()
+ if (drawn.isEmpty()) return
+ isEditing = true
+ viewModelScope.launch {
+ val output = withContext(Dispatchers.IO) {
+ createEditOutputFile(context, description.name, description.mimeType)?.let { file ->
+ val saved = renderDrawing(
+ context,
+ description.uri.toUri(),
+ drawn,
+ file,
+ editOutputIsPng(description.mimeType)
+ )
+ (if (saved) editedFileUri(context, file) else null).also { if (it == null) file.delete() }
+ }
+ }
+ finishEdit(description.uri, output)
+ }
+ }
+
+ /** Swaps in the file uCrop wrote to [croppedFile]; reports a failure if it can't be shared. */
+ fun applyCrop(sourceUri: String, croppedFile: File) {
+ val uri = editedFileUri(context, croppedFile)
+ if (uri == null) croppedFile.delete()
+ finishEdit(sourceUri, uri)
+ }
+
+ private fun finishEdit(oldUri: String, newUri: Uri?) {
+ if (newUri == null) {
+ editFailed = true
+ } else {
+ replaceFile(oldUri, newUri.toString())
+ discardIfOwnEdit(oldUri)
+ }
+ isEditing = false
+ }
+
+ /** An intermediate of an earlier edit is of no use once superseded; originals are never touched. */
+ private fun discardIfOwnEdit(uri: String) {
+ viewModelScope.launch(Dispatchers.IO) { ownEditedFile(context, uri)?.delete() }
}
fun reorder(from: Int, to: Int) {
@@ -84,13 +187,13 @@ internal class FileAttachmentPreviewViewModel(
// a stale job finishing last would retainAll away the description of a file added since
describeJob?.cancel()
describeJob = viewModelScope.launch(ioDispatcher) {
- val described = snapshot.associateWith { describer(context, it, compress) }
- // the blocking copy above cannot be interrupted, so a cancelled job reaches this point too;
- // withContext checks for cancellation and runs on main, where cancel() is also called
- withContext(mainDispatcher) {
- _descriptionsByUri.putAll(described)
- _descriptionsByUri.keys.retainAll(described.keys)
+ val described = snapshot.associateWith {
+ ensureActive()
+ describer(context, it, compress)
}
+ ensureActive()
+ // files is mutated on the main thread, so publish there
+ withContext(mainDispatcher) { publishDescriptions(described) }
}
}
@@ -109,4 +212,15 @@ internal class FileAttachmentPreviewViewModel(
}
}
}
+
+ /**
+ * Stores [described], keeping only files that are still in the list. A result computed before
+ * [replaceFile] therefore can't wipe out the replacement's carried-over description (which would
+ * drop the edited page from the pager) nor leave entries for files that are gone.
+ */
+ fun publishDescriptions(described: Map) {
+ val current = files.toSet()
+ described.filterKeys { it in current }.forEach { (uri, description) -> _descriptionsByUri[uri] = description }
+ _descriptionsByUri.keys.retainAll(current)
+ }
}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileDescriber.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileDescriber.kt
index 4c64ed757ca..4abb76ae51a 100644
--- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileDescriber.kt
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileDescriber.kt
@@ -29,14 +29,6 @@ private val TAG = FileAttachmentPreviewViewModel::class.java.simpleName
internal fun isCompressible(mimeType: String?): Boolean =
ImageCompressor.isCompressible(mimeType) || VideoCompressor.isCompressible(mimeType)
-/**
- * [current] is the detail text for the file's active compress setting, [alternate] is what it
- * would read as under the other setting. Both are computed regardless of [FileDescription]'s own
- * `compress` flag, so the large preview's detail chip can reserve width for whichever is wider and
- * never resize when the HQ toggle flips which one is actually shown.
- */
-private data class DetailVariants(val current: String, val alternate: String)
-
@Suppress("ReturnCount")
internal fun describeFile(context: Context, uriString: String, compress: Boolean): FileDescription {
val uri = uriString.toUri()
@@ -48,7 +40,7 @@ internal fun describeFile(context: Context, uriString: String, compress: Boolean
val sizeOnly = formatSize(context, file.length())
val variants = when (kind) {
- MediaKind.IMAGE -> describeImageDetail(context, file, compress, sizeOnly)
+ MediaKind.IMAGE -> describeImageDetail(context, file, mimeType, compress, sizeOnly)
MediaKind.VIDEO -> describeVideoDetail(context, file, compress, sizeOnly)
MediaKind.OTHER -> "$name, $sizeOnly".let { DetailVariants(it, it) }
}
@@ -72,31 +64,24 @@ internal fun describeFile(context: Context, uriString: String, compress: Boolean
@Suppress("ReturnCount")
private fun imageAspectRatio(file: File): Float? {
- val info = ImageCompressor.readImageInfo(file) ?: return null
+ val info = ImageCompressor.readImageInfo(file)?.inDisplayOrientation(readExifOrientation(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()
+ return info.width.toFloat() / info.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 =
+// swapped width/height compared to how it actually renders. Callers swap them back so the aspect
+// ratio and the size caption match what Coil and the compressor (both honor EXIF) actually produce.
+private fun readExifOrientation(file: File): Int =
try {
- val orientation = ExifInterface(file.absolutePath).getAttributeInt(
+ ExifInterface(file.absolutePath).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
- orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270
} catch (e: IOException) {
NextcloudTalkApplication.sharedApplication?.logger?.w(TAG, "Failed to read EXIF orientation", e)
- false
+ ExifInterface.ORIENTATION_NORMAL
}
@Suppress("ReturnCount")
@@ -156,12 +141,18 @@ private fun downscaleIfNeeded(bitmap: Bitmap): Bitmap {
}
@Suppress("ReturnCount")
-private fun describeImageDetail(context: Context, file: File, compress: Boolean, fallback: String): DetailVariants {
- val original = ImageCompressor.readImageInfo(file) ?: return DetailVariants(fallback, fallback)
- val originalText = describeMedia(context, original.width, original.height, original.sizeBytes)
- val compressed = ImageCompressor.estimateCompression(file)
- val compressedText = compressed?.let { describeMedia(context, it.width, it.height, it.sizeBytes) } ?: originalText
- return if (compress) DetailVariants(compressedText, originalText) else DetailVariants(originalText, compressedText)
+private fun describeImageDetail(
+ context: Context,
+ file: File,
+ mimeType: String?,
+ compress: Boolean,
+ fallback: String
+): DetailVariants {
+ val original = ImageCompressor.readImageInfo(file)?.inDisplayOrientation(readExifOrientation(file))
+ ?: return DetailVariants(fallback, fallback)
+ return imageDetailVariants(original, compressedImageInfo(mimeType, file), compress) {
+ describeMedia(context, it.width, it.height, it.sizeBytes)
+ }
}
@Suppress("ReturnCount")
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileThumbnailImage.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileThumbnailImage.kt
index 0f3d0b8a480..1379a36693d 100644
--- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileThumbnailImage.kt
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileThumbnailImage.kt
@@ -17,7 +17,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
@@ -41,17 +41,15 @@ internal fun FileThumbnailImage(
modifier: Modifier = Modifier,
iconSize: Dp = THUMBNAIL_ICON_SIZE_DP.dp,
contentScale: ContentScale = ContentScale.Crop,
- backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant
+ shape: Shape = RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp)
) {
- 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)
+ modifier = modifier.clip(shape).background(MaterialTheme.colorScheme.surfaceVariant)
)
MediaKind.VIDEO ->
@@ -60,11 +58,11 @@ internal fun FileThumbnailImage(
bitmap = description.videoThumbnail.asImageBitmap(),
contentDescription = description.name,
contentScale = contentScale,
- modifier = modifier.clip(shape).background(backgroundColor)
+ modifier = modifier.clip(shape).background(MaterialTheme.colorScheme.surfaceVariant)
)
} else {
Box(
- modifier = modifier.clip(shape).background(backgroundColor),
+ modifier = modifier.clip(shape).background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(
@@ -78,7 +76,7 @@ internal fun FileThumbnailImage(
MediaKind.OTHER -> {
val mimetypeIcon = DrawableUtils.getDrawableResourceIdForMimeType(description.mimeType)
Box(
- modifier = modifier.clip(shape).background(backgroundColor),
+ modifier = modifier.clip(shape).background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/ImageDetailLogic.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/ImageDetailLogic.kt
new file mode 100644
index 00000000000..da1bcba6a9d
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/ImageDetailLogic.kt
@@ -0,0 +1,45 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import androidx.exifinterface.media.ExifInterface
+import com.nextcloud.talk.utils.ImageCompressor
+import com.nextcloud.talk.utils.ImageCompressor.ImageInfo
+import java.io.File
+
+/** True when the EXIF orientation turns the image by 90/270 degrees (also mirrored), swapping width and height. */
+internal fun exifSwapsDimensions(orientation: Int): Boolean =
+ orientation == ExifInterface.ORIENTATION_ROTATE_90 ||
+ orientation == ExifInterface.ORIENTATION_ROTATE_270 ||
+ orientation == ExifInterface.ORIENTATION_TRANSPOSE ||
+ orientation == ExifInterface.ORIENTATION_TRANSVERSE
+
+/** Pixel size of an image as it is displayed: the decode-bounds size with the EXIF orientation applied. */
+internal fun ImageInfo.inDisplayOrientation(orientation: Int): ImageInfo =
+ if (exifSwapsDimensions(orientation)) copy(width = height, height = width) else this
+
+/**
+ * Detail texts of an image. [original] is the file as it is, in display orientation; [compressed] is what
+ * the compressor produces from it (null when it can't be estimated, then the original is shown for both).
+ */
+internal fun imageDetailVariants(
+ original: ImageInfo,
+ compressed: ImageInfo?,
+ compress: Boolean,
+ describe: (ImageInfo) -> String
+): DetailVariants {
+ val originalText = describe(original)
+ val compressedText = compressed?.let(describe) ?: originalText
+ return if (compress) DetailVariants(compressedText, originalText) else DetailVariants(originalText, compressedText)
+}
+
+/** What the upload worker makes of [file]; null for types it sends untouched (GIF) or when it can't be decoded. */
+internal fun compressedImageInfo(
+ mimeType: String?,
+ file: File,
+ estimate: (File) -> ImageInfo? = ImageCompressor::estimateCompression
+): ImageInfo? = if (ImageCompressor.isCompressible(mimeType)) estimate(file) else null
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/LargePreview.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/LargePreview.kt
index cc5a244e405..ab73b970828 100644
--- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/LargePreview.kt
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/LargePreview.kt
@@ -16,10 +16,9 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -44,6 +43,7 @@ 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.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@@ -66,8 +66,6 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import com.nextcloud.talk.R
-private const val LARGE_MAX_HEIGHT_DP = 480
-private const val LARGE_VERTICAL_SPACING_DP = 12
private const val LARGE_ICON_SIZE_DP = 96
private const val LARGE_OTHER_BORDER_WIDTH_DP = 1
private const val LARGE_OTHER_BORDER_PADDING_DP = 24
@@ -81,29 +79,29 @@ private const val CLOSE_BUTTON_SIZE_DP = 40
private const val CLOSE_ICON_SIZE_DP = 20
private const val CLOSE_BUTTON_PADDING_DP = 8
-/** The large, swipeable preview shown above the thumbnail strip. */
+/**
+ * The full-screen, swipeable preview. [videoPadding] keeps an inline video player's own controls and
+ * close button clear of the overlaid top and bottom bars; images simply run behind them.
+ */
@Composable
-internal fun LargePreview(descriptions: List, pagerState: PagerState, modifier: Modifier = Modifier) {
- Box(
- modifier = modifier
- .fillMaxWidth()
- .padding(vertical = LARGE_VERTICAL_SPACING_DP.dp),
- contentAlignment = Alignment.Center
- ) {
- HorizontalPager(
- state = pagerState,
- key = { page -> descriptions.getOrNull(page)?.uri ?: page },
- modifier = Modifier
- .fillMaxSize()
- .heightIn(max = LARGE_MAX_HEIGHT_DP.dp)
- ) { page ->
- descriptions.getOrNull(page)?.let { description -> LargePage(description) }
- }
+internal fun LargePreview(
+ descriptions: List,
+ pagerState: PagerState,
+ videoPadding: PaddingValues,
+ detailTopPadding: Dp,
+ modifier: Modifier = Modifier
+) {
+ HorizontalPager(
+ state = pagerState,
+ key = { page -> descriptions.getOrNull(page)?.uri ?: page },
+ modifier = modifier.fillMaxSize()
+ ) { page ->
+ descriptions.getOrNull(page)?.let { description -> LargePage(description, videoPadding, detailTopPadding) }
}
}
@Composable
-private fun LargePage(description: FileDescription) {
+private fun LargePage(description: FileDescription, videoPadding: PaddingValues, detailTopPadding: Dp) {
var isPlayingVideo by remember(description.uri, description.detail) { mutableStateOf(false) }
BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -113,8 +111,8 @@ private fun LargePage(description: FileDescription) {
} ?: 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.
+ // without an explicit outline the icon would appear to float directly on the dark screen
+ // instead of reading as a bounded preview, unlike images/videos.
if (description.kind == MediaKind.OTHER) {
cardModifier = cardModifier
.padding(LARGE_OTHER_BORDER_PADDING_DP.dp)
@@ -125,19 +123,23 @@ private fun LargePage(description: FileDescription) {
)
}
- Box(modifier = cardModifier) {
- if (description.kind == MediaKind.VIDEO && isPlayingVideo) {
- VideoPlayerCard(
- uri = description.uri,
- onStopped = { isPlayingVideo = false },
- modifier = Modifier.fillMaxSize()
- )
- } else {
+ if (description.kind == MediaKind.VIDEO && isPlayingVideo) {
+ VideoPlayerCard(
+ uri = description.uri,
+ onStopped = { isPlayingVideo = false },
+ modifier = Modifier.fillMaxSize().padding(videoPadding)
+ )
+ } else {
+ Box(modifier = cardModifier) {
FileThumbnailImage(
description,
iconSize = LARGE_ICON_SIZE_DP.dp,
contentScale = ContentScale.Fit,
- backgroundColor = MaterialTheme.colorScheme.surface,
+ shape = if (description.kind == MediaKind.OTHER) {
+ RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp)
+ } else {
+ RectangleShape
+ },
modifier = Modifier.fillMaxSize()
)
@@ -147,16 +149,16 @@ private fun LargePage(description: FileDescription) {
modifier = Modifier.align(Alignment.Center)
)
}
+ }
- description.detail?.let { detail ->
- LargeDetailOverlay(
- detail,
- description.alternateDetail,
- modifier = Modifier
- .align(Alignment.BottomStart)
- .padding(12.dp)
- )
- }
+ description.detail?.let { detail ->
+ LargeDetailOverlay(
+ detail,
+ description.alternateDetail,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(start = 12.dp, top = detailTopPadding)
+ )
}
}
}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStrip.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStrip.kt
index 9acac64bb0c..8cfeb3857a8 100644
--- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStrip.kt
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStrip.kt
@@ -6,7 +6,6 @@
*/
package com.nextcloud.talk.attachmentpreview
-import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -25,11 +24,6 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Delete
-import androidx.compose.material.icons.outlined.PhotoCamera
-import androidx.compose.material.icons.outlined.PhotoLibrary
-import androidx.compose.material.icons.outlined.Videocam
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -42,35 +36,34 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.nextcloud.talk.R
import kotlin.math.roundToInt
-private const val STRIP_THUMBNAIL_SIZE_DP = 64
-private const val STRIP_ICON_SIZE_DP = 28
-private const val STRIP_PLAY_ICON_SIZE_DP = 28
+private const val STRIP_THUMBNAIL_SIZE_DP = 48
+private const val STRIP_ICON_SIZE_DP = 24
+private const val STRIP_PLAY_ICON_SIZE_DP = 24
private const val STRIP_ITEM_SPACING_DP = 8
private const val SELECTED_BORDER_WIDTH_DP = 2
private const val INDICATOR_WIDTH_DP = 3
private const val INDICATOR_GAP_OFFSET_DP = 5
-@Suppress("LongParameterList")
+/** Compact strip of the picked files above the caption bar: tap to jump to a file, long-press and drag to reorder. */
@Composable
internal fun ThumbnailStrip(
descriptions: List,
selectedIndex: Int,
+ unselected: Set,
onSelect: (Int) -> Unit,
- onRemove: (String) -> Unit,
- onReorder: (from: Int, to: Int) -> Unit,
- onAddMore: () -> Unit,
- onTakePhoto: () -> Unit,
- onTakeVideo: () -> Unit
+ onReorder: (from: Int, to: Int) -> Unit
) {
val density = LocalDensity.current
val itemExtentPx = remember(density) {
@@ -78,57 +71,22 @@ internal fun ThumbnailStrip(
}
val dragState = remember { DragReorderState() }
- // With no file thumbnails shown (single-file case, handled below), the action tiles are the
- // only content — center them instead of leaving them stuck to the start like a scrollable list.
- val horizontalArrangement = if (descriptions.size > 1) {
- Arrangement.spacedBy(STRIP_ITEM_SPACING_DP.dp)
- } else {
- Arrangement.spacedBy(STRIP_ITEM_SPACING_DP.dp, Alignment.CenterHorizontally)
- }
-
LazyRow(
- horizontalArrangement = horizontalArrangement,
+ horizontalArrangement = Arrangement.spacedBy(STRIP_ITEM_SPACING_DP.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
modifier = Modifier.fillMaxWidth()
) {
- // With a single file it's already shown in full in the large preview above, and the
- // "selected" thumbnail styling (dark overlay + trash icon) would otherwise cover the
- // only thumbnail permanently — looking like the file is already marked for deletion
- // rather than just being the one file that was picked.
- if (descriptions.size > 1) {
- itemsIndexed(descriptions, key = { _, description -> description.uri }) { index, description ->
- DraggableStripItem(
- description = description,
- index = index,
- selected = index == selectedIndex,
- itemExtentPx = itemExtentPx,
- itemCount = descriptions.size,
- dragState = dragState,
- onSelect = onSelect,
- onRemove = onRemove,
- onReorder = onReorder
- )
- }
- }
- item {
- ActionTile(
- icon = Icons.Outlined.PhotoLibrary,
- contentDescription = R.string.nc_add_more_files,
- onClick = onAddMore
- )
- }
- item {
- ActionTile(
- icon = Icons.Outlined.PhotoCamera,
- contentDescription = R.string.take_photo,
- onClick = onTakePhoto
- )
- }
- item {
- ActionTile(
- icon = Icons.Outlined.Videocam,
- contentDescription = R.string.nc_take_video,
- onClick = onTakeVideo
+ itemsIndexed(descriptions, key = { _, description -> description.uri }) { index, description ->
+ DraggableStripItem(
+ description = description,
+ index = index,
+ current = index == selectedIndex,
+ included = description.uri !in unselected,
+ itemExtentPx = itemExtentPx,
+ itemCount = descriptions.size,
+ dragState = dragState,
+ onSelect = onSelect,
+ onReorder = onReorder
)
}
}
@@ -152,12 +110,12 @@ private class DragReorderState {
private fun DraggableStripItem(
description: FileDescription,
index: Int,
- selected: Boolean,
+ current: Boolean,
+ included: Boolean,
itemExtentPx: Float,
itemCount: Int,
dragState: DragReorderState,
onSelect: (Int) -> Unit,
- onRemove: (String) -> Unit,
onReorder: (Int, Int) -> Unit
) {
val isDragged = index == dragState.draggedIndex.value
@@ -166,9 +124,9 @@ private fun DraggableStripItem(
Box {
StripThumbnail(
description = description,
- selected = selected,
+ current = current,
+ included = included,
onClick = { onSelect(index) },
- onRemove = { onRemove(description.uri) },
modifier = Modifier
.graphicsLayer { translationX = if (isDragged) dragState.dragOffsetX.value else 0f }
.zIndex(if (isDragged) 1f else 0f)
@@ -245,19 +203,24 @@ private fun DropIndicatorLine(modifier: Modifier = Modifier) {
@Composable
private fun StripThumbnail(
description: FileDescription,
- selected: Boolean,
+ current: Boolean,
+ included: Boolean,
onClick: () -> Unit,
- onRemove: () -> Unit,
modifier: Modifier = Modifier
) {
- val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant
+ val borderColor = if (current) Color.White else Color.Transparent
val shape = RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp)
+ val excludedState = stringResource(R.string.nc_attachment_excluded)
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)
+ .clickable(onClick = onClick)
+ .semantics {
+ selected = current
+ if (!included) stateDescription = excludedState
+ }
) {
FileThumbnailImage(
description,
@@ -265,7 +228,7 @@ private fun StripThumbnail(
modifier = Modifier.fillMaxSize()
)
- if (description.kind == MediaKind.VIDEO && !selected) {
+ if (description.kind == MediaKind.VIDEO) {
Box(
modifier = Modifier
.size(STRIP_PLAY_ICON_SIZE_DP.dp)
@@ -283,39 +246,8 @@ private fun StripThumbnail(
}
}
- if (selected) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(Color.Black.copy(alpha = 0.5f)),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- imageVector = Icons.Outlined.Delete,
- contentDescription = stringResource(R.string.nc_remove_file),
- tint = Color.White,
- modifier = Modifier.size(STRIP_ICON_SIZE_DP.dp)
- )
- }
+ if (!included) {
+ Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)))
}
}
}
-
-@Composable
-private fun ActionTile(icon: ImageVector, @StringRes contentDescription: Int, onClick: () -> Unit) {
- Box(
- modifier = Modifier
- .size(STRIP_THUMBNAIL_SIZE_DP.dp)
- .clip(RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp))
- .background(MaterialTheme.colorScheme.secondaryContainer)
- .clickable(onClick = onClick),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- imageVector = icon,
- contentDescription = stringResource(contentDescription),
- tint = MaterialTheme.colorScheme.onSecondaryContainer,
- modifier = Modifier.size(STRIP_ICON_SIZE_DP.dp)
- )
- }
-}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStripPreviews.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStripPreviews.kt
index d2a57e987b8..0917522205e 100644
--- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStripPreviews.kt
+++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStripPreviews.kt
@@ -55,19 +55,20 @@ private fun previewDescriptions() =
)
@Composable
-private fun ThumbnailStripPreviewContainer(descriptions: List, selectedIndex: Int) {
+private fun ThumbnailStripPreviewContainer(
+ descriptions: List,
+ selectedIndex: Int,
+ unselected: Set = emptySet()
+) {
val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
MaterialTheme(colorScheme = colorScheme) {
Surface {
ThumbnailStrip(
descriptions = descriptions,
selectedIndex = selectedIndex,
+ unselected = unselected,
onSelect = {},
- onRemove = {},
- onReorder = { _, _ -> },
- onAddMore = {},
- onTakePhoto = {},
- onTakeVideo = {}
+ onReorder = { _, _ -> }
)
}
}
@@ -81,7 +82,11 @@ private fun ThumbnailStripPreviewContainer(descriptions: List,
)
@Composable
private fun ThumbnailStripPreview() {
- ThumbnailStripPreviewContainer(descriptions = previewDescriptions(), selectedIndex = 0)
+ ThumbnailStripPreviewContainer(
+ descriptions = previewDescriptions(),
+ selectedIndex = 0,
+ unselected = setOf("file:///sdcard/Download/archive.zip")
+ )
}
/** The mimetype-icon tiles, where [mimetypeIconTint] decides whether an icon is themed or keeps its own colors. */
@@ -103,13 +108,3 @@ private fun ThumbnailStripDocumentsPreview() {
selectedIndex = 1
)
}
-
-/** A single file shows no thumbnails at all - just the centered action tiles. */
-@Preview(name = "Single File", showBackground = true)
-@Composable
-private fun ThumbnailStripSingleFilePreview() {
- ThumbnailStripPreviewContainer(
- descriptions = previewDescriptions().take(1),
- selectedIndex = 0
- )
-}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentAction.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentAction.kt
new file mode 100644
index 00000000000..236cb28ab04
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentAction.kt
@@ -0,0 +1,56 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentsheet
+
+/**
+ * Entries of the attachment sheet that open another screen or flow. The declaration order is the display order.
+ */
+enum class AttachmentAction {
+ PICTURE_FROM_CAM,
+ VIDEO_FROM_CAM,
+ GALLERY,
+ FILE_FROM_LOCAL,
+ FILE_FROM_CLOUD,
+ CREATE_THREAD,
+ CREATE_POLL,
+ SHARE_LOCATION,
+ SHARE_CONTACT
+}
+
+data class AttachmentVisibilityInput(
+ val isRemoteConversation: Boolean,
+ val hasGeoLocationCapability: Boolean,
+ val hasPollsCapability: Boolean,
+ val isOneToOneConversation: Boolean,
+ val hasThreadsCapability: Boolean,
+ val isInsideThread: Boolean,
+ val hasCamera: Boolean
+)
+
+/**
+ * Decides which entries are offered. Federated conversations only allow the gallery, polls and threads; the video
+ * entry additionally needs camera hardware.
+ */
+fun resolveAttachmentActions(input: AttachmentVisibilityInput): List =
+ AttachmentAction.entries.filter { action ->
+ when (action) {
+ AttachmentAction.FILE_FROM_LOCAL,
+ AttachmentAction.FILE_FROM_CLOUD,
+ AttachmentAction.SHARE_CONTACT -> !input.isRemoteConversation
+
+ AttachmentAction.PICTURE_FROM_CAM,
+ AttachmentAction.VIDEO_FROM_CAM -> !input.isRemoteConversation && input.hasCamera
+
+ AttachmentAction.GALLERY -> true
+
+ AttachmentAction.SHARE_LOCATION -> !input.isRemoteConversation && input.hasGeoLocationCapability
+
+ AttachmentAction.CREATE_POLL -> input.hasPollsCapability && !input.isOneToOneConversation
+
+ AttachmentAction.CREATE_THREAD -> input.hasThreadsCapability && !input.isInsideThread
+ }
+ }
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentActionResources.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentActionResources.kt
new file mode 100644
index 00000000000..e363ce42f34
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentActionResources.kt
@@ -0,0 +1,42 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentsheet
+
+import androidx.annotation.DrawableRes
+import androidx.annotation.StringRes
+import com.nextcloud.talk.R
+
+@DrawableRes
+internal fun AttachmentAction.iconRes(): Int =
+ when (this) {
+ AttachmentAction.PICTURE_FROM_CAM -> R.drawable.ic_baseline_photo_camera_24
+ AttachmentAction.VIDEO_FROM_CAM -> R.drawable.ic_baseline_videocam_24
+ AttachmentAction.GALLERY -> R.drawable.baseline_photo_library_24
+ AttachmentAction.FILE_FROM_LOCAL -> R.drawable.upload
+ AttachmentAction.FILE_FROM_CLOUD -> R.drawable.ic_share_variant
+ AttachmentAction.CREATE_THREAD -> R.drawable.outline_forum_24
+ AttachmentAction.CREATE_POLL -> R.drawable.ic_baseline_bar_chart_24
+ AttachmentAction.SHARE_LOCATION -> R.drawable.ic_baseline_location_on_24
+ AttachmentAction.SHARE_CONTACT -> R.drawable.ic_baseline_person_24
+ }
+
+/**
+ * Label for the entry, or null when it needs a runtime argument (the cloud entry carries the server name).
+ */
+@StringRes
+internal fun AttachmentAction.labelRes(): Int? =
+ when (this) {
+ AttachmentAction.PICTURE_FROM_CAM -> R.string.nc_upload_picture_from_cam
+ AttachmentAction.VIDEO_FROM_CAM -> R.string.nc_upload_video_from_cam
+ AttachmentAction.GALLERY -> R.string.nc_gallery
+ AttachmentAction.FILE_FROM_LOCAL -> R.string.nc_upload_from_device
+ AttachmentAction.FILE_FROM_CLOUD -> null
+ AttachmentAction.CREATE_THREAD -> R.string.start_thread
+ AttachmentAction.CREATE_POLL -> R.string.nc_create_poll
+ AttachmentAction.SHARE_LOCATION -> R.string.nc_share_location
+ AttachmentAction.SHARE_CONTACT -> R.string.nc_share_contact
+ }
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentSheet.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentSheet.kt
new file mode 100644
index 00000000000..6820db600f4
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentSheet.kt
@@ -0,0 +1,439 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentsheet
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import androidx.activity.compose.LocalActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableIntState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInWindow
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalWindowInfo
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.selected
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
+import coil.compose.AsyncImage
+import com.nextcloud.talk.R
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlin.math.roundToInt
+
+private const val GRID_COLUMNS = 3
+private const val TILE_GAP_DP = 2
+private const val BADGE_SIZE_DP = 24
+private const val BAR_ITEM_WIDTH_DP = 84
+private const val SCRIM_ALPHA = 0.45f
+
+data class AttachmentSheetModel(
+ val actions: List,
+ val cloudLabel: String,
+ val maxSelection: Int,
+ val livePreviewEnabled: Boolean
+)
+
+internal data class CameraTileConfig(val livePreview: Boolean, val onClick: () -> Unit)
+
+data class AttachmentSheetCallbacks(
+ val onAction: (AttachmentAction) -> Unit,
+ val onTakePhoto: () -> Unit,
+ val onSend: (List) -> Unit,
+ val onDismiss: () -> Unit
+)
+
+/**
+ * Bottom sheet in the style of messengers: recent photos and videos in a grid (first tile is the camera), a bar with
+ * all other attachment entries below. It opens half-high and can be dragged up.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AttachmentSheet(model: AttachmentSheetModel, callbacks: AttachmentSheetCallbacks) {
+ ModalBottomSheet(
+ onDismissRequest = callbacks.onDismiss,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false),
+ containerColor = MaterialTheme.colorScheme.surface
+ ) {
+ AttachmentSheetBody(model, callbacks)
+ }
+}
+
+@Composable
+private fun AttachmentSheetBody(model: AttachmentSheetModel, callbacks: AttachmentSheetCallbacks) {
+ val context = LocalContext.current
+ val refreshKey = rememberResumeRefreshKey()
+ val granted = remember(refreshKey.intValue) { grantedMediaPermissions(context) }
+ val access = resolveMediaAccess(Build.VERSION.SDK_INT, granted)
+ val canSelectMore = canSelectMoreMedia(Build.VERSION.SDK_INT, granted)
+ val media by produceState?>(null, refreshKey.intValue, access) {
+ value = if (access == MediaAccess.NONE) {
+ emptyList()
+ } else {
+ withContext(Dispatchers.IO) { RecentMediaLoader.load(context.contentResolver) }
+ }
+ }
+ var selection by remember { mutableStateOf(MediaSelection(limit = model.maxSelection)) }
+ LaunchedEffect(media) {
+ media?.let { loaded -> selection = selection.retainAvailable(loaded.map { it.key }) }
+ }
+ // The system grants without a dialog (no pause, no resume) when the permission group is already granted
+ val permissionRequest = rememberMediaPermissionRequest { refreshKey.intValue++ }
+
+ val hiddenBottomPx = rememberHiddenBottomPx()
+ val hiddenBottomDp = with(LocalDensity.current) { hiddenBottomPx.value.toDp() }
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .onGloballyPositioned { hiddenBottomPx.update(it.positionInWindow().y, it.size.height) }
+ .padding(bottom = hiddenBottomDp)
+ ) {
+ Box(modifier = Modifier.weight(1f)) {
+ if (access == MediaAccess.NONE) {
+ MediaAccessRequest(onRequest = permissionRequest)
+ } else {
+ MediaGrid(
+ media = media.orEmpty(),
+ selection = selection,
+ onToggle = { selection = selection.toggle(it.key) },
+ camera = CameraTileConfig(model.livePreviewEnabled, callbacks.onTakePhoto)
+ .takeIf { AttachmentAction.PICTURE_FROM_CAM in model.actions },
+ onSelectMore = permissionRequest.takeIf { canSelectMore }
+ )
+ }
+ }
+ if (selection.isEmpty) {
+ ActionBar(
+ model = model,
+ onAction = callbacks.onAction,
+ modifier = if (hiddenBottomPx.value > 0) Modifier.navigationBarsPadding() else Modifier
+ )
+ } else {
+ SendBar(
+ count = selection.count,
+ onClear = { selection = MediaSelection(limit = model.maxSelection) },
+ onSend = {
+ val chosen = media.orEmpty().associateBy { it.key }
+ val uris = selection.ids.mapNotNull { chosen[it] }.map { RecentMediaLoader.uriOf(it) }
+ if (uris.isNotEmpty()) callbacks.onSend(uris)
+ }
+ )
+ }
+ }
+}
+
+/**
+ * Changes after the sheet's screen came back from the background (e.g. from the app settings), so the media list
+ * and the permissions are read again. The first resume while the sheet opens does not count. The caller also bumps
+ * it when a permission request returns.
+ */
+@Composable
+private fun rememberResumeRefreshKey(): MutableIntState {
+ val refreshKey = remember { mutableIntStateOf(0) }
+ var wasPaused by remember { mutableStateOf(false) }
+ LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { wasPaused = true }
+ LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
+ if (wasPaused) {
+ wasPaused = false
+ refreshKey.intValue++
+ }
+ }
+ return refreshKey
+}
+
+/**
+ * Part of the sheet body that is currently pushed below the window bottom while the sheet is half open, so the
+ * bars stay visible.
+ */
+private class HiddenBottom(private val windowHeightPx: Int) {
+ var value by mutableIntStateOf(0)
+ private set
+
+ fun update(topInWindow: Float, height: Int) {
+ value = (topInWindow.roundToInt() + height - windowHeightPx).coerceAtLeast(0)
+ }
+}
+
+@Composable
+private fun rememberHiddenBottomPx(): HiddenBottom {
+ val windowHeight = LocalWindowInfo.current.containerSize.height
+ return remember(windowHeight) { HiddenBottom(windowHeight) }
+}
+
+private fun grantedMediaPermissions(context: Context): Set =
+ mediaPermissionsToRequest(Build.VERSION.SDK_INT)
+ .filter { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
+ .toSet()
+
+private fun currentMediaAccess(context: Context): MediaAccess =
+ resolveMediaAccess(Build.VERSION.SDK_INT, grantedMediaPermissions(context))
+
+/**
+ * Asks for media access. When the system dialog will not appear again (denied with "don't ask again"), the call
+ * opens the app settings instead. [onPermissionResult] runs when the request returns, whatever the answer.
+ */
+@Composable
+private fun rememberMediaPermissionRequest(onPermissionResult: () -> Unit): () -> Unit {
+ val context = LocalContext.current
+ val activity = LocalActivity.current
+ var blocked by remember { mutableStateOf(false) }
+ val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
+ onPermissionResult()
+ blocked = currentMediaAccess(context) == MediaAccess.NONE &&
+ activity != null &&
+ mediaPermissionsToRequest(Build.VERSION.SDK_INT).none {
+ ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
+ }
+ }
+ return {
+ if (blocked) {
+ val appDetails = Uri.fromParts("package", context.packageName, null)
+ context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, appDetails))
+ } else {
+ launcher.launch(mediaPermissionsToRequest(Build.VERSION.SDK_INT))
+ }
+ }
+}
+
+@Composable
+private fun MediaAccessRequest(onRequest: () -> Unit) {
+ Column(
+ modifier = Modifier.fillMaxSize().padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.baseline_photo_library_24),
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = stringResource(R.string.read_storage_no_permission),
+ modifier = Modifier.padding(vertical = 16.dp),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Button(onClick = onRequest) {
+ Text(stringResource(R.string.nc_permissions_ask))
+ }
+ }
+}
+
+@Composable
+private fun MediaGrid(
+ media: List,
+ selection: MediaSelection,
+ onToggle: (RecentMedia) -> Unit,
+ camera: CameraTileConfig?,
+ onSelectMore: (() -> Unit)?
+) {
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(GRID_COLUMNS),
+ modifier = Modifier.fillMaxSize(),
+ horizontalArrangement = Arrangement.spacedBy(TILE_GAP_DP.dp),
+ verticalArrangement = Arrangement.spacedBy(TILE_GAP_DP.dp)
+ ) {
+ if (onSelectMore != null) {
+ item(span = { GridItemSpan(maxLineSpan) }) {
+ TextButton(onClick = onSelectMore, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.attachment_sheet_select_more))
+ }
+ }
+ }
+ if (camera != null) {
+ item { CameraTile(livePreview = camera.livePreview, onClick = camera.onClick) }
+ }
+ items(media, key = { it.key }) { item ->
+ MediaTile(item, selection.positionOf(item.key), onClick = { onToggle(item) })
+ }
+ }
+}
+
+@Composable
+private fun MediaTile(media: RecentMedia, position: Int?, onClick: () -> Unit) {
+ val description = if (media.isVideo) {
+ stringResource(R.string.attachment_sheet_video, formatVideoDuration(media.durationMs))
+ } else {
+ stringResource(R.string.attachment_sheet_photo)
+ }
+ Box(
+ modifier = Modifier
+ .aspectRatio(1f)
+ .semantics {
+ contentDescription = description
+ selected = position != null
+ role = Role.Checkbox
+ }
+ .clickable(onClick = onClick)
+ ) {
+ MediaThumbnail(media)
+ if (media.isVideo) {
+ Text(
+ text = formatVideoDuration(media.durationMs),
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(4.dp)
+ .background(Color.Black.copy(alpha = SCRIM_ALPHA), CircleShape)
+ .padding(horizontal = 6.dp),
+ color = Color.White,
+ fontSize = 11.sp
+ )
+ }
+ SelectionBadge(position, Modifier.align(Alignment.TopEnd).padding(6.dp))
+ }
+}
+
+@Composable
+private fun MediaThumbnail(media: RecentMedia) {
+ if (media.isVideo) {
+ val resolver = LocalContext.current.contentResolver
+ val frame by produceState(null, media.key) {
+ value = withContext(Dispatchers.IO) { RecentMediaLoader.videoThumbnail(resolver, media) }
+ }
+ frame?.let {
+ Image(
+ bitmap = it.asImageBitmap(),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize()
+ )
+ } ?: Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant))
+ } else {
+ AsyncImage(
+ model = RecentMediaLoader.uriOf(media),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant)
+ )
+ }
+}
+
+@Composable
+private fun SelectionBadge(position: Int?, modifier: Modifier = Modifier) {
+ val base = modifier.size(BADGE_SIZE_DP.dp).clip(CircleShape)
+ if (position == null) {
+ Box(base.border(2.dp, Color.White, CircleShape).background(Color.Black.copy(alpha = 0.15f)))
+ } else {
+ Box(
+ modifier = base.background(MaterialTheme.colorScheme.primary),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(text = position.toString(), color = MaterialTheme.colorScheme.onPrimary, fontSize = 13.sp)
+ }
+ }
+}
+
+@Composable
+private fun SendBar(count: Int, onClear: () -> Unit, onSend: () -> Unit) {
+ Row(
+ modifier = Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ TextButton(onClick = onClear) { Text(stringResource(R.string.nc_cancel)) }
+ Button(onClick = onSend) { Text(stringResource(R.string.attachment_sheet_send, count)) }
+ }
+}
+
+@Composable
+private fun ActionBar(
+ model: AttachmentSheetModel,
+ onAction: (AttachmentAction) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ model.actions.forEach { action ->
+ val label = action.labelRes()?.let { stringResource(it) } ?: model.cloudLabel
+ Column(
+ modifier = Modifier
+ .width(BAR_ITEM_WIDTH_DP.dp)
+ .clip(MaterialTheme.shapes.medium)
+ .clickable { onAction(action) }
+ .padding(vertical = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ painter = painterResource(action.iconRes()),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = label,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.labelSmall
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/CameraTile.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/CameraTile.kt
new file mode 100644
index 00000000000..45507becf2a
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/CameraTile.kt
@@ -0,0 +1,120 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentsheet
+
+import android.Manifest
+import android.content.pm.PackageManager
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.Preview
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.view.PreviewView
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.nextcloud.talk.R
+import com.nextcloud.talk.chat.bindSafely
+import com.nextcloud.talk.chat.resolveLens
+import com.nextcloud.talk.chat.whenCameraProviderReady
+
+/**
+ * Holds the bound preview so it can be released before the system camera starts.
+ */
+private class PreviewBinding {
+ private var provider: ProcessCameraProvider? = null
+ private var preview: Preview? = null
+
+ fun bind(provider: ProcessCameraProvider, preview: Preview?) {
+ this.provider = provider
+ this.preview = preview
+ }
+
+ fun release() {
+ preview?.let { provider?.unbind(it) }
+ preview = null
+ }
+}
+
+/**
+ * First grid tile. With [livePreview] and the camera permission it shows the back camera; otherwise only the camera
+ * icon (no camera is opened, e.g. during a call). The tap goes through the regular photo flow, which asks for the
+ * camera permission; the preview is released first so the system camera can open it.
+ */
+@Composable
+internal fun CameraTile(livePreview: Boolean, onClick: () -> Unit) {
+ val context = LocalContext.current
+ val hasCameraPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
+ PackageManager.PERMISSION_GRANTED
+ val binding = remember { PreviewBinding() }
+ Box(
+ modifier = Modifier
+ .aspectRatio(1f)
+ .background(Color.Black)
+ .clickable {
+ binding.release()
+ onClick()
+ }
+ ) {
+ if (livePreview && hasCameraPermission) {
+ LiveCameraPreview(binding, Modifier.fillMaxSize())
+ }
+ Icon(
+ painter = painterResource(R.drawable.ic_baseline_photo_camera_24),
+ contentDescription = stringResource(R.string.nc_upload_picture_from_cam),
+ modifier = Modifier.align(Alignment.Center).padding(8.dp),
+ tint = Color.White
+ )
+ }
+}
+
+@Composable
+private fun LiveCameraPreview(binding: PreviewBinding, modifier: Modifier) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val previewView = remember {
+ PreviewView(context).apply {
+ scaleType = PreviewView.ScaleType.FILL_CENTER
+ implementationMode = PreviewView.ImplementationMode.COMPATIBLE
+ }
+ }
+ DisposableEffect(lifecycleOwner) {
+ var disposed = false
+ whenCameraProviderReady(context) { provider ->
+ if (!disposed && provider != null) {
+ binding.bind(provider, bindBackPreview(provider, lifecycleOwner, previewView))
+ }
+ }
+ onDispose {
+ disposed = true
+ binding.release()
+ }
+ }
+ AndroidView(factory = { previewView }, modifier = modifier)
+}
+
+private fun bindBackPreview(provider: ProcessCameraProvider, owner: LifecycleOwner, view: PreviewView): Preview? {
+ val lens = resolveLens(provider, CameraSelector.LENS_FACING_BACK)
+ val preview = Preview.Builder().build().also { it.surfaceProvider = view.surfaceProvider }
+ return preview.takeIf { provider.bindSafely(owner, lens, it) != null }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt
new file mode 100644
index 00000000000..49f84db94e9
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt
@@ -0,0 +1,100 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentsheet
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+
+/**
+ * How much of the device media library the app may read.
+ */
+enum class MediaAccess { FULL, PARTIAL, NONE }
+
+/**
+ * Derives [MediaAccess] from the granted runtime permissions. Android 14 and newer can grant only the photos the
+ * user picked (READ_MEDIA_VISUAL_USER_SELECTED); Android 13 uses per-type permissions; older versions use the
+ * single storage permission.
+ */
+fun resolveMediaAccess(sdkInt: Int, granted: Set): MediaAccess =
+ when {
+ sdkInt >= Build.VERSION_CODES.TIRAMISU ->
+ when {
+ Manifest.permission.READ_MEDIA_IMAGES in granted ||
+ Manifest.permission.READ_MEDIA_VIDEO in granted -> MediaAccess.FULL
+
+ sdkInt >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
+ READ_MEDIA_VISUAL_USER_SELECTED in granted -> MediaAccess.PARTIAL
+
+ else -> MediaAccess.NONE
+ }
+
+ Manifest.permission.READ_EXTERNAL_STORAGE in granted -> MediaAccess.FULL
+
+ else -> MediaAccess.NONE
+ }
+
+/**
+ * Whether the "select more" action makes sense: the access is partial, or Android 14 holds the user-selected grant
+ * next to only one of the per-type permissions (photos without videos or the other way round). [resolveMediaAccess]
+ * reports FULL for that mix because media can be read, yet the second type is still limited to the picked items.
+ */
+fun canSelectMoreMedia(sdkInt: Int, granted: Set): Boolean =
+ resolveMediaAccess(sdkInt, granted) == MediaAccess.PARTIAL ||
+ (
+ sdkInt >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
+ READ_MEDIA_VISUAL_USER_SELECTED in granted &&
+ !(Manifest.permission.READ_MEDIA_IMAGES in granted && Manifest.permission.READ_MEDIA_VIDEO in granted)
+ )
+
+/**
+ * Whether the app may read files of the user without own picker: any media permission, including the partial
+ * grant of Android 14, or audio. Used by flows that already hold a URI (recording, camera, share-to-Talk).
+ */
+fun hasMediaFilesAccess(sdkInt: Int, granted: Set): Boolean =
+ sdkInt >= Build.VERSION_CODES.TIRAMISU &&
+ (resolveMediaAccess(sdkInt, granted) != MediaAccess.NONE || Manifest.permission.READ_MEDIA_AUDIO in granted)
+
+/**
+ * Permissions to request in one dialog so that Android 14 offers the "select photos" choice next to "allow all".
+ */
+fun mediaPermissionsToRequest(sdkInt: Int): Array =
+ when {
+ sdkInt >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE ->
+ arrayOf(
+ Manifest.permission.READ_MEDIA_IMAGES,
+ Manifest.permission.READ_MEDIA_VIDEO,
+ READ_MEDIA_VISUAL_USER_SELECTED
+ )
+
+ sdkInt >= Build.VERSION_CODES.TIRAMISU ->
+ arrayOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO)
+
+ else -> arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
+ }
+
+/**
+ * Permissions of the "file from device" request: the set of the attachment sheet, plus audio from Android 13 on,
+ * which the request has always included.
+ */
+fun shareFilePermissionsToRequest(sdkInt: Int): Array =
+ if (sdkInt >= Build.VERSION_CODES.TIRAMISU) {
+ mediaPermissionsToRequest(sdkInt) + Manifest.permission.READ_MEDIA_AUDIO
+ } else {
+ mediaPermissionsToRequest(sdkInt)
+ }
+
+/**
+ * Whether the result of the "file from device" request allows to pick a file. No single result is the answer:
+ * Android 14 may grant only the selected photos, and before Android 10 the granted read permission differs from the
+ * one [effectivelyGranted] checks. Any granted permission or an effective access is enough.
+ */
+fun isShareFileRequestGranted(grantResults: IntArray, effectivelyGranted: Boolean): Boolean =
+ grantResults.any { it == PackageManager.PERMISSION_GRANTED } || effectivelyGranted
+
+// Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED exists only from API 34; the string is stable.
+internal const val READ_MEDIA_VISUAL_USER_SELECTED = "android.permission.READ_MEDIA_VISUAL_USER_SELECTED"
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaSelection.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaSelection.kt
new file mode 100644
index 00000000000..492a22ee369
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaSelection.kt
@@ -0,0 +1,47 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentsheet
+
+/**
+ * Ordered multi-selection of media ids with an upper limit. Immutable; every change returns a new instance.
+ */
+data class MediaSelection(val limit: Int, val ids: List = emptyList()) {
+
+ val count: Int
+ get() = ids.size
+
+ val isEmpty: Boolean
+ get() = ids.isEmpty()
+
+ val isFull: Boolean
+ get() = ids.size >= limit
+
+ fun contains(id: Long): Boolean = id in ids
+
+ /**
+ * 1-based position of [id] in the selection order, or null when it is not selected.
+ */
+ fun positionOf(id: Long): Int? = ids.indexOf(id).takeIf { it >= 0 }?.plus(1)
+
+ /**
+ * Selects [id] or deselects it when already selected. Selecting is ignored once the limit is reached.
+ */
+ fun toggle(id: Long): MediaSelection =
+ when {
+ contains(id) -> copy(ids = ids - id)
+ isFull -> this
+ else -> copy(ids = ids + id)
+ }
+
+ /**
+ * Drops ids that are no longer part of [available], e.g. after the media list was reloaded.
+ */
+ fun retainAvailable(available: Collection): MediaSelection {
+ val availableSet = available.toSet()
+ return copy(ids = ids.filter { it in availableSet })
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/RecentMedia.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/RecentMedia.kt
new file mode 100644
index 00000000000..d5fd5e5a6ec
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/RecentMedia.kt
@@ -0,0 +1,46 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentsheet
+
+import java.util.Locale
+
+/**
+ * One photo or video of the device library. [id] is the MediaStore row id within its own table (images and videos).
+ */
+data class RecentMedia(val id: Long, val isVideo: Boolean, val dateAddedSeconds: Long, val durationMs: Long = 0L) {
+
+ /**
+ * Images and videos have separate id spaces, so the key combines both.
+ */
+ val key: Long
+ get() = if (isVideo) -id - 1 else id
+}
+
+private const val SECONDS_PER_MINUTE = 60
+private const val SECONDS_PER_HOUR = 3600
+private const val MILLIS_PER_SECOND = 1000
+
+/**
+ * Newest first, at most [limit] entries. Equal dates keep images before videos so the order is stable.
+ */
+fun mergeRecentMedia(images: List, videos: List, limit: Int): List =
+ (images + videos).sortedByDescending { it.dateAddedSeconds }.take(limit)
+
+/**
+ * "m:ss" below an hour, "h:mm:ss" from an hour on.
+ */
+fun formatVideoDuration(durationMs: Long): String {
+ val totalSeconds = (durationMs.coerceAtLeast(0L) / MILLIS_PER_SECOND).toInt()
+ val hours = totalSeconds / SECONDS_PER_HOUR
+ val minutes = totalSeconds % SECONDS_PER_HOUR / SECONDS_PER_MINUTE
+ val seconds = totalSeconds % SECONDS_PER_MINUTE
+ return if (hours > 0) {
+ String.format(Locale.ROOT, "%d:%02d:%02d", hours, minutes, seconds)
+ } else {
+ String.format(Locale.ROOT, "%d:%02d", minutes, seconds)
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/RecentMediaLoader.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/RecentMediaLoader.kt
new file mode 100644
index 00000000000..01f8959ee6a
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/RecentMediaLoader.kt
@@ -0,0 +1,118 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentsheet
+
+import android.content.ContentResolver
+import android.content.ContentUris
+import android.database.Cursor
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.provider.MediaStore
+import android.util.Log
+import android.util.Size
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
+import java.io.IOException
+
+/**
+ * Reads the newest photos and videos from MediaStore. Without read access (or with partial access) the system
+ * returns only what the app may see, so no permission check happens here.
+ */
+object RecentMediaLoader {
+ private val TAG = RecentMediaLoader::class.java.simpleName
+ private const val LIMIT = 300
+ private const val THUMBNAIL_PX = 320
+
+ suspend fun load(resolver: ContentResolver, limit: Int = LIMIT): List {
+ val images = query(resolver, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, isVideo = false, limit)
+ val videos = query(resolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, isVideo = true, limit)
+ return mergeRecentMedia(images, videos, limit)
+ }
+
+ fun uriOf(media: RecentMedia): Uri {
+ val base = if (media.isVideo) {
+ MediaStore.Video.Media.EXTERNAL_CONTENT_URI
+ } else {
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI
+ }
+ return ContentUris.withAppendedId(base, media.id)
+ }
+
+ /**
+ * Newest first with a row limit. Before Android 11 the provider ignores a limit passed in the query bundle, so
+ * it is appended to the sort order there.
+ */
+ private fun openCursor(resolver: ContentResolver, uri: Uri, projection: Array, limit: Int): Cursor? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val args = Bundle().apply {
+ putStringArray(ContentResolver.QUERY_ARG_SORT_COLUMNS, arrayOf(MediaStore.MediaColumns.DATE_ADDED))
+ putInt(ContentResolver.QUERY_ARG_SORT_DIRECTION, ContentResolver.QUERY_SORT_DIRECTION_DESCENDING)
+ putInt(ContentResolver.QUERY_ARG_LIMIT, limit)
+ }
+ resolver.query(uri, projection, args, null)
+ } else {
+ resolver.query(uri, projection, null, null, "${MediaStore.MediaColumns.DATE_ADDED} DESC LIMIT $limit")
+ }
+
+ /**
+ * A small preview frame of a video, or null when the system has none.
+ */
+ @Suppress("DEPRECATION")
+ fun videoThumbnail(resolver: ContentResolver, media: RecentMedia): Bitmap? =
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ resolver.loadThumbnail(uriOf(media), Size(THUMBNAIL_PX, THUMBNAIL_PX), null)
+ } else {
+ MediaStore.Video.Thumbnails.getThumbnail(
+ resolver,
+ media.id,
+ MediaStore.Video.Thumbnails.MINI_KIND,
+ null
+ )
+ }
+ } catch (e: IOException) {
+ Log.w(TAG, "no video thumbnail for ${media.id}", e)
+ null
+ } catch (e: SecurityException) {
+ Log.w(TAG, "video thumbnail not readable for ${media.id}", e)
+ null
+ }
+
+ private suspend fun query(resolver: ContentResolver, uri: Uri, isVideo: Boolean, limit: Int): List {
+ val projection = if (isVideo) {
+ arrayOf(
+ MediaStore.MediaColumns._ID,
+ MediaStore.MediaColumns.DATE_ADDED,
+ MediaStore.Video.VideoColumns.DURATION
+ )
+ } else {
+ arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATE_ADDED)
+ }
+ val result = mutableListOf()
+ openCursor(resolver, uri, projection, limit)?.use { cursor ->
+ val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
+ val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
+ val durationColumn = if (isVideo) {
+ cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.DURATION)
+ } else {
+ -1
+ }
+ while (cursor.moveToNext()) {
+ currentCoroutineContext().ensureActive()
+ result += RecentMedia(
+ id = cursor.getLong(idColumn),
+ isVideo = isVideo,
+ dateAddedSeconds = cursor.getLong(dateColumn),
+ durationMs = if (durationColumn >= 0) cursor.getLong(durationColumn) else 0L
+ )
+ }
+ }
+ return result
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/camera/CaptureActivity.kt b/app/src/main/java/com/nextcloud/talk/camera/CaptureActivity.kt
new file mode 100644
index 00000000000..00cbe863d82
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/camera/CaptureActivity.kt
@@ -0,0 +1,71 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.camera
+
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
+import autodagger.AutoInjector
+import com.nextcloud.talk.R
+import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.utils.preferences.AppPreferences
+import java.io.File
+import java.util.Date
+import javax.inject.Inject
+
+/**
+ * Takes one photo with the in-app camera and returns its content uri as the result data. There is no confirmation
+ * step: the shutter finishes the activity. Without the camera permission the activity ends as cancelled, the caller
+ * asks for the permission before it starts this screen.
+ */
+@AutoInjector(NextcloudTalkApplication::class)
+class CaptureActivity : ComponentActivity() {
+
+ @Inject
+ lateinit var appPreferences: AppPreferences
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ val hasPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
+ PackageManager.PERMISSION_GRANTED
+ if (!hasPermission) {
+ finish()
+ return
+ }
+ NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
+ setContent {
+ PhotoCaptureScreen(
+ appPreferences = appPreferences,
+ newPhotoFile = ::newPhotoFile,
+ onCaptured = ::returnPhoto,
+ onFailed = {
+ Toast.makeText(this, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show()
+ },
+ onClose = ::finish
+ )
+ }
+ }
+
+ private fun newPhotoFile(): File? {
+ val baseName = getString(R.string.nc_picture_filename, formatCaptureTimestamp(Date()))
+ return createPhotoFile(cacheDir, baseName)
+ }
+
+ private fun returnPhoto(file: File) {
+ val uri = FileProvider.getUriForFile(this, packageName, file)
+ setResult(RESULT_OK, Intent().setData(uri))
+ finish()
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/camera/CaptureOrientation.kt b/app/src/main/java/com/nextcloud/talk/camera/CaptureOrientation.kt
new file mode 100644
index 00000000000..e6d90877f6c
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/camera/CaptureOrientation.kt
@@ -0,0 +1,57 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.camera
+
+import android.view.Surface
+
+private const val ROTATIONS = 4
+private const val QUARTER_TURN = 90
+private const val HALF_TURN = 180
+private const val FULL_TURN = 360
+
+/**
+ * A spot on the device body in the natural orientation: [x] is -1 at the left edge, 0 in the middle and 1 at the
+ * right edge, [y] is -1 at the top edge, 0 in the middle and 1 at the bottom edge.
+ */
+internal data class BodyPoint(val x: Int, val y: Int)
+
+/**
+ * Where a [BodyPoint] lies in the window when the display has the given [Surface] rotation. The capture controls are
+ * tied to the device body, so the shutter stays under the same finger in every window rotation, whether the system
+ * kept the window in the natural orientation or turned it.
+ *
+ * ROTATION_90 means the device was turned counter-clockwise: the natural bottom edge is on the right of the window.
+ */
+internal fun BodyPoint.inWindow(displayRotation: Int): BodyPoint =
+ when (displayRotation) {
+ Surface.ROTATION_90 -> BodyPoint(y, -x)
+ Surface.ROTATION_180 -> BodyPoint(-x, -y)
+ Surface.ROTATION_270 -> BodyPoint(-y, x)
+ else -> this
+ }
+
+/**
+ * Clockwise angle (0, 90, 180 or 270 degrees) that keeps an icon upright for the user. [deviceOrientation] is the
+ * angle of OrientationEventListener, [displayRotation] the [Surface] rotation of the window. Returns null while the
+ * device orientation is unknown.
+ *
+ * The photo rotation is the display rotation the window would need to be upright for the current hold, so the icon
+ * turns by the difference between it and the rotation the window really has.
+ */
+internal fun iconRotationDegrees(deviceOrientation: Int, displayRotation: Int): Int? {
+ val upright = rotationForDeviceOrientation(deviceOrientation) ?: return null
+ return Math.floorMod(upright - displayRotation, ROTATIONS) * QUARTER_TURN
+}
+
+/**
+ * The value to animate an icon to, so that it reaches [targetDegrees] by the shortest way. [currentDegrees] is
+ * the unbounded angle shown now; a turn from 0 to 270 gives -90, not 270.
+ */
+internal fun shortestRotationTarget(currentDegrees: Float, targetDegrees: Int): Float {
+ val delta = Math.floorMod((targetDegrees - currentDegrees).toInt() + HALF_TURN, FULL_TURN) - HALF_TURN
+ return currentDegrees + delta
+}
diff --git a/app/src/main/java/com/nextcloud/talk/camera/PhotoCamera.kt b/app/src/main/java/com/nextcloud/talk/camera/PhotoCamera.kt
new file mode 100644
index 00000000000..87add856157
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/camera/PhotoCamera.kt
@@ -0,0 +1,216 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.camera
+
+import android.content.Context
+import android.os.SystemClock
+import android.util.Log
+import androidx.camera.core.Camera
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.ImageCaptureException
+import androidx.camera.core.Preview
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.view.PreviewView
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.LifecycleOwner
+import com.nextcloud.talk.chat.bindSafely
+import com.nextcloud.talk.chat.cameraSelectorFor
+import com.nextcloud.talk.chat.oppositeLens
+import com.nextcloud.talk.chat.resolveLens
+import com.nextcloud.talk.chat.whenCameraProviderReady
+import java.io.File
+
+/**
+ * Still-photo camera of the capture screen: a live preview plus [ImageCapture] on the front or the back lens.
+ *
+ * All methods must be called on the main thread. The observable properties drive the capture screen.
+ */
+internal class PhotoCamera(
+ private val context: Context,
+ private val lifecycleOwner: LifecycleOwner,
+ private val previewView: PreviewView,
+ initialCaptureMode: CaptureModeSetting = CaptureModeSetting.DEFAULT,
+ private val onCaptureModeChanged: (CaptureModeSetting) -> Unit = {}
+) {
+ var lensFacing by mutableIntStateOf(CameraSelector.LENS_FACING_BACK)
+ private set
+ var flash by mutableStateOf(FlashSetting.OFF)
+ private set
+ var captureMode by mutableStateOf(initialCaptureMode)
+ private set
+ var hasFlashUnit by mutableStateOf(false)
+ private set
+ var canSwitchLens by mutableStateOf(false)
+ private set
+ var isBound by mutableStateOf(false)
+ private set
+ var isCapturing by mutableStateOf(false)
+ private set
+
+ private var provider: ProcessCameraProvider? = null
+ private var preview: Preview? = null
+ private var imageCapture: ImageCapture? = null
+ private var released = false
+
+ // The latest rotation of the hold; it may arrive before the ImageCapture exists and is applied to every new one.
+ private var photoRotation: Int? = null
+
+ fun start() {
+ whenCameraProviderReady(context) { readyProvider ->
+ if (released || readyProvider == null) return@whenCameraProviderReady
+ provider = readyProvider
+ // No target rotation: the preview follows the display, the photo follows setTargetRotation().
+ preview = Preview.Builder().build()
+ imageCapture = buildImageCapture(captureMode)
+ canSwitchLens = readyProvider.hasCamera(cameraSelectorFor(CameraSelector.LENS_FACING_BACK)) &&
+ readyProvider.hasCamera(cameraSelectorFor(CameraSelector.LENS_FACING_FRONT))
+ bind(resolveLens(readyProvider, lensFacing))
+ }
+ }
+
+ fun switchLens() {
+ if (provider == null || !canSwitchLens || isCapturing) return
+ val previousLens = lensFacing
+ unbind()
+ if (!bind(oppositeLens(previousLens))) {
+ bind(previousLens)
+ }
+ }
+
+ /**
+ * Switches between the fast and the maximum quality shutter. The mode is fixed when [ImageCapture] is built, so
+ * the use case is replaced and bound again. Ignored while a photo is taken: unbinding would cancel it.
+ */
+ fun toggleCaptureMode() {
+ if (provider == null || isCapturing) return
+ val previousMode = captureMode
+ val lens = lensFacing
+ if (!rebindWith(previousMode.toggled(), lens)) {
+ rebindWith(previousMode, lens)
+ return
+ }
+ onCaptureModeChanged(captureMode)
+ }
+
+ fun cycleFlash() {
+ if (!hasFlashUnit) return
+ flash = flash.next()
+ imageCapture?.flashMode = effectiveFlashMode(flash, hasFlashUnit)
+ }
+
+ /**
+ * Sets the rotation of the next photo, so the picture is upright however the phone is held.
+ */
+ fun setTargetRotation(rotation: Int) {
+ photoRotation = rotation
+ imageCapture?.targetRotation = rotation
+ }
+
+ /**
+ * Writes a JPEG with EXIF orientation to [file]. [onResult] gets true when the file is complete.
+ */
+ fun takePicture(file: File, onResult: (Boolean) -> Unit) {
+ val capture = imageCapture
+ if (capture == null || !isBound || isCapturing) {
+ onResult(false)
+ return
+ }
+ isCapturing = true
+ val mode = captureMode
+ val startedAt = SystemClock.elapsedRealtime()
+ val options = ImageCapture.OutputFileOptions.Builder(file).build()
+ capture.takePicture(
+ options,
+ ContextCompat.getMainExecutor(context),
+ object : ImageCapture.OnImageSavedCallback {
+ override fun onImageSaved(output: ImageCapture.OutputFileResults) {
+ Log.i(TAG, "capture mode=${mode.logName} took=${SystemClock.elapsedRealtime() - startedAt} ms")
+ isCapturing = false
+ onResult(true)
+ }
+
+ override fun onError(exception: ImageCaptureException) {
+ Log.w(
+ TAG,
+ "capture mode=${mode.logName} failed after ${SystemClock.elapsedRealtime() - startedAt} ms",
+ exception
+ )
+ isCapturing = false
+ onResult(false)
+ }
+ }
+ )
+ }
+
+ fun release() {
+ released = true
+ unbind()
+ }
+
+ private fun buildImageCapture(mode: CaptureModeSetting): ImageCapture =
+ ImageCapture.Builder()
+ .setCaptureMode(mode.imageCaptureMode)
+ .setResolutionSelector(photoResolutionSelector())
+ .apply { photoRotation?.let { setTargetRotation(it) } }
+ .build()
+
+ private fun rebindWith(mode: CaptureModeSetting, lens: Int): Boolean {
+ unbind()
+ // the new use case gets the rotation of the hold from buildImageCapture()
+ imageCapture = buildImageCapture(mode)
+ captureMode = mode
+ return bind(lens)
+ }
+
+ private fun bind(lens: Int): Boolean {
+ val previewUseCase = preview
+ val captureUseCase = imageCapture
+ val camera = if (previewUseCase != null && captureUseCase != null) {
+ previewUseCase.surfaceProvider = previewView.surfaceProvider
+ provider?.bindSafely(lifecycleOwner, lens, previewUseCase, captureUseCase)
+ } else {
+ null
+ }
+ isBound = camera != null
+ if (camera != null) {
+ onBound(camera, lens)
+ }
+ return isBound
+ }
+
+ private fun onBound(camera: Camera, lens: Int) {
+ lensFacing = lens
+ hasFlashUnit = camera.cameraInfo.hasFlashUnit()
+ imageCapture?.flashMode = effectiveFlashMode(flash, hasFlashUnit)
+ logCaptureResolution()
+ }
+
+ private fun logCaptureResolution() {
+ val info = imageCapture?.resolutionInfo ?: return
+ val size = info.resolution
+ Log.d(
+ TAG,
+ "photo resolution ${size.width}x${size.height}, " +
+ "displayed ${describeCaptureResolution(size.width, size.height, info.rotationDegrees)}"
+ )
+ }
+
+ private fun unbind() {
+ preview?.let { provider?.unbind(it) }
+ imageCapture?.let { provider?.unbind(it) }
+ isBound = false
+ }
+
+ private companion object {
+ private val TAG = PhotoCamera::class.java.simpleName
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureLogic.kt b/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureLogic.kt
new file mode 100644
index 00000000000..3f0d8c758c9
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureLogic.kt
@@ -0,0 +1,112 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.camera
+
+import android.view.Surface
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.resolutionselector.AspectRatioStrategy
+import androidx.camera.core.resolutionselector.ResolutionSelector
+import androidx.camera.core.resolutionselector.ResolutionStrategy
+import com.nextcloud.talk.utils.FileUtils
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+private const val FILE_DATE_PATTERN = "yyyy-MM-dd HH-mm-ss"
+private const val PHOTO_SUFFIX = ".jpg"
+private const val MAX_FILE_VARIANTS = 100
+private const val ORIENTATION_QUARTER = 90
+private const val ORIENTATION_EIGHTH = 45
+private const val FULL_CIRCLE = 360
+private const val HALF_TURN = 180
+
+// Device turned clockwise by 0, 90, 180, 270 degrees: the display rotates the other way.
+private val rotationByQuadrant = intArrayOf(
+ Surface.ROTATION_0,
+ Surface.ROTATION_270,
+ Surface.ROTATION_180,
+ Surface.ROTATION_90
+)
+
+/**
+ * Flash choice of the capture screen. [next] is the order of the flash button: off, auto, on.
+ */
+internal enum class FlashSetting(val imageCaptureMode: Int) {
+ OFF(ImageCapture.FLASH_MODE_OFF),
+ AUTO(ImageCapture.FLASH_MODE_AUTO),
+ ON(ImageCapture.FLASH_MODE_ON);
+
+ fun next(): FlashSetting = entries[(ordinal + 1) % entries.size]
+}
+
+/**
+ * Shutter mode of the capture screen. [storageValue] is what the app preferences keep, [logName] the name in the
+ * capture log. [toggled] is the order of the mode button.
+ */
+internal enum class CaptureModeSetting(val storageValue: String, val imageCaptureMode: Int, val logName: String) {
+ FAST("fast", ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY, "MINIMIZE_LATENCY"),
+ QUALITY("quality", ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY, "MAXIMIZE_QUALITY");
+
+ fun toggled(): CaptureModeSetting = if (this == FAST) QUALITY else FAST
+
+ companion object {
+ val DEFAULT = FAST
+
+ /**
+ * The mode kept in the preferences; nothing stored or an unknown value gives [DEFAULT].
+ */
+ fun fromStorage(value: String?): CaptureModeSetting =
+ entries.firstOrNull { it.storageValue == value } ?: DEFAULT
+ }
+}
+
+/**
+ * The flash mode to apply: a camera without a flash unit always gets [ImageCapture.FLASH_MODE_OFF].
+ */
+internal fun effectiveFlashMode(setting: FlashSetting, hasFlashUnit: Boolean): Int =
+ if (hasFlashUnit) setting.imageCaptureMode else ImageCapture.FLASH_MODE_OFF
+
+/**
+ * Maps the device orientation reported by OrientationEventListener (degrees, clockwise from upright) to the
+ * [Surface] rotation for ImageCapture, or null when the orientation is unknown.
+ */
+internal fun rotationForDeviceOrientation(degrees: Int): Int? {
+ if (degrees < 0) return null
+ return rotationByQuadrant[((degrees + ORIENTATION_EIGHTH) % FULL_CIRCLE) / ORIENTATION_QUARTER]
+}
+
+/**
+ * Full sensor resolution at 4:3 (the aspect ratio of the sensor), falling back to the closest ratio the camera
+ * offers. Ask for it explicitly so the photo size doesn't depend on the library's defaults.
+ */
+internal fun photoResolutionSelector(): ResolutionSelector =
+ ResolutionSelector.Builder()
+ .setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY)
+ .setResolutionStrategy(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY)
+ .build()
+
+/**
+ * Pixel size of the photo as it looks once the EXIF [rotationDegrees] is applied, e.g. "3000x4000".
+ */
+internal fun describeCaptureResolution(width: Int, height: Int, rotationDegrees: Int): String =
+ if (rotationDegrees % HALF_TURN == 0) "${width}x$height" else "${height}x$width"
+
+internal fun formatCaptureTimestamp(date: Date): String = SimpleDateFormat(FILE_DATE_PATTERN, Locale.ROOT).format(date)
+
+/**
+ * Creates the target of a photo in the shared attachments cache directory, the one the file provider exposes.
+ * An existing file is never reused: the name gets a number instead. Returns null when no usable name exists.
+ */
+internal fun createPhotoFile(cacheDir: File, baseName: String): File? {
+ val directory = FileUtils.getSharedAttachmentsDirectory(cacheDir) ?: return null
+ return (0 until MAX_FILE_VARIANTS)
+ .asSequence()
+ .map { if (it == 0) baseName else "$baseName ($it)" }
+ .mapNotNull { FileUtils.resolveFileInDirectory(directory, it + PHOTO_SUFFIX) }
+ .firstOrNull { !it.exists() }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureScreen.kt b/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureScreen.kt
new file mode 100644
index 00000000000..c869c2250e0
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureScreen.kt
@@ -0,0 +1,342 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.camera
+
+import android.hardware.display.DisplayManager
+import android.view.OrientationEventListener
+import android.view.Surface
+import androidx.camera.view.PreviewView
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.displayCutoutPadding
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.BiasAlignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.nextcloud.talk.R
+import com.nextcloud.talk.utils.preferences.AppPreferences
+import java.io.File
+
+private val ControlSize = 48.dp
+private val ShutterSize = 72.dp
+private val ShutterRingWidth = 4.dp
+private val ScreenPadding = 16.dp
+private val LensSwitchGap = 84.dp
+private val ModeButtonSize = 56.dp
+private val ModeButtonCorner = 16.dp
+private val ModeLabelSize = 11.sp
+private const val DISABLED_ALPHA = 0.4f
+
+/**
+ * Full screen photo capture: preview, close, shutter mode and flash at the top edge of the device body, shutter and
+ * lens switch at its bottom edge.
+ * The shutter mode is read from and kept in [appPreferences].
+ * [newPhotoFile] makes the target of a photo, [onCaptured] gets the finished file, [onFailed] the target of a
+ * photo that could not be written.
+ */
+@Composable
+internal fun PhotoCaptureScreen(
+ appPreferences: AppPreferences,
+ newPhotoFile: () -> File?,
+ onCaptured: (File) -> Unit,
+ onFailed: () -> Unit,
+ onClose: () -> Unit
+) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val previewView = remember {
+ PreviewView(context).apply {
+ scaleType = PreviewView.ScaleType.FILL_CENTER
+ implementationMode = PreviewView.ImplementationMode.COMPATIBLE
+ }
+ }
+ val camera = remember {
+ PhotoCamera(
+ context,
+ lifecycleOwner,
+ previewView,
+ CaptureModeSetting.fromStorage(appPreferences.cameraCaptureMode)
+ ) { appPreferences.cameraCaptureMode = it.storageValue }
+ }
+
+ var deviceOrientation by remember { mutableIntStateOf(0) }
+
+ DisposableEffect(camera) {
+ camera.start()
+ val orientationListener = object : OrientationEventListener(context) {
+ override fun onOrientationChanged(orientation: Int) {
+ val rotation = rotationForDeviceOrientation(orientation) ?: return
+ camera.setTargetRotation(rotation)
+ if (rotationForDeviceOrientation(deviceOrientation) != rotation) {
+ deviceOrientation = orientation
+ }
+ }
+ }
+ orientationListener.enable()
+ onDispose {
+ orientationListener.disable()
+ camera.release()
+ }
+ }
+
+ val displayRotation = rememberDisplayRotation()
+ val iconAngle = rememberIconAngle(iconRotationDegrees(deviceOrientation, displayRotation) ?: 0)
+
+ Box(Modifier.fillMaxSize().background(Color.Black)) {
+ AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
+ CaptureControls(camera, displayRotation, iconAngle, onClose) {
+ if (camera.isCapturing || !camera.isBound) return@CaptureControls
+ val file = newPhotoFile()
+ if (file == null) {
+ onFailed()
+ } else {
+ camera.takePicture(file) { success ->
+ if (success) {
+ onCaptured(file)
+ } else {
+ file.delete()
+ onFailed()
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * The window rotation of the display ([android.view.Surface] constant). The activity handles configuration changes
+ * itself and a half turn is no configuration change, so the display is observed.
+ */
+@Composable
+private fun rememberDisplayRotation(): Int {
+ val view = LocalView.current
+ val context = LocalContext.current
+ var rotation by remember { mutableIntStateOf(view.display?.rotation ?: Surface.ROTATION_0) }
+ DisposableEffect(view) {
+ val manager = context.getSystemService(DisplayManager::class.java)
+ val listener = object : DisplayManager.DisplayListener {
+ override fun onDisplayAdded(displayId: Int) = Unit
+
+ override fun onDisplayRemoved(displayId: Int) = Unit
+
+ override fun onDisplayChanged(displayId: Int) {
+ view.display?.let { rotation = it.rotation }
+ }
+ }
+ manager.registerDisplayListener(listener, null)
+ rotation = view.display?.rotation ?: rotation
+ onDispose { manager.unregisterDisplayListener(listener) }
+ }
+ return rotation
+}
+
+/**
+ * The animated icon angle for [targetDegrees]: every change takes the shortest way round.
+ */
+@Composable
+private fun rememberIconAngle(targetDegrees: Int): Float {
+ var unbounded by remember { mutableFloatStateOf(targetDegrees.toFloat()) }
+ LaunchedEffect(targetDegrees) {
+ unbounded = shortestRotationTarget(unbounded, targetDegrees)
+ }
+ return animateFloatAsState(unbounded, label = "captureIconAngle").value
+}
+
+/**
+ * Controls tied to the device body, not to the window: the shutter stays at the natural bottom edge, close and
+ * flash at the natural top edge with the shutter mode button between them, the lens switch next to the shutter,
+ * whatever rotation the window has.
+ */
+@Composable
+private fun CaptureControls(
+ camera: PhotoCamera,
+ displayRotation: Int,
+ iconAngle: Float,
+ onClose: () -> Unit,
+ onShutter: () -> Unit
+) {
+ Box(Modifier.fillMaxSize().systemBarsPadding().displayCutoutPadding().padding(ScreenPadding)) {
+ fun BoxScope.at(x: Int, y: Int): Modifier =
+ Modifier.align(BodyPoint(x, y).inWindow(displayRotation).toAlignment())
+
+ CaptureIconButton(
+ icon = R.drawable.ic_baseline_close_24,
+ description = stringResource(R.string.close),
+ angle = iconAngle,
+ modifier = at(-1, -1),
+ onClick = onClose
+ )
+ val modeEnabled = camera.isBound && !camera.isCapturing
+ CaptureModeButton(
+ mode = camera.captureMode,
+ angle = iconAngle,
+ enabled = modeEnabled,
+ modifier = at(0, -1),
+ onClick = camera::toggleCaptureMode
+ )
+ if (camera.hasFlashUnit) {
+ val (icon, description) = flashPresentation(camera.flash)
+ CaptureIconButton(
+ icon = icon,
+ description = stringResource(description),
+ angle = iconAngle,
+ modifier = at(1, -1),
+ onClick = camera::cycleFlash
+ )
+ }
+ val enabled = camera.isBound && !camera.isCapturing
+ val shutterDescription = stringResource(R.string.take_photo)
+ Box(
+ modifier = at(0, 1)
+ .size(ShutterSize)
+ .alpha(if (enabled) 1f else DISABLED_ALPHA)
+ .border(ShutterRingWidth, Color.White, CircleShape)
+ .padding(ShutterRingWidth * 2)
+ .clip(CircleShape)
+ .background(Color.White)
+ .semantics {
+ contentDescription = shutterDescription
+ role = Role.Button
+ }
+ .clickable(enabled = enabled, onClick = onShutter)
+ )
+ if (camera.canSwitchLens) {
+ LensSwitchButton(camera, displayRotation, iconAngle, at(0, 1))
+ }
+ }
+}
+
+/**
+ * Beside the shutter along the edge, and centered on it across the edge. [shutterModifier] places the shutter.
+ */
+@Composable
+private fun LensSwitchButton(camera: PhotoCamera, displayRotation: Int, iconAngle: Float, shutterModifier: Modifier) {
+ val beside = BodyPoint(1, 0).inWindow(displayRotation)
+ val inward = BodyPoint(0, -1).inWindow(displayRotation)
+ val centering = (ShutterSize - ControlSize) / 2
+ CaptureIconButton(
+ icon = R.drawable.ic_baseline_flip_camera_android_24,
+ description = stringResource(R.string.nc_video_recording_switch_camera),
+ angle = iconAngle,
+ modifier = shutterModifier.offset(
+ LensSwitchGap * beside.x + centering * inward.x,
+ LensSwitchGap * beside.y + centering * inward.y
+ ),
+ onClick = camera::switchLens
+ )
+}
+
+private fun BodyPoint.toAlignment(): Alignment = BiasAlignment(x.toFloat(), y.toFloat())
+
+@Composable
+private fun CaptureIconButton(icon: Int, description: String, angle: Float, modifier: Modifier, onClick: () -> Unit) {
+ Box(
+ modifier = modifier
+ .size(ControlSize)
+ .clip(CircleShape)
+ .background(Color.Black.copy(alpha = 0.4f))
+ .clickable(role = Role.Button, onClick = onClick),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(icon),
+ contentDescription = description,
+ tint = Color.White,
+ modifier = Modifier.rotate(angle)
+ )
+ }
+}
+
+/**
+ * The shutter mode button: icon over a short label, both turn with [angle]. The tile is nearly square, so a quarter
+ * turn keeps it inside the controls area.
+ */
+@Composable
+private fun CaptureModeButton(
+ mode: CaptureModeSetting,
+ angle: Float,
+ enabled: Boolean,
+ modifier: Modifier,
+ onClick: () -> Unit
+) {
+ val (icon, label, description) = modePresentation(mode)
+ val descriptionText = stringResource(description)
+ Column(
+ modifier = modifier
+ .size(ModeButtonSize)
+ .alpha(if (enabled) 1f else DISABLED_ALPHA)
+ .clip(RoundedCornerShape(ModeButtonCorner))
+ .background(Color.Black.copy(alpha = 0.4f))
+ .clickable(enabled = enabled, role = Role.Button, onClick = onClick)
+ .semantics { contentDescription = descriptionText }
+ .rotate(angle),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(painter = painterResource(icon), contentDescription = null, tint = Color.White)
+ Text(text = stringResource(label), color = Color.White, fontSize = ModeLabelSize, maxLines = 1)
+ }
+}
+
+private fun modePresentation(mode: CaptureModeSetting): Triple =
+ when (mode) {
+ CaptureModeSetting.FAST -> Triple(
+ R.drawable.ic_baseline_speed_24,
+ R.string.nc_photo_capture_mode_fast,
+ R.string.nc_photo_capture_mode_fast_description
+ )
+ CaptureModeSetting.QUALITY -> Triple(
+ R.drawable.ic_baseline_hd_24,
+ R.string.nc_photo_capture_mode_quality,
+ R.string.nc_photo_capture_mode_quality_description
+ )
+ }
+
+private fun flashPresentation(setting: FlashSetting): Pair =
+ when (setting) {
+ FlashSetting.OFF -> R.drawable.ic_baseline_flash_off_24 to R.string.nc_photo_capture_flash_off
+ FlashSetting.AUTO -> R.drawable.ic_baseline_flash_auto_24 to R.string.nc_photo_capture_flash_auto
+ FlashSetting.ON -> R.drawable.ic_baseline_flash_on_24 to R.string.nc_photo_capture_flash_on
+ }
diff --git a/app/src/main/java/com/nextcloud/talk/camera/TakePhotoInApp.kt b/app/src/main/java/com/nextcloud/talk/camera/TakePhotoInApp.kt
new file mode 100644
index 00000000000..97f944e1a47
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/camera/TakePhotoInApp.kt
@@ -0,0 +1,24 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.camera
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.activity.result.contract.ActivityResultContract
+
+/**
+ * Opens [CaptureActivity]. The result is the uri of the photo, or null when the user closed the camera.
+ * The camera permission must be granted before the contract is launched.
+ */
+class TakePhotoInApp : ActivityResultContract() {
+ override fun createIntent(context: Context, input: Unit): Intent = Intent(context, CaptureActivity::class.java)
+
+ override fun parseResult(resultCode: Int, intent: Intent?): Uri? =
+ intent?.data.takeIf { resultCode == Activity.RESULT_OK }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/chat/CameraLens.kt b/app/src/main/java/com/nextcloud/talk/chat/CameraLens.kt
new file mode 100644
index 00000000000..b9176f47009
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/chat/CameraLens.kt
@@ -0,0 +1,60 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.chat
+
+import android.content.Context
+import android.util.Log
+import androidx.camera.core.Camera
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.UseCase
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.LifecycleOwner
+import java.util.concurrent.ExecutionException
+
+private const val TAG = "CameraLens"
+
+internal fun cameraSelectorFor(lens: Int): CameraSelector = CameraSelector.Builder().requireLensFacing(lens).build()
+
+internal fun oppositeLens(lens: Int): Int =
+ if (lens == CameraSelector.LENS_FACING_FRONT) CameraSelector.LENS_FACING_BACK else CameraSelector.LENS_FACING_FRONT
+
+/**
+ * The lens to bind: [preferred] when the device has it, otherwise the other one.
+ */
+internal fun resolveLens(provider: ProcessCameraProvider, preferred: Int): Int =
+ if (provider.hasCamera(cameraSelectorFor(preferred))) preferred else oppositeLens(preferred)
+
+/**
+ * Calls [onResult] on the main thread with the camera provider, or with null when it is not available.
+ */
+internal fun whenCameraProviderReady(context: Context, onResult: (ProcessCameraProvider?) -> Unit) {
+ val providerFuture = ProcessCameraProvider.getInstance(context)
+ providerFuture.addListener({
+ val provider = try {
+ providerFuture.get()
+ } catch (e: ExecutionException) {
+ Log.w(TAG, "camera provider is not available", e)
+ null
+ }
+ onResult(provider)
+ }, ContextCompat.getMainExecutor(context))
+}
+
+/**
+ * Binds [useCases] to the camera with [lens]. Returns null when the camera cannot be bound.
+ */
+internal fun ProcessCameraProvider.bindSafely(owner: LifecycleOwner, lens: Int, vararg useCases: UseCase): Camera? =
+ try {
+ bindToLifecycle(owner, cameraSelectorFor(lens), *useCases)
+ } catch (e: IllegalArgumentException) {
+ Log.w(TAG, "cannot bind camera use cases", e)
+ null
+ } catch (e: IllegalStateException) {
+ Log.w(TAG, "cannot bind camera use cases", e)
+ null
+ }
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 30bbf4d5658..69906939f1c 100644
--- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
+++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
@@ -24,6 +24,7 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.AssetFileDescriptor
import android.database.Cursor
+import android.graphics.Outline
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
@@ -34,10 +35,14 @@ import android.provider.ContactsContract
import android.provider.MediaStore
import android.text.TextUtils
import android.util.Log
+import android.view.Gravity
+import android.view.Surface
import android.view.View
import android.view.ViewConfiguration
import android.view.ViewGroup
+import android.view.ViewOutlineProvider
import android.view.WindowManager
+import android.widget.FrameLayout
import android.widget.PopupWindow
import android.widget.TextView
import android.widget.Toast
@@ -48,6 +53,7 @@ import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia
import androidx.activity.viewModels
+import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListState
@@ -108,6 +114,15 @@ import com.nextcloud.talk.api.NcApi
import com.nextcloud.talk.api.NcApiCoroutines
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.attachmentpreview.FileAttachmentPreviewFragment
+import com.nextcloud.talk.attachmentsheet.AttachmentAction
+import com.nextcloud.talk.attachmentsheet.AttachmentSheet
+import com.nextcloud.talk.attachmentsheet.AttachmentSheetCallbacks
+import com.nextcloud.talk.attachmentsheet.AttachmentSheetModel
+import com.nextcloud.talk.attachmentsheet.AttachmentVisibilityInput
+import com.nextcloud.talk.attachmentsheet.isShareFileRequestGranted
+import com.nextcloud.talk.attachmentsheet.resolveAttachmentActions
+import com.nextcloud.talk.attachmentsheet.shareFilePermissionsToRequest
+import com.nextcloud.talk.camera.TakePhotoInApp
import com.nextcloud.talk.chat.data.io.VoiceMessageMediaService
import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.chat.data.model.FileParameters
@@ -122,6 +137,7 @@ import com.nextcloud.talk.chat.ui.ProfileModalBottomSheet
import com.nextcloud.talk.chat.ui.ShowReactionsModalBottomSheet
import com.nextcloud.talk.chat.ui.TempMessageActionsBottomSheet
import com.nextcloud.talk.chat.ui.TypingIndicatorBanner
+import com.nextcloud.talk.chat.ui.MessageActionsState
import com.nextcloud.talk.chat.ui.buildMessageActionsState
import com.nextcloud.talk.chat.ui.model.MessageTypeContent
import com.nextcloud.talk.chat.viewmodels.ChatViewModel
@@ -138,6 +154,12 @@ import com.nextcloud.talk.events.WebSocketCommunicationEvent
import com.nextcloud.talk.jobs.DeleteConversationWorker
import com.nextcloud.talk.jobs.DownloadFileToCacheWorker
import com.nextcloud.talk.jobs.ShareOperationWorker
+import com.nextcloud.talk.mediaviewer.model.MediaViewerChatAction
+import com.nextcloud.talk.mediaviewer.model.MediaViewerChatRequest
+import com.nextcloud.talk.mediaviewer.model.PendingFileForward
+import com.nextcloud.talk.mediaviewer.model.isInsideDirectory
+import com.nextcloud.talk.mediaviewer.model.isMediaActionAllowed
+import com.nextcloud.talk.mediaviewer.model.remoteSharePath
import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
import com.nextcloud.talk.location.LocationPickerActivity
import com.nextcloud.talk.mediaviewer.activities.MediaViewerActivity
@@ -240,6 +262,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
@@ -268,6 +291,8 @@ class ChatActivity :
private lateinit var binding: ActivityChatBinding
+ private val orientationTracker by lazy { DeviceOrientationTracker(this) }
+
@Inject
lateinit var ncApi: NcApi
@@ -354,6 +379,10 @@ class ChatActivity :
}
}
+ private val takePhotoInApp = registerForActivityResult(TakePhotoInApp()) { uri ->
+ uri?.let { showCapturedFile(it) }
+ }
+
override val view: View
get() = binding.root
@@ -422,7 +451,11 @@ class ChatActivity :
val participantPermissionsFlow: StateFlow = _participantPermissionsFlow.asStateFlow()
private var pendingCameraUri: Uri? = null
+ private var attachmentSheetModel by mutableStateOf(null)
private var pendingTargetMessageId: Long? = null
+
+ /** An action the media viewer handed over (reply, delete, forward, draw); runs once the room is joined. */
+ private var pendingMediaRequest: MediaViewerChatRequest? = null
private var pendingTargetThreadId: Long? = null
private var pendingTargetSearchQuery: String? = null
@@ -602,6 +635,19 @@ class ChatActivity :
// Not loaded in a coroutine: initData() must have run before onResume() (activity and ChatViewModel),
// and registerForActivityResult() must be called before the activity is started.
initialUser = setUpBoundUserOrFinish() ?: return
+ registerAttachmentPreviewResultListener()
+ // The request of the media viewer comes from the intent only on a fresh start. When the activity is restored
+ // (rotation, process death) the system hands over the original intent again, which must not run it twice:
+ // then only what was saved, i.e. not yet run, counts.
+ pendingMediaRequest = if (savedInstanceState == null) {
+ readMediaRequest(intent)
+ } else {
+ MediaViewerChatRequest.parse(
+ savedInstanceState.getString(MediaViewerChatRequest.KEY_ACTION),
+ savedInstanceState.getLong(MediaViewerChatRequest.KEY_MESSAGE_ID),
+ savedInstanceState.getString(MediaViewerChatRequest.KEY_LOCAL_PATH)
+ )
+ }
binding = ActivityChatBinding.inflate(layoutInflater)
setContentView(binding.root)
@@ -668,19 +714,9 @@ class ChatActivity :
messageInputViewModel.setData(chatViewModel.getChatRepository())
initObservers()
+ resumeRecordingAfterRecreation()
- pendingTargetMessageId?.let { messageId ->
- lifecycleScope.launch {
- chatViewModel.openMessageFromGlobalSearch(
- messageId = messageId,
- threadId = pendingTargetThreadId,
- searchQuery = pendingTargetSearchQuery
- )
- }
- pendingTargetMessageId = null
- pendingTargetThreadId = null
- pendingTargetSearchQuery = null
- }
+ openPendingTargetMessage()
pickMultipleMedia = registerForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(MAX_AMOUNT_MEDIA_FILE_PICKER)
@@ -1026,17 +1062,7 @@ class ChatActivity :
)
} else {
MessageActionsBottomSheet(
- actionsState = buildMessageActionsState(
- message = msg,
- user = conversationUser,
- conversation = currentConversation,
- hasChatPermission = participantPermissions?.hasChatPermission() == true,
- hasReactPermission = participantPermissions?.hasReactPermission() == true,
- spreedCapabilities = spreedCapabilities,
- isOnline = isOnline,
- dateUtils = dateUtils,
- conversationThreadId = conversationThreadId
- ),
+ actionsState = actionsStateFor(msg, isOnline),
onEmojiClick = { emoji ->
if (msg.reactionsSelf?.contains(emoji) == true) {
chatViewModel.deleteReaction(roomToken, msg, emoji)
@@ -1044,13 +1070,7 @@ class ChatActivity :
chatViewModel.addReaction(roomToken, msg, emoji)
}
},
- onReply = {
- if (msg.isThread && conversationThreadId == null) {
- openThread(msg)
- } else {
- messageInputViewModel.reply(msg)
- }
- },
+ onReply = { replyTo(msg) },
onReplyPrivately = { replyPrivately(msg) },
onOpenThread = { msg.threadId?.let { openThread(it) } },
onForward = { forwardMessage(msg) },
@@ -1079,6 +1099,8 @@ class ChatActivity :
)
}
}
+
+ AttachmentSheetHost()
}
}
}
@@ -1374,9 +1396,49 @@ class ChatActivity :
if (newRoomToken != roomToken) {
switchToRoom(newRoomToken, startCallAfterRoomSwitch, isVoiceOnlyCall)
}
+ } else if (isForOtherRoom(extras)) {
+ // e.g. back from the media viewer to a chat which is not the one underneath: open it as a new chat
+ intent.removeFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ startActivity(intent)
} else {
handleIntent(intent)
+ readMediaRequest(intent)?.let { pendingMediaRequest = it }
+ applyIntentTargetsToJoinedRoom()
+ }
+ }
+
+ /** Scrolls to the message the intent asked for (a search hit, "show in chat"), once. */
+ private fun openPendingTargetMessage() {
+ pendingTargetMessageId?.let { messageId ->
+ lifecycleScope.launch {
+ chatViewModel.openMessageFromGlobalSearch(
+ messageId = messageId,
+ threadId = pendingTargetThreadId,
+ searchQuery = pendingTargetSearchQuery
+ )
+ }
+ pendingTargetMessageId = null
+ pendingTargetThreadId = null
+ pendingTargetSearchQuery = null
+ }
+ }
+
+ private fun isForOtherRoom(extras: Bundle?): Boolean {
+ val requestedToken = extras?.getString(KEY_ROOM_TOKEN)
+ return requestedToken != null && requestedToken != roomToken
+ }
+
+ /**
+ * An intent for the chat which is already open: scrolls to the message it names and runs the request of the media
+ * viewer, but only when the conversation is already loaded. Otherwise both stay pending and are run when the
+ * joined-room state arrives.
+ */
+ private fun applyIntentTargetsToJoinedRoom() {
+ if (currentConversation == null) {
+ return
}
+ openPendingTargetMessage()
+ runPendingMediaRequest()
}
private fun handleIntent(intent: Intent) {
@@ -1415,9 +1477,22 @@ class ChatActivity :
pendingTargetSearchQuery = extras?.getString(BundleKeys.KEY_SEARCH_QUERY)
}
+ /** The request of the media viewer in [source], if any; removes it from the intent so it is read only once. */
+ private fun readMediaRequest(source: Intent): MediaViewerChatRequest? {
+ val extras = source.extras
+ val request = MediaViewerChatRequest.parse(
+ actionName = extras?.getString(MediaViewerChatRequest.KEY_ACTION),
+ messageId = extras?.getLong(MediaViewerChatRequest.KEY_MESSAGE_ID) ?: 0L,
+ localPath = extras?.getString(MediaViewerChatRequest.KEY_LOCAL_PATH)
+ )
+ source.removeExtra(MediaViewerChatRequest.KEY_ACTION)
+ return request
+ }
+
override fun onStart() {
super.onStart()
active = true
+ orientationTracker.enable()
this.lifecycle.addObserver(AudioUtils)
this.lifecycle.addObserver(chatViewModel)
@@ -1454,12 +1529,23 @@ class ChatActivity :
override fun onSaveInstanceState(outState: Bundle) {
chatViewModel.handleOrientationChange()
+ pendingMediaRequest?.let {
+ outState.putString(MediaViewerChatRequest.KEY_ACTION, it.action.name)
+ outState.putLong(MediaViewerChatRequest.KEY_MESSAGE_ID, it.messageId)
+ outState.putString(MediaViewerChatRequest.KEY_LOCAL_PATH, it.localPath)
+ }
super.onSaveInstanceState(outState)
}
override fun onStop() {
+ val changingConfigurations = isChangingConfigurations
super.onStop()
active = false
+ orientationTracker.disable()
+ if (!changingConfigurations) {
+ // the user leaves the chat: a video recording must not go on with camera and microphone
+ chatViewModel.activeVideoMessageRecorder?.cancel()
+ }
this.lifecycle.removeObserver(AudioUtils)
this.lifecycle.removeObserver(chatViewModel)
@@ -1617,10 +1703,18 @@ class ChatActivity :
joinRoomWithPassword()
+ // a video result which waited for the conversation and the capabilities of this activity
+ chatViewModel.activeVideoMessageRecorder?.takeIf { it.hasPendingResult }?.let {
+ attachVideoRecorder(it)
+ }
+
if (sharedFilePaths.isNotEmpty()) {
onChooseFileResult(sharedFilePaths.map { it.toUri() })
sharedFilePaths = emptyList()
}
+ // "show in chat" for a chat which was open before its conversation was loaded
+ openPendingTargetMessage()
+ runPendingMediaRequest()
refreshScheduledMessages()
@@ -2466,6 +2560,233 @@ class ChatActivity :
)
}
+ fun isVideoRecordPermissionGranted(): Boolean =
+ permissionUtil.isCameraPermissionGranted() && permissionUtil.isMicrophonePermissionGranted()
+
+ fun requestVideoRecordPermissions() {
+ requestPermissions(
+ arrayOf(
+ Manifest.permission.CAMERA,
+ Manifest.permission.RECORD_AUDIO
+ ),
+ REQUEST_VIDEO_RECORD_PERMISSIONS
+ )
+ }
+
+ /**
+ * Starts recording a video message with the front camera. The recording shares the in-progress and locked state
+ * of the voice recording, so the gestures of the record button work the same for both.
+ *
+ * @return true if the recording was started
+ */
+ fun startVideoRecording(): Boolean {
+ val recorder = chatViewModel.videoMessageRecorder(this)
+ val file = if (recorder.isActive) null else createAttachmentFile(R.string.nc_video_filename, VIDEO_SUFFIX)
+ if (file == null || !chatViewModel.onVideoRecordingStarted()) {
+ return false
+ }
+ showVideoRecordingPreview(true)
+ recorder.attach(
+ this,
+ binding.videoRecordingPreview,
+ ::onVideoRecordingFinished,
+ ::updateVideoRecordingPreviewLayout
+ )
+ recorder.start(
+ file,
+ VideoMessageRecorder.videoTargetRotation(
+ orientationTracker.degrees,
+ binding.videoRecordingPreview.display?.rotation ?: Surface.ROTATION_0
+ )
+ )
+ return true
+ }
+
+ fun stopAndSendRecording() {
+ if (chatViewModel.activeRecordingMode == RecordInputMode.VIDEO) {
+ chatViewModel.activeVideoMessageRecorder?.stopAndSend()
+ } else {
+ chatViewModel.stopAndSendAudioRecording(
+ roomToken = roomToken,
+ replyToMessageId = getReplyToMessageId(),
+ displayName = currentConversation!!.displayName
+ )
+ }
+ }
+
+ fun stopAndDiscardRecording() {
+ if (chatViewModel.activeRecordingMode == RecordInputMode.VIDEO) {
+ chatViewModel.activeVideoMessageRecorder?.cancel()
+ } else {
+ chatViewModel.stopAndDiscardAudioRecording()
+ }
+ }
+
+ private fun onVideoRecordingFinished(outcome: VideoMessageRecorder.Outcome, file: File?) {
+ showVideoRecordingPreview(false)
+ if (chatViewModel.getVoiceRecordingInProgress.value == true) {
+ chatViewModel.onVideoRecordingEnded()
+ }
+ if (chatViewModel.getVoiceRecordingLocked.value == true) {
+ chatViewModel.setVoiceRecordingLocked(false)
+ }
+ when (outcome) {
+ VideoMessageRecorder.Outcome.SEND -> file?.let {
+ uploadFiles(mutableListOf(FileProvider.getUriForFile(context, context.packageName, it).toString()))
+ }
+
+ VideoMessageRecorder.Outcome.TOO_SHORT ->
+ Snackbar.make(
+ binding.root,
+ R.string.nc_video_message_hold_to_record_info_switch_to_voice,
+ Snackbar.LENGTH_SHORT
+ ).show()
+
+ VideoMessageRecorder.Outcome.FAILED ->
+ Snackbar.make(binding.root, R.string.nc_video_message_recording_failed, Snackbar.LENGTH_LONG).show()
+
+ VideoMessageRecorder.Outcome.INTERRUPTED -> file?.let {
+ onChooseFileResult(listOf(FileProvider.getUriForFile(context, context.packageName, it)))
+ }
+
+ VideoMessageRecorder.Outcome.CANCELLED -> Unit
+ }
+ }
+
+ /**
+ * After a recreation of the activity: picks up the recording which went on in the view model of the chat. The
+ * finger of the record button is gone with the old activity, so a recording which was held is locked, and the
+ * locked recording UI (timer, stop, send, cancel) is shown by the observer of the locked state.
+ */
+ private fun resumeRecordingAfterRecreation() {
+ val recorder = chatViewModel.activeVideoMessageRecorder
+ val action = resolveRecordingResume(
+ recorderActive = recorder?.isActive == true,
+ hasPendingResult = recorder?.hasPendingResult == true,
+ recordingInProgress = chatViewModel.getVoiceRecordingInProgress.value == true,
+ recordingLocked = chatViewModel.getVoiceRecordingLocked.value == true,
+ // the result is uploaded or previewed with the conversation and the capabilities, which load later
+ chatReady = currentConversation != null && ::spreedCapabilities.isInitialized
+ )
+ if (recorder != null && recorder.isActive) {
+ showVideoRecordingPreview(true)
+ }
+ val attach = action == RecordingResume.ATTACH ||
+ action == RecordingResume.ATTACH_AND_LOCK ||
+ action == RecordingResume.DELIVER_RESULT
+ if (recorder != null && attach) attachVideoRecorder(recorder)
+ if (action == RecordingResume.ATTACH_AND_LOCK) {
+ chatViewModel.setVoiceRecordingLocked(true)
+ }
+ }
+
+ private fun attachVideoRecorder(recorder: VideoMessageRecorder) {
+ recorder.attach(
+ this,
+ binding.videoRecordingPreview,
+ ::onVideoRecordingFinished,
+ ::updateVideoRecordingPreviewLayout
+ )
+ }
+
+ private val accessibilityBeforeVideoRecording = HashMap()
+
+ // the update only touches the layout of the preview when its size changed, so it does not loop
+ private val videoScrimLayoutListener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+ updateVideoRecordingPreviewLayout()
+ }
+
+ /**
+ * Places the preview in the middle of the dimming layer, which covers the area of the chat above the recording
+ * panel: in the aspect of the recorded frame, fitted into the area. Called whenever the area changes its size
+ * (rotation, panels of a wide screen) and when the recorder knows the aspect of the frame.
+ */
+ private fun updateVideoRecordingPreviewLayout() {
+ val scrim = binding.videoRecordingScrim
+ if (scrim.width <= 0 || scrim.height <= 0) return
+ val resources = scrim.resources
+ val displayRotation = ContextCompat.getDisplayOrDefault(scrim.context)?.rotation ?: Surface.ROTATION_0
+ val placement = videoPreviewPlacement(
+ areaWidth = scrim.width,
+ areaHeight = scrim.height,
+ aspect = chatViewModel.activeVideoMessageRecorder
+ ?.let { screenFrameAspect(it.frameAspect, it.videoRotation, displayRotation) }
+ ?: fallbackVideoFrameAspect(displayRotation),
+ limits = VideoPreviewLimits(
+ margin = resources.getDimensionPixelSize(R.dimen.standard_margin),
+ maxWidthFraction = VIDEO_PREVIEW_MAX_WIDTH_FRACTION,
+ maxSide = resources.getDimensionPixelSize(R.dimen.video_recording_preview_max_side),
+ shortAreaHeight = resources.getDimensionPixelSize(R.dimen.video_recording_preview_short_area),
+ shortMargin = resources.getDimensionPixelSize(R.dimen.standard_half_margin)
+ )
+ )
+ val container = binding.videoRecordingContainer
+ val params = container.layoutParams as FrameLayout.LayoutParams
+ if (params.width != placement.width || params.height != placement.height) {
+ params.width = placement.width
+ params.height = placement.height
+ params.gravity = Gravity.CENTER
+ container.layoutParams = params
+ container.invalidateOutline()
+ }
+ }
+
+ /**
+ * TalkBack reaches the views under the dimming layer, which only swallows touches. While the video is recorded
+ * they are hidden from it; the values they had are put back afterwards. A new activity after a rotation starts
+ * with nothing saved and saves the defaults of its own fresh views.
+ */
+ private fun hideChatFromAccessibility(hide: Boolean) {
+ val parent = binding.videoRecordingScrim.parent as? ViewGroup ?: return
+ if (hide) {
+ if (accessibilityBeforeVideoRecording.isNotEmpty()) return
+ for (i in 0 until parent.childCount) {
+ val child = parent.getChildAt(i)
+ if (child === binding.videoRecordingScrim) continue
+ accessibilityBeforeVideoRecording[child] = child.importantForAccessibility
+ child.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
+ }
+ } else {
+ accessibilityBeforeVideoRecording.forEach { (view, value) -> view.importantForAccessibility = value }
+ accessibilityBeforeVideoRecording.clear()
+ }
+ }
+
+ private fun showVideoRecordingPreview(show: Boolean) {
+ if (show) {
+ binding.videoRecordingScrim.removeOnLayoutChangeListener(videoScrimLayoutListener)
+ binding.videoRecordingScrim.addOnLayoutChangeListener(videoScrimLayoutListener)
+ binding.videoRecordingContainer.clipToOutline = true
+ binding.videoRecordingContainer.outlineProvider = object : ViewOutlineProvider() {
+ override fun getOutline(view: View, outline: Outline) {
+ val radius = view.resources.getDimension(R.dimen.standard_margin)
+ outline.setRoundRect(0, 0, view.width, view.height, radius)
+ }
+ }
+ binding.videoRecordingSwitchCamera.setOnClickListener {
+ chatViewModel.activeVideoMessageRecorder?.switchCamera()
+ }
+ }
+ hideChatFromAccessibility(show)
+ binding.videoRecordingScrim.visibility = if (show) View.VISIBLE else View.GONE
+ }
+
+ private fun createAttachmentFile(@StringRes nameRes: Int, suffix: String): File? =
+ try {
+ val outputDir = FileUtils.getSharedAttachmentsDirectory(context.cacheDir)
+ ?: throw IOException("Could not create shared attachments directory")
+ val date = SimpleDateFormat(FILE_DATE_PATTERN, Locale.ROOT).format(Date())
+ File(outputDir, "${context.resources.getString(nameRes, date)}$suffix")
+ } catch (e: IOException) {
+ logger.e(TAG, "error while creating attachment file", e)
+ Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
+ null
+ }
+
+ // The photo of the in-app camera was asked for and waits for the camera permission. Kept in the activity only: if
+ // the activity is recreated while the dialog is open, the user taps the camera again.
+ private var pendingInAppPhoto = false
+
private fun requestCameraPermissions() {
requestPermissions(
arrayOf(
@@ -2485,23 +2806,7 @@ class ChatActivity :
}
private fun requestReadFilesPermissions() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- requestPermissions(
- arrayOf(
- Manifest.permission.READ_MEDIA_IMAGES,
- Manifest.permission.READ_MEDIA_VIDEO,
- Manifest.permission.READ_MEDIA_AUDIO
- ),
- REQUEST_SHARE_FILE_PERMISSION
- )
- } else {
- requestPermissions(
- arrayOf(
- Manifest.permission.READ_EXTERNAL_STORAGE
- ),
- REQUEST_SHARE_FILE_PERMISSION
- )
- }
+ requestPermissions(shareFilePermissionsToRequest(Build.VERSION.SDK_INT), REQUEST_SHARE_FILE_PERMISSION)
}
private fun checkShowCallButtons() {
@@ -2656,15 +2961,30 @@ class ChatActivity :
}
}
- private fun showFileAttachmentPreview(files: MutableList) {
+ private fun registerAttachmentPreviewResultListener() {
+ supportFragmentManager.setFragmentResultListener(
+ FileAttachmentPreviewFragment.RESULT_KEY,
+ this
+ ) { _, result ->
+ val files = result.getStringArrayList(FileAttachmentPreviewFragment.RESULT_FILES)
+ if (files != null) {
+ uploadFiles(
+ files,
+ result.getString(FileAttachmentPreviewFragment.RESULT_CAPTION, ""),
+ result.getBoolean(FileAttachmentPreviewFragment.RESULT_COMPRESS_IMAGES),
+ result.getBoolean(FileAttachmentPreviewFragment.RESULT_ALLOW_UPDATE)
+ )
+ }
+ }
+ }
+
+ private fun showFileAttachmentPreview(files: MutableList, startDrawing: Boolean = false) {
val newFragment = FileAttachmentPreviewFragment.newInstance(
files,
currentConversation?.displayName ?: "",
- CapabilitiesUtil.hasConversationSubfoldersForAttachments(spreedCapabilities)
+ CapabilitiesUtil.hasConversationSubfoldersForAttachments(spreedCapabilities),
+ startDrawing
)
- newFragment.setListener { selectedFiles, caption, compressImages, allowUpdate ->
- uploadFiles(selectedFiles, caption, compressImages, allowUpdate)
- }
newFragment.show(supportFragmentManager, FileAttachmentPreviewFragment.TAG)
}
@@ -2710,14 +3030,17 @@ class ChatActivity :
@Throws(IllegalStateException::class)
private fun onPickCameraResult(intent: Intent?) {
+ // 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
+ showCapturedFile(uri)
+ }
+
+ private fun showCapturedFile(uri: Uri?) {
try {
filesToUpload.clear()
-
- // 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 {
@@ -2789,7 +3112,7 @@ class ChatActivity :
.show()
}
} else if (requestCode == REQUEST_SHARE_FILE_PERMISSION) {
- if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ if (isShareFileRequestGranted(grantResults, permissionUtil.isFilesPermissionGranted())) {
showLocalFilePicker()
} else {
Snackbar.make(
@@ -2819,11 +3142,25 @@ class ChatActivity :
Snackbar.LENGTH_LONG
).show()
}
+ } else if (requestCode == REQUEST_VIDEO_RECORD_PERMISSIONS) {
+ if (grantResults.isEmpty() || grantResults.any { it != PackageManager.PERMISSION_GRANTED }) {
+ Snackbar.make(
+ binding.root,
+ context.getString(R.string.nc_video_message_missing_permissions),
+ Snackbar.LENGTH_LONG
+ ).show()
+ }
} else if (requestCode == REQUEST_CAMERA_PERMISSION) {
+ val openInAppCamera = pendingInAppPhoto
+ pendingInAppPhoto = false
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
- Snackbar
- .make(binding.root, context.getString(R.string.camera_permission_granted), Snackbar.LENGTH_LONG)
- .show()
+ if (openInAppCamera) {
+ takePhotoInApp.launch(Unit)
+ } else {
+ Snackbar
+ .make(binding.root, context.getString(R.string.camera_permission_granted), Snackbar.LENGTH_LONG)
+ .show()
+ }
} else {
Snackbar
.make(binding.root, context.getString(R.string.take_photo_permission), Snackbar.LENGTH_LONG)
@@ -2875,7 +3212,7 @@ class ChatActivity :
)
}
- fun sendSelectLocalFileIntent() {
+ private fun sendSelectLocalFileIntent() {
if (!permissionUtil.isFilesPermissionGranted()) {
requestReadFilesPermissions()
} else {
@@ -2883,17 +3220,17 @@ class ChatActivity :
}
}
- fun sendChooseContactIntent() {
+ private fun sendChooseContactIntent() {
requestReadContacts()
}
- fun showBrowserScreen() {
+ private fun showBrowserScreen() {
val sharingFileBrowserIntent = Intent(this, RemoteFileBrowserActivity::class.java)
sharingFileBrowserIntent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId)
startRemoteFileBrowsingForResult.launch(sharingFileBrowserIntent)
}
- fun showShareLocationScreen() {
+ private fun showShareLocationScreen() {
Log.d(TAG, "showShareLocationScreen")
// location permission and location services are optional: without them, a place can still be picked on the
@@ -3410,19 +3747,16 @@ class ChatActivity :
}
private fun showSharedItems() {
- val intent = Intent(this, SharedItemsActivity::class.java)
- intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId)
- intent.putExtra(KEY_CONVERSATION_NAME, currentConversation?.displayName)
- intent.putExtra(KEY_ROOM_TOKEN, roomToken)
- intent.putExtra(
- SharedItemsActivity.KEY_USER_IS_OWNER_OR_MODERATOR,
- ConversationUtils.isParticipantOwnerOrModerator(currentConversation!!)
- )
- intent.putExtra(
- SharedItemsActivity.KEY_IS_ONE_2_ONE,
- currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
+ startActivity(
+ SharedItemsActivity.createIntent(
+ context = this,
+ userId = conversationUserId,
+ roomToken = roomToken,
+ conversationName = currentConversation?.displayName,
+ isOwnerOrModerator = ConversationUtils.isParticipantOwnerOrModerator(currentConversation!!),
+ isOneToOne = currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
+ )
)
- startActivity(intent)
}
private fun startMessageSearch() {
@@ -3556,6 +3890,27 @@ class ChatActivity :
}
}
+ private fun replyTo(message: ChatMessage) {
+ if (message.isThread && conversationThreadId == null) {
+ openThread(message)
+ } else {
+ messageInputViewModel.reply(message)
+ }
+ }
+
+ private fun actionsStateFor(message: ChatMessage, isOnline: Boolean): MessageActionsState =
+ buildMessageActionsState(
+ message = message,
+ user = conversationUser,
+ conversation = currentConversation,
+ hasChatPermission = participantPermissionsFlow.value?.hasChatPermission() == true,
+ hasReactPermission = participantPermissionsFlow.value?.hasReactPermission() == true,
+ spreedCapabilities = spreedCapabilities,
+ isOnline = isOnline,
+ dateUtils = dateUtils,
+ conversationThreadId = conversationThreadId
+ )
+
private fun openMessageActionsDialog(message: ChatMessage) {
if (message.isTemporary || (hasVisibleItems(message) && !isSystemMessage(message))) {
chatViewModel.showMessageActions(message.jsonMessageId.toLong())
@@ -3620,6 +3975,81 @@ class ChatActivity :
finish()
}
+ /**
+ * Runs the request the media viewer handed over, with the chat's own handlers. The rules of the message actions
+ * are checked again here, with the chat's data, so a request is never run for what the chat would not offer.
+ */
+ private fun runPendingMediaRequest() {
+ val request = pendingMediaRequest ?: return
+ // the conversation can arrive before the capabilities; the request waits for the initial-load state then
+ if (currentConversation == null || !::spreedCapabilities.isInitialized) {
+ return
+ }
+ pendingMediaRequest = null
+ lifecycleScope.launch {
+ val message = loadMessageOrNull(request.messageId)
+ val state = message?.let { actionsStateFor(it, networkMonitor.isOnline.value) }
+ if (message == null || state == null || !isMediaActionAllowed(request.action, state)) {
+ Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
+ return@launch
+ }
+ when (request.action) {
+ MediaViewerChatAction.REPLY -> replyTo(message)
+ MediaViewerChatAction.DELETE -> deleteMessage(message)
+ MediaViewerChatAction.FORWARD -> forwardFile(message)
+ MediaViewerChatAction.DRAW -> startDrawingOnCopy(request.localPath)
+ }
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private suspend fun loadMessageOrNull(messageId: Long): ChatMessage? =
+ try {
+ withTimeoutOrNull(MESSAGE_LOAD_TIMEOUT_MS) { chatViewModel.getMessageById(messageId).first() }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Log.e(TAG, "Could not load the message of the media viewer request", e)
+ null
+ }
+
+ /**
+ * Opens the upload preview of the cached file, with the drawing editor on. The preview works on copies: drawing
+ * and cropping write new files, the cached file and the sent message stay as they are.
+ */
+ private fun startDrawingOnCopy(localPath: String?) {
+ val directory = FileUtils.getSharedAttachmentsDirectory(cacheDir)
+ val file = localPath?.let { File(it) }
+ if (directory == null || file == null || !file.isFile || !isInsideDirectory(directory, file)) {
+ Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
+ return
+ }
+ val uri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID, file)
+ showFileAttachmentPreview(mutableListOf(uri.toString()), startDrawing = true)
+ }
+
+ /**
+ * Forwards the file of [message] to a conversation to be picked. The path comes from the message in the database
+ * and goes to the conversation list as the key of an entry in [PendingFileForward], never as a path in an intent.
+ */
+ private fun forwardFile(message: ChatMessage) {
+ val path = message.fileParameters.path
+ if (path.isNullOrBlank()) {
+ Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
+ return
+ }
+ val caption = message.message?.takeIf { it != "{file}" }.orEmpty()
+ val key = PendingFileForward.put(PendingFileForward.Entry(conversationUserId, remoteSharePath(path), caption))
+ val intent = Intent(this, ConversationsListActivity::class.java)
+ intent.putExtra(BundleKeys.KEY_FORWARD_MSG_FLAG, true)
+ intent.putExtra(BundleKeys.KEY_FORWARD_FILE_KEY, key)
+ intent.putExtra(BundleKeys.KEY_FORWARD_HIDE_SOURCE_ROOM, roomToken)
+ intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId)
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ startActivity(intent)
+ finish()
+ }
+
fun remindMeLater(message: ChatMessage?) {
Log.d(TAG, "remindMeLater called")
@@ -4053,59 +4483,88 @@ class ChatActivity :
}
}
- fun sendPictureFromCamIntent() {
+ @Composable
+ private fun AttachmentSheetHost() {
+ val model = attachmentSheetModel ?: return
+ val callbacks = remember {
+ AttachmentSheetCallbacks(
+ onAction = { runAttachmentAction(it) },
+ onTakePhoto = {
+ attachmentSheetModel = null
+ takePhotoWithInAppCamera()
+ },
+ onSend = {
+ attachmentSheetModel = null
+ onChooseFileResult(it)
+ },
+ onDismiss = { attachmentSheetModel = null }
+ )
+ }
+ AttachmentSheet(model = model, callbacks = callbacks)
+ }
+
+ fun showAttachmentSheet() {
+ attachmentSheetModel = buildAttachmentSheetModel()
+ }
+
+ private fun buildAttachmentSheetModel(): AttachmentSheetModel? {
+ val conversation = currentConversation ?: return null
+ val actions = resolveAttachmentActions(
+ AttachmentVisibilityInput(
+ isRemoteConversation = !conversation.remoteServer.isNullOrEmpty(),
+ hasGeoLocationCapability = hasSpreedFeatureCapability(
+ spreedCapabilities,
+ SpreedFeatures.GEO_LOCATION_SHARING
+ ),
+ hasPollsCapability = hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.TALK_POLLS),
+ isOneToOneConversation = isOneToOneConversation(),
+ hasThreadsCapability = hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.THREADS),
+ isInsideThread = conversationThreadId != null,
+ hasCamera = packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)
+ )
+ )
+ val serverName = CapabilitiesUtil.getServerName(conversationUser)
+ .takeUnless { it.isNullOrEmpty() } ?: getString(R.string.nc_server_product_name)
+ return AttachmentSheetModel(
+ actions = actions,
+ cloudLabel = getString(R.string.nc_upload_from_cloud, serverName),
+ maxSelection = MAX_AMOUNT_MEDIA_FILE_PICKER,
+ livePreviewEnabled = !(CallActivity.active || !isNotInCall())
+ )
+ }
+
+ private fun runAttachmentAction(action: AttachmentAction) {
+ attachmentSheetModel = null
+ when (action) {
+ AttachmentAction.PICTURE_FROM_CAM -> takePhotoWithInAppCamera()
+ AttachmentAction.VIDEO_FROM_CAM -> sendVideoFromCamIntent()
+ AttachmentAction.GALLERY -> showGalleryPicker()
+ AttachmentAction.FILE_FROM_LOCAL -> sendSelectLocalFileIntent()
+ AttachmentAction.FILE_FROM_CLOUD -> showBrowserScreen()
+ AttachmentAction.CREATE_THREAD -> createThread()
+ AttachmentAction.CREATE_POLL -> createPoll()
+ AttachmentAction.SHARE_LOCATION -> showShareLocationScreen()
+ AttachmentAction.SHARE_CONTACT -> sendChooseContactIntent()
+ }
+ }
+
+ private fun takePhotoWithInAppCamera() {
if (!permissionUtil.isCameraPermissionGranted()) {
+ pendingInAppPhoto = true
requestCameraPermissions()
} else {
- 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) {
- logger.e(TAG, "error while creating photo file", e)
- Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
- null
- }
-
- photoFile?.also {
- pendingCameraUri = FileProvider.getUriForFile(context, context.packageName, it)
- takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, pendingCameraUri)
- startPickCameraIntentForResult.launch(takePictureIntent)
- }
- }
- }
+ takePhotoInApp.launch(Unit)
}
}
- fun sendVideoFromCamIntent() {
+ private fun sendVideoFromCamIntent() {
if (!permissionUtil.isCameraPermissionGranted()) {
+ pendingInAppPhoto = false
requestCameraPermissions()
} else {
Intent(MediaStore.ACTION_VIDEO_CAPTURE).also { takeVideoIntent ->
takeVideoIntent.resolveActivity(packageManager)?.also {
- val videoFile: 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 videoName = String.format(
- context.resources.getString(R.string.nc_video_filename),
- date
- )
- File(outputDir, "$videoName$VIDEO_SUFFIX")
- } catch (e: IOException) {
- logger.e(TAG, "error while creating video file", e)
- Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
- null
- }
+ val videoFile = createAttachmentFile(R.string.nc_video_filename, VIDEO_SUFFIX)
videoFile?.also {
pendingCameraUri = FileProvider.getUriForFile(context, context.packageName, it)
@@ -4117,12 +4576,12 @@ class ChatActivity :
}
}
- fun createPoll() {
+ private fun createPoll() {
val pollVoteDialog = PollCreateDialogFragment.newInstance(conversationUser, roomToken)
pollVoteDialog.show(supportFragmentManager, TAG)
}
- fun createThread() {
+ private fun createThread() {
messageInputViewModel.startThreadCreation()
}
@@ -4235,6 +4694,7 @@ class ChatActivity :
}
companion object {
+ private const val VIDEO_PREVIEW_MAX_WIDTH_FRACTION = 0.75f
val TAG = ChatActivity::class.java.simpleName
/**
@@ -4258,6 +4718,7 @@ class ChatActivity :
private const val GET_ROOM_INFO_DELAY_NORMAL: Long = 30000
private const val GET_ROOM_INFO_DELAY_LOBBY: Long = 5000
private const val MILLIS_250 = 250L
+ private const val MESSAGE_LOAD_TIMEOUT_MS = 10_000L
private const val MILLIS_150 = 150L
private const val MILLIS_1000 = 1000L
private const val FLOAT_100 = 100f
@@ -4266,9 +4727,9 @@ class ChatActivity :
private const val REQUEST_RECORD_AUDIO_PERMISSION = 222
private const val REQUEST_READ_CONTACT_PERMISSION = 234
private const val REQUEST_CAMERA_PERMISSION = 223
+ private const val REQUEST_VIDEO_RECORD_PERMISSIONS = 224
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/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt
index a5d3fa86c7b..817f0eded75 100644
--- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt
+++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt
@@ -12,6 +12,8 @@ import android.content.res.Resources
import android.os.Build
import android.os.Bundle
import android.os.CountDownTimer
+import android.os.Handler
+import android.os.Looper
import android.os.SystemClock
import android.text.Editable
import android.text.InputFilter
@@ -25,6 +27,7 @@ import android.view.LayoutInflater
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
+import android.view.ViewConfiguration
import android.view.ViewGroup
import android.view.animation.AlphaAnimation
import android.view.animation.Animation
@@ -38,6 +41,7 @@ import android.widget.LinearLayout
import android.widget.PopupMenu
import android.widget.RelativeLayout
import android.widget.SeekBar
+import androidx.annotation.StringRes
import androidx.appcompat.view.ContextThemeWrapper
import androidx.compose.material3.MaterialTheme
import androidx.core.content.ContextCompat
@@ -55,7 +59,6 @@ import autodagger.AutoInjector
import coil.load
import com.google.android.flexbox.FlexboxLayout
import com.google.android.material.button.MaterialButton
-import com.google.android.material.snackbar.Snackbar
import com.nextcloud.android.common.ui.theme.utils.ColorRole
import com.nextcloud.talk.R
import com.nextcloud.talk.application.NextcloudTalkApplication
@@ -74,7 +77,6 @@ import com.nextcloud.talk.models.json.signaling.NCSignalingMessageDto
import com.nextcloud.talk.presenters.MentionAutocompletePresenter
import com.nextcloud.talk.ui.CallStartedBanner
import com.nextcloud.talk.ui.MicInputCloud
-import com.nextcloud.talk.ui.dialog.AttachmentDialog
import com.nextcloud.talk.ui.theme.ViewThemeUtils
import com.nextcloud.talk.ui.theme.hostViewThemeUtils
import com.nextcloud.talk.users.UserManager
@@ -131,6 +133,12 @@ class MessageInputFragment : Fragment() {
private lateinit var spreedCapabilities: SpreedCapabilityDto
private var hasSharedText = false
+ private val recordButtonHandler = Handler(Looper.getMainLooper())
+ private var pendingRecordStart: Runnable? = null
+ private var recordingStartedAt = 0L
+ private var recordHintPopup: RecordHintPopup? = null
+ private var recordingUiShown = false
+
private var lastQuotedJsonId: Int? = null
private var lastEditMessageId: Int? = null
private var lastIsThreadCreationInProgress: Boolean = false
@@ -155,8 +163,16 @@ class MessageInputFragment : Fragment() {
return binding.root
}
+ override fun onPause() {
+ cancelPendingRecordStart()
+ super.onPause()
+ }
+
override fun onDestroyView() {
restoreKeyboardOnEmojiDismiss = false
+ cancelPendingRecordStart()
+ recordHintPopup?.dismiss()
+ recordHintPopup = null
super.onDestroyView()
if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) {
mentionAutocomplete?.dismissPopup()
@@ -275,6 +291,13 @@ class MessageInputFragment : Fragment() {
}
}
+ chatActivity.chatViewModel.getVoiceRecordingInProgress.observe(viewLifecycleOwner) { inProgress ->
+ if (inProgress != true && recordingUiShown) {
+ showRecordAudioUi(false)
+ resetSlider()
+ }
+ }
+
chatActivity.chatViewModel.scheduledMessagesCount.observe(viewLifecycleOwner) { count ->
if (chatActivity.conversationThreadId != null && chatActivity.conversationThreadId!! > 0) {
val threadId = chatActivity.conversationThreadId
@@ -497,7 +520,7 @@ class MessageInputFragment : Fragment() {
}
binding.fragmentMessageInputView.setAttachmentsListener {
- AttachmentDialog(requireActivity(), requireActivity() as ChatActivity).show()
+ chatActivity.showAttachmentSheet()
}
binding.fragmentMessageInputView.attachmentButton.setOnLongClickListener {
@@ -583,86 +606,78 @@ class MessageInputFragment : Fragment() {
@Suppress("ClickableViewAccessibility", "CyclomaticComplexMethod", "LongMethod")
private fun initVoiceRecordButton() {
handleButtonsVisibility()
+ updateRecordButtonMode()
var prevDx = 0f
- var voiceRecordStartTime = 0L
- var voiceRecordEndTime: Long
+ val gesture = RecordButtonGesture(VOICE_RECORD_CANCEL_SLIDER_X.toFloat())
binding.fragmentMessageInputView.recordAudioButton.setOnTouchListener { v, event ->
v?.performClick()
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
- if (!chatActivity.isRecordAudioPermissionGranted()) {
- chatActivity.requestRecordAudioPermissions()
- return@setOnTouchListener true
- }
- if (!chatActivity.permissionUtil.isFilesPermissionGranted()) {
- UploadAndShareFilesWorker.requestStoragePermission(chatActivity)
- return@setOnTouchListener true
- }
-
- val base = SystemClock.elapsedRealtime()
- voiceRecordStartTime = System.currentTimeMillis()
- binding.fragmentMessageInputView.audioRecordDuration.base = base
- messageInputViewModel.setRecordingTime(base)
- binding.fragmentMessageInputView.audioRecordDuration.start()
- chatActivity.chatViewModel.startAudioRecording(requireContext(), chatActivity.currentConversation!!)
- showRecordAudioUi(true)
+ // A tap only switches the mode, the recording starts only after the hold threshold.
+ recordHintPopup?.dismiss()
prevDx = event.x
+ gesture.down()
+ scheduleRecordStart(gesture)
}
MotionEvent.ACTION_CANCEL -> {
Log.d(TAG, "ACTION_CANCEL")
- if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value == false ||
- !chatActivity.isRecordAudioPermissionGranted()
+ gesture.cancel()
+ if (cancelPendingRecordStart() ||
+ chatActivity.chatViewModel.getVoiceRecordingInProgress.value != true ||
+ !isActiveRecordingPermissionGranted()
) {
return@setOnTouchListener true
}
showRecordAudioUi(false)
if (chatActivity.chatViewModel.getVoiceRecordingLocked.value != true) { // can also be null
- chatActivity.chatViewModel.stopAndDiscardAudioRecording()
+ chatActivity.stopAndDiscardRecording()
}
}
MotionEvent.ACTION_UP -> {
Log.d(TAG, "ACTION_UP")
- if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value == false ||
+ val release = gesture.up()
+ if (cancelPendingRecordStart() && release == RecordButtonGesture.Release.TOGGLE_MODE) {
+ toggleRecordInputMode()
+ return@setOnTouchListener true
+ }
+ if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value != true ||
chatActivity.chatViewModel.getVoiceRecordingLocked.value == true ||
- !chatActivity.isRecordAudioPermissionGranted()
+ !isActiveRecordingPermissionGranted()
) {
return@setOnTouchListener false
}
showRecordAudioUi(false)
- voiceRecordEndTime = System.currentTimeMillis()
- val voiceRecordDuration = voiceRecordEndTime - voiceRecordStartTime
- if (voiceRecordDuration < MINIMUM_VOICE_RECORD_DURATION) {
- Snackbar.make(
- binding.root,
- requireContext().getString(R.string.nc_voice_message_hold_to_record_info),
- Snackbar.LENGTH_SHORT
- ).show()
- chatActivity.chatViewModel.stopAndDiscardAudioRecording()
+ val isVideo = chatActivity.chatViewModel.activeRecordingMode == RecordInputMode.VIDEO
+ if (isVideo) {
+ chatActivity.stopAndSendRecording()
+ } else if (isVoiceRecordTooShort()) {
+ showRecordHint(R.string.nc_voice_message_hold_to_record_info)
+ chatActivity.stopAndDiscardRecording()
return@setOnTouchListener false
} else {
- chatActivity.chatViewModel.stopAndSendAudioRecording(
- roomToken = chatActivity.roomToken,
- replyToMessageId = chatActivity.getReplyToMessageId(),
- displayName = chatActivity.currentConversation!!.displayName
- )
+ chatActivity.stopAndSendRecording()
}
resetSlider()
}
MotionEvent.ACTION_MOVE -> {
- if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value == false ||
- !chatActivity.isRecordAudioPermissionGranted()
+ if (gesture.isPending) {
+ if (gesture.move(event.x)) cancelPendingRecordStart()
+ return@setOnTouchListener true
+ }
+ if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value != true ||
+ !isActiveRecordingPermissionGranted()
) {
return@setOnTouchListener false
}
if (event.x < VOICE_RECORD_CANCEL_SLIDER_X) {
- chatActivity.chatViewModel.stopAndDiscardAudioRecording()
+ chatActivity.stopAndDiscardRecording()
showRecordAudioUi(false)
resetSlider()
return@setOnTouchListener true
@@ -689,6 +704,96 @@ class MessageInputFragment : Fragment() {
}
}
+ private fun isVoiceRecordTooShort(): Boolean =
+ RecordButtonGesture.isTooShort(recordingStartedAt, SystemClock.elapsedRealtime(), MINIMUM_VOICE_RECORD_DURATION)
+
+ private fun beginRecording(isVideo: Boolean) {
+ val base = SystemClock.elapsedRealtime()
+ if (isVideo) {
+ if (!chatActivity.startVideoRecording()) return
+ } else {
+ chatActivity.chatViewModel.startAudioRecording(requireContext(), chatActivity.currentConversation!!)
+ }
+ // the minimum duration counts from the start of the recording, not from the touch which preceded the hold
+ recordingStartedAt = SystemClock.elapsedRealtime()
+ binding.fragmentMessageInputView.audioRecordDuration.base = base
+ messageInputViewModel.setRecordingTime(base)
+ binding.fragmentMessageInputView.audioRecordDuration.start()
+ showRecordAudioUi(true)
+ }
+
+ /**
+ * Neither the microphone nor the camera is opened before the hold threshold has passed, so that a tap on the
+ * record button can switch the mode without flashing the recording UI. After the threshold the permissions are
+ * checked: without them they are requested and nothing is recorded.
+ */
+ private fun scheduleRecordStart(gesture: RecordButtonGesture) {
+ cancelPendingRecordStart()
+ val runnable = Runnable {
+ pendingRecordStart = null
+ if (!gesture.holdElapsed()) return@Runnable
+ val isVideo = chatActivity.chatViewModel.recordInputMode == RecordInputMode.VIDEO
+ if (isVideo && !chatActivity.isVideoRecordPermissionGranted()) {
+ chatActivity.requestVideoRecordPermissions()
+ } else if (!isVideo && !chatActivity.isRecordAudioPermissionGranted()) {
+ chatActivity.requestRecordAudioPermissions()
+ } else if (!chatActivity.permissionUtil.isFilesPermissionGranted()) {
+ UploadAndShareFilesWorker.requestStoragePermission(chatActivity)
+ } else {
+ beginRecording(isVideo)
+ }
+ }
+ pendingRecordStart = runnable
+ recordButtonHandler.postDelayed(
+ runnable,
+ RecordButtonGesture.holdThresholdMs(ViewConfiguration.getLongPressTimeout())
+ )
+ }
+
+ private fun cancelPendingRecordStart(): Boolean {
+ val runnable = pendingRecordStart ?: return false
+ recordButtonHandler.removeCallbacks(runnable)
+ pendingRecordStart = null
+ return true
+ }
+
+ private fun isActiveRecordingPermissionGranted(): Boolean =
+ if (chatActivity.chatViewModel.activeRecordingMode == RecordInputMode.VIDEO) {
+ chatActivity.isVideoRecordPermissionGranted()
+ } else {
+ chatActivity.isRecordAudioPermissionGranted()
+ }
+
+ private fun toggleRecordInputMode() {
+ val newMode = chatActivity.chatViewModel.toggleRecordInputMode()
+ updateRecordButtonMode()
+ showRecordHint(
+ if (newMode == RecordInputMode.VIDEO) {
+ R.string.nc_video_message_hold_to_record_info_switch_to_voice
+ } else {
+ R.string.nc_voice_message_hold_to_record_info_switch_to_video
+ }
+ )
+ }
+
+ private fun showRecordHint(@StringRes messageRes: Int) {
+ val popup = recordHintPopup ?: RecordHintPopup(binding.fragmentMessageInputView.recordAudioButton)
+ .also { recordHintPopup = it }
+ popup.show(messageRes)
+ }
+
+ private fun updateRecordButtonMode() {
+ val isVideo = chatActivity.chatViewModel.recordInputMode == RecordInputMode.VIDEO
+ binding.fragmentMessageInputView.recordAudioButton.apply {
+ setImageResource(
+ if (isVideo) R.drawable.ic_baseline_videocam_24 else R.drawable.ic_baseline_mic_24
+ )
+ contentDescription = context.getString(
+ if (isVideo) R.string.nc_description_record_video else R.string.nc_description_record_voice
+ )
+ }
+ }
+
private fun initThreadHandling() {
binding.fragmentMessageInputView.submitThreadButton.setOnClickListener {
submitMessage(false)
@@ -820,6 +925,7 @@ class MessageInputFragment : Fragment() {
}
private fun showRecordAudioUi(show: Boolean) {
+ recordingUiShown = show
if (show) {
restoreKeyboardOnEmojiDismiss = false
binding.emojiPicker.isVisible = false
diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt
index 6832cfcf70e..7523a60f061 100644
--- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt
+++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt
@@ -12,6 +12,8 @@ import android.os.SystemClock
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import android.widget.LinearLayout
import android.widget.SeekBar
import android.widget.SeekBar.OnSeekBarChangeListener
import androidx.core.content.ContextCompat
@@ -38,6 +40,7 @@ class MessageInputVoiceRecordingFragment : Fragment() {
companion object {
val TAG: String = MessageInputVoiceRecordingFragment::class.java.simpleName
private const val SEEK_LIMIT = 98
+ private const val PROGRESS_MAX = 1000
@JvmStatic
fun newInstance() = MessageInputVoiceRecordingFragment()
@@ -75,6 +78,9 @@ class MessageInputVoiceRecordingFragment : Fragment() {
}
private fun initObservers() {
+ if (isVideoRecording()) {
+ return
+ }
messageInputViewModel.startMicInput(requireContext())
messageInputViewModel.micInputAudioObserver.observe(viewLifecycleOwner) {
binding.micInputCloud.setRotationSpeed(it.first, it.second)
@@ -114,21 +120,21 @@ class MessageInputVoiceRecordingFragment : Fragment() {
private fun initVoiceRecordingView() {
binding.deleteVoiceRecording.setOnClickListener {
- chatActivity.chatViewModel.stopAndDiscardAudioRecording()
+ chatActivity.stopAndDiscardRecording()
clear()
}
binding.sendVoiceRecording.setOnClickListener {
- chatActivity.chatViewModel.stopAndSendAudioRecording(
- roomToken = chatActivity.roomToken,
- replyToMessageId = chatActivity.getReplyToMessageId(),
- displayName = chatActivity.currentConversation!!.displayName
- )
+ chatActivity.stopAndSendRecording()
clear()
}
- binding.micInputCloud.setOnClickListener {
- togglePreviewVisibility()
+ if (isVideoRecording()) {
+ showCompactVideoRow()
+ } else {
+ binding.micInputCloud.setOnClickListener {
+ togglePreviewVisibility()
+ }
}
binding.playPauseBtn.setOnClickListener {
@@ -155,10 +161,54 @@ class MessageInputVoiceRecordingFragment : Fragment() {
})
}
+ /**
+ * Video is recorded with a one-line panel (delete, red dot in the progress ring, timer, send) so that the preview
+ * gets the height. The views are the ones of the voice panel, only arranged differently; the voice panel stays
+ * as it is. The fragment is recreated after a rotation and arranges the row again. The buttons get new layout
+ * params here, so their vertical margins from the layout do not apply in this row (48dp + 2 x 8dp padding).
+ */
+ private fun showCompactVideoRow() {
+ val row = binding.recordingControlsRow
+ val timer = binding.audioRecordDuration
+ val margin = resources.getDimensionPixelSize(R.dimen.standard_half_margin)
+ (timer.parent as ViewGroup).removeView(timer)
+ row.addView(timer, row.indexOfChild(binding.micInputCloud), LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f))
+ timer.setPaddingRelative(margin, 0, 0, 0)
+ row.setPadding(margin, margin, margin, margin)
+ row.weightSum = 0f
+ for (button in listOf(binding.deleteVoiceRecording, binding.sendVoiceRecording)) {
+ button.layoutParams = LinearLayout.LayoutParams(button.layoutParams.width, button.layoutParams.height)
+ }
+ binding.deleteVoiceRecording.contentDescription = getString(R.string.nc_video_message_delete_recording)
+ binding.sendVoiceRecording.contentDescription = getString(R.string.nc_video_message_send_recording)
+ binding.micInputCloud.visibility = View.GONE
+ binding.videoRecordingIndicator.visibility = View.VISIBLE
+ binding.videoRecordingProgress.max = PROGRESS_MAX
+ timer.setOnChronometerTickListener {
+ val elapsed = SystemClock.elapsedRealtime() - it.base
+ binding.videoRecordingProgress.progress =
+ (elapsed * PROGRESS_MAX / VideoMessageRecorder.MAX_DURATION_MS).toInt().coerceIn(0, PROGRESS_MAX)
+ }
+ }
+
+ private fun isVideoRecording() = chatActivity.chatViewModel.activeRecordingMode == RecordInputMode.VIDEO
+
private fun clear() {
- chatActivity.chatViewModel.setVoiceRecordingLocked(false)
+ val isVideo = isVideoRecording()
+ val recorderActive = chatActivity.chatViewModel.activeVideoMessageRecorder?.isActive == true
messageInputViewModel.stopMicInput()
- chatActivity.chatViewModel.stopAudioRecording()
+ // A video recording is finished by CameraX later: ChatActivity.onVideoRecordingFinished releases the lock
+ // and the in-progress state then, also when the recording failed or was cancelled.
+ if (!clearsRecordingStateOnFinalize(isVideo, recorderActive)) {
+ chatActivity.chatViewModel.setVoiceRecordingLocked(false)
+ if (isVideo) {
+ if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value == true) {
+ chatActivity.chatViewModel.onVideoRecordingEnded()
+ }
+ } else {
+ chatActivity.chatViewModel.stopAudioRecording()
+ }
+ }
messageInputViewModel.stopMediaPlayer()
binding.audioRecordDuration.stop()
binding.audioRecordDuration.clearAnimation()
diff --git a/app/src/main/java/com/nextcloud/talk/chat/RecordButtonGesture.kt b/app/src/main/java/com/nextcloud/talk/chat/RecordButtonGesture.kt
new file mode 100644
index 00000000000..6a0a101ac08
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/chat/RecordButtonGesture.kt
@@ -0,0 +1,76 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.chat
+
+/**
+ * Classifies one touch on the record button: a tap only switches the record mode, a hold starts the recording.
+ *
+ * The recording starts only once [holdElapsed] reports that the hold threshold has passed. A release or a cancel
+ * before that is never a recording. Has no Android dependencies, the caller owns the timer and the touch events.
+ */
+class RecordButtonGesture(private val cancelX: Float) {
+
+ enum class State { IDLE, PENDING, HELD }
+
+ enum class Release { TOGGLE_MODE, NONE }
+
+ var state: State = State.IDLE
+ private set
+
+ val isPending: Boolean get() = state == State.PENDING
+
+ fun down() {
+ state = State.PENDING
+ }
+
+ /** The hold threshold passed. Returns true if the recording may start now. */
+ fun holdElapsed(): Boolean {
+ if (state != State.PENDING) return false
+ state = State.HELD
+ return true
+ }
+
+ /** Finger moved to [x] (button coordinates). Returns true if the swipe cancelled a not yet started recording. */
+ fun move(x: Float): Boolean {
+ if (state != State.PENDING || x >= cancelX) return false
+ state = State.IDLE
+ return true
+ }
+
+ /** Finger released. A release before the hold threshold is a tap. */
+ fun up(): Release {
+ val wasPending = state == State.PENDING
+ state = State.IDLE
+ return if (wasPending) Release.TOGGLE_MODE else Release.NONE
+ }
+
+ /** The system cancelled the touch. Never switches the mode. */
+ fun cancel() {
+ state = State.IDLE
+ }
+
+ companion object {
+ const val MIN_HOLD_MS = 400L
+ const val MAX_HOLD_MS = 600L
+
+ /**
+ * Hold threshold from the system long-press timeout (400 ms by default, longer when the user raised it for
+ * accessibility). A touch shorter than the system long press is a click for every other control, so the same
+ * touch must not start a recording. The upper bound keeps the start delay bearable.
+ */
+ fun holdThresholdMs(systemLongPressMs: Int): Long =
+ systemLongPressMs.toLong().coerceIn(MIN_HOLD_MS, MAX_HOLD_MS)
+
+ /**
+ * Whether a recording which started at [startedAtMs] and ends at [endedAtMs] is shorter than [minMs]. Both
+ * times are taken when the recording actually starts and stops, not at the touch of the button, which
+ * precedes the start by the hold threshold.
+ */
+ fun isTooShort(startedAtMs: Long, endedAtMs: Long, minMs: Int): Boolean = endedAtMs - startedAtMs < minMs
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/chat/RecordHintPopup.kt b/app/src/main/java/com/nextcloud/talk/chat/RecordHintPopup.kt
new file mode 100644
index 00000000000..c2163dd9e8b
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/chat/RecordHintPopup.kt
@@ -0,0 +1,165 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.chat
+
+import android.content.res.ColorStateList
+import android.graphics.drawable.GradientDrawable
+import android.os.Handler
+import android.os.Looper
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.PopupWindow
+import android.widget.TextView
+import androidx.annotation.StringRes
+import androidx.core.view.doOnLayout
+import com.google.android.material.color.MaterialColors
+import com.nextcloud.talk.R
+
+/**
+ * Hint bubble above the record button, with an arrow pointing at the button. It does not take touches, so the
+ * button stays usable while the hint is visible.
+ */
+class RecordHintPopup(private val anchor: View) {
+
+ private val handler = Handler(Looper.getMainLooper())
+ private val dismissRunnable = Runnable { dismiss() }
+ private var popup: PopupWindow? = null
+ private var shown = 0
+ private var layoutListener: View.OnLayoutChangeListener? = null
+
+ /**
+ * Shows the hint above the anchor. The position is taken after the layout of the anchor, because the caller may
+ * have changed the layout just before (the recording UI is hidden and the input shown again, which moves the
+ * button), and it follows the anchor while the hint is shown.
+ */
+ fun show(@StringRes messageRes: Int) {
+ dismiss()
+ if (!anchor.isAttachedToWindow) return
+ val token = ++shown
+ anchor.doOnLayout {
+ if (token == shown && it.isAttachedToWindow) present(messageRes)
+ }
+ }
+
+ private fun present(@StringRes messageRes: Int) {
+ val context = anchor.context
+ val content = LayoutInflater.from(context).inflate(R.layout.view_record_hint, null)
+ val bubbleColor = MaterialColors.getColor(anchor, com.google.android.material.R.attr.colorSurfaceInverse)
+ val textColor = MaterialColors.getColor(anchor, com.google.android.material.R.attr.colorOnSurfaceInverse)
+ val text = content.findViewById(R.id.recordHintText)
+ text.setText(messageRes)
+ text.setTextColor(textColor)
+ (text.background.mutate() as GradientDrawable).setColor(bubbleColor)
+ val arrow = content.findViewById(R.id.recordHintArrow)
+ arrow.imageTintList = ColorStateList.valueOf(bubbleColor)
+
+ val metrics = context.resources.displayMetrics
+ val margin = (SCREEN_MARGIN_DP * metrics.density).toInt()
+ val gap = (GAP_DP * metrics.density).toInt()
+ val arrowWidth = (ARROW_WIDTH_DP * metrics.density).toInt()
+ content.measure(
+ View.MeasureSpec.makeMeasureSpec(anchor.rootView.width - 2 * margin, View.MeasureSpec.AT_MOST),
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
+ )
+ val hintWidth = content.measuredWidth
+
+ // showAtLocation positions relative to the window of the anchor, so the anchor is measured in its window
+ fun placement(): Placement {
+ val location = IntArray(2)
+ anchor.getLocationInWindow(location)
+ return hintPlacement(
+ anchorLeft = location[0],
+ anchorTop = location[1],
+ anchorWidth = anchor.width,
+ windowWidth = anchor.rootView.width,
+ windowHeight = anchor.rootView.height,
+ hintWidth = hintWidth,
+ margin = margin,
+ gap = gap,
+ arrowWidth = arrowWidth
+ )
+ }
+
+ fun applyArrow(placement: Placement) {
+ val arrowParams = arrow.layoutParams as LinearLayout.LayoutParams
+ arrowParams.leftMargin = placement.arrowLeftMargin
+ arrow.layoutParams = arrowParams
+ }
+
+ val first = placement()
+ applyArrow(first)
+ val wrap = ViewGroup.LayoutParams.WRAP_CONTENT
+ val window = PopupWindow(content, wrap, wrap, false)
+ .apply {
+ isTouchable = false
+ isFocusable = false
+ isOutsideTouchable = false
+ // anchored by its bottom edge, so that the hint grows upwards, away from the button
+ showAtLocation(anchor, Gravity.BOTTOM or Gravity.LEFT, first.x, first.bottomOffset)
+ }
+ popup = window
+
+ val listener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+ val moved = placement()
+ applyArrow(moved)
+ window.update(moved.x, moved.bottomOffset, -1, -1)
+ }
+ layoutListener = listener
+ anchor.addOnLayoutChangeListener(listener)
+ handler.postDelayed(dismissRunnable, SHOW_DURATION_MS)
+ }
+
+ fun dismiss() {
+ shown++
+ handler.removeCallbacks(dismissRunnable)
+ layoutListener?.let { anchor.removeOnLayoutChangeListener(it) }
+ layoutListener = null
+ popup?.dismiss()
+ popup = null
+ }
+
+ /**
+ * Where the hint goes, in the coordinates of the window of the anchor: [x] from the left edge of the window,
+ * [bottomOffset] from the bottom edge of the window up to the bottom edge of the hint.
+ */
+ data class Placement(val x: Int, val bottomOffset: Int, val arrowLeftMargin: Int)
+
+ companion object {
+ const val SHOW_DURATION_MS = 1500L
+ private const val SCREEN_MARGIN_DP = 8
+ private const val GAP_DP = 4
+ private const val ARROW_WIDTH_DP = 16
+
+ /**
+ * The hint sits above the anchor with [gap] between the bottom edge of the hint and the top edge of the
+ * anchor, centred on it and kept [margin] away from the side edges of the window; the arrow points at the
+ * centre of the anchor. The height of the hint does not enter: it is anchored by its bottom edge.
+ */
+ @Suppress("LongParameterList")
+ fun hintPlacement(
+ anchorLeft: Int,
+ anchorTop: Int,
+ anchorWidth: Int,
+ windowWidth: Int,
+ windowHeight: Int,
+ hintWidth: Int,
+ margin: Int,
+ gap: Int,
+ arrowWidth: Int
+ ): Placement {
+ val anchorCenterX = anchorLeft + anchorWidth / 2
+ val x = (anchorCenterX - hintWidth / 2).coerceIn(margin, maxOf(margin, windowWidth - margin - hintWidth))
+ val arrow = (anchorCenterX - x - arrowWidth / 2).coerceIn(0, maxOf(0, hintWidth - arrowWidth))
+ return Placement(x, windowHeight - anchorTop + gap, arrow)
+ }
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/chat/RecordInputMode.kt b/app/src/main/java/com/nextcloud/talk/chat/RecordInputMode.kt
new file mode 100644
index 00000000000..0db7cdb4e24
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/chat/RecordInputMode.kt
@@ -0,0 +1,22 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.chat
+
+/**
+ * What the record button of the message input records while it is held.
+ */
+enum class RecordInputMode {
+ VOICE,
+ VIDEO;
+
+ fun toggled(): RecordInputMode = if (this == VOICE) VIDEO else VOICE
+
+ companion object {
+ fun fromVideoFlag(isVideo: Boolean): RecordInputMode = if (isVideo) VIDEO else VOICE
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/chat/RecordingStopCoordinator.kt b/app/src/main/java/com/nextcloud/talk/chat/RecordingStopCoordinator.kt
new file mode 100644
index 00000000000..ac584bf3369
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/chat/RecordingStopCoordinator.kt
@@ -0,0 +1,104 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.chat
+
+import android.os.Handler
+import android.os.Looper
+
+/**
+ * Runs a delayed [Runnable] on the main thread; replaced in tests.
+ */
+internal interface DelayScheduler {
+ fun postDelayed(runnable: Runnable, delayMs: Long)
+ fun cancel(runnable: Runnable)
+}
+
+internal class MainThreadScheduler : DelayScheduler {
+ private val handler by lazy { Handler(Looper.getMainLooper()) }
+
+ override fun postDelayed(runnable: Runnable, delayMs: Long) {
+ handler.postDelayed(runnable, delayMs)
+ }
+
+ override fun cancel(runnable: Runnable) {
+ handler.removeCallbacks(runnable)
+ }
+}
+
+/**
+ * How a running recording is asked to end.
+ */
+internal enum class StopAction { SEND, DISCARD }
+
+/**
+ * Decides when a running recording may be stopped.
+ *
+ * After a camera switch the CameraX recorder sets up a new video surface for [SWITCH_SETTLE_MS]. If it is stopped
+ * meanwhile, the setup ends in an AssertionError of CameraX (onConfigured() in a STOPPING state). So no stop is
+ * issued inside that window: it is held back and issued when the window ends.
+ *
+ * The first request ends the recording and is final: a send after a cancel must not send the cancelled video, nor a
+ * cancel after a send discard the video which is being sent.
+ */
+internal class RecordingStopCoordinator(private val scheduler: DelayScheduler, private val stopRecording: () -> Unit) {
+ var action: StopAction? = null
+ private set
+
+ var settling = false
+ private set
+
+ private var stopPending = false
+ private val windowEnded = Runnable {
+ settling = false
+ if (stopPending) {
+ stopPending = false
+ stopRecording()
+ }
+ }
+
+ /**
+ * Opens the window after a camera switch. Refused while another window is open or once a stop was requested.
+ */
+ fun beginSwitch(): Boolean {
+ if (action != null || settling) return false
+ settling = true
+ scheduler.postDelayed(windowEnded, SWITCH_SETTLE_MS)
+ return true
+ }
+
+ /**
+ * @return false if the request is ignored because an earlier one decided already
+ */
+ fun requestStop(requested: StopAction): Boolean {
+ if (action != null) return false
+ action = requested
+ if (settling) {
+ stopPending = true
+ } else {
+ stopRecording()
+ }
+ return true
+ }
+
+ /**
+ * Forgets everything, the recording is over.
+ */
+ fun reset() {
+ scheduler.cancel(windowEnded)
+ settling = false
+ stopPending = false
+ action = null
+ }
+
+ companion object {
+ /**
+ * How long after a camera switch the CameraX recorder may still be setting up its new video surface.
+ */
+ const val SWITCH_SETTLE_MS = 1_500L
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt
new file mode 100644
index 00000000000..f7c2f5ef548
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt
@@ -0,0 +1,513 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.chat
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.media.AudioManager
+import android.util.Log
+import android.view.OrientationEventListener
+import android.view.Surface
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.Preview
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.video.ExperimentalPersistentRecording
+import androidx.camera.video.FallbackStrategy
+import androidx.camera.video.FileOutputOptions
+import androidx.camera.video.Quality
+import androidx.camera.video.QualitySelector
+import androidx.camera.video.Recorder
+import androidx.camera.video.Recording
+import androidx.camera.video.VideoCapture
+import androidx.camera.video.VideoRecordEvent
+import androidx.camera.view.PreviewView
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import com.nextcloud.talk.camera.rotationForDeviceOrientation
+import com.nextcloud.talk.chat.data.io.MusicSilenceGate
+import java.io.File
+
+/**
+ * Records a plain mp4 video with CameraX and shows a live preview while doing so.
+ *
+ * The recorder does not belong to an activity: it lives in the view model of the chat and survives the recreation of
+ * the activity (rotation). The camera is bound once per recording to a lifecycle of the recorder itself
+ * ([RecordingLifecycleOwner]), not to the one of the activity: the activity stops on every rotation and would stop
+ * the camera, and binding the use cases again to the next activity makes the CameraX recorder configure itself in a
+ * state where that is not allowed. The activity hands only its preview view and its callback in with [attach] and
+ * the recorder lets go of both in [detach], which also happens when the activity lifecycle is destroyed, so no
+ * activity or view is leaked. While nothing is attached the recording goes on; a result that arrives then is kept
+ * and delivered by the next [attach].
+ *
+ * All methods must be called on the main thread. The callback is called exactly once per [start] on the main thread:
+ * with [Outcome.SEND] and the file to send, with [Outcome.INTERRUPTED] and the file that was recorded up to the
+ * interruption, or with another outcome and null (the file is deleted then).
+ * The recording survives [switchCamera]; the preview and the camera are released as soon as it ends.
+ */
+class VideoMessageRecorder(context: Context) {
+
+ /**
+ * [INTERRUPTED]: the recording ended without being asked to, but what was recorded is usable. It is not sent
+ * on its own but shown as a preview.
+ */
+ enum class Outcome { SEND, CANCELLED, TOO_SHORT, FAILED, INTERRUPTED }
+
+ private enum class State { IDLE, STARTING, RECORDING }
+
+ private class Finished(val outcome: Outcome, val file: File?)
+
+ private val context: Context = context.applicationContext
+ private var activityLifecycle: Lifecycle? = null
+ private var cameraOwner: RecordingLifecycleOwner? = null
+ private var previewView: PreviewView? = null
+ private var onFinished: ((Outcome, File?) -> Unit)? = null
+ private var onFrameAspectChanged: (() -> Unit)? = null
+ private var pendingResult: Finished? = null
+ private var released = false
+
+ private val ownerObserver = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_DESTROY) detach()
+ }
+
+ private var state = State.IDLE
+ private var cameraProvider: ProcessCameraProvider? = null
+ private var preview: Preview? = null
+ private var videoCapture: VideoCapture? = null
+ private var recording: Recording? = null
+ private var outputFile: File? = null
+ private var lensFacing = CameraSelector.LENS_FACING_FRONT
+ private var session = 0
+ private val musicSilenceGate = MusicSilenceGate(
+ (context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)::isMusicActive
+ )
+
+ /** The [Surface] rotation the video is recorded in. */
+ var videoRotation = Surface.ROTATION_0
+ private set
+ private val stopCoordinator = RecordingStopCoordinator(MainThreadScheduler()) { recording?.stop() }
+
+ val isActive: Boolean
+ get() = state != State.IDLE
+
+ /**
+ * Width divided by height of the frame that is recorded, as the viewer sees it: portrait for a recording of a
+ * phone held upright, landscape for one held on its side. It comes from the resolution the camera was bound
+ * with; until that is known, from the rotation the video is recorded in.
+ */
+ val frameAspect: Float
+ get() {
+ val info = videoCapture?.resolutionInfo
+ val crop = info?.cropRect
+ val resolution = info?.resolution
+ val aspect = when {
+ info == null -> null
+ crop != null && !crop.isEmpty -> videoFrameAspect(crop.width(), crop.height(), info.rotationDegrees)
+ resolution != null -> videoFrameAspect(resolution.width, resolution.height, info.rotationDegrees)
+ else -> null
+ }
+ return aspect ?: fallbackVideoFrameAspect(videoRotation)
+ }
+
+ /**
+ * A recording ended while no activity was attached, its result waits for the next [attach].
+ */
+ val hasPendingResult: Boolean
+ get() = pendingResult != null
+
+ /**
+ * Shows the preview in [view] and reports the end of the recording to [callback]. A running recording keeps its
+ * camera, only the preview picture moves to the new view; a result which is waiting is delivered at once. The
+ * previously attached view is dropped. [owner] is only watched: its destruction detaches the recorder.
+ * [onFrameAspect] is called when the aspect ratio of the recorded frame became known, see [frameAspect].
+ */
+ fun attach(
+ owner: LifecycleOwner,
+ view: PreviewView,
+ callback: (Outcome, File?) -> Unit,
+ onFrameAspect: () -> Unit = {}
+ ) {
+ if (released) return
+ detach()
+ activityLifecycle = owner.lifecycle
+ previewView = view
+ onFinished = callback
+ onFrameAspectChanged = onFrameAspect
+ owner.lifecycle.addObserver(ownerObserver)
+
+ preview?.let { previewUseCase ->
+ // only the preview is reconfigured by this, the video use case and the recording are not touched
+ previewUseCase.targetRotation = displayRotation(view, videoRotation)
+ previewUseCase.surfaceProvider = view.surfaceProvider
+ }
+ pendingResult?.let {
+ pendingResult = null
+ callback(it.outcome, it.file)
+ }
+ }
+
+ /**
+ * Lets go of the activity. A running recording continues with its camera and without a picture on the screen
+ * until the next [attach].
+ */
+ fun detach() {
+ activityLifecycle?.removeObserver(ownerObserver)
+ preview?.surfaceProvider = null
+ activityLifecycle = null
+ previewView = null
+ onFinished = null
+ onFrameAspectChanged = null
+ }
+
+ /**
+ * Ends the recorder for good, when the chat is left: a running recording is discarded, nothing is delivered.
+ */
+ fun release() {
+ released = true
+ detach()
+ pendingResult?.file?.delete()
+ pendingResult = null
+ cancel()
+ // Inside the window after a camera switch the stop is held back and the camera is not unbound: that would
+ // let CameraX set up the new surface while stopping. The finalization (or the end of the window) closes it.
+ if (!stopCoordinator.settling) releaseCamera()
+ }
+
+ /**
+ * @param targetRotation the [Surface] rotation the video is recorded in, see [videoTargetRotation]
+ */
+ fun start(file: File, targetRotation: Int) {
+ check(state == State.IDLE) { "Video recording already active" }
+ state = State.STARTING
+ session++
+ val startedSession = session
+ stopCoordinator.reset()
+ outputFile = file
+ videoRotation = targetRotation
+ lensFacing = CameraSelector.LENS_FACING_FRONT
+
+ whenCameraProviderReady(context) { provider ->
+ if (startedSession != session) return@whenCameraProviderReady
+ if (provider != null) {
+ onCameraProviderReady(provider)
+ } else {
+ finish(Outcome.FAILED, null)
+ }
+ }
+ }
+
+ fun stopAndSend() {
+ stop(StopAction.SEND)
+ }
+
+ /**
+ * Discards the recording unless it was already stopped to be sent: that one is finished and sent regardless.
+ */
+ fun cancel() {
+ stop(StopAction.DISCARD)
+ }
+
+ /**
+ * Switches between the front and the back camera. A running recording continues, the picture is frozen
+ * until the new camera delivers frames. The use cases are bound again, which makes the CameraX recorder set up a
+ * new video surface, and that must not meet a stop: see [RecordingStopCoordinator].
+ */
+ fun switchCamera() {
+ val provider = cameraProvider
+ val newLens = oppositeLens(lensFacing)
+ if (provider == null || state != State.RECORDING || !provider.hasCamera(cameraSelectorFor(newLens))) return
+ if (!stopCoordinator.beginSwitch()) return
+
+ val previousLens = lensFacing
+ lensFacing = newLens
+ provider.unbind(preview, videoCapture)
+ if (!bindUseCases(provider)) {
+ lensFacing = previousLens
+ bindUseCases(provider)
+ }
+ // the new camera may deliver another resolution
+ onFrameAspectChanged?.invoke()
+ }
+
+ private fun stop(action: StopAction) {
+ when (state) {
+ State.IDLE -> Unit
+
+ State.STARTING -> {
+ session++
+ finish(if (action == StopAction.SEND) Outcome.TOO_SHORT else Outcome.CANCELLED, null)
+ }
+
+ State.RECORDING -> stopCoordinator.requestStop(action)
+ }
+ }
+
+ private fun onCameraProviderReady(provider: ProcessCameraProvider) {
+ if (state != State.STARTING) {
+ return
+ }
+ cameraProvider = provider
+ val owner = RecordingLifecycleOwner().also { cameraOwner = it }
+ owner.start()
+
+ val recorder = Recorder.Builder()
+ .setQualitySelector(
+ QualitySelector.from(Quality.HD, FallbackStrategy.lowerQualityOrHigherThan(Quality.HD))
+ )
+ .setTargetVideoEncodingBitRate(TARGET_VIDEO_BIT_RATE)
+ .build()
+ videoCapture = VideoCapture.Builder(recorder).setTargetRotation(videoRotation).build()
+ preview = Preview.Builder()
+ .setTargetRotation(previewView?.let { displayRotation(it, videoRotation) } ?: videoRotation)
+ .build()
+ .also { it.surfaceProvider = previewView?.surfaceProvider }
+
+ if (!bindUseCases(provider)) {
+ finish(Outcome.FAILED, null)
+ return
+ }
+ startRecording()
+ }
+
+ private fun bindUseCases(provider: ProcessCameraProvider): Boolean {
+ lensFacing = resolveLens(provider, lensFacing)
+ val owner = cameraOwner
+ val previewUseCase = preview
+ val videoUseCase = videoCapture
+ if (owner == null || previewUseCase == null || videoUseCase == null) return false
+ return provider.bindSafely(owner, lensFacing, previewUseCase, videoUseCase) != null
+ }
+
+ @SuppressLint("MissingPermission")
+ @androidx.annotation.OptIn(ExperimentalPersistentRecording::class)
+ private fun startRecording() {
+ val file = outputFile ?: return
+ val videoUseCase = videoCapture ?: return
+ val options = FileOutputOptions.Builder(file)
+ .setDurationLimitMillis(MAX_DURATION_MS)
+ .build()
+ // The microphone is opened when the music of another app is silent, which has lost the audio focus a moment
+ // ago. Stopping meanwhile ends the start: it takes the session away, see stop().
+ val startedSession = session
+ musicSilenceGate.whenSilent {
+ if (startedSession != session || state != State.STARTING) return@whenSilent
+ recording = videoUseCase.output
+ .prepareRecording(context, options)
+ .withAudioEnabled()
+ .asPersistentRecording()
+ .start(ContextCompat.getMainExecutor(context), ::onRecordEvent)
+ state = State.RECORDING
+ onFrameAspectChanged?.invoke()
+ }
+ }
+
+ private fun onRecordEvent(event: VideoRecordEvent) {
+ if (event is VideoRecordEvent.Finalize) {
+ val duration = event.recordingStats.recordedDurationNanos
+ var outcome = resolveOutcome(
+ discardRequested = stopCoordinator.action == StopAction.DISCARD,
+ hasError = event.hasError(),
+ error = event.error,
+ recordedDurationNanos = duration
+ )
+ val cutOff = outcome == Outcome.FAILED &&
+ isSalvageable(event.error, duration, outputFile?.length() ?: 0L)
+ if (cutOff) {
+ outcome = Outcome.INTERRUPTED
+ }
+ if (event.hasError()) {
+ Log.w(TAG, "recording finalized with error ${event.error}, outcome: $outcome")
+ }
+ finish(outcome, if (outcome == Outcome.SEND || outcome == Outcome.INTERRUPTED) outputFile else null)
+ }
+ }
+
+ private fun finish(outcome: Outcome, fileToSend: File?) {
+ val file = outputFile
+ musicSilenceGate.cancel()
+ releaseCamera()
+ recording = null
+ outputFile = null
+ state = State.IDLE
+ if (fileToSend == null || released) {
+ file?.delete()
+ }
+ if (released) return
+
+ val callback = onFinished
+ if (callback != null) {
+ callback(outcome, fileToSend)
+ } else {
+ pendingResult = Finished(outcome, fileToSend)
+ }
+ }
+
+ /**
+ * Ends the lifecycle the camera is bound to, which closes the camera, and lets go of the use cases.
+ */
+ private fun releaseCamera() {
+ stopCoordinator.reset()
+ preview?.surfaceProvider = null
+ cameraProvider?.unbind(preview, videoCapture)
+ cameraOwner?.destroy()
+ cameraOwner = null
+ preview = null
+ videoCapture = null
+ }
+
+ companion object {
+ private val TAG = VideoMessageRecorder::class.java.simpleName
+ const val MAX_DURATION_MS = 120_000L
+ const val TARGET_VIDEO_BIT_RATE = 2_500_000
+
+ const val MIN_DURATION_NANOS = 1_000_000_000L
+
+ /**
+ * A recording that ended because of the duration or size limit is complete and valid; any other error leaves
+ * a file that must not be sent.
+ */
+ fun isUsableFinalize(hasError: Boolean, error: Int): Boolean =
+ !hasError ||
+ error == VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED ||
+ error == VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED
+
+ /**
+ * Decides what happens with a finalized recording. "No valid data" and a recording shorter than
+ * [MIN_DURATION_NANOS] mean the button was released too early, every other error is a failure.
+ */
+ fun resolveOutcome(
+ discardRequested: Boolean,
+ hasError: Boolean,
+ error: Int,
+ recordedDurationNanos: Long
+ ): Outcome =
+ when {
+ discardRequested -> Outcome.CANCELLED
+ hasError && error == VideoRecordEvent.Finalize.ERROR_NO_VALID_DATA -> Outcome.TOO_SHORT
+ !isUsableFinalize(hasError, error) -> Outcome.FAILED
+ recordedDurationNanos < MIN_DURATION_NANOS -> Outcome.TOO_SHORT
+ else -> Outcome.SEND
+ }
+
+ /**
+ * A recording which the camera cut off (source inactive) leaves a valid file with everything recorded
+ * before that. It is worth keeping when it is long enough and not empty.
+ */
+ fun isSalvageable(error: Int, recordedDurationNanos: Long, fileLength: Long): Boolean =
+ error == VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE &&
+ recordedDurationNanos >= MIN_DURATION_NANOS &&
+ fileLength > 0
+
+ /**
+ * The rotation to record the video in: the one of the sensor when it is known (the video then matches how
+ * the phone is held, whatever the activity shows), otherwise the one of the display.
+ *
+ * @param sensorDegrees what [OrientationEventListener] reported, or ORIENTATION_UNKNOWN
+ */
+ fun videoTargetRotation(sensorDegrees: Int, displayRotation: Int): Int =
+ rotationForDeviceOrientation(sensorDegrees) ?: displayRotation
+ }
+}
+
+/**
+ * The rotation of the display the view is shown on. It is not read from the view itself: the activity attaches its
+ * preview in onCreate, before the view is in a window, and View.getDisplay() is null then.
+ */
+private fun displayRotation(view: PreviewView, fallback: Int): Int =
+ ContextCompat.getDisplayOrDefault(view.context)?.rotation ?: fallback
+
+/**
+ * The lifecycle the camera of a recording is bound to. It is not the one of an activity, so that rotating the screen
+ * (stop and destroy of the activity) neither stops the camera nor unbinds its use cases. It is started when the
+ * recording starts and destroyed when it ends, which closes the camera.
+ */
+internal class RecordingLifecycleOwner : LifecycleOwner {
+ private val registry = LifecycleRegistry.createUnsafe(this)
+
+ override val lifecycle: Lifecycle
+ get() = registry
+
+ fun start() {
+ if (registry.currentState != Lifecycle.State.DESTROYED) registry.currentState = Lifecycle.State.STARTED
+ }
+
+ fun destroy() {
+ // a lifecycle cannot go from INITIALIZED to DESTROYED directly
+ if (registry.currentState == Lifecycle.State.INITIALIZED) registry.currentState = Lifecycle.State.CREATED
+ registry.currentState = Lifecycle.State.DESTROYED
+ }
+}
+
+/**
+ * What a new activity does about a recording it finds in the view model of the chat.
+ */
+enum class RecordingResume {
+ /** Nothing was recording. */
+ NONE,
+
+ /** Attach to the running recording, the lock is already set. */
+ ATTACH,
+
+ /** Attach and lock: the finger which held the record button is gone with the old activity. */
+ ATTACH_AND_LOCK,
+
+ /** The recording ended while there was no activity: deliver its result. */
+ DELIVER_RESULT,
+
+ /** Same, but the chat of the new activity is not loaded yet: attach later, once it is. */
+ DEFER_RESULT
+}
+
+fun resolveRecordingResume(
+ recorderActive: Boolean,
+ hasPendingResult: Boolean,
+ recordingInProgress: Boolean,
+ recordingLocked: Boolean,
+ chatReady: Boolean = true
+): RecordingResume =
+ when {
+ hasPendingResult && !chatReady -> RecordingResume.DEFER_RESULT
+ hasPendingResult -> RecordingResume.DELIVER_RESULT
+ recorderActive && recordingLocked -> RecordingResume.ATTACH
+ recorderActive -> RecordingResume.ATTACH_AND_LOCK
+ recordingInProgress && !recordingLocked -> RecordingResume.ATTACH_AND_LOCK
+ else -> RecordingResume.NONE
+ }
+
+/**
+ * Whether "send" or "delete" of a locked recording leaves the recording state to [VideoMessageRecorder]: a video
+ * recording is not over before CameraX has finalized the file, and its callback releases the lock and the in-progress
+ * state. Cleared earlier, the UI would accept a new start which the still busy recorder drops. An audio recording,
+ * or a video recorder which is already idle (no finalization to wait for), is cleared at once.
+ */
+fun clearsRecordingStateOnFinalize(isVideo: Boolean, recorderActive: Boolean): Boolean = isVideo && recorderActive
+
+/**
+ * Follows the orientation of the device with the sensor, which keeps working while the activity is not rotated.
+ */
+class DeviceOrientationTracker(context: Context) {
+ var degrees: Int = OrientationEventListener.ORIENTATION_UNKNOWN
+ private set
+
+ private val listener = object : OrientationEventListener(context) {
+ override fun onOrientationChanged(orientation: Int) {
+ degrees = orientation
+ }
+ }
+
+ fun enable() {
+ if (listener.canDetectOrientation()) listener.enable()
+ }
+
+ fun disable() {
+ listener.disable()
+ degrees = OrientationEventListener.ORIENTATION_UNKNOWN
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/chat/VideoPreviewPlacement.kt b/app/src/main/java/com/nextcloud/talk/chat/VideoPreviewPlacement.kt
new file mode 100644
index 00000000000..6461b9e67d6
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/chat/VideoPreviewPlacement.kt
@@ -0,0 +1,118 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Krainov Gleb
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.chat
+
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.roundToInt
+
+/**
+ * Size of the video recording preview inside the area of the chat, in the unit of the area (pixels). The preview is
+ * centered by its parent (gravity), so no offsets are given: they would have to be resolved for right-to-left.
+ */
+data class VideoPreviewPlacement(val width: Int, val height: Int)
+
+/**
+ * Bounds of the preview inside the area.
+ *
+ * @param margin the gap kept free at every edge of the area (shrinks on a very small area)
+ * @param maxWidthFraction the part of the area width the preview may take at most
+ * @param maxSide the longest side the preview may have, so that it does not grow without limit on a tablet
+ * @param shortAreaHeight an area lower than this keeps [shortMargin] instead of [margin]: on a short landscape area two
+ * full margins would take a noticeable part of the height. Zero turns this off.
+ */
+data class VideoPreviewLimits(
+ val margin: Int,
+ val maxWidthFraction: Float,
+ val maxSide: Int,
+ val shortAreaHeight: Int = 0,
+ val shortMargin: Int = margin
+)
+
+/**
+ * Fits a frame of the given aspect ratio into the area inside the [limits]. A portrait frame is limited
+ * by the width of the area and by its height, a landscape frame is fitted by the height when the area is short:
+ * the frame is never distorted, only scaled down.
+ *
+ * @param aspect width divided by height of the recorded frame as the viewer sees it (9/16 portrait, 16/9 landscape)
+ */
+fun videoPreviewPlacement(
+ areaWidth: Int,
+ areaHeight: Int,
+ aspect: Float,
+ limits: VideoPreviewLimits
+): VideoPreviewPlacement {
+ val safeAspect = if (aspect.isFinite() && aspect > 0f) aspect else DEFAULT_FRAME_ASPECT
+ val width = max(areaWidth, 0)
+ val height = max(areaHeight, 0)
+ // a margin may not eat the area: at most an eighth of the shorter side on each edge
+ val wanted = if (height < limits.shortAreaHeight) limits.shortMargin else limits.margin
+ val margin = max(min(wanted, min(width, height) / MARGIN_DIVISOR), 0)
+ val boxWidth = min((width - 2 * margin).toFloat(), width * limits.maxWidthFraction)
+ val boxHeight = (height - 2 * margin).toFloat()
+
+ var w = max(boxWidth, 0f)
+ var h = w / safeAspect
+ if (h > boxHeight) {
+ h = max(boxHeight, 0f)
+ w = h * safeAspect
+ }
+ val longest = max(w, h)
+ if (longest > limits.maxSide && longest > 0f) {
+ val scale = limits.maxSide / longest
+ w *= scale
+ h *= scale
+ }
+
+ return VideoPreviewPlacement(width = w.roundToInt(), height = h.roundToInt())
+}
+
+/**
+ * Aspect ratio (width / height) of the frame the viewer sees, from the size of the camera surface and the rotation
+ * needed to show it upright. A quarter turn swaps the sides. Null when the size is not known (yet).
+ */
+fun videoFrameAspect(surfaceWidth: Int, surfaceHeight: Int, rotationDegrees: Int): Float? =
+ if (surfaceWidth <= 0 || surfaceHeight <= 0) {
+ null
+ } else if (rotationDegrees % HALF_TURN == 0) {
+ surfaceWidth.toFloat() / surfaceHeight
+ } else {
+ surfaceHeight.toFloat() / surfaceWidth
+ }
+
+/**
+ * The aspect ratio of the recorded frame in the coordinates of the screen. The sensor image always runs along the
+ * same side of the phone; the rotation the video is recorded in only sets the flag in the file. The preview is
+ * drawn upright for the display, so when the recording rotation and the display rotation differ by a quarter turn
+ * (auto-rotate off with the phone held sideways, or the phone turned during the recording) the frame on the screen
+ * is the other way round than the recorded one.
+ *
+ * @param frameAspect width / height of the frame as the recording shows it
+ * @param videoRotation [android.view.Surface] rotation the video is recorded in
+ * @param displayRotation [android.view.Surface] rotation of the display
+ */
+fun screenFrameAspect(frameAspect: Float, videoRotation: Int, displayRotation: Int): Float =
+ if ((videoRotation - displayRotation + QUARTER_TURNS) % 2 == 1) 1f / frameAspect else frameAspect
+
+/**
+ * Aspect ratio of a recording before the camera has told its resolution: the recording is 16:9, upright in the
+ * rotation the video is recorded in ([android.view.Surface] ROTATION_0 and ROTATION_180 are portrait).
+ */
+fun fallbackVideoFrameAspect(surfaceRotation: Int): Float =
+ if (surfaceRotation == android.view.Surface.ROTATION_0 || surfaceRotation == android.view.Surface.ROTATION_180) {
+ PORTRAIT_ASPECT
+ } else {
+ LANDSCAPE_ASPECT
+ }
+
+private const val QUARTER_TURNS = 4
+private const val MARGIN_DIVISOR = 8
+private const val HALF_TURN = 180
+private const val DEFAULT_FRAME_ASPECT = 9f / 16f
+private const val PORTRAIT_ASPECT = 9f / 16f
+private const val LANDSCAPE_ASPECT = 16f / 9f
diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/io/MusicSilenceGate.kt b/app/src/main/java/com/nextcloud/talk/chat/data/io/MusicSilenceGate.kt
new file mode 100644
index 00000000000..b9db99ab778
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/chat/data/io/MusicSilenceGate.kt
@@ -0,0 +1,56 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package com.nextcloud.talk.chat.data.io
+
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+
+private const val MUSIC_SILENCE_POLL_MS = 50L
+private const val MUSIC_SILENCE_MAX_WAIT_MS = 1000L
+
+/**
+ * Whether the start of a capture still has to wait for the music of another app to stop. The wait is limited: a
+ * player that does not react to the lost audio focus must not keep the recording from starting.
+ */
+fun shouldWaitForMusicToStop(isMusicActive: Boolean, waitedMs: Long, maxWaitMs: Long = MUSIC_SILENCE_MAX_WAIT_MS) =
+ isMusicActive && waitedMs < maxWaitMs
+
+/**
+ * Delays an action until no music is playing any more. A player pauses some time after it lost the audio focus, and
+ * a microphone that is opened in between records that music from the speaker. Without music the action runs at once.
+ */
+class MusicSilenceGate(
+ private val isMusicActive: () -> Boolean,
+ private val handler: Handler = Handler(Looper.getMainLooper()),
+ private val elapsedRealtime: () -> Long = SystemClock::elapsedRealtime
+) {
+ private var pending: Runnable? = null
+
+ fun whenSilent(onSilent: () -> Unit) {
+ cancel()
+ val startedAt = elapsedRealtime()
+ object : Runnable {
+ override fun run() {
+ pending = null
+ if (shouldWaitForMusicToStop(isMusicActive(), elapsedRealtime() - startedAt)) {
+ pending = this
+ handler.postDelayed(this, MUSIC_SILENCE_POLL_MS)
+ } else {
+ onSilent()
+ }
+ }
+ }.run()
+ }
+
+ /** Drops the action that still waits. */
+ fun cancel() {
+ pending?.let { handler.removeCallbacks(it) }
+ pending = null
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/MessageActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/MessageActionsBottomSheet.kt
index 5866f7e70da..67a54c1e2d4 100644
--- a/app/src/main/java/com/nextcloud/talk/chat/ui/MessageActionsBottomSheet.kt
+++ b/app/src/main/java/com/nextcloud/talk/chat/ui/MessageActionsBottomSheet.kt
@@ -125,6 +125,10 @@ data class MessageActionsState(
val showReplyPrivately: Boolean,
val showOpenThread: Boolean,
val showForward: Boolean,
+ /** Sending a file message on to another conversation; offered by the media viewer, not by the sheet. */
+ val showForwardFile: Boolean,
+ /** Whether new content (e.g. a drawn copy of a photo) may be posted to this conversation. */
+ val canSendToConversation: Boolean,
val showEdit: Boolean,
val showCopy: Boolean,
val showCopyMessageLink: Boolean,
@@ -244,6 +248,14 @@ internal fun buildMessageActionsState(
!(message.isDeletedCommentMessage || message.isDeleted) &&
isOnline &&
!isClassifiedRoom,
+ // a file of a federated conversation does not lie in the user's own storage, so it can't be shared on
+ showForwardFile = messageHasFileAttachment &&
+ !message.isDeleted &&
+ isOnline &&
+ !isClassifiedRoom &&
+ conversation?.remoteServer.isNullOrEmpty() &&
+ !message.fileParameters.path.isNullOrBlank(),
+ canSendToConversation = hasChatPermission && !isConversationReadOnly,
showEdit = isMessageEditable,
showCopy = !message.isDeleted,
showCopyMessageLink = !message.isDeleted &&
@@ -777,7 +789,7 @@ private fun EditedInfo(editedBy: String, editedAt: String) {
}
@Composable
-private fun DeleteConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
+internal fun DeleteConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
@@ -834,6 +846,8 @@ private fun PreviewMessageActionsSheetContent() {
showReplyPrivately = true,
showOpenThread = false,
showForward = true,
+ showForwardFile = false,
+ canSendToConversation = true,
showEdit = true,
showCopy = true,
showCopyMessageLink = true,
@@ -894,6 +908,8 @@ private fun PreviewMessageActionsSheetPinned() {
showReplyPrivately = false,
showOpenThread = false,
showForward = false,
+ showForwardFile = false,
+ canSendToConversation = true,
showEdit = false,
showCopy = true,
showCopyMessageLink = true,
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 140f2f8b0ea..87eccf583b9 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
@@ -25,6 +25,8 @@ import com.google.gson.Gson
import com.nextcloud.talk.R
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager
+import com.nextcloud.talk.chat.RecordInputMode
+import com.nextcloud.talk.chat.VideoMessageRecorder
import com.nextcloud.talk.chat.data.ChatMessageRepository
import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager
import com.nextcloud.talk.chat.data.io.MediaPlayerManager
@@ -2380,6 +2382,7 @@ class ChatViewModel @AssistedInject constructor(
private fun reactionLockFor(messageId: Int): Mutex = reactionLocks.getOrPut(messageId) { Mutex() }
fun startAudioRecording(context: Context, currentConversation: ConversationModel) {
+ activeRecordingMode = RecordInputMode.VOICE
audioFocusRequestManager.audioFocusRequest(true) {
Log.d(TAG, "Recording Started")
mediaRecorderManager.start(context, currentConversation)
@@ -2412,6 +2415,64 @@ class ChatViewModel @AssistedInject constructor(
}
}
+ private var storedRecordInputMode: RecordInputMode? = null
+
+ /**
+ * What the record button records while held. Persisted, so the choice survives leaving the chat.
+ */
+ val recordInputMode: RecordInputMode
+ get() = storedRecordInputMode
+ ?: RecordInputMode.fromVideoFlag(appPreferences.videoRecordMode).also { storedRecordInputMode = it }
+
+ /**
+ * What the recording that is currently in progress (or was last started) records.
+ */
+ var activeRecordingMode: RecordInputMode = RecordInputMode.VOICE
+ private set
+
+ fun toggleRecordInputMode(): RecordInputMode {
+ val newMode = recordInputMode.toggled()
+ storedRecordInputMode = newMode
+ appPreferences.videoRecordMode = newMode == RecordInputMode.VIDEO
+ return newMode
+ }
+
+ /**
+ * @return true if the audio focus was granted and the recording state was set
+ */
+ fun onVideoRecordingStarted(): Boolean {
+ activeRecordingMode = RecordInputMode.VIDEO
+ var granted = false
+ audioFocusRequestManager.audioFocusRequest(true) {
+ granted = true
+ _getVoiceRecordingInProgress.value = true
+ }
+ return granted
+ }
+
+ private var videoRecorder: VideoMessageRecorder? = null
+
+ /**
+ * The recorder of the video messages of this room. It lives here, not in the activity, so that it survives the
+ * recreation of the activity like the voice recorder does.
+ */
+ fun videoMessageRecorder(context: Context): VideoMessageRecorder =
+ videoRecorder ?: VideoMessageRecorder(context).also { videoRecorder = it }
+
+ val activeVideoMessageRecorder: VideoMessageRecorder?
+ get() = videoRecorder
+
+ override fun onCleared() {
+ super.onCleared()
+ videoRecorder?.release()
+ videoRecorder = null
+ }
+
+ fun onVideoRecordingEnded() {
+ audioFocusRequestManager.audioFocusRequest(false) {}
+ _getVoiceRecordingInProgress.value = false
+ }
+
fun stopAndDiscardAudioRecording() {
stopAudioRecording()
Log.d(TAG, "File discarded")
diff --git a/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt b/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt
index ebcf37f78e8..7e5b54a9a29 100644
--- a/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt
+++ b/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt
@@ -11,6 +11,8 @@ import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -20,6 +22,7 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
@@ -28,6 +31,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.colorResource
@@ -38,77 +42,100 @@ import com.nextcloud.talk.R
private const val SUBTITLE_ALPHA = 0.75f
+/** A button shown in the bar next to the overflow menu. */
+data class AppBarAction(val icon: ImageVector, val contentDescription: String, val onClick: () -> Unit)
+
+@Suppress("LongParameterList")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StandardAppBar(
title: String,
menuItems: List Unit>>?,
colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(),
- subtitle: String? = null
+ subtitle: String? = null,
+ centerTitle: Boolean = false,
+ actionButtons: List = emptyList()
) {
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
var expanded by remember { mutableStateOf(false) }
- TopAppBar(
- title = { StandardAppBarTitle(title = title, subtitle = subtitle) },
- colors = colors,
- navigationIcon = {
- IconButton(
- onClick = { backDispatcher?.onBackPressed() }
- ) {
- Icon(
- imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_back_black_24dp),
- contentDescription = stringResource(R.string.back_button)
- )
+ val titleContent: @Composable () -> Unit = {
+ StandardAppBarTitle(title = title, subtitle = subtitle, centered = centerTitle)
+ }
+ val navigation: @Composable () -> Unit = {
+ IconButton(
+ onClick = { backDispatcher?.onBackPressed() }
+ ) {
+ Icon(
+ imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_back_black_24dp),
+ contentDescription = stringResource(R.string.back_button)
+ )
+ }
+ }
+ val actions: @Composable RowScope.() -> Unit = {
+ actionButtons.forEach { action ->
+ IconButton(onClick = action.onClick) {
+ Icon(imageVector = action.icon, contentDescription = action.contentDescription)
}
- },
- actions = {
- if (!menuItems.isNullOrEmpty()) {
- Box {
- IconButton(onClick = { expanded = true }) {
- Icon(
- imageVector = ImageVector.vectorResource(R.drawable.ic_more_vert_24px),
- contentDescription = stringResource(R.string.nc_common_more_options)
- )
- }
+ }
+ if (!menuItems.isNullOrEmpty()) {
+ Box {
+ IconButton(onClick = { expanded = true }) {
+ Icon(
+ imageVector = ImageVector.vectorResource(R.drawable.ic_more_vert_24px),
+ contentDescription = stringResource(R.string.nc_common_more_options)
+ )
+ }
- DropdownMenu(
- expanded = expanded,
- onDismissRequest = { expanded = false },
- modifier = Modifier.background(color = colorResource(id = R.color.bg_default))
- ) {
- menuItems?.forEach { (label, action) ->
- DropdownMenuItem(
- text = { Text(label) },
- onClick = {
- action()
- expanded = false
- }
- )
- }
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ modifier = Modifier.background(color = colorResource(id = R.color.bg_default))
+ ) {
+ menuItems.forEach { (label, action) ->
+ DropdownMenuItem(
+ text = { Text(label) },
+ onClick = {
+ action()
+ expanded = false
+ }
+ )
}
}
}
}
- )
+ }
+
+ if (centerTitle) {
+ CenterAlignedTopAppBar(
+ title = titleContent,
+ colors = colors,
+ navigationIcon = navigation,
+ actions = actions
+ )
+ } else {
+ TopAppBar(title = titleContent, colors = colors, navigationIcon = navigation, actions = actions)
+ }
}
@Composable
-private fun StandardAppBarTitle(title: String, subtitle: String?) {
+private fun StandardAppBarTitle(title: String, subtitle: String?, centered: Boolean) {
+ val textAlign = if (centered) TextAlign.Center else null
if (subtitle != null) {
- Column {
- Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Column(horizontalAlignment = if (centered) Alignment.CenterHorizontally else Alignment.Start) {
+ Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = textAlign)
Text(
text = subtitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
- color = LocalContentColor.current.copy(alpha = SUBTITLE_ALPHA)
+ color = LocalContentColor.current.copy(alpha = SUBTITLE_ALPHA),
+ textAlign = textAlign
)
}
} else {
- Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = textAlign)
}
}
diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt
index 12b8ea0a0e8..8ae211158f4 100644
--- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt
+++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt
@@ -396,19 +396,14 @@ class ConversationInfoActivity : BaseActivity() {
private fun showSharedItems() {
val conv = viewModel.uiState.value.conversation ?: return
startActivity(
- Intent(this, SharedItemsActivity::class.java).apply {
- putExtra(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser.id)
- putExtra(BundleKeys.KEY_CONVERSATION_NAME, conv.displayName)
- putExtra(KEY_ROOM_TOKEN, conversationToken)
- putExtra(
- SharedItemsActivity.KEY_USER_IS_OWNER_OR_MODERATOR,
- ConversationUtils.isParticipantOwnerOrModerator(conv)
- )
- putExtra(
- SharedItemsActivity.KEY_IS_ONE_2_ONE,
- conv.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
- )
- }
+ SharedItemsActivity.createIntent(
+ context = this,
+ userId = conversationUser.id!!,
+ roomToken = conversationToken,
+ conversationName = conv.displayName,
+ isOwnerOrModerator = ConversationUtils.isParticipantOwnerOrModerator(conv),
+ isOneToOne = conv.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
+ )
)
}
diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt
index f03d2abe101..b79c02665c4 100644
--- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt
+++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt
@@ -41,6 +41,7 @@ import androidx.work.WorkInfo
import androidx.work.WorkManager
import autodagger.AutoInjector
import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.gson.Gson
import com.nextcloud.android.common.core.utils.ecosystem.AccountReceiverCallback
import com.nextcloud.android.common.core.utils.ecosystem.EcosystemManager
import com.nextcloud.talk.R
@@ -72,7 +73,9 @@ import com.nextcloud.talk.jobs.ContactAddressBookWorker.Companion.run
import com.nextcloud.talk.jobs.DeleteConversationWorker
import com.nextcloud.talk.jobs.LeaveConversationWorker
import com.nextcloud.talk.jobs.RemoteWipeSuccessWorker
+import com.nextcloud.talk.jobs.ShareOperationWorker
import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
+import com.nextcloud.talk.mediaviewer.model.PendingFileForward
import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.domain.SearchMessageEntry
import com.nextcloud.talk.models.json.conversations.ConversationEnums
@@ -105,6 +108,7 @@ import com.nextcloud.talk.utils.bundle.BundleKeys
import com.nextcloud.talk.utils.bundle.BundleKeys.ADD_ADDITIONAL_ACCOUNT
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME
+import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FORWARD_FILE_KEY
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FORWARD_HIDE_SOURCE_ROOM
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FORWARD_MSG_FLAG
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FORWARD_MSG_TEXT
@@ -890,7 +894,10 @@ class ConversationsListActivity : BaseActivity() {
showSnackbar(getString(R.string.send_to_forbidden))
}
} else if (forwardMessage) {
- if (hasChatPermission && !isReadOnlyConversation(selectedConversation!!)) {
+ val forwardFileKey = intent.getStringExtra(KEY_FORWARD_FILE_KEY)
+ if (hasChatPermission && !isReadOnlyConversation(selectedConversation!!) && forwardFileKey != null) {
+ confirmForwardFile(forwardFileKey)
+ } else if (hasChatPermission && !isReadOnlyConversation(selectedConversation!!)) {
openConversation(intent.getStringExtra(KEY_FORWARD_MSG_TEXT))
forwardMessageState.value = false
} else {
@@ -904,6 +911,43 @@ class ConversationsListActivity : BaseActivity() {
}
}
+ /**
+ * Forwarding a file: after the confirmation the file is shared to the chosen conversation, with the share step the
+ * upload of attachments ends with. Nothing is uploaded again. The file comes from [PendingFileForward] by the key
+ * of the intent, never from the intent itself (this activity is exported); without an entry nothing is forwarded.
+ */
+ private fun confirmForwardFile(key: String) {
+ val conversation = selectedConversation ?: return
+ val entry = PendingFileForward.peek(key)
+ if (entry == null || entry.userId != currentUser.id) {
+ forwardMessageState.value = false
+ intent.removeExtra(KEY_FORWARD_FILE_KEY)
+ showSnackbar(getString(R.string.nc_common_error_sorry))
+ return
+ }
+ val title = String.format(resources.getString(R.string.nc_upload_confirm_send_single), conversation.displayName)
+ val dialogBuilder = MaterialAlertDialogBuilder(this)
+ .setTitle(title)
+ .setMessage(entry.remotePath.substringAfterLast('/'))
+ .setPositiveButton(R.string.nc_yes) { _, _ ->
+ // taken only now: until then a rotation (which drops the dialog) must not lose the file
+ val confirmed = PendingFileForward.take(key) ?: return@setPositiveButton
+ val caption = confirmed.caption
+ val metaData = if (caption.isNotEmpty()) Gson().toJson(mapOf("caption" to caption)) else null
+ ShareOperationWorker.shareFile(conversation.token, currentUser, confirmed.remotePath, metaData)
+ intent.removeExtra(KEY_FORWARD_FILE_KEY)
+ forwardMessageState.value = false
+ openConversation()
+ }
+ .setNegativeButton(R.string.nc_no, null)
+ viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder)
+ val dialog = dialogBuilder.show()
+ viewThemeUtils.platform.colorTextButtons(
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE),
+ dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
+ )
+ }
+
private fun shouldShowLobby(conversation: ConversationModel): Boolean {
val participantPermissions = ParticipantPermissions(
currentUser.capabilities?.spreedCapability,
diff --git a/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt
index e643dfb8039..24bf8f63841 100644
--- a/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt
+++ b/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt
@@ -8,7 +8,10 @@
package com.nextcloud.talk.jobs
import android.content.Context
+import android.os.Handler
+import android.os.Looper
import android.util.Log
+import android.widget.Toast
import androidx.work.Data
import androidx.work.OneTimeWorkRequest
import com.nextcloud.talk.utils.setExpeditedIfSupported
@@ -16,6 +19,7 @@ import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import autodagger.AutoInjector
+import com.nextcloud.talk.R
import com.nextcloud.talk.api.NcApi
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication
@@ -50,15 +54,20 @@ class ShareOperationWorker(context: Context, workerParams: WorkerParameters) : W
private val metaData: String?
override fun doWork(): Result {
- for (filePath in filesArray) {
- tryCreateShare(filePath)
+ val failed = filesArray.count { !tryCreateShare(it) }
+ if (failed > 0) {
+ // The work itself is not retried (the share was refused), so the user has to be told.
+ Handler(Looper.getMainLooper()).post {
+ Toast.makeText(applicationContext, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show()
+ }
}
roomToken?.let { _shareCompletedFlow.tryEmit(it) }
return Result.success()
}
@Suppress("TooGenericExceptionCaught")
- private fun tryCreateShare(filePath: String?) {
+ private fun tryCreateShare(filePath: String?): Boolean {
+ var created = false
for (attempt in 1..SHARE_MAX_ATTEMPTS) {
var succeeded = false
var shouldRetry = false
@@ -82,9 +91,13 @@ class ShareOperationWorker(context: Context, workerParams: WorkerParameters) : W
}
}
)
- if (succeeded || !shouldRetry) return
+ if (succeeded || !shouldRetry) {
+ created = succeeded
+ break
+ }
Thread.sleep(SHARE_RETRY_DELAY_MS)
}
+ return created
}
init {
diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt
index 13b8bb7b748..7a51f0e4027 100644
--- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt
+++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt
@@ -28,7 +28,15 @@ import com.nextcloud.talk.R
import com.nextcloud.talk.activities.BaseActivity
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider
+import com.nextcloud.talk.chat.ChatActivity
+import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.mediaviewer.model.MediaViewerChatAction
+import com.nextcloud.talk.mediaviewer.model.MediaViewerChatRequest
import com.nextcloud.talk.mediaviewer.model.MediaViewerItem
+import com.nextcloud.talk.mediaviewer.model.isInsideDirectory
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
+import com.nextcloud.talk.shareditems.activities.SharedItemsActivity
+import com.nextcloud.talk.utils.ConversationUtils
import com.nextcloud.talk.mediaviewer.viewmodels.MediaViewerViewModel
import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment
import com.nextcloud.talk.utils.FileUtils
@@ -53,12 +61,16 @@ class MediaViewerActivity : BaseActivity() {
lateinit var viewModelFactory: ViewModelProvider.Factory
private lateinit var viewModel: MediaViewerViewModel
+ private lateinit var user: User
+ private lateinit var roomToken: String
+ private var openedFromSharedItems = false
private lateinit var windowInsetsController: WindowInsetsControllerCompat
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
- val user = setUpBoundUserOrFinish() ?: return
+ val boundUser = setUpBoundUserOrFinish() ?: return
+ user = boundUser
val imageAuthHeader = ApiUtils.getCredentials(user.username, user.token)
val roomToken = intent.getStringExtra(KEY_ROOM_TOKEN)
@@ -71,6 +83,9 @@ class MediaViewerActivity : BaseActivity() {
return
}
+ this.roomToken = roomToken
+ openedFromSharedItems = intent.getBooleanExtra(EXTRA_FROM_SHARED_ITEMS, false)
+
viewModel = ViewModelProvider(this, viewModelFactory)[MediaViewerViewModel::class.java]
viewModel.initialize(user, roomToken, seedItems, startMessageId)
@@ -89,11 +104,16 @@ class MediaViewerActivity : BaseActivity() {
CompositionLocalProvider(LocalImageAuthHeader provides imageAuthHeader) {
MediaViewerScreen(
viewModel = viewModel,
- onShare = ::shareFile,
- onSave = ::showSaveDialog,
- onControlsVisibilityChanged = { visible ->
- if (visible) exitImmersiveMode() else enterImmersiveMode()
- }
+ callbacks = MediaViewerCallbacks(
+ onShare = ::shareFile,
+ onSave = ::showSaveDialog,
+ onShowAllMedia = ::showAllMedia,
+ onShowInChat = ::showInChat,
+ onChatAction = ::sendActionToChat,
+ onControlsVisibilityChanged = { visible ->
+ if (visible) exitImmersiveMode() else enterImmersiveMode()
+ }
+ )
)
}
}
@@ -125,33 +145,88 @@ class MediaViewerActivity : BaseActivity() {
}
private fun showSaveDialog(item: MediaViewerItem, localPath: String) {
- val safeFile = FileUtils.resolveSharedAttachmentFile(cacheDir, File(localPath).name)
- if (safeFile == null) {
- logger.e(TAG, "Refused to save file with unsafe name: ${File(localPath).name}")
+ val safeFile = FileUtils.resolveSharedAttachmentFile(cacheDir, item.fileId, item.fileName)
+ if (safeFile == null || !safeFile.exists()) {
+ logger.e(TAG, "Refused to save file with unsafe name or path: ${item.fileName}")
Snackbar.make(window.decorView, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
return
}
- val saveFragment: DialogFragment = SaveToStorageDialogFragment.newInstance(safeFile.name)
+ // The viewer caches every file in its own per-file-id folder, which the dialog has to look in.
+ val saveFragment: DialogFragment = SaveToStorageDialogFragment.newInstance(item.fileName, item.fileId)
saveFragment.show(supportFragmentManager, SaveToStorageDialogFragment.TAG)
}
+ private fun showAllMedia() {
+ if (openedFromSharedItems) {
+ // The media grid this viewer was opened from is right underneath.
+ finish()
+ return
+ }
+ val conversation = viewModel.uiState.value.conversation
+ startActivity(
+ SharedItemsActivity.createIntent(
+ context = this,
+ userId = user.id!!,
+ roomToken = roomToken,
+ conversationName = conversation?.displayName,
+ isOwnerOrModerator = conversation?.let { ConversationUtils.isParticipantOwnerOrModerator(it) } == true,
+ isOneToOne = conversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
+ )
+ )
+ }
+
+ /** Scrolls the chat to the message, through the chat's own "open message" extra (as the shared items do). */
+ private fun showInChat(item: MediaViewerItem) {
+ startActivity(openChat(scrollToMessage = item.messageId))
+ finish()
+ }
+
+ /** Hands reply, delete, forward and draw to the chat, which runs its own handlers for them. */
+ private fun sendActionToChat(action: MediaViewerChatAction, item: MediaViewerItem, localPath: String?) {
+ val attachmentsDirectory = FileUtils.getSharedAttachmentsDirectory(cacheDir)
+ val safeLocalPath = localPath?.takeIf {
+ attachmentsDirectory != null && isInsideDirectory(attachmentsDirectory, File(it))
+ }
+ val intent = openChat().apply {
+ putExtra(MediaViewerChatRequest.KEY_ACTION, action.name)
+ putExtra(MediaViewerChatRequest.KEY_MESSAGE_ID, item.messageId)
+ putExtra(MediaViewerChatRequest.KEY_LOCAL_PATH, safeLocalPath)
+ }
+ startActivity(intent)
+ finish()
+ }
+
+ /**
+ * Returns to this conversation's chat: the already open instance below this screen when there is one, else a
+ * new one. With [scrollToMessage] the chat scrolls to that message through [BundleKeys.KEY_MESSAGE_ID].
+ */
+ private fun openChat(scrollToMessage: Long? = null): Intent =
+ ChatActivity.createIntent(this, user.id!!, roomToken).apply {
+ scrollToMessage?.let { putExtra(BundleKeys.KEY_MESSAGE_ID, it) }
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ }
+
companion object {
private val TAG = MediaViewerActivity::class.java.simpleName
private const val EXTRA_SEED_ITEMS = "MEDIA_VIEWER_SEED_ITEMS"
private const val EXTRA_START_MESSAGE_ID = "MEDIA_VIEWER_START_MESSAGE_ID"
+ private const val EXTRA_FROM_SHARED_ITEMS = "MEDIA_VIEWER_FROM_SHARED_ITEMS"
+ @Suppress("LongParameterList")
fun newIntent(
context: Context,
userId: Long,
roomToken: String,
seedItems: List,
- startMessageId: Long
+ startMessageId: Long,
+ fromSharedItems: Boolean = false
): Intent =
Intent(context, MediaViewerActivity::class.java).apply {
putExtra(BundleKeys.KEY_INTERNAL_USER_ID, userId)
putExtra(KEY_ROOM_TOKEN, roomToken)
putParcelableArrayListExtra(EXTRA_SEED_ITEMS, ArrayList(seedItems))
putExtra(EXTRA_START_MESSAGE_ID, startMessageId)
+ putExtra(EXTRA_FROM_SHARED_ITEMS, fromSharedItems)
}
}
}
diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt
index 31218c2847f..6d6c257e422 100644
--- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt
+++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt
@@ -40,6 +40,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Brush
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -51,6 +53,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
@@ -62,7 +65,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
@@ -81,9 +86,14 @@ import coil.request.ImageRequest
import coil.request.SuccessResult
import com.github.chrisbanes.photoview.PhotoView
import com.nextcloud.talk.R
+import com.nextcloud.talk.components.AppBarAction
+import com.nextcloud.talk.chat.ui.DeleteConfirmationDialog
import com.nextcloud.talk.components.StandardAppBar
import com.nextcloud.talk.mediaviewer.model.MediaViewerGroup
+import com.nextcloud.talk.mediaviewer.model.MediaViewerChatAction
import com.nextcloud.talk.mediaviewer.model.MediaViewerItem
+import com.nextcloud.talk.mediaviewer.model.MediaViewerMenuState
+import com.nextcloud.talk.mediaviewer.model.mediaViewerMenuState
import com.nextcloud.talk.mediaviewer.viewmodels.MediaViewerViewModel
import com.nextcloud.talk.utils.DateConstants
import com.nextcloud.talk.utils.DateUtils
@@ -107,15 +117,21 @@ private val thumbnailSpacing = 4.dp
private val thumbnailStripVerticalPadding = 12.dp
private val controlsSlideDistance = 24.dp
+/** What the viewer's actions do; implemented by [MediaViewerActivity], which owns the intents. */
+data class MediaViewerCallbacks(
+ val onShare: (MediaViewerItem, String) -> Unit,
+ val onSave: (MediaViewerItem, String) -> Unit,
+ val onShowAllMedia: () -> Unit,
+ val onShowInChat: (MediaViewerItem) -> Unit,
+ /** Reply, delete, forward and draw run in the chat; the cached copy's path is given when it exists. */
+ val onChatAction: (MediaViewerChatAction, MediaViewerItem, String?) -> Unit,
+ val onControlsVisibilityChanged: (Boolean) -> Unit = {}
+)
+
@Suppress("Detekt.LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun MediaViewerScreen(
- viewModel: MediaViewerViewModel,
- onShare: (MediaViewerItem, String) -> Unit,
- onSave: (MediaViewerItem, String) -> Unit,
- onControlsVisibilityChanged: (Boolean) -> Unit = {}
-) {
+fun MediaViewerScreen(viewModel: MediaViewerViewModel, callbacks: MediaViewerCallbacks) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val items = uiState.flattenedItems
if (items.isEmpty()) return
@@ -175,7 +191,7 @@ fun MediaViewerScreen(
// The status/nav bars toggle together with the top bar and thumbnail strip - one tap hides all
// of it.
LaunchedEffect(showControls) {
- onControlsVisibilityChanged(showControls)
+ callbacks.onControlsVisibilityChanged(showControls)
}
// Hoisted here (rather than inside ThumbnailStrip) so its scroll position survives showControls
@@ -186,6 +202,17 @@ fun MediaViewerScreen(
val density = LocalDensity.current
val slideOffsetPx = with(density) { controlsSlideDistance.roundToPx() }
+ var confirmDelete by rememberSaveable { mutableStateOf(false) }
+ if (confirmDelete && currentItem != null) {
+ DeleteConfirmationDialog(
+ onConfirm = {
+ confirmDelete = false
+ callbacks.onChatAction(MediaViewerChatAction.DELETE, currentItem, currentLocalPath)
+ },
+ onDismiss = { confirmDelete = false }
+ )
+ }
+
val group = uiState.currentGroup
val hasThumbnailStrip = group != null && group.items.size > 1
// Reserved so the video's own ExoPlayer controller (progress bar, play/pause row) never renders
@@ -227,20 +254,33 @@ fun MediaViewerScreen(
navigationIconContentColor = Color.White,
actionIconContentColor = Color.White
)
- val menuItems = buildList {
- if (currentItem != null && currentLocalPath != null) {
- add(stringResource(R.string.share) to { onShare(currentItem, currentLocalPath) })
- add(stringResource(R.string.nc_save_message) to { onSave(currentItem, currentLocalPath) })
- }
+ val menuState = currentItem?.let {
+ mediaViewerMenuState(
+ mimeType = it.mimeType,
+ hasLocalFile = currentLocalPath != null,
+ actions = uiState.actionStates[it.messageId]
+ )
}
+ val menuItems = buildMenuItems(currentItem, currentLocalPath, menuState, callbacks) { confirmDelete = true }
+ val actionButtons = buildActionButtons(currentItem, currentLocalPath, menuState, callbacks)
val sentDateTime = currentItem?.let {
dateUtils.getLocalDateTimeStringFromTimestamp(it.timestamp * DateConstants.SECOND_DIVIDER)
}
+ val counter = uiState.counter
+ val counterText = when {
+ counter == null -> ""
+ counter.totalIsLowerBound ->
+ stringResource(R.string.media_viewer_counter_more, counter.position, counter.total)
+ else -> stringResource(R.string.media_viewer_counter, counter.position, counter.total)
+ }
+ val sender = currentItem?.actorDisplayName.orEmpty()
StandardAppBar(
- title = currentItem?.actorDisplayName.orEmpty(),
+ title = counterText,
menuItems = menuItems,
colors = toolbarColors,
- subtitle = sentDateTime
+ subtitle = listOf(sender, sentDateTime.orEmpty()).filter { it.isNotBlank() }.joinToString(", "),
+ centerTitle = true,
+ actionButtons = actionButtons
)
}
@@ -279,6 +319,71 @@ fun MediaViewerScreen(
}
}
+@Composable
+private fun buildMenuItems(
+ item: MediaViewerItem?,
+ localPath: String?,
+ state: MediaViewerMenuState?,
+ callbacks: MediaViewerCallbacks,
+ onDelete: () -> Unit
+): List Unit>> {
+ if (item == null || state == null) return emptyList()
+ return buildList {
+ if (state.saveToGallery && localPath != null) {
+ add(stringResource(R.string.media_viewer_save_to_gallery) to { callbacks.onSave(item, localPath) })
+ }
+ if (state.showAllMedia) {
+ add(stringResource(R.string.media_viewer_show_all_media) to { callbacks.onShowAllMedia() })
+ }
+ if (state.showInChat) {
+ add(stringResource(R.string.media_viewer_show_in_chat) to { callbacks.onShowInChat(item) })
+ }
+ if (state.reply) {
+ add(
+ stringResource(R.string.nc_reply) to {
+ callbacks.onChatAction(MediaViewerChatAction.REPLY, item, localPath)
+ }
+ )
+ }
+ if (state.share && localPath != null) {
+ add(stringResource(R.string.share) to { callbacks.onShare(item, localPath) })
+ }
+ if (state.delete) {
+ add(stringResource(R.string.nc_delete) to onDelete)
+ }
+ }
+}
+
+@Composable
+private fun buildActionButtons(
+ item: MediaViewerItem?,
+ localPath: String?,
+ state: MediaViewerMenuState?,
+ callbacks: MediaViewerCallbacks
+): List {
+ if (item == null || state == null) return emptyList()
+ return buildList {
+ if (state.forward) {
+ add(
+ AppBarAction(
+ icon = ImageVector.vectorResource(R.drawable.forward_24),
+ contentDescription = stringResource(R.string.nc_forward_message),
+ onClick = { callbacks.onChatAction(MediaViewerChatAction.FORWARD, item, localPath) }
+ )
+ )
+ }
+ if (state.draw) {
+ add(
+ AppBarAction(
+ icon = Icons.Outlined.Brush,
+ contentDescription = stringResource(R.string.nc_attachment_draw),
+ onClick = { callbacks.onChatAction(MediaViewerChatAction.DRAW, item, localPath) }
+ )
+ )
+ }
+ }
+}
+
@OptIn(UnstableApi::class)
@Composable
private fun rememberViewerExoPlayer(): ExoPlayer {
diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerActions.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerActions.kt
new file mode 100644
index 00000000000..bf2311e7eb8
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerActions.kt
@@ -0,0 +1,131 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.mediaviewer.model
+
+import com.nextcloud.talk.chat.ui.MessageActionsState
+import com.nextcloud.talk.utils.Mimetype
+import com.nextcloud.talk.utils.MimetypeUtils
+import java.io.File
+import java.io.IOException
+
+/**
+ * What the counter in the viewer's top bar shows. [position] counts from the oldest loaded item, so the
+ * newest item is "M of M", as in other messengers. When older items are paged in, both [position] and
+ * [total] grow by their number while the viewer stays on the same item.
+ * [totalIsLowerBound] is true while older items may still be loaded, shown as "N of M+".
+ */
+data class MediaViewerCounter(val position: Int, val total: Int, val totalIsLowerBound: Boolean)
+
+/**
+ * [index] is the pager position (0 = oldest loaded item), [total] the number of loaded items and
+ * [hasMoreOlder] whether older history may still be fetched. Null when there is nothing to count.
+ */
+fun mediaViewerCounter(index: Int, total: Int, hasMoreOlder: Boolean): MediaViewerCounter? =
+ if (total <= 0 || index !in 0 until total) {
+ null
+ } else {
+ MediaViewerCounter(position = index + 1, total = total, totalIsLowerBound = hasMoreOlder)
+ }
+
+/** Photos only: GIFs would lose their animation and SVG cannot be edited by the crop/draw tools. */
+fun isDrawableImage(mimeType: String): Boolean =
+ mimeType.startsWith(Mimetype.IMAGE_PREFIX) &&
+ !MimetypeUtils.isGif(mimeType) &&
+ !mimeType.startsWith(SVG_MIME_PREFIX)
+
+private const val SVG_MIME_PREFIX = "image/svg"
+
+/** Which of the viewer's menu entries and top bar buttons are offered for one item. */
+data class MediaViewerMenuState(
+ val saveToGallery: Boolean,
+ val share: Boolean,
+ val showAllMedia: Boolean,
+ val showInChat: Boolean,
+ val reply: Boolean,
+ val delete: Boolean,
+ val forward: Boolean,
+ val draw: Boolean
+)
+
+/**
+ * Visibility of the viewer's actions. The rules that depend on the chat (reply, delete, forward, drawing into
+ * the conversation) come from [actions], built by the chat's own `buildMessageActionsState`; when the message
+ * is not in the local database ([actions] null) none of them is offered, as their permissions can't be checked.
+ * Sharing and saving only act on the local copy, so they just need the file to be downloaded.
+ */
+fun mediaViewerMenuState(mimeType: String, hasLocalFile: Boolean, actions: MessageActionsState?): MediaViewerMenuState =
+ MediaViewerMenuState(
+ saveToGallery = hasLocalFile && (actions?.showSave ?: true),
+ share = hasLocalFile && (actions?.showShare ?: true),
+ showAllMedia = true,
+ showInChat = true,
+ reply = actions?.showReply == true,
+ delete = actions?.showDelete == true,
+ forward = actions?.showForwardFile == true,
+ draw = hasLocalFile && isDrawableImage(mimeType) && actions?.canSendToConversation == true
+ )
+
+/** An action the viewer hands over to the chat, which owns the message handlers it needs. */
+enum class MediaViewerChatAction {
+ REPLY,
+ DELETE,
+ FORWARD,
+ DRAW
+}
+
+/**
+ * The request the viewer sends to ChatActivity through intent extras (the KEY_ constants below).
+ * [localPath] is the cached copy (drawing). Nothing else is trusted from the intent: what is forwarded is read from the
+ * message in the database.
+ */
+data class MediaViewerChatRequest(
+ val action: MediaViewerChatAction,
+ val messageId: Long,
+ val localPath: String? = null
+) {
+ companion object {
+ const val KEY_ACTION = "MEDIA_VIEWER_CHAT_ACTION"
+ const val KEY_MESSAGE_ID = "MEDIA_VIEWER_CHAT_MESSAGE_ID"
+ const val KEY_LOCAL_PATH = "MEDIA_VIEWER_CHAT_LOCAL_PATH"
+
+ /** Null for a missing or unknown action, a missing message id, or an action without the data it needs. */
+ fun parse(actionName: String?, messageId: Long, localPath: String?): MediaViewerChatRequest? {
+ val action = MediaViewerChatAction.entries.firstOrNull { it.name == actionName }
+ val complete = when (action) {
+ null -> false
+ MediaViewerChatAction.DRAW -> !localPath.isNullOrBlank()
+ else -> true
+ }
+ return if (action != null && complete && messageId > 0L) {
+ MediaViewerChatRequest(action, messageId, localPath)
+ } else {
+ null
+ }
+ }
+ }
+}
+
+/** The chat's own rule for [action] (see `buildMessageActionsState`), for the request received from the viewer. */
+fun isMediaActionAllowed(action: MediaViewerChatAction, state: MessageActionsState): Boolean =
+ when (action) {
+ MediaViewerChatAction.REPLY -> state.showReply
+ MediaViewerChatAction.DELETE -> state.showDelete
+ MediaViewerChatAction.FORWARD -> state.showForwardFile
+ MediaViewerChatAction.DRAW -> state.canSendToConversation
+ }
+
+/** The path of a file in the user's own storage as the share API expects it: with exactly one leading slash. */
+fun remoteSharePath(path: String): String = "/" + path.trimStart('/')
+
+/** True when [file] lies below [directory] (not [directory] itself), after resolving "..", symlinks and the like. */
+fun isInsideDirectory(directory: File, file: File): Boolean =
+ try {
+ val base = directory.canonicalFile
+ file.canonicalFile.path.startsWith(base.path + File.separator)
+ } catch (_: IOException) {
+ false
+ }
diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/model/PendingFileForward.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/PendingFileForward.kt
new file mode 100644
index 00000000000..f0c176ba165
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/PendingFileForward.kt
@@ -0,0 +1,34 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.mediaviewer.model
+
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * A file the user is forwarding, kept in the memory of this process while the conversation is picked. The conversation
+ * list is exported, so the path of the file must never come in through an intent: the intent only carries the random
+ * key of an entry made here. A key without an entry (foreign intent, dead process) forwards nothing.
+ */
+object PendingFileForward {
+
+ data class Entry(val userId: Long, val remotePath: String, val caption: String)
+
+ private val entries = ConcurrentHashMap()
+
+ fun put(entry: Entry): String {
+ val key = UUID.randomUUID().toString()
+ entries[key] = entry
+ return key
+ }
+
+ /** Reads the entry without removing it, e.g. to show the confirmation (a rotation must not lose it). */
+ fun peek(key: String?): Entry? = key?.let { entries[it] }
+
+ /** Reads the entry and removes it, so that a key works once. */
+ fun take(key: String?): Entry? = key?.let { entries.remove(it) }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt
index 694c6f7e544..7a7c16c388f 100644
--- a/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt
+++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt
@@ -6,6 +6,7 @@
*/
package com.nextcloud.talk.mediaviewer.viewmodels
+import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
@@ -17,6 +18,17 @@ import com.nextcloud.talk.utils.setExpeditedIfSupported
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.chat.ui.MessageActionsState
+import com.nextcloud.talk.chat.ui.buildMessageActionsState
+import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository
+import com.nextcloud.talk.data.database.dao.ChatMessagesDao
+import com.nextcloud.talk.data.database.mappers.toDomainModel
+import com.nextcloud.talk.data.network.NetworkMonitor
+import com.nextcloud.talk.mediaviewer.model.MediaViewerCounter
+import com.nextcloud.talk.mediaviewer.model.mediaViewerCounter
+import com.nextcloud.talk.models.domain.ConversationModel
+import com.nextcloud.talk.utils.DateUtils
+import com.nextcloud.talk.utils.ParticipantPermissions
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.jobs.DownloadFileToCacheWorker
import com.nextcloud.talk.mediaviewer.model.MediaViewerGroup
@@ -34,6 +46,7 @@ import io.reactivex.Observable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -49,8 +62,14 @@ import kotlin.coroutines.resumeWithException
* why "older" does). "Older" navigation pages further back through conversation history via
* [SharedItemsRepository] once the locally seeded items run out.
*/
-class MediaViewerViewModel @Inject constructor(private val sharedItemsRepository: SharedItemsRepository) :
- ViewModel() {
+@Suppress("LongParameterList")
+class MediaViewerViewModel @Inject constructor(
+ private val sharedItemsRepository: SharedItemsRepository,
+ private val chatMessagesDao: ChatMessagesDao,
+ private val conversationsRepository: OfflineConversationsRepository,
+ private val networkMonitor: NetworkMonitor,
+ private val context: Context
+) : ViewModel() {
data class PendingShift(val id: Long, val amount: Int)
@@ -61,8 +80,14 @@ class MediaViewerViewModel @Inject constructor(private val sharedItemsRepository
val canLoadOlder: Boolean = true,
val cachedFilePaths: Map = emptyMap(),
val downloadingMessageIds: Set = emptySet(),
- val pendingShift: PendingShift? = null
+ val pendingShift: PendingShift? = null,
+ /** Per message id: what the chat allows for it. A message missing here is not in the local database. */
+ val actionStates: Map = emptyMap(),
+ val conversation: ConversationModel? = null
) {
+ val counter: MediaViewerCounter?
+ get() = mediaViewerCounter(currentGlobalIndex, flattenedItems.size, canLoadOlder)
+
val flattenedItems: List get() = groups.flatMap { it.items }
val currentItem: MediaViewerItem? get() = flattenedItems.getOrNull(currentGlobalIndex)
val currentGroup: MediaViewerGroup?
@@ -95,11 +120,65 @@ class MediaViewerViewModel @Inject constructor(private val sharedItemsRepository
_uiState.value = UiState(groups = groups, currentGlobalIndex = startIndex)
ensureCachedAround(startIndex)
+ loadConversation(roomToken)
+ observeOnlineState()
+ }
+
+ /** What the chat offers depends on being online (forwarding, deleting), so the states are worked out again. */
+ private fun observeOnlineState() {
+ viewModelScope.launch {
+ networkMonitor.isOnline.drop(1).collect {
+ _uiState.update { state -> state.copy(actionStates = emptyMap()) }
+ _uiState.value.currentItem?.let(::loadActionState)
+ }
+ }
+ }
+
+ private fun loadConversation(roomToken: String) {
+ viewModelScope.launch {
+ @Suppress("DEPRECATION")
+ val conversation = conversationsRepository.getLocallyStoredConversation(user, roomToken)
+ _uiState.update { it.copy(conversation = conversation) }
+ // Items may have been settled before the conversation was known.
+ _uiState.value.currentItem?.let(::loadActionState)
+ }
+ }
+
+ /**
+ * Works out what the chat allows for [item] with the chat's own rules (`buildMessageActionsState`), from the
+ * message and the conversation in the local database. Nothing is stored when the message is not there (an item
+ * paged in from the network), so the actions that need permissions stay hidden.
+ */
+ private fun loadActionState(item: MediaViewerItem) {
+ val state = _uiState.value
+ val conversation = state.conversation
+ if (conversation == null || state.actionStates.containsKey(item.messageId)) return
+ viewModelScope.launch {
+ val message = chatMessagesDao
+ .getChatMessageEntity("${user.id}@${conversation.token}", item.messageId)
+ ?.toDomainModel()
+ ?: return@launch
+ val capabilities = user.capabilities?.spreedCapability ?: return@launch
+ val permissions = ParticipantPermissions(capabilities, conversation)
+ val actions = buildMessageActionsState(
+ message = message,
+ user = user,
+ conversation = conversation,
+ hasChatPermission = permissions.hasChatPermission(),
+ hasReactPermission = permissions.hasReactPermission(),
+ spreedCapabilities = capabilities,
+ isOnline = networkMonitor.isOnline.value,
+ dateUtils = DateUtils(context),
+ conversationThreadId = null
+ )
+ _uiState.update { it.copy(actionStates = it.actionStates + (item.messageId to actions)) }
+ }
}
fun onPageSettled(globalIndex: Int) {
_uiState.update { it.copy(currentGlobalIndex = globalIndex) }
ensureCachedAround(globalIndex)
+ _uiState.value.currentItem?.let(::loadActionState)
if (globalIndex <= EDGE_LOAD_THRESHOLD) {
loadOlderGroups()
}
diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt
index ac080d75ccd..4c8870cec5c 100644
--- a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt
+++ b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt
@@ -9,6 +9,8 @@
*/
package com.nextcloud.talk.shareditems.activities
+import android.content.Context
+import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.MenuItem
@@ -32,6 +34,7 @@ import com.nextcloud.talk.shareditems.adapters.SharedItemsAdapter
import com.nextcloud.talk.shareditems.model.SharedItemType
import com.nextcloud.talk.shareditems.viewmodels.SharedItemsViewModel
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME
+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.bundle.BundleKeys.KEY_THREAD_ID
import javax.inject.Inject
@@ -297,5 +300,23 @@ class SharedItemsActivity : BaseActivity() {
const val SPAN_COUNT: Int = 4
const val KEY_USER_IS_OWNER_OR_MODERATOR = "userIsOwnerOrModerator"
const val KEY_IS_ONE_2_ONE = "KEY_IS_ONE_2_ONE"
+
+ /** The media tab is the first one, so it is what such an intent shows. */
+ @Suppress("LongParameterList")
+ fun createIntent(
+ context: Context,
+ userId: Long,
+ roomToken: String,
+ conversationName: String?,
+ isOwnerOrModerator: Boolean,
+ isOneToOne: Boolean
+ ): Intent =
+ Intent(context, SharedItemsActivity::class.java).apply {
+ putExtra(KEY_INTERNAL_USER_ID, userId)
+ putExtra(KEY_CONVERSATION_NAME, conversationName)
+ putExtra(KEY_ROOM_TOKEN, roomToken)
+ putExtra(KEY_USER_IS_OWNER_OR_MODERATOR, isOwnerOrModerator)
+ putExtra(KEY_IS_ONE_2_ONE, isOneToOne)
+ }
}
}
diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt
index fa360a8d622..badf205b3d0 100644
--- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt
+++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt
@@ -125,7 +125,14 @@ class SharedItemsAdapter(
.map { it.toMediaViewerItem() }
.capSeedAroundMessage(item.messageId)
context.startActivity(
- MediaViewerActivity.newIntent(context, user.id!!, roomToken, seedItems, item.messageId)
+ MediaViewerActivity.newIntent(
+ context,
+ user.id!!,
+ roomToken,
+ seedItems,
+ item.messageId,
+ fromSharedItems = true
+ )
)
}
diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt
deleted file mode 100644
index a1cdb1e73c1..00000000000
--- a/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt
+++ /dev/null
@@ -1,153 +0,0 @@
-/*
- * Nextcloud Talk - Android Client
- *
- * SPDX-FileCopyrightText: 2021 Marcel Hibbe
- * SPDX-FileCopyrightText: 2021 Andy Scherzinger
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-package com.nextcloud.talk.ui.dialog
-
-import android.app.Activity
-import android.content.pm.PackageManager
-import android.os.Bundle
-import android.view.View
-import android.view.ViewGroup
-import autodagger.AutoInjector
-import com.google.android.material.bottomsheet.BottomSheetBehavior
-import com.google.android.material.bottomsheet.BottomSheetDialog
-import com.nextcloud.talk.R
-import com.nextcloud.talk.application.NextcloudTalkApplication
-import com.nextcloud.talk.chat.ChatActivity
-import com.nextcloud.talk.databinding.DialogAttachmentBinding
-import com.nextcloud.talk.ui.theme.ViewThemeUtils
-import com.nextcloud.talk.ui.theme.hostViewThemeUtils
-import com.nextcloud.talk.utils.CapabilitiesUtil
-import com.nextcloud.talk.utils.SpreedFeatures
-import javax.inject.Inject
-
-@AutoInjector(NextcloudTalkApplication::class)
-class AttachmentDialog(val activity: Activity, var chatActivity: ChatActivity) : BottomSheetDialog(activity) {
-
- @Inject
- lateinit var viewThemeUtils: ViewThemeUtils
-
- private lateinit var dialogAttachmentBinding: DialogAttachmentBinding
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this)
- viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils)
-
- dialogAttachmentBinding = DialogAttachmentBinding.inflate(layoutInflater)
- setContentView(dialogAttachmentBinding.root)
- window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
-
- viewThemeUtils.material.colorBottomSheetBackground(dialogAttachmentBinding.root)
- viewThemeUtils.material.colorBottomSheetDragHandle(dialogAttachmentBinding.bottomSheetDragHandle)
- initItemsStrings()
- initItemsVisibility()
- initItemsClickListeners()
- }
-
- private fun initItemsStrings() {
- var serverName = CapabilitiesUtil.getServerName(chatActivity.conversationUser)
- dialogAttachmentBinding.txtAttachFileFromCloud.text = chatActivity.resources?.let {
- if (serverName.isNullOrEmpty()) {
- serverName = it.getString(R.string.nc_server_product_name)
- }
- String.format(it.getString(R.string.nc_upload_from_cloud), serverName)
- }
- }
-
- private fun initItemsVisibility() {
- if (!chatActivity.currentConversation!!.remoteServer.isNullOrEmpty()) {
- dialogAttachmentBinding.menuAttachContact.visibility = View.GONE
- dialogAttachmentBinding.menuShareLocation.visibility = View.GONE
- dialogAttachmentBinding.menuAttachPictureFromCam.visibility = View.GONE
- dialogAttachmentBinding.menuAttachVideoFromCam.visibility = View.GONE
- dialogAttachmentBinding.menuAttachFileFromLocal.visibility = View.GONE
- dialogAttachmentBinding.menuAttachFileFromCloud.visibility = View.GONE
- }
-
- if (!CapabilitiesUtil.hasSpreedFeatureCapability(
- chatActivity.spreedCapabilities,
- SpreedFeatures.GEO_LOCATION_SHARING
- )
- ) {
- dialogAttachmentBinding.menuShareLocation.visibility = View.GONE
- }
-
- if (!CapabilitiesUtil.hasSpreedFeatureCapability(chatActivity.spreedCapabilities, SpreedFeatures.TALK_POLLS) ||
- chatActivity.isOneToOneConversation()
- ) {
- dialogAttachmentBinding.menuAttachPoll.visibility = View.GONE
- }
-
- if (!CapabilitiesUtil.hasSpreedFeatureCapability(
- chatActivity.spreedCapabilities,
- SpreedFeatures.THREADS
- ) ||
- chatActivity.conversationThreadId != null
- ) {
- dialogAttachmentBinding.menuCreateThread.visibility = View.GONE
- }
-
- if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) {
- dialogAttachmentBinding.menuAttachVideoFromCam.visibility = View.GONE
- }
- }
-
- private fun initItemsClickListeners() {
- dialogAttachmentBinding.menuShareLocation.setOnClickListener {
- chatActivity.showShareLocationScreen()
- dismiss()
- }
-
- dialogAttachmentBinding.menuAttachFileFromGallery.setOnClickListener {
- chatActivity.showGalleryPicker()
- dismiss()
- }
-
- dialogAttachmentBinding.menuAttachFileFromLocal.setOnClickListener {
- chatActivity.sendSelectLocalFileIntent()
- dismiss()
- }
-
- dialogAttachmentBinding.menuAttachPictureFromCam.setOnClickListener {
- chatActivity.sendPictureFromCamIntent()
- dismiss()
- }
-
- dialogAttachmentBinding.menuAttachVideoFromCam.setOnClickListener {
- chatActivity.sendVideoFromCamIntent()
- dismiss()
- }
-
- dialogAttachmentBinding.menuCreateThread.setOnClickListener {
- chatActivity.createThread()
- dismiss()
- }
-
- dialogAttachmentBinding.menuAttachPoll.setOnClickListener {
- chatActivity.createPoll()
- dismiss()
- }
-
- dialogAttachmentBinding.menuAttachFileFromCloud.setOnClickListener {
- chatActivity.showBrowserScreen()
- dismiss()
- }
-
- dialogAttachmentBinding.menuAttachContact.setOnClickListener {
- chatActivity.sendChooseContactIntent()
- dismiss()
- }
- }
-
- override fun onStart() {
- super.onStart()
- val bottomSheet = findViewById(R.id.design_bottom_sheet)
- val behavior = BottomSheetBehavior.from(bottomSheet as View)
- behavior.state = BottomSheetBehavior.STATE_COLLAPSED
- }
-}
diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt
index e68d91fedc9..b8a20d744ce 100644
--- a/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt
+++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt
@@ -37,12 +37,14 @@ class SaveToStorageDialogFragment : DialogFragment() {
@Inject
lateinit var viewThemeUtils: ViewThemeUtils
lateinit var fileName: String
+ private var fileId: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedApplication!!.componentApplication.inject(this)
viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils)
fileName = arguments?.getString(KEY_FILE_NAME)!!
+ fileId = arguments?.getString(KEY_FILE_ID)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialogText = StringBuilder()
@@ -74,11 +76,15 @@ class SaveToStorageDialogFragment : DialogFragment() {
@SuppressLint("LongLogTag")
private fun saveImageToStorage(fileName: String) {
- val sourceDirectory = FileUtils.getSharedAttachmentsDirectory(requireContext().cacheDir) ?: run {
- Log.e(TAG, "Failed to resolve shared attachments directory")
+ // Files cached by the media viewer live in a folder named after their file id, chat attachments directly in
+ // the shared attachments directory.
+ val sourceFile = fileId?.let { FileUtils.resolveSharedAttachmentFile(requireContext().cacheDir, it, fileName) }
+ ?: FileUtils.resolveSharedAttachmentFile(requireContext().cacheDir, fileName)
+ if (sourceFile == null) {
+ Log.e(TAG, "Failed to resolve the file to save")
return
}
- val workerTag = SAVE_TO_STORAGE_WORKER_PREFIX + fileName
+ val workerTag = SAVE_TO_STORAGE_WORKER_PREFIX + sourceFile.absolutePath
val workers = WorkManager.getInstance(requireContext()).getWorkInfosByTag(workerTag)
try {
@@ -95,7 +101,7 @@ class SaveToStorageDialogFragment : DialogFragment() {
val data: Data = Data.Builder()
.putString(SaveFileToStorageWorker.KEY_FILE_NAME, fileName)
- .putString(SaveFileToStorageWorker.KEY_SOURCE_FILE_PATH, "$sourceDirectory/$fileName")
+ .putString(SaveFileToStorageWorker.KEY_SOURCE_FILE_PATH, sourceFile.absolutePath)
.build()
val saveWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(SaveFileToStorageWorker::class.java)
@@ -110,11 +116,13 @@ class SaveToStorageDialogFragment : DialogFragment() {
companion object {
val TAG = SaveToStorageDialogFragment::class.java.simpleName
private const val KEY_FILE_NAME = "keyFileName"
+ private const val KEY_FILE_ID = "keyFileId"
private const val SAVE_TO_STORAGE_WORKER_PREFIX = "saveToStorage_"
- fun newInstance(fileName: String): SaveToStorageDialogFragment {
+ fun newInstance(fileName: String, fileId: String? = null): SaveToStorageDialogFragment {
val args = Bundle()
args.putString(KEY_FILE_NAME, fileName)
+ fileId?.let { args.putString(KEY_FILE_ID, it) }
val fragment = SaveToStorageDialogFragment()
fragment.arguments = args
return fragment
diff --git a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt
index 4041f5218a0..72b15b30166 100644
--- a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt
+++ b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt
@@ -61,6 +61,7 @@ object BundleKeys {
const val KEY_FORWARD_MSG_FLAG = "KEY_FORWARD_MSG_FLAG"
const val KEY_FORWARD_MSG_TEXT = "KEY_FORWARD_MSG_TEXT"
const val KEY_FORWARD_HIDE_SOURCE_ROOM = "KEY_FORWARD_HIDE_SOURCE_ROOM"
+ const val KEY_FORWARD_FILE_KEY = "KEY_FORWARD_FILE_KEY"
const val KEY_SYSTEM_NOTIFICATION_ID = "KEY_SYSTEM_NOTIFICATION_ID"
const val KEY_MESSAGE_ID = "KEY_MESSAGE_ID"
const val KEY_MIME_TYPE_FILTER = "KEY_MIME_TYPE_FILTER"
diff --git a/app/src/main/java/com/nextcloud/talk/utils/permissions/PlatformPermissionUtilImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/permissions/PlatformPermissionUtilImpl.kt
index f29ef0e4bc7..6f3e8eff51f 100644
--- a/app/src/main/java/com/nextcloud/talk/utils/permissions/PlatformPermissionUtilImpl.kt
+++ b/app/src/main/java/com/nextcloud/talk/utils/permissions/PlatformPermissionUtilImpl.kt
@@ -14,6 +14,8 @@ import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.content.PermissionChecker
import com.nextcloud.talk.BuildConfig
+import com.nextcloud.talk.attachmentsheet.READ_MEDIA_VISUAL_USER_SELECTED
+import com.nextcloud.talk.attachmentsheet.hasMediaFilesAccess
class PlatformPermissionUtilImpl(private val context: Context) : PlatformPermissionUtil {
override val privateBroadcastPermission: String =
@@ -41,20 +43,12 @@ class PlatformPermissionUtilImpl(private val context: Context) : PlatformPermiss
override fun isFilesPermissionGranted(): Boolean =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
- if (
- PermissionChecker.checkSelfPermission(context, Manifest.permission.READ_MEDIA_IMAGES)
- == PermissionChecker.PERMISSION_GRANTED ||
- PermissionChecker.checkSelfPermission(context, Manifest.permission.READ_MEDIA_VIDEO)
- == PermissionChecker.PERMISSION_GRANTED ||
- PermissionChecker.checkSelfPermission(context, Manifest.permission.READ_MEDIA_AUDIO)
- == PermissionChecker.PERMISSION_GRANTED
- ) {
- Log.d(TAG, "Permission is granted (SDK 33 or greater)")
- true
- } else {
- Log.d(TAG, "Permission is revoked (SDK 33 or greater)")
- false
- }
+ val granted = MEDIA_PERMISSIONS.filter {
+ PermissionChecker.checkSelfPermission(context, it) == PermissionChecker.PERMISSION_GRANTED
+ }.toSet()
+ val hasAccess = hasMediaFilesAccess(Build.VERSION.SDK_INT, granted)
+ Log.d(TAG, "Media permission is ${if (hasAccess) "granted" else "revoked"} (SDK 33 or greater)")
+ hasAccess
}
Build.VERSION.SDK_INT > Build.VERSION_CODES.Q -> {
if (PermissionChecker.checkSelfPermission(
@@ -102,6 +96,13 @@ class PlatformPermissionUtilImpl(private val context: Context) : PlatformPermiss
) == PermissionChecker.PERMISSION_GRANTED
companion object {
+ private val MEDIA_PERMISSIONS = listOf(
+ Manifest.permission.READ_MEDIA_IMAGES,
+ Manifest.permission.READ_MEDIA_VIDEO,
+ Manifest.permission.READ_MEDIA_AUDIO,
+ READ_MEDIA_VISUAL_USER_SELECTED
+ )
+
private val TAG = PlatformPermissionUtilImpl::class.simpleName
}
}
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 440a83f37ef..7c17b5cb1d8 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
@@ -211,5 +211,13 @@ public interface AppPreferences {
long getConversationListLastUserId();
+ String getCameraCaptureMode();
+
+ void setCameraCaptureMode(String value);
+
+ boolean getVideoRecordMode();
+
+ void setVideoRecordMode(boolean value);
+
void clear();
}
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 ccda4e3e032..95c6568ef95 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
@@ -626,6 +626,32 @@ class AppPreferencesImpl(val context: Context) : AppPreferences {
}
}
+ override fun getCameraCaptureMode(): String =
+ runBlocking {
+ async { readString(CAMERA_CAPTURE_MODE).first() }
+ }.getCompleted()
+
+ override fun setCameraCaptureMode(value: String?) =
+ runBlocking {
+ async {
+ if (value != null) {
+ writeString(CAMERA_CAPTURE_MODE, value)
+ }
+ }
+ }
+
+ override fun getVideoRecordMode(): Boolean =
+ runBlocking {
+ async { readBoolean(VIDEO_RECORD_MODE).first() }
+ }.getCompleted()
+
+ override fun setVideoRecordMode(value: Boolean) =
+ runBlocking {
+ async {
+ writeBoolean(VIDEO_RECORD_MODE, value)
+ }
+ }
+
override fun clear() {}
private suspend fun writeString(key: String, value: String) =
@@ -708,6 +734,8 @@ class AppPreferencesImpl(val context: Context) : AppPreferences {
const val DB_ROOM_MIGRATED = "db_room_migrated"
const val PHONE_BOOK_INTEGRATION_LAST_RUN = "phone_book_integration_last_run"
const val TYPING_STATUS = "typing_status"
+ const val VIDEO_RECORD_MODE = "video_record_mode"
+ const val CAMERA_CAPTURE_MODE = "camera_capture_mode"
const val MESSAGE_QUEUE = "@message_queue"
const val PLAY_BACK = "_playback"
const val VOICE_MESSAGE_PLAYBACK_SPEEDS = "voice_message_playback_speeds"
diff --git a/app/src/main/res/drawable/bg_record_hint.xml b/app/src/main/res/drawable/bg_record_hint.xml
new file mode 100644
index 00000000000..f3958f677d9
--- /dev/null
+++ b/app/src/main/res/drawable/bg_record_hint.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_flash_auto_24.xml b/app/src/main/res/drawable/ic_baseline_flash_auto_24.xml
new file mode 100644
index 00000000000..585b040f786
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_flash_auto_24.xml
@@ -0,0 +1,16 @@
+
+
+
+
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
new file mode 100644
index 00000000000..39d39936420
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_flash_off_24.xml
@@ -0,0 +1,16 @@
+
+
+
+
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
new file mode 100644
index 00000000000..5890bafef6e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_flash_on_24.xml
@@ -0,0 +1,16 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_hd_24.xml b/app/src/main/res/drawable/ic_baseline_hd_24.xml
new file mode 100644
index 00000000000..2f2bd09a7cb
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_hd_24.xml
@@ -0,0 +1,16 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_speed_24.xml b/app/src/main/res/drawable/ic_baseline_speed_24.xml
new file mode 100644
index 00000000000..8dc1f30a1b2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_speed_24.xml
@@ -0,0 +1,16 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_record_hint_arrow.xml b/app/src/main/res/drawable/ic_record_hint_arrow.xml
new file mode 100644
index 00000000000..cbcbb257a7d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_record_hint_arrow.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/video_recording_dot.xml b/app/src/main/res/drawable/video_recording_dot.xml
new file mode 100644
index 00000000000..5215a6ff422
--- /dev/null
+++ b/app/src/main/res/drawable/video_recording_dot.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml
index 16a10d70a5b..72522c2f22d 100644
--- a/app/src/main/res/layout/activity_chat.xml
+++ b/app/src/main/res/layout/activity_chat.xml
@@ -118,6 +118,47 @@
app:fabCustomSize="@dimen/min_size_clickable_area"
app:srcCompat="@drawable/ic_lock_open_grey600_24dp" />
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_message_input_voice_recording.xml b/app/src/main/res/layout/fragment_message_input_voice_recording.xml
index ad6fd24795b..6753223994a 100644
--- a/app/src/main/res/layout/fragment_message_input_voice_recording.xml
+++ b/app/src/main/res/layout/fragment_message_input_voice_recording.xml
@@ -64,6 +64,7 @@
android:textStyle="bold" />
+
+
+
+
+
+
+
+
+ android:src="@drawable/ic_baseline_mic_24"
+ app:tint="?attr/colorControlNormal" />
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 3c80902ce09..4c5949b2314 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -101,4 +101,5 @@
#EF3B02
#DBE2E9
+ #99000000
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index ba77a21a5e5..3ae7d9f45b9 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -18,6 +18,12 @@
48dp
48dp
+ 480dp
+
+ 2dp
+ 24dp
+
+ 200dp
8dp
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5ef9f26e226..8b6cb4fe8cc 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -695,7 +695,10 @@ How to translate with transifex:
Play media file
- Add to conversation
+ Select more photos and videos
+ Photo
+ Video, %1$s
+ Send (%1$d)
Take photo
Take video
Create poll
@@ -744,6 +747,14 @@ How to translate with transifex:
Talk recording from %1$s (%2$s)
Hold to record, release to send.
Record voice message
+ Record video message
+ Hold to record, release to send. Tap to switch to video mode.
+ Hold to record video, release to send. Tap to switch to voice mode.
+ Video recording failed
+ Delete video recording
+ Send video recording
+ Permissions for camera and audio recording are required
+ Switch camera
« Slide to cancel
Play/pause voice message
Play/pause audio file
@@ -839,6 +850,13 @@ How to translate with transifex:
Take a photo
+ Flash off
+ Flash automatic
+ Flash on
+ Fast
+ Quality
+ Fast shutter. Tap to switch to maximum quality
+ Maximum quality. Tap to switch to fast shutter
Taking a photo is not possible without permissions
Camera permission granted. Please choose camera again.
Take a video
@@ -1010,7 +1028,25 @@ How to translate with transifex:
Compress images and videos
HQ
High quality, no compression
- Remove file
+ %1$d / %2$d
+ Include this file
+ Crop and rotate
+ Draw
+ %1$d of %2$d
+ %1$d of %2$d+
+ Save to gallery
+ Show all media
+ Show in chat
+ Undo
+ Add photos or video
+ Not included in the message
+ Could not save the changes to the picture
+ White
+ Black
+ Red
+ Yellow
+ Green
+ Blue
Add more files
Retrieval failed
Languages could not be retrieved
diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/AttachmentSelectionAndNamingTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/AttachmentSelectionAndNamingTest.kt
new file mode 100644
index 00000000000..1ddec9a0071
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/AttachmentSelectionAndNamingTest.kt
@@ -0,0 +1,93 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class AttachmentSelectionAndNamingTest {
+
+ private val files = listOf("a", "b", "c")
+
+ @Test
+ fun replaceUriKeepsPosition() {
+ assertEquals(listOf("a", "B", "c"), replaceUri(files, "b", "B"))
+ }
+
+ @Test
+ fun replaceUriOfUnknownFileChangesNothing() {
+ assertSame(files, replaceUri(files, "x", "y"))
+ }
+
+ @Test
+ fun replaceUriWithSameValueChangesNothing() {
+ assertSame(files, replaceUri(files, "a", "a"))
+ }
+
+ @Test
+ fun toggleExcludesAndReincludesFile() {
+ val excluded = toggleSelection(files, emptySet(), "b")
+ assertEquals(setOf("b"), excluded)
+ assertEquals(emptySet(), toggleSelection(files, excluded, "b"))
+ }
+
+ @Test
+ fun lastSelectedFileCannotBeExcluded() {
+ val unselected = setOf("a", "b")
+ assertEquals(unselected, toggleSelection(files, unselected, "c"))
+ }
+
+ @Test
+ fun singleFileCannotBeExcluded() {
+ assertEquals(emptySet(), toggleSelection(listOf("a"), emptySet(), "a"))
+ }
+
+ @Test
+ fun toggleOfUnknownFileChangesNothing() {
+ assertEquals(setOf("a"), toggleSelection(files, setOf("a"), "zzz"))
+ }
+
+ @Test
+ fun selectedFilesKeepListOrder() {
+ assertEquals(listOf("a", "c"), selectedFiles(files, setOf("b")))
+ }
+
+ @Test
+ fun editedFileNameAddsSuffixAndStamp() {
+ assertEquals("photo_edited_20261004-115100-123.jpg", editedFileName("photo.jpg", "20261004-115100-123", "jpg"))
+ }
+
+ @Test
+ fun editedFileNameDoesNotStackSuffixes() {
+ val once = editedFileName("photo.jpg", "20261004-115100-123", "jpg")
+ assertEquals("photo_edited_20261004-120000-001.png", editedFileName(once, "20261004-120000-001", "png"))
+ }
+
+ @Test
+ fun editedFileNameHandlesNamesWithoutExtensionOrBase() {
+ assertEquals("scan_edited_1.jpg", editedFileName("scan", "1", "jpg"))
+ assertEquals("image_edited_1.jpg", editedFileName(".jpg", "1", "jpg"))
+ }
+
+ @Test
+ fun editedFileNamesAreRecognisedAndOriginalsAreNot() {
+ assertTrue(isEditedFileName(editedFileName("photo.jpg", "20261004-115100-123", "jpg")))
+ assertFalse(isEditedFileName("photo.jpg"))
+ assertFalse(isEditedFileName("2026-10-04 11-51-00.jpg"))
+ assertFalse(isEditedFileName("my_edited_notes.jpg"))
+ }
+
+ @Test
+ fun onlyPngSourcesStayPng() {
+ assertTrue(editOutputIsPng("image/png"))
+ assertFalse(editOutputIsPng("image/jpeg"))
+ assertFalse(editOutputIsPng(null))
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBarLogicTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBarLogicTest.kt
new file mode 100644
index 00000000000..efc6c426d10
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBarLogicTest.kt
@@ -0,0 +1,25 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class AttachmentToolBarLogicTest {
+
+ @Test
+ fun permissionShrinksToIconWhenFullPillIsWiderThanSlot() {
+ assertTrue(shouldCompactPermission(fullWidthPx = 910, availableWidthPx = 775))
+ }
+
+ @Test
+ fun permissionKeepsLabelWhenFullPillFits() {
+ assertFalse(shouldCompactPermission(fullWidthPx = 775, availableWidthPx = 775))
+ assertFalse(shouldCompactPermission(fullWidthPx = 600, availableWidthPx = 775))
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/DrawingGeometryTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/DrawingGeometryTest.kt
new file mode 100644
index 00000000000..ce0b54ab52a
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/DrawingGeometryTest.kt
@@ -0,0 +1,85 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class DrawingGeometryTest {
+
+ @Test
+ fun fitRectLetterboxesWideImageInTallView() {
+ val rect = fitRect(1000f, 2000f, 2f)
+ assertEquals(FitRect(0f, 750f, 1000f, 500f), rect)
+ }
+
+ @Test
+ fun fitRectPillarboxesTallImageInWideView() {
+ val rect = fitRect(2000f, 1000f, 0.5f)
+ assertEquals(FitRect(750f, 0f, 500f, 1000f), rect)
+ }
+
+ @Test
+ fun toNormalizedMapsRectCornersAndClampsOutside() {
+ val rect = FitRect(0f, 750f, 1000f, 500f)
+ assertEquals(NormalizedPoint(0f, 0f), toNormalized(0f, 750f, rect))
+ assertEquals(NormalizedPoint(1f, 1f), toNormalized(1000f, 1250f, rect))
+ assertEquals(NormalizedPoint(0.5f, 0.5f), toNormalized(500f, 1000f, rect))
+ assertEquals(NormalizedPoint(0f, 1f), toNormalized(-50f, 5000f, rect))
+ }
+
+ @Test
+ fun viewTouchLandsOnSamePixelRegardlessOfViewSize() {
+ // A touch at the same relative spot of the picture must give the same pixel on two screens.
+ val small = toNormalized(150f, 100f, fitRect(300f, 200f, 1.5f))
+ val large = toNormalized(450f, 300f, fitRect(900f, 600f, 1.5f))
+ assertEquals(small, large)
+ assertEquals(1224f to 1632f, toPixels(NormalizedPoint(0.5f, 0.5f), 2448, 3264))
+ }
+
+ @Test
+ fun strokeWidthScalesWithBitmapAndHasOnePixelFloor() {
+ val stroke = DrawStroke(0, 0.01f, emptyList())
+ assertEquals(24.48f, strokeWidthPixels(stroke, 2448), 0.001f)
+ assertEquals(1f, strokeWidthPixels(stroke, 10), 0.001f)
+ }
+
+ @Test
+ fun undoLastDropsOnlyNewestStroke() {
+ val first = DrawStroke(1, 0.01f, listOf(NormalizedPoint(0f, 0f)))
+ val second = DrawStroke(2, 0.01f, listOf(NormalizedPoint(1f, 1f)))
+ assertEquals(listOf(first), undoLast(listOf(first, second)))
+ assertEquals(emptyList(), undoLast(emptyList()))
+ }
+
+ @Test
+ fun decodeSampleSizeKeepsFullResolutionBelowLimit() {
+ assertEquals(1, decodeSampleSize(4000, 3000))
+ assertEquals(1, decodeSampleSize(5000, 4000))
+ }
+
+ @Test
+ fun decodeSampleSizeDownsamplesHugeImagesByPowersOfTwo() {
+ // 48 MP -> 12 MP, 108 MP -> 6.75 MP, 400 MP -> 6.25 MP
+ assertEquals(2, decodeSampleSize(8000, 6000))
+ assertEquals(4, decodeSampleSize(12000, 9000))
+ assertEquals(8, decodeSampleSize(20000, 20000))
+ }
+
+ @Test
+ fun drawingSessionKeepsStrokesAndUndoesThem() {
+ val session = DrawingSession()
+ val first = DrawStroke(1, 0.01f, listOf(NormalizedPoint(0f, 0f)))
+ val second = DrawStroke(2, 0.01f, listOf(NormalizedPoint(1f, 1f)))
+ session.add(first)
+ session.add(second)
+ session.undo()
+ assertEquals(listOf(first), session.strokes)
+ session.clear()
+ assertEquals(emptyList(), session.strokes)
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/EditNameClaimTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/EditNameClaimTest.kt
new file mode 100644
index 00000000000..a0b9f1e4298
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/EditNameClaimTest.kt
@@ -0,0 +1,52 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class EditNameClaimTest {
+
+ @Test
+ fun claimFirstFreeKeepsThePlainNameWhenItIsFree() {
+ assertEquals("a", claimFirstFree(3, { if (it == 0) "a" else "a-$it" }, { true }))
+ }
+
+ @Test
+ fun claimFirstFreeAddsNumbersWhileNamesAreTaken() {
+ val taken = setOf("a", "a-1")
+ val claimed = mutableListOf()
+ val result = claimFirstFree(
+ maxAttempts = 5,
+ candidate = { if (it == 0) "a" else "a-$it" },
+ claim = { name -> (name !in taken).also { if (it) claimed.add(name) } }
+ )
+ assertEquals("a-2", result)
+ assertEquals(listOf("a-2"), claimed)
+ }
+
+ @Test
+ fun claimFirstFreeGivesUpAfterTheLastAttempt() {
+ var tried = 0
+ val result = claimFirstFree(3, { it }) {
+ tried++
+ false
+ }
+ assertNull(result)
+ assertEquals(3, tried)
+ }
+
+ @Test
+ fun numberedEditNamesStillCountAsOwnEdits() {
+ // the numeric suffix goes into the stamp, which the edit-name recognition accepts
+ val numbered = editedFileName("photo.jpg", "20261004-115100-123-2", "jpg")
+ assertTrue(isEditedFileName(numbered))
+ assertEquals("photo_edited_20261004-120000-001.jpg", editedFileName(numbered, "20261004-120000-001", "jpg"))
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/EditedFileOwnershipTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/EditedFileOwnershipTest.kt
new file mode 100644
index 00000000000..5f6df84ef96
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/EditedFileOwnershipTest.kt
@@ -0,0 +1,53 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class EditedFileOwnershipTest {
+
+ private val edited = editedFileName("photo.jpg", "20261004-115100-123", "jpg")
+
+ @Test
+ fun ownEditedNameAcceptsOnlyOurProviderAndTheSharedDirectory() {
+ val segments = listOf("shared_attachments", edited)
+ assertEquals(edited, ownEditedName("com.nextcloud.talk2", "com.nextcloud.talk2", segments))
+ }
+
+ @Test
+ fun ownEditedNameRejectsForeignAuthority() {
+ assertNull(ownEditedName("com.other", "com.nextcloud.talk2", listOf("shared_attachments", edited)))
+ assertNull(ownEditedName(null, "com.nextcloud.talk2", listOf("shared_attachments", edited)))
+ }
+
+ @Test
+ fun ownEditedNameRejectsNamesWithoutEditSuffix() {
+ assertNull(ownEditedName("a", "a", listOf("shared_attachments", "photo.jpg")))
+ }
+
+ @Test
+ fun ownEditedNameRejectsNestedOrOtherPaths() {
+ assertNull(ownEditedName("a", "a", listOf("shared_attachments", "42", edited)))
+ assertNull(ownEditedName("a", "a", listOf("photos", edited)))
+ assertNull(ownEditedName("a", "a", listOf(edited)))
+ assertNull(ownEditedName("a", "a", emptyList()))
+ }
+
+ @Test
+ fun onlyJpegWithPlainRotationKeepsTheExifTag() {
+ assertFalse(rotatesPixels(png = false, rotationDegrees = 90, flipped = false))
+ assertFalse(rotatesPixels(png = false, rotationDegrees = 0, flipped = false))
+ assertTrue(rotatesPixels(png = true, rotationDegrees = 90, flipped = false))
+ assertFalse(rotatesPixels(png = true, rotationDegrees = 0, flipped = false))
+ assertTrue(rotatesPixels(png = false, rotationDegrees = 0, flipped = true))
+ assertTrue(rotatesPixels(png = true, rotationDegrees = 270, flipped = true))
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewResultTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewResultTest.kt
new file mode 100644
index 00000000000..1be51f303f1
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewResultTest.kt
@@ -0,0 +1,36 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class FileAttachmentPreviewResultTest {
+
+ @Test
+ fun resultKeepsFilesCaptionAndFlags() {
+ val bundle = FileAttachmentPreviewFragment.packResult(listOf("a", "b"), "caption", true, false)
+
+ assertEquals(listOf("a", "b"), bundle.getStringArrayList(FileAttachmentPreviewFragment.RESULT_FILES))
+ assertEquals("caption", bundle.getString(FileAttachmentPreviewFragment.RESULT_CAPTION))
+ assertTrue(bundle.getBoolean(FileAttachmentPreviewFragment.RESULT_COMPRESS_IMAGES))
+ assertFalse(bundle.getBoolean(FileAttachmentPreviewFragment.RESULT_ALLOW_UPDATE))
+ }
+
+ @Test
+ fun resultKeepsFileOrderAndAllowUpdate() {
+ val bundle = FileAttachmentPreviewFragment.packResult(listOf("2", "1", "3"), "", false, true)
+
+ assertEquals(listOf("2", "1", "3"), bundle.getStringArrayList(FileAttachmentPreviewFragment.RESULT_FILES))
+ assertTrue(bundle.getBoolean(FileAttachmentPreviewFragment.RESULT_ALLOW_UPDATE))
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewSelectionTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewSelectionTest.kt
new file mode 100644
index 00000000000..40561416906
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewSelectionTest.kt
@@ -0,0 +1,105 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import android.app.Application
+import android.content.Context
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.mock
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [33])
+class FileAttachmentPreviewSelectionTest {
+
+ private fun viewModelWith(vararg uris: String) =
+ FileAttachmentPreviewViewModel(mock(Context::class.java)).apply { setInitialFiles(uris.toList()) }
+
+ @Test
+ fun replaceFileSwapsUriInPlace() {
+ val vm = viewModelWith("a", "b", "c")
+ vm.replaceFile("b", "b2")
+ assertEquals(listOf("a", "b2", "c"), vm.files.toList())
+ }
+
+ @Test
+ fun replaceFileKeepsExcludedState() {
+ val vm = viewModelWith("a", "b")
+ vm.toggleSelected("b")
+ vm.replaceFile("b", "b2")
+ assertEquals(listOf("a"), vm.selectedFiles())
+ assertEquals(listOf("b2"), vm.unselected.toList())
+ }
+
+ @Test
+ fun replaceFileOfUnknownUriIsIgnored() {
+ val vm = viewModelWith("a")
+ vm.replaceFile("zzz", "y")
+ assertEquals(listOf("a"), vm.files.toList())
+ }
+
+ @Test
+ fun excludedFileIsNotSelectedButStaysInList() {
+ val vm = viewModelWith("a", "b", "c")
+ vm.toggleSelected("b")
+ assertEquals(listOf("a", "c"), vm.selectedFiles())
+ assertEquals(listOf("a", "b", "c"), vm.files.toList())
+ vm.toggleSelected("b")
+ assertEquals(listOf("a", "b", "c"), vm.selectedFiles())
+ }
+
+ @Test
+ fun lastSelectedFileStaysSelected() {
+ val vm = viewModelWith("a", "b")
+ vm.toggleSelected("a")
+ vm.toggleSelected("b")
+ assertEquals(listOf("b"), vm.selectedFiles())
+ assertEquals(listOf("a"), vm.unselected.toList())
+ }
+
+ @Test
+ fun filesAddedLaterAreSelected() {
+ val vm = viewModelWith("a", "b")
+ vm.toggleSelected("a")
+ vm.addFiles(listOf("c"))
+ assertEquals(listOf("b", "c"), vm.selectedFiles())
+ }
+
+ private fun description(uri: String, detail: String = "d") =
+ FileDescription(uri, "name", MediaKind.IMAGE, "image/jpeg", detail)
+
+ @Test
+ fun replacedPageKeepsItsDescriptionUntilRefreshed() {
+ val vm = viewModelWith("a", "b")
+ vm.publishDescriptions(mapOf("a" to description("a"), "b" to description("b")))
+ vm.replaceFile("b", "b2")
+ assertEquals(listOf("a", "b2"), vm.files.mapNotNull { vm.descriptionsByUri[it]?.uri })
+ }
+
+ @Test
+ fun staleDescriptionResultCannotWipeOutReplacement() {
+ val vm = viewModelWith("a", "b")
+ vm.publishDescriptions(mapOf("a" to description("a"), "b" to description("b")))
+ vm.replaceFile("b", "b2")
+ // a describe run started before the replacement finishes late and only knows the old uris
+ vm.publishDescriptions(mapOf("a" to description("a", "late"), "b" to description("b", "late")))
+ assertEquals(setOf("a", "b2"), vm.descriptionsByUri.keys)
+ assertEquals("b2", vm.descriptionsByUri["b2"]?.uri)
+ }
+
+ @Test
+ fun freshDescriptionsReplaceCarriedOverOnes() {
+ val vm = viewModelWith("a")
+ vm.publishDescriptions(mapOf("a" to description("a")))
+ vm.replaceFile("a", "a2")
+ vm.publishDescriptions(mapOf("a2" to description("a2", "fresh")))
+ assertEquals("fresh", vm.descriptionsByUri["a2"]?.detail)
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt
index 7becb4d81d2..243658ab424 100644
--- a/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt
+++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt
@@ -59,16 +59,16 @@ class FileAttachmentPreviewViewModelTest {
}
@Test
- fun `mime types of removed files are dropped`() {
+ fun `mime types of replaced files are dropped`() {
viewModel.setInitialFiles(listOf("content://a/1.txt", "content://a/2.txt"))
viewModel.resolveMimeTypes()
testScope.advanceUntilIdle()
- viewModel.removeFile("content://a/1.txt")
+ viewModel.replaceFile("content://a/1.txt", "content://a/3.txt")
viewModel.resolveMimeTypes()
testScope.advanceUntilIdle()
- assertEquals(setOf("content://a/2.txt"), viewModel.mimeTypesByUri.keys)
+ assertEquals(setOf("content://a/3.txt", "content://a/2.txt"), viewModel.mimeTypesByUri.keys)
}
@Test
diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/ImageDetailLogicTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/ImageDetailLogicTest.kt
new file mode 100644
index 00000000000..67db042366d
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/ImageDetailLogicTest.kt
@@ -0,0 +1,109 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import androidx.exifinterface.media.ExifInterface
+import com.nextcloud.talk.utils.ImageCompressor.ImageInfo
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+class ImageDetailLogicTest {
+
+ private val describe: (ImageInfo) -> String = { "${it.width}x${it.height} ${it.sizeBytes}" }
+ private val cameraShot = ImageInfo(3000, 4000, 3_000_000)
+ private val compressed = ImageInfo(960, 1280, 153_000)
+
+ @Test
+ fun quarterTurnOrientationsSwapDimensions() {
+ listOf(
+ ExifInterface.ORIENTATION_ROTATE_90,
+ ExifInterface.ORIENTATION_ROTATE_270,
+ ExifInterface.ORIENTATION_TRANSPOSE,
+ ExifInterface.ORIENTATION_TRANSVERSE
+ ).forEach { assertTrue(exifSwapsDimensions(it)) }
+ }
+
+ @Test
+ fun otherOrientationsKeepDimensions() {
+ listOf(
+ ExifInterface.ORIENTATION_UNDEFINED,
+ ExifInterface.ORIENTATION_NORMAL,
+ ExifInterface.ORIENTATION_FLIP_HORIZONTAL,
+ ExifInterface.ORIENTATION_ROTATE_180,
+ ExifInterface.ORIENTATION_FLIP_VERTICAL
+ ).forEach { assertFalse(exifSwapsDimensions(it)) }
+ }
+
+ @Test
+ fun rotatedPhotoReportsPortraitSize() {
+ val shown = ImageInfo(4000, 3000, 3_000_000).inDisplayOrientation(ExifInterface.ORIENTATION_ROTATE_90)
+
+ assertEquals(ImageInfo(3000, 4000, 3_000_000), shown)
+ }
+
+ @Test
+ fun uprightPhotoKeepsItsSize() {
+ assertEquals(cameraShot, cameraShot.inDisplayOrientation(ExifInterface.ORIENTATION_NORMAL))
+ }
+
+ @Test
+ fun standardQualityShowsCompressedResult() {
+ val variants = imageDetailVariants(cameraShot, compressed, compress = true, describe)
+
+ assertEquals("960x1280 153000", variants.current)
+ assertEquals("3000x4000 3000000", variants.alternate)
+ }
+
+ @Test
+ fun highQualityShowsOriginal() {
+ val variants = imageDetailVariants(cameraShot, compressed, compress = false, describe)
+
+ assertEquals("3000x4000 3000000", variants.current)
+ assertEquals("960x1280 153000", variants.alternate)
+ }
+
+ @Test
+ fun missingEstimateFallsBackToOriginal() {
+ val variants = imageDetailVariants(cameraShot, null, compress = true, describe)
+
+ assertEquals("3000x4000 3000000", variants.current)
+ assertEquals(variants.current, variants.alternate)
+ }
+
+ @Test
+ fun rotatedPhotoAndItsCompressedResultAgreeOnOrientation() {
+ val original = ImageInfo(4000, 3000, 3_000_000).inDisplayOrientation(ExifInterface.ORIENTATION_ROTATE_90)
+
+ val variants = imageDetailVariants(original, compressed, compress = false, describe)
+
+ assertEquals("3000x4000 3000000", variants.current)
+ assertEquals("960x1280 153000", variants.alternate)
+ }
+
+ @Test
+ fun gifIsNeverEstimatedSoBothCaptionsShowTheOriginal() {
+ var estimated = false
+ val info = compressedImageInfo("image/gif", File("a.gif")) {
+ estimated = true
+ compressed
+ }
+
+ val variants = imageDetailVariants(cameraShot, info, compress = true, describe)
+
+ assertFalse(estimated)
+ assertEquals("3000x4000 3000000", variants.current)
+ assertEquals(variants.current, variants.alternate)
+ }
+
+ @Test
+ fun jpegIsEstimated() {
+ assertEquals(compressed, compressedImageInfo("image/jpeg", File("a.jpg")) { compressed })
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/UprightMatrixTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/UprightMatrixTest.kt
new file mode 100644
index 00000000000..3fc37f6b940
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/UprightMatrixTest.kt
@@ -0,0 +1,85 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentpreview
+
+import android.app.Application
+import android.graphics.Matrix
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Drawing happens in upright coordinates but onto the raw, un-rotated pixels, so the inverse of
+ * [uprightMatrix] must send an upright point to the raw pixel the EXIF orientation says it shows.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [33])
+class UprightMatrixTest {
+
+ private val rawWidth = 40
+ private val rawHeight = 20
+
+ /** (rotationDegrees, flipped) as androidx ExifInterface reports them, and upright->raw per EXIF spec. */
+ private data class Orientation(
+ val exifValue: Int,
+ val degrees: Int,
+ val flipped: Boolean,
+ val uprightWidth: Int,
+ val uprightToRaw: (Float, Float) -> Pair
+ )
+
+ private val orientations = listOf(
+ Orientation(1, 0, false, 40) { x, y -> x to y },
+ Orientation(2, 0, true, 40) { x, y -> (40f - x) to y },
+ Orientation(3, 180, false, 40) { x, y -> (40f - x) to (20f - y) },
+ Orientation(4, 180, true, 40) { x, y -> x to (20f - y) },
+ Orientation(5, 270, true, 20) { x, y -> y to x },
+ Orientation(6, 90, false, 20) { x, y -> y to (20f - x) },
+ Orientation(7, 90, true, 20) { x, y -> (40f - y) to (20f - x) },
+ Orientation(8, 270, false, 20) { x, y -> (40f - y) to x }
+ )
+
+ private fun uprightToRaw(matrix: Matrix, x: Float, y: Float): Pair {
+ val inverse = Matrix().also { matrix.invert(it) }
+ val point = floatArrayOf(x, y)
+ inverse.mapPoints(point)
+ return point[0] to point[1]
+ }
+
+ @Test
+ fun uprightPointsLandOnTheExpectedRawPixelForAllEightOrientations() {
+ orientations.forEach { o ->
+ val matrix = uprightMatrix(o.degrees, o.flipped, rawWidth, rawHeight)
+ val uprightHeight = if (o.uprightWidth == rawWidth) rawHeight else rawWidth
+ listOf(
+ 0f to 0f,
+ o.uprightWidth.toFloat() to uprightHeight.toFloat(),
+ 3f to 7f,
+ o.uprightWidth / 2f to 1f
+ ).forEach { (x, y) ->
+ val (expectedX, expectedY) = o.uprightToRaw(x, y)
+ val (actualX, actualY) = uprightToRaw(matrix, x, y)
+ assertEquals("orientation ${o.exifValue} x for ($x,$y)", expectedX, actualX, 0.001f)
+ assertEquals("orientation ${o.exifValue} y for ($x,$y)", expectedY, actualY, 0.001f)
+ }
+ }
+ }
+
+ @Test
+ fun uprightSizeSwapsOnlyForQuarterTurns() {
+ orientations.forEach { o ->
+ val matrix = uprightMatrix(o.degrees, o.flipped, rawWidth, rawHeight)
+ val bounds = android.graphics.RectF(0f, 0f, rawWidth.toFloat(), rawHeight.toFloat())
+ .also { matrix.mapRect(it) }
+ assertEquals("orientation ${o.exifValue}", o.uprightWidth.toFloat(), bounds.width(), 0.001f)
+ assertEquals(0f, bounds.left, 0.001f)
+ assertEquals(0f, bounds.top, 0.001f)
+ }
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/attachmentsheet/AttachmentActionTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentsheet/AttachmentActionTest.kt
new file mode 100644
index 00000000000..265e5cab2d9
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/AttachmentActionTest.kt
@@ -0,0 +1,80 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentsheet
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Test
+
+class AttachmentActionTest {
+
+ private val everything = AttachmentVisibilityInput(
+ isRemoteConversation = false,
+ hasGeoLocationCapability = true,
+ hasPollsCapability = true,
+ isOneToOneConversation = false,
+ hasThreadsCapability = true,
+ isInsideThread = false,
+ hasCamera = true
+ )
+
+ @Test
+ fun localGroupConversationOffersEverything() {
+ assertEquals(AttachmentAction.entries, resolveAttachmentActions(everything))
+ }
+
+ @Test
+ fun remoteConversationKeepsOnlyGalleryPollAndThread() {
+ val actions = resolveAttachmentActions(everything.copy(isRemoteConversation = true))
+ assertEquals(
+ listOf(AttachmentAction.GALLERY, AttachmentAction.CREATE_THREAD, AttachmentAction.CREATE_POLL),
+ actions
+ )
+ }
+
+ @Test
+ fun missingCameraHidesPhotoAndVideo() {
+ val actions = resolveAttachmentActions(everything.copy(hasCamera = false))
+ assertFalse(AttachmentAction.VIDEO_FROM_CAM in actions)
+ assertFalse(AttachmentAction.PICTURE_FROM_CAM in actions)
+ }
+
+ @Test
+ fun missingGeoCapabilityHidesLocation() {
+ val actions = resolveAttachmentActions(everything.copy(hasGeoLocationCapability = false))
+ assertFalse(AttachmentAction.SHARE_LOCATION in actions)
+ }
+
+ @Test
+ fun pollNeedsCapabilityAndGroupConversation() {
+ val withoutCapability = resolveAttachmentActions(everything.copy(hasPollsCapability = false))
+ val oneToOne = resolveAttachmentActions(everything.copy(isOneToOneConversation = true))
+ assertFalse(AttachmentAction.CREATE_POLL in withoutCapability)
+ assertFalse(AttachmentAction.CREATE_POLL in oneToOne)
+ }
+
+ @Test
+ fun threadNeedsCapabilityAndNoThreadOpen() {
+ val withoutCapability = resolveAttachmentActions(everything.copy(hasThreadsCapability = false))
+ val insideThread = resolveAttachmentActions(everything.copy(isInsideThread = true))
+ assertFalse(AttachmentAction.CREATE_THREAD in withoutCapability)
+ assertFalse(AttachmentAction.CREATE_THREAD in insideThread)
+ }
+
+ @Test
+ fun remoteConversationWithoutCapabilitiesKeepsOnlyGallery() {
+ val actions = resolveAttachmentActions(
+ everything.copy(
+ isRemoteConversation = true,
+ hasPollsCapability = false,
+ hasThreadsCapability = false,
+ hasCamera = false
+ )
+ )
+ assertEquals(listOf(AttachmentAction.GALLERY), actions)
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/attachmentsheet/CanSelectMoreMediaTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentsheet/CanSelectMoreMediaTest.kt
new file mode 100644
index 00000000000..b2a3e818667
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/CanSelectMoreMediaTest.kt
@@ -0,0 +1,55 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentsheet
+
+import android.Manifest
+import android.os.Build
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CanSelectMoreMediaTest {
+
+ private val images = Manifest.permission.READ_MEDIA_IMAGES
+ private val video = Manifest.permission.READ_MEDIA_VIDEO
+ private val storage = Manifest.permission.READ_EXTERNAL_STORAGE
+ private val selected = READ_MEDIA_VISUAL_USER_SELECTED
+
+ @Test
+ fun selectMoreIsOfferedForPartialAccess() {
+ assertTrue(canSelectMoreMedia(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(selected)))
+ }
+
+ @Test
+ fun selectMoreIsOfferedForPhotosWithoutVideosOnAndroid14() {
+ assertTrue(canSelectMoreMedia(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(images, selected)))
+ }
+
+ @Test
+ fun selectMoreIsOfferedForVideosWithoutPhotosOnAndroid14() {
+ assertTrue(canSelectMoreMedia(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(video, selected)))
+ }
+
+ @Test
+ fun selectMoreIsHiddenForFullAccessOnAndroid14() {
+ assertFalse(canSelectMoreMedia(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(images, video, selected)))
+ assertFalse(canSelectMoreMedia(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(images, video)))
+ }
+
+ @Test
+ fun selectMoreIsHiddenWithoutUserSelectedGrantOnAndroid14() {
+ assertFalse(canSelectMoreMedia(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(images)))
+ assertFalse(canSelectMoreMedia(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, emptySet()))
+ }
+
+ @Test
+ fun selectMoreIsHiddenBeforeAndroid14() {
+ assertFalse(canSelectMoreMedia(Build.VERSION_CODES.TIRAMISU, setOf(images, selected)))
+ assertFalse(canSelectMoreMedia(Build.VERSION_CODES.TIRAMISU, setOf(selected)))
+ assertFalse(canSelectMoreMedia(Build.VERSION_CODES.S, setOf(storage)))
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaAccessTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaAccessTest.kt
new file mode 100644
index 00000000000..8acca066642
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaAccessTest.kt
@@ -0,0 +1,114 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentsheet
+
+import android.Manifest
+import android.os.Build
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MediaAccessTest {
+
+ private val images = Manifest.permission.READ_MEDIA_IMAGES
+ private val video = Manifest.permission.READ_MEDIA_VIDEO
+ private val storage = Manifest.permission.READ_EXTERNAL_STORAGE
+ private val selected = READ_MEDIA_VISUAL_USER_SELECTED
+
+ @Test
+ fun android14WithAllMediaIsFull() {
+ assertEquals(
+ MediaAccess.FULL,
+ resolveMediaAccess(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(images, video, selected))
+ )
+ }
+
+ @Test
+ fun android14WithOnlyUserSelectedIsPartial() {
+ assertEquals(
+ MediaAccess.PARTIAL,
+ resolveMediaAccess(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(selected))
+ )
+ }
+
+ @Test
+ fun android14WithNothingIsNone() {
+ assertEquals(MediaAccess.NONE, resolveMediaAccess(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, emptySet()))
+ }
+
+ @Test
+ fun android13WithOneMediaTypeIsFull() {
+ assertEquals(MediaAccess.FULL, resolveMediaAccess(Build.VERSION_CODES.TIRAMISU, setOf(video)))
+ }
+
+ @Test
+ fun android13IgnoresUserSelectedPermission() {
+ assertEquals(MediaAccess.NONE, resolveMediaAccess(Build.VERSION_CODES.TIRAMISU, setOf(selected)))
+ }
+
+ @Test
+ fun oldAndroidUsesStoragePermission() {
+ assertEquals(MediaAccess.FULL, resolveMediaAccess(Build.VERSION_CODES.S, setOf(storage)))
+ assertEquals(MediaAccess.NONE, resolveMediaAccess(Build.VERSION_CODES.S, setOf(images)))
+ }
+
+ @Test
+ fun android14WithOneMediaTypeIsFull() {
+ assertEquals(MediaAccess.FULL, resolveMediaAccess(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(images)))
+ }
+
+ @Test
+ fun grantingEveryRequestedPermissionGivesFullAccess() {
+ listOf(Build.VERSION_CODES.S, Build.VERSION_CODES.TIRAMISU, Build.VERSION_CODES.UPSIDE_DOWN_CAKE).forEach {
+ val requested = mediaPermissionsToRequest(it).toSet()
+ assertEquals("sdk $it", MediaAccess.FULL, resolveMediaAccess(it, requested))
+ }
+ }
+
+ @Test
+ fun grantingNothingGivesNoAccessOnEveryVersion() {
+ listOf(Build.VERSION_CODES.S, Build.VERSION_CODES.TIRAMISU, Build.VERSION_CODES.UPSIDE_DOWN_CAKE).forEach {
+ assertEquals("sdk $it", MediaAccess.NONE, resolveMediaAccess(it, emptySet()))
+ }
+ }
+
+ @Test
+ fun filesAccessCountsPartialGrantOnAndroid14() {
+ assertTrue(hasMediaFilesAccess(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(selected)))
+ }
+
+ @Test
+ fun filesAccessIgnoresPartialGrantBeforeAndroid14() {
+ assertFalse(hasMediaFilesAccess(Build.VERSION_CODES.TIRAMISU, setOf(selected)))
+ }
+
+ @Test
+ fun filesAccessCountsAudioOnAndroid13() {
+ assertTrue(hasMediaFilesAccess(Build.VERSION_CODES.TIRAMISU, setOf(Manifest.permission.READ_MEDIA_AUDIO)))
+ }
+
+ @Test
+ fun filesAccessIsFalseWithoutAnyMediaPermission() {
+ assertFalse(hasMediaFilesAccess(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, emptySet()))
+ }
+
+ @Test
+ fun partialGrantOnAndroid14IsAccessEvenThoughTheFirstRequestedPermissionIsDenied() {
+ // User-selected photos are asked for from Android 14 on, not before.
+ val sdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE
+ val requested = mediaPermissionsToRequest(sdk)
+ assertEquals(setOf(images, video, selected), requested.toSet())
+ assertEquals(setOf(images, video), mediaPermissionsToRequest(Build.VERSION_CODES.TIRAMISU).toSet())
+
+ // The result handler must not look at grantResults[0] only: with "selected photos" the first permission of
+ // the request (READ_MEDIA_IMAGES) is denied and only READ_MEDIA_VISUAL_USER_SELECTED is granted.
+ val granted = setOf(selected)
+ assertFalse(requested.first() in granted)
+ assertTrue(hasMediaFilesAccess(sdk, granted))
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaSelectionTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaSelectionTest.kt
new file mode 100644
index 00000000000..dc5f338a658
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaSelectionTest.kt
@@ -0,0 +1,58 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentsheet
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MediaSelectionTest {
+
+ @Test
+ fun toggleSelectsInOrderAndNumbersFromOne() {
+ val selection = MediaSelection(limit = 10).toggle(7).toggle(3).toggle(9)
+ assertEquals(listOf(7L, 3L, 9L), selection.ids)
+ assertEquals(1, selection.positionOf(7))
+ assertEquals(3, selection.positionOf(9))
+ assertEquals(3, selection.count)
+ }
+
+ @Test
+ fun toggleTwiceDeselectsAndRenumbers() {
+ val selection = MediaSelection(limit = 10).toggle(1).toggle(2).toggle(3).toggle(1)
+ assertEquals(listOf(2L, 3L), selection.ids)
+ assertNull(selection.positionOf(1))
+ assertEquals(1, selection.positionOf(2))
+ }
+
+ @Test
+ fun selectingBeyondLimitIsIgnored() {
+ val selection = MediaSelection(limit = 2).toggle(1).toggle(2).toggle(3)
+ assertEquals(listOf(1L, 2L), selection.ids)
+ assertTrue(selection.isFull)
+ }
+
+ @Test
+ fun deselectingAtLimitFreesASlot() {
+ val selection = MediaSelection(limit = 2).toggle(1).toggle(2).toggle(1).toggle(3)
+ assertEquals(listOf(2L, 3L), selection.ids)
+ }
+
+ @Test
+ fun emptySelectionIsEmpty() {
+ assertTrue(MediaSelection(limit = 10).isEmpty)
+ assertFalse(MediaSelection(limit = 10).toggle(1).isEmpty)
+ }
+
+ @Test
+ fun retainAvailableDropsMissingIdsAndKeepsOrder() {
+ val selection = MediaSelection(limit = 10).toggle(5).toggle(6).toggle(7).retainAvailable(listOf(7L, 5L))
+ assertEquals(listOf(5L, 7L), selection.ids)
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/attachmentsheet/RecentMediaTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentsheet/RecentMediaTest.kt
new file mode 100644
index 00000000000..322fee064ca
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/RecentMediaTest.kt
@@ -0,0 +1,66 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentsheet
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+
+class RecentMediaTest {
+
+ private fun image(id: Long, date: Long) = RecentMedia(id, isVideo = false, dateAddedSeconds = date)
+
+ private fun video(id: Long, date: Long) =
+ RecentMedia(id, isVideo = true, dateAddedSeconds = date, durationMs = 1000)
+
+ @Test
+ fun mergeOrdersNewestFirstAcrossTypes() {
+ val merged = mergeRecentMedia(listOf(image(1, 50), image(2, 10)), listOf(video(1, 30)), limit = 10)
+ assertEquals(listOf(50L, 30L, 10L), merged.map { it.dateAddedSeconds })
+ }
+
+ @Test
+ fun mergeCutsToLimit() {
+ val merged = mergeRecentMedia(listOf(image(1, 5), image(2, 4)), listOf(video(1, 6), video(2, 3)), limit = 3)
+ assertEquals(listOf(6L, 5L, 4L), merged.map { it.dateAddedSeconds })
+ }
+
+ @Test
+ fun equalDatesKeepImagesBeforeVideos() {
+ val merged = mergeRecentMedia(listOf(image(1, 5)), listOf(video(2, 5)), limit = 10)
+ assertEquals(listOf(false, true), merged.map { it.isVideo })
+ }
+
+ @Test
+ fun mergeOfNothingIsEmpty() {
+ assertEquals(emptyList(), mergeRecentMedia(emptyList(), emptyList(), limit = 5))
+ }
+
+ @Test
+ fun imageAndVideoWithSameRowIdHaveDifferentKeys() {
+ assertNotEquals(image(4, 1).key, video(4, 1).key)
+ }
+
+ @Test
+ fun durationBelowHourShowsMinutesAndSeconds() {
+ assertEquals("0:00", formatVideoDuration(0))
+ assertEquals("0:07", formatVideoDuration(7_400))
+ assertEquals("2:05", formatVideoDuration(125_000))
+ assertEquals("59:59", formatVideoDuration(3_599_999))
+ }
+
+ @Test
+ fun durationFromHourShowsHours() {
+ assertEquals("1:00:00", formatVideoDuration(3_600_000))
+ assertEquals("2:03:04", formatVideoDuration(7_384_000))
+ }
+
+ @Test
+ fun negativeDurationIsZero() {
+ assertEquals("0:00", formatVideoDuration(-5))
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/attachmentsheet/ShareFilePermissionTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentsheet/ShareFilePermissionTest.kt
new file mode 100644
index 00000000000..6c307380285
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/ShareFilePermissionTest.kt
@@ -0,0 +1,53 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.attachmentsheet
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ShareFilePermissionTest {
+
+ private val granted = PackageManager.PERMISSION_GRANTED
+ private val denied = PackageManager.PERMISSION_DENIED
+
+ @Test
+ fun requestIsTheSheetSetPlusAudioFromAndroid13() {
+ val audio = Manifest.permission.READ_MEDIA_AUDIO
+ listOf(Build.VERSION_CODES.TIRAMISU, Build.VERSION_CODES.UPSIDE_DOWN_CAKE).forEach {
+ assertEquals(mediaPermissionsToRequest(it).toSet() + audio, shareFilePermissionsToRequest(it).toSet())
+ }
+ assertEquals(
+ setOf(Manifest.permission.READ_EXTERNAL_STORAGE),
+ shareFilePermissionsToRequest(Build.VERSION_CODES.Q).toSet()
+ )
+ }
+
+ @Test
+ fun anyGrantedPermissionAllowsThePicker() {
+ // Android 14, "selected photos": the first result is denied, a later one is granted
+ assertTrue(isShareFileRequestGranted(intArrayOf(denied, denied, granted), effectivelyGranted = false))
+ // Android 8-10: READ is granted, the effective check looks at WRITE
+ assertTrue(isShareFileRequestGranted(intArrayOf(granted), effectivelyGranted = false))
+ }
+
+ @Test
+ fun effectiveAccessAllowsThePickerEvenWithoutGrantedResult() {
+ assertTrue(isShareFileRequestGranted(intArrayOf(denied), effectivelyGranted = true))
+ assertTrue(isShareFileRequestGranted(intArrayOf(), effectivelyGranted = true))
+ }
+
+ @Test
+ fun nothingGrantedIsRefused() {
+ assertFalse(isShareFileRequestGranted(intArrayOf(denied, denied), effectivelyGranted = false))
+ assertFalse(isShareFileRequestGranted(intArrayOf(), effectivelyGranted = false))
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/camera/CaptureModeSettingTest.kt b/app/src/test/java/com/nextcloud/talk/camera/CaptureModeSettingTest.kt
new file mode 100644
index 00000000000..b37cadba116
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/camera/CaptureModeSettingTest.kt
@@ -0,0 +1,49 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.camera
+
+import androidx.camera.core.ImageCapture
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class CaptureModeSettingTest {
+
+ @Test
+ fun captureModeDefaultsToFast() {
+ assertEquals(CaptureModeSetting.FAST, CaptureModeSetting.DEFAULT)
+ assertEquals(CaptureModeSetting.FAST, CaptureModeSetting.fromStorage(null))
+ assertEquals(CaptureModeSetting.FAST, CaptureModeSetting.fromStorage(""))
+ }
+
+ @Test
+ fun captureModeMapsToImageCaptureMode() {
+ assertEquals(
+ ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY,
+ CaptureModeSetting.fromStorage("fast").imageCaptureMode
+ )
+ assertEquals(
+ ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY,
+ CaptureModeSetting.fromStorage("quality").imageCaptureMode
+ )
+ }
+
+ @Test
+ fun captureModeUnknownStoredValueGivesDefault() {
+ assertEquals(CaptureModeSetting.DEFAULT, CaptureModeSetting.fromStorage("maximum"))
+ assertEquals(CaptureModeSetting.DEFAULT, CaptureModeSetting.fromStorage("QUALITY"))
+ }
+
+ @Test
+ fun captureModeTogglesThereAndBack() {
+ assertEquals(CaptureModeSetting.QUALITY, CaptureModeSetting.FAST.toggled())
+ assertEquals(CaptureModeSetting.FAST, CaptureModeSetting.QUALITY.toggled())
+ CaptureModeSetting.entries.forEach {
+ assertEquals(it, CaptureModeSetting.fromStorage(it.storageValue))
+ assertEquals(it, it.toggled().toggled())
+ }
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/camera/CaptureOrientationTest.kt b/app/src/test/java/com/nextcloud/talk/camera/CaptureOrientationTest.kt
new file mode 100644
index 00000000000..3f660af6d36
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/camera/CaptureOrientationTest.kt
@@ -0,0 +1,99 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.camera
+
+import android.view.OrientationEventListener.ORIENTATION_UNKNOWN
+import android.view.Surface
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class CaptureOrientationTest {
+
+ @Test
+ fun sensorAngleMapsToTargetRotationAtTheBoundaries() {
+ assertEquals(Surface.ROTATION_0, rotationForDeviceOrientation(0))
+ assertEquals(Surface.ROTATION_0, rotationForDeviceOrientation(44))
+ assertEquals(Surface.ROTATION_270, rotationForDeviceOrientation(45))
+ assertEquals(Surface.ROTATION_0, rotationForDeviceOrientation(315))
+ assertEquals(Surface.ROTATION_90, rotationForDeviceOrientation(314))
+ assertEquals(Surface.ROTATION_180, rotationForDeviceOrientation(180))
+ assertNull(rotationForDeviceOrientation(-1))
+ assertNull(rotationForDeviceOrientation(ORIENTATION_UNKNOWN))
+ }
+
+ @Test
+ fun iconStaysUprightForTheHoldThatMatchesTheWindow() {
+ // Window in the natural orientation, phone held upright.
+ assertEquals(0, iconRotationDegrees(0, Surface.ROTATION_0))
+ // Window turned counter-clockwise (ROTATION_90) and the phone really held that way (sensor 270).
+ assertEquals(0, iconRotationDegrees(270, Surface.ROTATION_90))
+ assertEquals(0, iconRotationDegrees(90, Surface.ROTATION_270))
+ assertEquals(0, iconRotationDegrees(180, Surface.ROTATION_180))
+ }
+
+ @Test
+ fun iconCompensatesAFixedWindow() {
+ // Phone turned clockwise by 90 in a window that stayed natural: the icon turns back by 90.
+ assertEquals(270, iconRotationDegrees(90, Surface.ROTATION_0))
+ assertEquals(180, iconRotationDegrees(180, Surface.ROTATION_0))
+ assertEquals(90, iconRotationDegrees(270, Surface.ROTATION_0))
+ }
+
+ @Test
+ fun iconCompensatesAWindowTurnedAgainstTheHold() {
+ // Phone upright but the window turned (large screen ignoring the orientation lock).
+ assertEquals(270, iconRotationDegrees(0, Surface.ROTATION_90))
+ assertEquals(180, iconRotationDegrees(0, Surface.ROTATION_180))
+ assertEquals(90, iconRotationDegrees(0, Surface.ROTATION_270))
+ }
+
+ @Test
+ fun iconAngleIsUnknownWithoutSensorData() {
+ assertNull(iconRotationDegrees(ORIENTATION_UNKNOWN, Surface.ROTATION_0))
+ }
+
+ @Test
+ fun animationTakesTheShortestWay() {
+ assertEquals(-90f, shortestRotationTarget(0f, 270), 0f)
+ assertEquals(90f, shortestRotationTarget(0f, 90), 0f)
+ assertEquals(-90f, shortestRotationTarget(-90f, 270), 0f)
+ assertEquals(-180f, shortestRotationTarget(-90f, 180), 0f)
+ assertEquals(360f, shortestRotationTarget(270f, 0), 0f)
+ assertEquals(0f, shortestRotationTarget(0f, 0), 0f)
+ assertEquals(-180f, shortestRotationTarget(0f, 180), 0f)
+ }
+
+ @Test
+ fun shutterStaysAtTheNaturalBottomEdgeWhateverTheWindowRotation() {
+ val shutter = BodyPoint(0, 1)
+ assertEquals(BodyPoint(0, 1), shutter.inWindow(Surface.ROTATION_0))
+ assertEquals(BodyPoint(1, 0), shutter.inWindow(Surface.ROTATION_90))
+ assertEquals(BodyPoint(0, -1), shutter.inWindow(Surface.ROTATION_180))
+ assertEquals(BodyPoint(-1, 0), shutter.inWindow(Surface.ROTATION_270))
+ }
+
+ @Test
+ fun closeAndFlashStayAtTheNaturalTopEdge() {
+ val close = BodyPoint(-1, -1)
+ val flash = BodyPoint(1, -1)
+ assertEquals(BodyPoint(-1, 1), close.inWindow(Surface.ROTATION_90))
+ assertEquals(BodyPoint(-1, -1), flash.inWindow(Surface.ROTATION_90))
+ assertEquals(BodyPoint(1, -1), close.inWindow(Surface.ROTATION_270))
+ assertEquals(BodyPoint(1, 1), flash.inWindow(Surface.ROTATION_270))
+ assertEquals(BodyPoint(1, 1), close.inWindow(Surface.ROTATION_180))
+ }
+
+ @Test
+ fun lensSwitchDirectionFollowsTheBody() {
+ val right = BodyPoint(1, 0)
+ assertEquals(BodyPoint(1, 0), right.inWindow(Surface.ROTATION_0))
+ assertEquals(BodyPoint(0, -1), right.inWindow(Surface.ROTATION_90))
+ assertEquals(BodyPoint(-1, 0), right.inWindow(Surface.ROTATION_180))
+ assertEquals(BodyPoint(0, 1), right.inWindow(Surface.ROTATION_270))
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/camera/PhotoCaptureLogicTest.kt b/app/src/test/java/com/nextcloud/talk/camera/PhotoCaptureLogicTest.kt
new file mode 100644
index 00000000000..dca843363c4
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/camera/PhotoCaptureLogicTest.kt
@@ -0,0 +1,128 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.camera
+
+import android.view.Surface
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.resolutionselector.AspectRatioStrategy
+import androidx.camera.core.resolutionselector.ResolutionStrategy
+import com.nextcloud.talk.chat.oppositeLens
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.util.Date
+
+class PhotoCaptureLogicTest {
+
+ @get:Rule
+ val folder = TemporaryFolder()
+
+ @Test
+ fun flashCyclesOffAutoOnAndBack() {
+ assertEquals(FlashSetting.AUTO, FlashSetting.OFF.next())
+ assertEquals(FlashSetting.ON, FlashSetting.AUTO.next())
+ assertEquals(FlashSetting.OFF, FlashSetting.ON.next())
+ }
+
+ @Test
+ fun flashSettingMapsToImageCaptureMode() {
+ assertEquals(ImageCapture.FLASH_MODE_OFF, effectiveFlashMode(FlashSetting.OFF, true))
+ assertEquals(ImageCapture.FLASH_MODE_AUTO, effectiveFlashMode(FlashSetting.AUTO, true))
+ assertEquals(ImageCapture.FLASH_MODE_ON, effectiveFlashMode(FlashSetting.ON, true))
+ }
+
+ @Test
+ fun cameraWithoutFlashUnitNeverFires() {
+ FlashSetting.entries.forEach {
+ assertEquals(ImageCapture.FLASH_MODE_OFF, effectiveFlashMode(it, false))
+ }
+ }
+
+ @Test
+ fun lensSwitchesBetweenFrontAndBack() {
+ assertEquals(CameraSelector.LENS_FACING_BACK, oppositeLens(CameraSelector.LENS_FACING_FRONT))
+ assertEquals(CameraSelector.LENS_FACING_FRONT, oppositeLens(CameraSelector.LENS_FACING_BACK))
+ }
+
+ @Test
+ fun deviceOrientationMapsToSurfaceRotation() {
+ assertEquals(Surface.ROTATION_0, rotationForDeviceOrientation(0))
+ assertEquals(Surface.ROTATION_0, rotationForDeviceOrientation(44))
+ assertEquals(Surface.ROTATION_0, rotationForDeviceOrientation(315))
+ assertEquals(Surface.ROTATION_0, rotationForDeviceOrientation(359))
+ assertEquals(Surface.ROTATION_270, rotationForDeviceOrientation(45))
+ assertEquals(Surface.ROTATION_270, rotationForDeviceOrientation(90))
+ assertEquals(Surface.ROTATION_180, rotationForDeviceOrientation(180))
+ assertEquals(Surface.ROTATION_90, rotationForDeviceOrientation(270))
+ assertEquals(Surface.ROTATION_90, rotationForDeviceOrientation(314))
+ }
+
+ @Test
+ fun unknownDeviceOrientationKeepsRotation() {
+ assertNull(rotationForDeviceOrientation(ORIENTATION_UNKNOWN))
+ }
+
+ @Test
+ fun photoFileLandsInSharedAttachmentsDirectory() {
+ val file = createPhotoFile(folder.root, "Picture from 2026-10-04 12-00-00")
+
+ assertNotNull(file)
+ assertEquals("Picture from 2026-10-04 12-00-00.jpg", file!!.name)
+ assertEquals("shared_attachments", file.parentFile!!.name)
+ assertTrue(file.parentFile!!.isDirectory)
+ }
+
+ @Test
+ fun existingPhotoFileIsNotReused() {
+ val first = createPhotoFile(folder.root, "photo")!!
+ first.writeText("data")
+
+ val second = createPhotoFile(folder.root, "photo")!!
+
+ assertNotEquals(first, second)
+ assertEquals("photo (1).jpg", second.name)
+ }
+
+ @Test
+ fun unsafePhotoNameIsRefused() {
+ assertNull(createPhotoFile(folder.root, "../escape"))
+ }
+
+ @Test
+ fun timestampHasFileSafeFormat() {
+ val text = formatCaptureTimestamp(Date(0))
+
+ assertTrue(Regex("\\d{4}-\\d{2}-\\d{2} \\d{2}-\\d{2}-\\d{2}").matches(text))
+ }
+
+ @Test
+ fun photoSelectorAsksForHighestResolutionAtFourToThree() {
+ val selector = photoResolutionSelector()
+
+ assertSame(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY, selector.aspectRatioStrategy)
+ assertSame(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY, selector.resolutionStrategy)
+ }
+
+ @Test
+ fun captureResolutionIsDescribedInDisplayOrientation() {
+ assertEquals("4000x3000", describeCaptureResolution(4000, 3000, 0))
+ assertEquals("4000x3000", describeCaptureResolution(4000, 3000, 180))
+ assertEquals("3000x4000", describeCaptureResolution(4000, 3000, 90))
+ assertEquals("3000x4000", describeCaptureResolution(4000, 3000, 270))
+ }
+
+ private companion object {
+ const val ORIENTATION_UNKNOWN = -1
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/chat/RecordButtonGestureTest.kt b/app/src/test/java/com/nextcloud/talk/chat/RecordButtonGestureTest.kt
new file mode 100644
index 00000000000..feeb5cff55a
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/chat/RecordButtonGestureTest.kt
@@ -0,0 +1,100 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.chat
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class RecordButtonGestureTest {
+
+ private val gesture = RecordButtonGesture(cancelX = -300f)
+
+ @Test
+ fun releaseBeforeThresholdIsTapThatTogglesMode() {
+ gesture.down()
+ assertEquals(RecordButtonGesture.Release.TOGGLE_MODE, gesture.up())
+ assertEquals(RecordButtonGesture.State.IDLE, gesture.state)
+ }
+
+ @Test
+ fun releaseAfterThresholdDoesNotToggleMode() {
+ gesture.down()
+ assertTrue(gesture.holdElapsed())
+ assertEquals(RecordButtonGesture.Release.NONE, gesture.up())
+ }
+
+ @Test
+ fun holdStartsRecordingOnlyOnce() {
+ gesture.down()
+ assertTrue(gesture.holdElapsed())
+ assertFalse(gesture.holdElapsed())
+ }
+
+ @Test
+ fun holdTimerAfterReleaseDoesNotStartRecording() {
+ gesture.down()
+ gesture.up()
+ assertFalse(gesture.holdElapsed())
+ }
+
+ @Test
+ fun swipeLeftBeforeStartCancelsWithoutToggle() {
+ gesture.down()
+ assertFalse(gesture.move(-100f))
+ assertTrue(gesture.move(-301f))
+ assertFalse(gesture.holdElapsed())
+ assertEquals(RecordButtonGesture.Release.NONE, gesture.up())
+ }
+
+ @Test
+ fun swipeAfterStartIsLeftToRecordingHandler() {
+ gesture.down()
+ gesture.holdElapsed()
+ assertFalse(gesture.move(-500f))
+ assertEquals(RecordButtonGesture.State.HELD, gesture.state)
+ }
+
+ @Test
+ fun systemCancelNeverToggles() {
+ gesture.down()
+ gesture.cancel()
+ assertEquals(RecordButtonGesture.Release.NONE, gesture.up())
+ assertFalse(gesture.holdElapsed())
+ }
+
+ @Test
+ fun releaseWithoutDownDoesNothing() {
+ assertEquals(RecordButtonGesture.Release.NONE, gesture.up())
+ }
+
+ @Test
+ fun nextTouchStartsFresh() {
+ gesture.down()
+ gesture.holdElapsed()
+ gesture.up()
+ gesture.down()
+ assertEquals(RecordButtonGesture.Release.TOGGLE_MODE, gesture.up())
+ }
+
+ @Test
+ fun thresholdFollowsSystemLongPressWithinBounds() {
+ assertEquals(400L, RecordButtonGesture.holdThresholdMs(400))
+ assertEquals(500L, RecordButtonGesture.holdThresholdMs(500))
+ assertEquals(400L, RecordButtonGesture.holdThresholdMs(250))
+ assertEquals(600L, RecordButtonGesture.holdThresholdMs(1500))
+ }
+
+ @Test
+ fun minimumDurationIsMeasuredFromTheRecordingStart() {
+ // touch at 0 ms, recording starts at 500 ms (hold threshold), released at 1300 ms: 800 ms recorded
+ assertTrue(RecordButtonGesture.isTooShort(startedAtMs = 500, endedAtMs = 1300, minMs = 1000))
+ assertFalse(RecordButtonGesture.isTooShort(startedAtMs = 500, endedAtMs = 1500, minMs = 1000))
+ assertFalse(RecordButtonGesture.isTooShort(startedAtMs = 500, endedAtMs = 2000, minMs = 1000))
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/chat/RecordHintPopupPlacementTest.kt b/app/src/test/java/com/nextcloud/talk/chat/RecordHintPopupPlacementTest.kt
new file mode 100644
index 00000000000..e6bc2584e0c
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/chat/RecordHintPopupPlacementTest.kt
@@ -0,0 +1,72 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.chat
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class RecordHintPopupPlacementTest {
+
+ private fun place(anchorLeft: Int, anchorTop: Int = 1000, windowWidth: Int = 1000) =
+ RecordHintPopup.hintPlacement(
+ anchorLeft = anchorLeft,
+ anchorTop = anchorTop,
+ anchorWidth = 100,
+ windowWidth = windowWidth,
+ windowHeight = 2000,
+ hintWidth = 300,
+ margin = 20,
+ gap = 10,
+ arrowWidth = 40
+ )
+
+ @Test
+ fun hintBottomIsTheGapAboveTheAnchorTop() {
+ val placement = place(anchorLeft = 450, anchorTop = 1000)
+ // measured from the bottom of the window: the bottom of the hint is 10 px above the top of the anchor
+ assertEquals(2000 - 1000 + 10, placement.bottomOffset)
+ }
+
+ @Test
+ fun hintFollowsAnAnchorWhichMovedUp() {
+ val before = place(anchorLeft = 450, anchorTop = 1000)
+ val after = place(anchorLeft = 450, anchorTop = 900)
+ // a button moved up by 100 px takes the hint up by 100 px, however high the hint is
+ assertEquals(before.bottomOffset + 100, after.bottomOffset)
+ }
+
+ @Test
+ fun hintIsCentredOnTheAnchor() {
+ val placement = place(anchorLeft = 450)
+ assertEquals(500 - 150, placement.x)
+ // the arrow is in the middle of the hint
+ assertEquals(150 - 20, placement.arrowLeftMargin)
+ }
+
+ @Test
+ fun hintIsKeptInsideTheWindowAtTheRightEdge() {
+ val placement = place(anchorLeft = 900)
+ assertEquals(1000 - 20 - 300, placement.x)
+ // the arrow still points at the anchor centre (950)
+ assertEquals(950, placement.x + placement.arrowLeftMargin + 20)
+ }
+
+ @Test
+ fun hintIsKeptInsideTheWindowAtTheLeftEdge() {
+ val placement = place(anchorLeft = 0)
+ assertEquals(20, placement.x)
+ assertEquals(50 - 20 - 20, placement.arrowLeftMargin)
+ }
+
+ @Test
+ fun anchorInANarrowWindowIsStillPointedAt() {
+ // the chat in a pane of 600 px: only the window width matters, not the width of the screen
+ val placement = place(anchorLeft = 500, windowWidth = 600)
+ assertEquals(600 - 20 - 300, placement.x)
+ assertEquals(550, placement.x + placement.arrowLeftMargin + 20)
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/chat/RecordingStopCoordinatorTest.kt b/app/src/test/java/com/nextcloud/talk/chat/RecordingStopCoordinatorTest.kt
new file mode 100644
index 00000000000..a4c8d6f0fdd
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/chat/RecordingStopCoordinatorTest.kt
@@ -0,0 +1,141 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.chat
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class RecordingStopCoordinatorTest {
+
+ private class FakeScheduler : DelayScheduler {
+ var pending: Runnable? = null
+ var delay = -1L
+
+ override fun postDelayed(runnable: Runnable, delayMs: Long) {
+ pending = runnable
+ delay = delayMs
+ }
+
+ override fun cancel(runnable: Runnable) {
+ if (pending === runnable) pending = null
+ }
+
+ /** The window ends. */
+ fun fire() {
+ pending?.also { pending = null }?.run()
+ }
+ }
+
+ private val scheduler = FakeScheduler()
+ private var stops = 0
+ private val coordinator = RecordingStopCoordinator(scheduler) { stops++ }
+
+ @Test
+ fun stopOutsideTheWindowIsIssuedAtOnce() {
+ assertTrue(coordinator.requestStop(StopAction.SEND))
+ assertEquals(1, stops)
+ }
+
+ @Test
+ fun stopInsideTheWindowWaitsAndIsIssuedWhenItEnds() {
+ assertTrue(coordinator.beginSwitch())
+ assertEquals(RecordingStopCoordinator.SWITCH_SETTLE_MS, scheduler.delay)
+
+ assertTrue(coordinator.requestStop(StopAction.SEND))
+ assertEquals(0, stops)
+
+ scheduler.fire()
+
+ assertEquals(1, stops)
+ assertFalse(coordinator.settling)
+ }
+
+ @Test
+ fun releaseInsideTheWindowDoesNotStopBeforeItEnds() {
+ coordinator.beginSwitch()
+
+ // what release() does: cancel the recording, and the camera stays bound while the window is open
+ coordinator.requestStop(StopAction.DISCARD)
+ assertEquals(0, stops)
+ assertTrue(coordinator.settling)
+
+ scheduler.fire()
+
+ assertEquals(1, stops)
+ assertEquals(StopAction.DISCARD, coordinator.action)
+ }
+
+ @Test
+ fun noStopIsIssuedWhileTheWindowIsOpenWhateverIsRequested() {
+ coordinator.beginSwitch()
+ coordinator.requestStop(StopAction.DISCARD)
+ coordinator.requestStop(StopAction.SEND)
+ coordinator.requestStop(StopAction.DISCARD)
+ assertEquals(0, stops)
+ }
+
+ @Test
+ fun secondSwitchInsideTheWindowIsRefused() {
+ assertTrue(coordinator.beginSwitch())
+ assertFalse(coordinator.beginSwitch())
+
+ scheduler.fire()
+
+ assertTrue(coordinator.beginSwitch())
+ }
+
+ @Test
+ fun switchAfterAStopRequestIsRefused() {
+ coordinator.requestStop(StopAction.SEND)
+ assertFalse(coordinator.beginSwitch())
+ }
+
+ @Test
+ fun sendAfterCancelDoesNotSendTheCancelledVideo() {
+ assertTrue(coordinator.requestStop(StopAction.DISCARD))
+ assertFalse(coordinator.requestStop(StopAction.SEND))
+
+ assertEquals(StopAction.DISCARD, coordinator.action)
+ assertEquals(1, stops)
+ }
+
+ @Test
+ fun sendAfterCancelInsideTheWindowStaysCancelled() {
+ coordinator.beginSwitch()
+ coordinator.requestStop(StopAction.DISCARD)
+ coordinator.requestStop(StopAction.SEND)
+
+ scheduler.fire()
+
+ assertEquals(StopAction.DISCARD, coordinator.action)
+ assertEquals(1, stops)
+ }
+
+ @Test
+ fun cancelAfterSendDoesNotDiscardTheVideoBeingSent() {
+ coordinator.requestStop(StopAction.SEND)
+ assertFalse(coordinator.requestStop(StopAction.DISCARD))
+ assertEquals(StopAction.SEND, coordinator.action)
+ }
+
+ @Test
+ fun resetClosesTheWindowAndForgetsTheRequest() {
+ coordinator.beginSwitch()
+ coordinator.requestStop(StopAction.SEND)
+
+ coordinator.reset()
+ scheduler.fire()
+
+ assertEquals(0, stops)
+ assertNull(coordinator.action)
+ assertFalse(coordinator.settling)
+ assertTrue(coordinator.requestStop(StopAction.SEND))
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/chat/VideoFrameAspectTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoFrameAspectTest.kt
new file mode 100644
index 00000000000..17c91c18b08
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/chat/VideoFrameAspectTest.kt
@@ -0,0 +1,71 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Krainov Gleb
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.chat
+
+import android.view.Surface
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class VideoFrameAspectTest {
+
+ private val portrait = 9f / 16f
+ private val landscape = 16f / 9f
+
+ @Test
+ fun frameAspectFromTheCameraSurface() {
+ // a sensor delivers 1280x720; turned a quarter it is a portrait frame
+ assertEquals(720f / 1280f, videoFrameAspect(1280, 720, 90)!!, 0.0001f)
+ assertEquals(720f / 1280f, videoFrameAspect(1280, 720, 270)!!, 0.0001f)
+ assertEquals(1280f / 720f, videoFrameAspect(1280, 720, 0)!!, 0.0001f)
+ assertEquals(1280f / 720f, videoFrameAspect(1280, 720, 180)!!, 0.0001f)
+ }
+
+ @Test
+ fun frameAspectIsUnknownWithoutASize() {
+ assertNull(videoFrameAspect(0, 720, 90))
+ assertNull(videoFrameAspect(1280, 0, 0))
+ }
+
+ @Test
+ fun fallbackAspectFollowsTheRecordingRotation() {
+ assertEquals(portrait, fallbackVideoFrameAspect(Surface.ROTATION_0), 0.0001f)
+ assertEquals(portrait, fallbackVideoFrameAspect(Surface.ROTATION_180), 0.0001f)
+ assertEquals(landscape, fallbackVideoFrameAspect(Surface.ROTATION_90), 0.0001f)
+ assertEquals(landscape, fallbackVideoFrameAspect(Surface.ROTATION_270), 0.0001f)
+ }
+
+ @Test
+ fun screenAspectKeepsTheFrameWhenRotationsAgreeOrAreOpposite() {
+ val r = listOf(Surface.ROTATION_0, Surface.ROTATION_90, Surface.ROTATION_180, Surface.ROTATION_270)
+ r.forEach { rotation ->
+ assertEquals(portrait, screenFrameAspect(portrait, rotation, rotation), 0.0001f)
+ assertEquals(landscape, screenFrameAspect(landscape, rotation, rotation), 0.0001f)
+ val opposite = (rotation + 2) % 4
+ assertEquals(portrait, screenFrameAspect(portrait, rotation, opposite), 0.0001f)
+ }
+ }
+
+ @Test
+ fun screenAspectTurnsTheFrameOnAQuarterTurnDifference() {
+ // (video rotation, display rotation) pairs which differ by one or three quarters
+ val pairs = listOf(
+ Surface.ROTATION_90 to Surface.ROTATION_0, // auto-rotate off, phone held sideways
+ Surface.ROTATION_270 to Surface.ROTATION_0,
+ Surface.ROTATION_0 to Surface.ROTATION_90, // phone turned during the recording
+ Surface.ROTATION_0 to Surface.ROTATION_270,
+ Surface.ROTATION_180 to Surface.ROTATION_90,
+ Surface.ROTATION_90 to Surface.ROTATION_180,
+ Surface.ROTATION_270 to Surface.ROTATION_180,
+ Surface.ROTATION_180 to Surface.ROTATION_270
+ )
+ pairs.forEach { (video, display) ->
+ assertEquals("$video/$display", landscape, screenFrameAspect(portrait, video, display), 0.0001f)
+ assertEquals("$video/$display", portrait, screenFrameAspect(landscape, video, display), 0.0001f)
+ }
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/chat/VideoMessageRecordingTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoMessageRecordingTest.kt
new file mode 100644
index 00000000000..ab4cdaa1651
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/chat/VideoMessageRecordingTest.kt
@@ -0,0 +1,98 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.chat
+
+import androidx.camera.video.VideoRecordEvent
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class VideoMessageRecordingTest {
+
+ @Test
+ fun toggleSwitchesBetweenVoiceAndVideo() {
+ assertEquals(RecordInputMode.VIDEO, RecordInputMode.VOICE.toggled())
+ assertEquals(RecordInputMode.VOICE, RecordInputMode.VIDEO.toggled())
+ assertEquals(RecordInputMode.VOICE, RecordInputMode.VOICE.toggled().toggled())
+ }
+
+ @Test
+ fun storedFlagMapsToMode() {
+ assertEquals(RecordInputMode.VIDEO, RecordInputMode.fromVideoFlag(true))
+ assertEquals(RecordInputMode.VOICE, RecordInputMode.fromVideoFlag(false))
+ }
+
+ @Test
+ fun recordingWithoutErrorIsUsable() {
+ assertTrue(VideoMessageRecorder.isUsableFinalize(false, VideoRecordEvent.Finalize.ERROR_NONE))
+ }
+
+ @Test
+ fun recordingStoppedByLimitIsUsable() {
+ assertTrue(
+ VideoMessageRecorder.isUsableFinalize(true, VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED)
+ )
+ assertTrue(
+ VideoMessageRecorder.isUsableFinalize(true, VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED)
+ )
+ }
+
+ @Test
+ fun recordingWithOtherErrorIsNotUsable() {
+ assertFalse(VideoMessageRecorder.isUsableFinalize(true, VideoRecordEvent.Finalize.ERROR_NO_VALID_DATA))
+ assertFalse(VideoMessageRecorder.isUsableFinalize(true, VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE))
+ assertFalse(VideoMessageRecorder.isUsableFinalize(true, VideoRecordEvent.Finalize.ERROR_UNKNOWN))
+ }
+
+ private fun outcome(discard: Boolean, hasError: Boolean, error: Int, durationNanos: Long) =
+ VideoMessageRecorder.resolveOutcome(discard, hasError, error, durationNanos)
+
+ @Test
+ fun discardedRecordingIsCancelledEvenWhenLongEnough() {
+ assertEquals(
+ VideoMessageRecorder.Outcome.CANCELLED,
+ outcome(true, false, VideoRecordEvent.Finalize.ERROR_NONE, 5_000_000_000L)
+ )
+ }
+
+ @Test
+ fun releasedBeforeAnyDataIsTooShort() {
+ assertEquals(
+ VideoMessageRecorder.Outcome.TOO_SHORT,
+ outcome(false, true, VideoRecordEvent.Finalize.ERROR_NO_VALID_DATA, 0L)
+ )
+ }
+
+ @Test
+ fun recordingShorterThanMinimumIsTooShort() {
+ assertEquals(
+ VideoMessageRecorder.Outcome.TOO_SHORT,
+ outcome(false, false, VideoRecordEvent.Finalize.ERROR_NONE, VideoMessageRecorder.MIN_DURATION_NANOS - 1)
+ )
+ }
+
+ @Test
+ fun cameraErrorIsFailure() {
+ assertEquals(
+ VideoMessageRecorder.Outcome.FAILED,
+ outcome(false, true, VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE, 5_000_000_000L)
+ )
+ }
+
+ @Test
+ fun longEnoughRecordingAndLimitHitAreSent() {
+ assertEquals(
+ VideoMessageRecorder.Outcome.SEND,
+ outcome(false, false, VideoRecordEvent.Finalize.ERROR_NONE, VideoMessageRecorder.MIN_DURATION_NANOS)
+ )
+ assertEquals(
+ VideoMessageRecorder.Outcome.SEND,
+ outcome(false, true, VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED, 120_000_000_000L)
+ )
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementCompactPanelTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementCompactPanelTest.kt
new file mode 100644
index 00000000000..584ae09f212
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementCompactPanelTest.kt
@@ -0,0 +1,59 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Krainov Gleb
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.chat
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/** Preview size for the one-line video recording panel on the screens of the owner. The values are pixels. */
+class VideoPreviewPlacementCompactPanelTest {
+
+ private val portrait = 9f / 16f
+ private val landscape = 16f / 9f
+ private val limits = VideoPreviewLimits(margin = 16, maxWidthFraction = 0.75f, maxSide = 480)
+
+ private fun place(areaWidth: Int, areaHeight: Int, aspect: Float, limits: VideoPreviewLimits = this.limits) =
+ videoPreviewPlacement(areaWidth, areaHeight, aspect, limits)
+
+ // Owner screenshots, density 3.1, so the values below are pixels. Limits as ChatActivity builds them: margin 16dp,
+ // short area below 200dp keeps 8dp, longest side 480dp. The scrim begins at y = 321 px in both orientations.
+ private val density = 3.1f
+ private val phoneLimits = VideoPreviewLimits(
+ margin = (16 * density).toInt(),
+ maxWidthFraction = 0.75f,
+ maxSide = (480 * density).toInt(),
+ shortAreaHeight = (200 * density).toInt(),
+ shortMargin = (8 * density).toInt()
+ )
+
+ @Test
+ fun foldedHuaweiLandscapePreviewGrowsWithTheCompactPanel() {
+ // 1080 px high screen: scrim 321 px from the top, old panel 411 px, compact panel 64 dp = 198 px
+ val old = place(2444, 1080 - 321 - 411, landscape, phoneLimits.copy(shortAreaHeight = 0))
+ assertEquals(VideoPreviewPlacement(width = 466, height = 262), old) // 150 x 84 dp
+ val compact = place(2444, 1080 - 321 - 198, landscape, phoneLimits)
+ assertEquals(VideoPreviewPlacement(width = 912, height = 513), compact) // 294 x 165 dp
+ assertEquals(landscape.toDouble(), compact.width.toDouble() / compact.height, 0.02)
+ }
+
+ @Test
+ fun portraitOnOwnerPhoneIsBoundByTheWidthSoTheCompactPanelDoesNotChangeIt() {
+ // 348 dp = 1080 px wide, 2444 px high screen: the area grows from 1712 to 1925 px, the width stays the limit
+ val old = place(1080, 2444 - 321 - 411, portrait, phoneLimits)
+ val compact = place(1080, 2444 - 321 - 198, portrait, phoneLimits)
+ assertEquals(VideoPreviewPlacement(width = 810, height = 1440), old) // 261 x 464 dp
+ assertEquals(old, compact)
+ }
+
+ @Test
+ fun shortAreaKeepsTheSmallerMarginAndATallAreaKeepsTheFullOne() {
+ val tall = place(1000, 400, portrait, limits.copy(shortAreaHeight = 200, shortMargin = 8))
+ assertEquals(368, tall.height) // 400 is not below 200: full margin 16
+ val low = place(1000, 190, portrait, limits.copy(shortAreaHeight = 200, shortMargin = 8))
+ assertEquals(174, low.height) // 190 - 2 * 8
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementTest.kt
new file mode 100644
index 00000000000..c88301fb805
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementTest.kt
@@ -0,0 +1,133 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Krainov Gleb
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.chat
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/** The values are dp: the function does not care for the unit. */
+class VideoPreviewPlacementTest {
+
+ private val portrait = 9f / 16f
+ private val landscape = 16f / 9f
+ private val limits = VideoPreviewLimits(margin = 16, maxWidthFraction = 0.75f, maxSide = 480)
+
+ private fun place(areaWidth: Int, areaHeight: Int, aspect: Float, limits: VideoPreviewLimits = this.limits) =
+ videoPreviewPlacement(areaWidth, areaHeight, aspect, limits)
+
+ /** Centered by the parent: the preview fits the area with the margin kept on every side. */
+ private fun assertInside(p: VideoPreviewPlacement, areaWidth: Int, areaHeight: Int) {
+ assertTrue("width ${p.width}", p.width in 0..areaWidth)
+ assertTrue("height ${p.height}", p.height in 0..areaHeight)
+ }
+
+ private fun assertAspect(p: VideoPreviewPlacement, aspect: Float) {
+ assertEquals(aspect.toDouble(), p.width.toDouble() / p.height, 0.02)
+ }
+
+ @Test
+ fun portraitOnOwnerPhoneTakesThreeQuartersOfTheWidthAndKeepsTheAspect() {
+ val p = place(348, 560, portrait)
+ assertEquals(261, p.width)
+ assertEquals(464, p.height)
+ assertAspect(p, portrait)
+ assertInside(p, 348, 560)
+ }
+
+ @Test
+ fun portraitOnAShortAreaIsFittedByTheHeightWithMargins() {
+ val p = place(348, 400, portrait)
+ // the height minus the margins: 400 - 2 * 16
+ assertEquals(368, p.height)
+ assertEquals(207, p.width)
+ assertAspect(p, portrait)
+ assertTrue(p.height <= 400 - 32)
+ }
+
+ @Test
+ fun landscapeInALandscapeAreaIsFittedByTheHeightAndNotSquashed() {
+ val p = place(700, 200, landscape)
+ assertEquals(168, p.height)
+ assertEquals(299, p.width)
+ assertAspect(p, landscape)
+ assertTrue(p.height <= 200 - 32)
+ assertTrue(p.width <= 700 - 32)
+ assertInside(p, 700, 200)
+ }
+
+ @Test
+ fun landscapeFrameInAPortraitAreaIsFittedByTheWidth() {
+ val p = place(348, 560, landscape)
+ assertEquals(261, p.width)
+ assertEquals(147, p.height)
+ assertAspect(p, landscape)
+ }
+
+ @Test
+ fun narrowPanelOfTwoPanesKeepsTheMarginAndTheAspect() {
+ val p = place(240, 520, portrait)
+ // 75 % of 240; 240 - 2 * 16 = 208 would be wider
+ assertEquals(180, p.width)
+ assertEquals(320, p.height)
+ assertAspect(p, portrait)
+ assertTrue(p.width <= 240 - 32)
+ assertInside(p, 240, 520)
+ }
+
+ @Test
+ fun narrowPanelWithALandscapeFrameIsLimitedByTheMarginedWidth() {
+ val p = place(200, 520, landscape, limits.copy(maxWidthFraction = 0.95f))
+ // 200 - 2 * 16 = 168 is less than 95 % of 200
+ assertEquals(168, p.width)
+ assertAspect(p, landscape)
+ assertTrue(p.width <= 200 - 32)
+ }
+
+ @Test
+ fun veryLowAreaStillGivesAPositivePreviewInsideTheArea() {
+ val p = place(348, 40, portrait)
+ assertTrue(p.height in 1..40)
+ assertTrue(p.width > 0)
+ assertAspect(p, portrait)
+ assertInside(p, 348, 40)
+ }
+
+ @Test
+ fun veryLowAreaWithALandscapeFrameStaysInside() {
+ val p = place(700, 24, landscape)
+ assertTrue(p.height > 0)
+ assertAspect(p, landscape)
+ assertInside(p, 700, 24)
+ }
+
+ @Test
+ fun emptyAreaGivesAnEmptyPreview() {
+ val p = place(0, 0, portrait)
+ assertEquals(VideoPreviewPlacement(0, 0), p)
+ assertEquals(0, place(-5, 100, portrait).width)
+ }
+
+ @Test
+ fun hugeAreaIsCappedByTheLongestSide() {
+ val p = place(1200, 1000, portrait)
+ assertEquals(480, p.height)
+ assertEquals(270, p.width)
+ assertAspect(p, portrait)
+ assertInside(p, 1200, 1000)
+ val l = place(2000, 1000, landscape)
+ assertEquals(480, l.width)
+ assertEquals(270, l.height)
+ }
+
+ @Test
+ fun invalidAspectFallsBackToPortrait() {
+ assertEquals(place(348, 560, portrait), place(348, 560, Float.NaN))
+ assertEquals(place(348, 560, portrait), place(348, 560, 0f))
+ assertEquals(place(348, 560, portrait), place(348, 560, -1f))
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/chat/VideoRecorderLifecycleTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoRecorderLifecycleTest.kt
new file mode 100644
index 00000000000..c4ec2ec9df6
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/chat/VideoRecorderLifecycleTest.kt
@@ -0,0 +1,45 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.chat
+
+import androidx.lifecycle.Lifecycle
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class VideoRecorderLifecycleTest {
+
+ @Test
+ fun cameraLifecycleStartsWithTheRecordingAndEndsWithIt() {
+ val owner = RecordingLifecycleOwner()
+ assertEquals(Lifecycle.State.INITIALIZED, owner.lifecycle.currentState)
+
+ owner.start()
+ assertEquals(Lifecycle.State.STARTED, owner.lifecycle.currentState)
+
+ owner.destroy()
+ assertEquals(Lifecycle.State.DESTROYED, owner.lifecycle.currentState)
+ }
+
+ @Test
+ fun destroyedCameraLifecycleCannotBeStartedAgain() {
+ val owner = RecordingLifecycleOwner()
+ owner.start()
+ owner.destroy()
+
+ owner.start()
+
+ assertEquals(Lifecycle.State.DESTROYED, owner.lifecycle.currentState)
+ }
+
+ @Test
+ fun destroyingTwiceIsHarmless() {
+ val owner = RecordingLifecycleOwner()
+ owner.destroy()
+ owner.destroy()
+ assertEquals(Lifecycle.State.DESTROYED, owner.lifecycle.currentState)
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingRecreationTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingRecreationTest.kt
new file mode 100644
index 00000000000..6f4e9650aad
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingRecreationTest.kt
@@ -0,0 +1,112 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.chat
+
+import android.view.OrientationEventListener
+import android.view.Surface
+import androidx.camera.video.VideoRecordEvent
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class VideoRecordingRecreationTest {
+
+ private fun resume(active: Boolean, pending: Boolean, inProgress: Boolean, locked: Boolean) =
+ resolveRecordingResume(active, pending, inProgress, locked)
+
+ @Test
+ fun nothingRecordingMeansNothingToResume() {
+ assertEquals(RecordingResume.NONE, resume(false, false, false, false))
+ }
+
+ @Test
+ fun lockedVideoRecordingIsAttachedAgain() {
+ assertEquals(RecordingResume.ATTACH, resume(true, false, true, true))
+ }
+
+ @Test
+ fun heldVideoRecordingIsLockedBecauseTheFingerIsGone() {
+ assertEquals(RecordingResume.ATTACH_AND_LOCK, resume(true, false, true, false))
+ }
+
+ @Test
+ fun heldVoiceRecordingIsLockedToo() {
+ assertEquals(RecordingResume.ATTACH_AND_LOCK, resume(false, false, true, false))
+ }
+
+ @Test
+ fun lockedVoiceRecordingNeedsNoRecorder() {
+ assertEquals(RecordingResume.NONE, resume(false, false, true, true))
+ }
+
+ @Test
+ fun resultWhichArrivedWithoutActivityIsDelivered() {
+ assertEquals(RecordingResume.DELIVER_RESULT, resume(false, true, true, true))
+ }
+
+ @Test
+ fun resultIsDeferredUntilTheChatIsLoaded() {
+ assertEquals(
+ RecordingResume.DEFER_RESULT,
+ resolveRecordingResume(false, true, true, true, chatReady = false)
+ )
+ assertEquals(
+ RecordingResume.DELIVER_RESULT,
+ resolveRecordingResume(false, true, true, true, chatReady = true)
+ )
+ }
+
+ @Test
+ fun activeRecordingIsAttachedEvenIfTheChatIsNotLoaded() {
+ assertEquals(RecordingResume.ATTACH, resolveRecordingResume(true, false, true, true, chatReady = false))
+ }
+
+ @Test
+ fun videoRotationFollowsTheSensor() {
+ // phone turned clockwise by 90 degrees: the display rotates the other way
+ assertEquals(Surface.ROTATION_270, VideoMessageRecorder.videoTargetRotation(90, Surface.ROTATION_0))
+ assertEquals(Surface.ROTATION_0, VideoMessageRecorder.videoTargetRotation(10, Surface.ROTATION_90))
+ assertEquals(Surface.ROTATION_180, VideoMessageRecorder.videoTargetRotation(180, Surface.ROTATION_0))
+ assertEquals(Surface.ROTATION_90, VideoMessageRecorder.videoTargetRotation(270, Surface.ROTATION_0))
+ }
+
+ @Test
+ fun videoRotationFallsBackToTheDisplayWithoutSensorValue() {
+ assertEquals(
+ Surface.ROTATION_90,
+ VideoMessageRecorder.videoTargetRotation(OrientationEventListener.ORIENTATION_UNKNOWN, Surface.ROTATION_90)
+ )
+ }
+
+ @Test
+ fun cutOffRecordingIsKeptWhenLongEnough() {
+ val inactive = VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE
+ assertTrue(VideoMessageRecorder.isSalvageable(inactive, VideoMessageRecorder.MIN_DURATION_NANOS, 1024))
+ assertFalse(VideoMessageRecorder.isSalvageable(inactive, VideoMessageRecorder.MIN_DURATION_NANOS - 1, 1024))
+ assertFalse(VideoMessageRecorder.isSalvageable(inactive, VideoMessageRecorder.MIN_DURATION_NANOS, 0))
+ assertFalse(
+ VideoMessageRecorder.isSalvageable(
+ VideoRecordEvent.Finalize.ERROR_ENCODING_FAILED,
+ VideoMessageRecorder.MIN_DURATION_NANOS,
+ 1024
+ )
+ )
+ }
+
+ @Test
+ fun activeVideoRecorderClearsStateOnlyOnFinalize() {
+ assertTrue(clearsRecordingStateOnFinalize(isVideo = true, recorderActive = true))
+ }
+
+ @Test
+ fun idleVideoRecorderAndAudioAreClearedAtOnce() {
+ assertFalse(clearsRecordingStateOnFinalize(isVideo = true, recorderActive = false))
+ assertFalse(clearsRecordingStateOnFinalize(isVideo = false, recorderActive = false))
+ assertFalse(clearsRecordingStateOnFinalize(isVideo = false, recorderActive = true))
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/chat/ui/MessageActionsStateTest.kt b/app/src/test/java/com/nextcloud/talk/chat/ui/MessageActionsStateTest.kt
index 2e329e1c7ab..ce2555cf673 100644
--- a/app/src/test/java/com/nextcloud/talk/chat/ui/MessageActionsStateTest.kt
+++ b/app/src/test/java/com/nextcloud/talk/chat/ui/MessageActionsStateTest.kt
@@ -8,7 +8,10 @@ package com.nextcloud.talk.chat.ui
import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.data.user.model.User
+import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.json.capabilities.SpreedCapabilityDto
+import com.nextcloud.talk.models.json.conversations.ConversationEnums
+import com.nextcloud.talk.models.json.participants.ParticipantDto
import com.nextcloud.talk.utils.DateUtils
import org.junit.Assert
import org.junit.Test
@@ -18,19 +21,51 @@ class MessageActionsStateTest {
private val dateUtils = Mockito.mock(DateUtils::class.java)
- private fun buildStateFor(message: ChatMessage) =
- buildMessageActionsState(
- message = message,
- user = User().apply { userId = "alice" },
- conversation = null,
- hasChatPermission = true,
- hasReactPermission = true,
- spreedCapabilities = SpreedCapabilityDto(),
- isOnline = true,
- dateUtils = dateUtils,
- conversationThreadId = null
+ private fun conversation(remoteServer: String? = null) =
+ ConversationModel(
+ internalId = "1@abc",
+ accountId = 1L,
+ token = "abc",
+ name = "abc",
+ displayName = "abc",
+ description = "",
+ type = ConversationEnums.ConversationType.ROOM_GROUP_CALL,
+ participantType = ParticipantDto.ParticipantType.USER,
+ sessionId = "0",
+ actorId = "alice",
+ actorType = "users",
+ objectType = ConversationEnums.ObjectType.DEFAULT,
+ notificationLevel = ConversationEnums.NotificationLevel.DEFAULT,
+ conversationReadOnlyState = ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_WRITE,
+ lobbyState = ConversationEnums.LobbyState.LOBBY_STATE_ALL_PARTICIPANTS,
+ lobbyTimer = 0L,
+ canLeaveConversation = true,
+ canDeleteConversation = false,
+ unreadMentionDirect = false,
+ notificationCalls = 0,
+ avatarVersion = "",
+ hasCustomAvatar = false,
+ callStartTime = 0L,
+ remoteServer = remoteServer
)
+ private fun buildStateFor(
+ message: ChatMessage,
+ isOnline: Boolean = true,
+ hasChatPermission: Boolean = true,
+ conversation: ConversationModel? = null
+ ) = buildMessageActionsState(
+ message = message,
+ user = User().apply { userId = "alice" },
+ conversation = conversation,
+ hasChatPermission = hasChatPermission,
+ hasReactPermission = true,
+ spreedCapabilities = SpreedCapabilityDto(),
+ isOnline = isOnline,
+ dateUtils = dateUtils,
+ conversationThreadId = null
+ )
+
@Test
fun showCopyMessageLink_trueForRegularMessage() {
val msg = ChatMessage().apply {
@@ -59,4 +94,54 @@ class MessageActionsStateTest {
)
Assert.assertFalse(buildStateFor(msg).showCopyMessageLink)
}
+
+ private fun fileMessage() =
+ ChatMessage(
+ jsonMessageId = 7,
+ message = "{file}",
+ messageParameters = hashMapOf("file" to hashMapOf("id" to "1", "name" to "a.jpg", "path" to "Talk/a.jpg"))
+ )
+
+ @Test
+ fun showForwardFile_trueForOnlineFileMessage_butTheTextForwardStaysOff() {
+ val state = buildStateFor(fileMessage())
+ Assert.assertTrue(state.showForwardFile)
+ Assert.assertFalse(state.showForward)
+ }
+
+ @Test
+ fun showForwardFile_falseOfflineDeletedOrForText() {
+ Assert.assertFalse(buildStateFor(fileMessage(), isOnline = false).showForwardFile)
+ Assert.assertFalse(buildStateFor(fileMessage().apply { isDeleted = true }).showForwardFile)
+ Assert.assertFalse(buildStateFor(ChatMessage(jsonMessageId = 8, message = "hi")).showForwardFile)
+ }
+
+ @Test
+ fun showForwardFile_falseWithoutThePathOfTheFile() {
+ val noPath = ChatMessage(
+ jsonMessageId = 9,
+ message = "{file}",
+ messageParameters = hashMapOf("file" to hashMapOf("id" to "1", "name" to "a.jpg"))
+ )
+ val withPath = ChatMessage(
+ jsonMessageId = 9,
+ message = "{file}",
+ messageParameters = hashMapOf("file" to hashMapOf("id" to "1", "name" to "a.jpg", "path" to "Talk/a.jpg"))
+ )
+ Assert.assertFalse(buildStateFor(noPath).showForwardFile)
+ Assert.assertTrue(buildStateFor(withPath).showForwardFile)
+ }
+
+ @Test
+ fun showForwardFile_falseInFederatedConversation() {
+ val federated = conversation(remoteServer = "https://other.example")
+ Assert.assertFalse(buildStateFor(fileMessage(), conversation = federated).showForwardFile)
+ Assert.assertTrue(buildStateFor(fileMessage(), conversation = conversation()).showForwardFile)
+ }
+
+ @Test
+ fun canSendToConversation_followsTheChatPermission() {
+ Assert.assertTrue(buildStateFor(fileMessage()).canSendToConversation)
+ Assert.assertFalse(buildStateFor(fileMessage(), hasChatPermission = false).canSendToConversation)
+ }
}
diff --git a/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerActionsTest.kt b/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerActionsTest.kt
new file mode 100644
index 00000000000..828ee52d8f7
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerActionsTest.kt
@@ -0,0 +1,149 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.mediaviewer.model
+
+import com.nextcloud.talk.chat.ui.MessageActionsState
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MediaViewerActionsTest {
+
+ private fun actions(
+ reply: Boolean = true,
+ delete: Boolean = true,
+ forwardFile: Boolean = true,
+ canSend: Boolean = true
+ ) = allowedActions().copy(
+ showReply = reply,
+ showDelete = delete,
+ showForwardFile = forwardFile,
+ canSendToConversation = canSend
+ )
+
+ private fun allowedActions() =
+ MessageActionsState(
+ showEmojiBar = false,
+ selfReactions = emptySet(),
+ showEditInfo = false,
+ lastEditedBy = "",
+ lastEditedAt = "",
+ showReply = true,
+ showReplyPrivately = false,
+ showOpenThread = false,
+ showForward = false,
+ showForwardFile = true,
+ canSendToConversation = true,
+ showEdit = false,
+ showCopy = false,
+ showCopyMessageLink = false,
+ showMarkAsUnread = false,
+ showRemind = false,
+ showPin = false,
+ isPinned = false,
+ showTranslate = false,
+ showShareToNote = false,
+ showShare = true,
+ showSave = true,
+ showOpenInFiles = false,
+ showDelete = true
+ )
+
+ // counter
+
+ @Test
+ fun counter_newestItemIsTheLast() {
+ // pager order is oldest first: the newest item (index 4 of 5) = 5 of 5, the oldest = 1 of 5
+ assertEquals(MediaViewerCounter(5, 5, false), mediaViewerCounter(4, 5, false))
+ assertEquals(MediaViewerCounter(1, 5, false), mediaViewerCounter(0, 5, false))
+ }
+
+ @Test
+ fun counter_positionMovesWithTheItemWhenOlderItemsArePagedIn() {
+ val newest = mediaViewerCounter(index = 4, total = 5, hasMoreOlder = true)!!
+ assertEquals(MediaViewerCounter(5, 5, true), newest)
+ // 50 older items are loaded: the pager index of the viewed item moves by 50, it is still the newest
+ val after = mediaViewerCounter(index = 54, total = 55, hasMoreOlder = true)!!
+ assertEquals(MediaViewerCounter(55, 55, true), after)
+
+ val middle = mediaViewerCounter(index = 2, total = 5, hasMoreOlder = true)!!
+ val middleAfter = mediaViewerCounter(index = 52, total = 55, hasMoreOlder = false)!!
+ assertEquals(3, middle.position)
+ assertEquals(middle.position + 50, middleAfter.position)
+ assertFalse(middleAfter.totalIsLowerBound)
+ }
+
+ @Test
+ fun counter_totalIsExactOnceNothingOlderIsLeft() {
+ assertFalse(mediaViewerCounter(0, 3, hasMoreOlder = false)!!.totalIsLowerBound)
+ }
+
+ @Test
+ fun counter_nullWithoutItemsOrForIndexOutOfRange() {
+ assertNull(mediaViewerCounter(0, 0, false))
+ assertNull(mediaViewerCounter(-1, 3, false))
+ assertNull(mediaViewerCounter(3, 3, false))
+ }
+
+ // media kind
+
+ @Test
+ fun drawing_isOnlyForPhotos() {
+ assertTrue(isDrawableImage("image/jpeg"))
+ assertTrue(isDrawableImage("image/png"))
+ assertFalse(isDrawableImage("image/gif"))
+ assertFalse(isDrawableImage("image/svg+xml"))
+ assertFalse(isDrawableImage("video/mp4"))
+ assertFalse(isDrawableImage("application/pdf"))
+ }
+
+ // menu
+
+ @Test
+ fun menu_allEntriesForAnOwnPhotoWithAllRights() {
+ val menu = mediaViewerMenuState("image/jpeg", hasLocalFile = true, actions = actions())
+ assertEquals(MediaViewerMenuState(true, true, true, true, true, true, true, true), menu)
+ }
+
+ @Test
+ fun menu_videoGetsEverythingButDrawing() {
+ val menu = mediaViewerMenuState("video/mp4", hasLocalFile = true, actions = actions())
+ assertFalse(menu.draw)
+ assertTrue(menu.forward && menu.reply && menu.delete && menu.share && menu.saveToGallery)
+ }
+
+ @Test
+ fun menu_withoutTheMessageInTheDatabaseOnlyWhatNeedsNoRightsRemains() {
+ val menu = mediaViewerMenuState("image/jpeg", hasLocalFile = true, actions = null)
+ assertEquals(MediaViewerMenuState(true, true, true, true, false, false, false, false), menu)
+ }
+
+ @Test
+ fun menu_followsTheChatRules() {
+ val menu = mediaViewerMenuState(
+ "image/jpeg",
+ hasLocalFile = true,
+ actions = actions(reply = false, delete = false, forwardFile = false, canSend = false)
+ )
+ assertFalse(menu.reply)
+ assertFalse(menu.delete)
+ assertFalse(menu.forward)
+ assertFalse(menu.draw)
+ assertTrue(menu.showInChat && menu.showAllMedia)
+ }
+
+ @Test
+ fun menu_shareSaveAndDrawNeedTheDownloadedFile_forwardDoesNot() {
+ val menu = mediaViewerMenuState("image/jpeg", hasLocalFile = false, actions = actions())
+ assertFalse(menu.share)
+ assertFalse(menu.saveToGallery)
+ assertFalse(menu.draw)
+ assertTrue(menu.forward)
+ }
+}
diff --git a/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerChatRequestTest.kt b/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerChatRequestTest.kt
new file mode 100644
index 00000000000..fe2c503cb35
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerChatRequestTest.kt
@@ -0,0 +1,143 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package com.nextcloud.talk.mediaviewer.model
+
+import com.nextcloud.talk.chat.ui.MessageActionsState
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+
+class MediaViewerChatRequestTest {
+
+ @get:Rule
+ val temp = TemporaryFolder()
+
+ private fun state(allowed: Boolean) =
+ MessageActionsState(
+ showEmojiBar = false,
+ selfReactions = emptySet(),
+ showEditInfo = false,
+ lastEditedBy = "",
+ lastEditedAt = "",
+ showReply = allowed,
+ showReplyPrivately = false,
+ showOpenThread = false,
+ showForward = false,
+ showForwardFile = allowed,
+ canSendToConversation = allowed,
+ showEdit = false,
+ showCopy = false,
+ showCopyMessageLink = false,
+ showMarkAsUnread = false,
+ showRemind = false,
+ showPin = false,
+ isPinned = false,
+ showTranslate = false,
+ showShareToNote = false,
+ showShare = false,
+ showSave = false,
+ showOpenInFiles = false,
+ showDelete = allowed
+ )
+
+ // request
+
+ @Test
+ fun request_parsesWhatTheViewerSends() {
+ assertEquals(
+ MediaViewerChatRequest(MediaViewerChatAction.REPLY, 12L),
+ MediaViewerChatRequest.parse("REPLY", 12L, null)
+ )
+ assertEquals(
+ MediaViewerChatRequest(MediaViewerChatAction.DRAW, 12L, localPath = "/c/a.jpg"),
+ MediaViewerChatRequest.parse("DRAW", 12L, "/c/a.jpg")
+ )
+ assertEquals(
+ MediaViewerChatRequest(MediaViewerChatAction.FORWARD, 12L),
+ MediaViewerChatRequest.parse("FORWARD", 12L, null)
+ )
+ }
+
+ @Test
+ fun request_rejectsIncompleteOrUnknown() {
+ assertNull(MediaViewerChatRequest.parse(null, 12L, null))
+ assertNull(MediaViewerChatRequest.parse("EXPLODE", 12L, null))
+ assertNull(MediaViewerChatRequest.parse("REPLY", 0L, null))
+ assertNull(MediaViewerChatRequest.parse("DRAW", 12L, null))
+ }
+
+ @Test
+ fun chatRecheck_eachActionFollowsExactlyItsOwnFlag() {
+ val none = state(false)
+ val only = mapOf(
+ MediaViewerChatAction.REPLY to none.copy(showReply = true),
+ MediaViewerChatAction.DELETE to none.copy(showDelete = true),
+ MediaViewerChatAction.FORWARD to none.copy(showForwardFile = true),
+ MediaViewerChatAction.DRAW to none.copy(canSendToConversation = true)
+ )
+ MediaViewerChatAction.entries.forEach { action ->
+ assertFalse(action.name, isMediaActionAllowed(action, none))
+ MediaViewerChatAction.entries.forEach { flagOf ->
+ val allowed = isMediaActionAllowed(action, only.getValue(flagOf))
+ assertEquals("$action with only the flag of $flagOf", action == flagOf, allowed)
+ }
+ }
+ }
+
+ // forward entries
+
+ @Test
+ fun pendingForward_worksOnceAndNeedsTheKey() {
+ val entry = PendingFileForward.Entry(1L, "/Talk/a.jpg", "caption")
+ val key = PendingFileForward.put(entry)
+ assertEquals(entry, PendingFileForward.take(key))
+ assertNull(PendingFileForward.take(key))
+ assertNull(PendingFileForward.take(null))
+ assertNull(PendingFileForward.take("not-a-key"))
+ }
+
+ @Test
+ fun pendingForward_peekDoesNotConsumeTheEntry() {
+ val entry = PendingFileForward.Entry(1L, "/Talk/a.jpg", "")
+ val key = PendingFileForward.put(entry)
+ assertEquals(entry, PendingFileForward.peek(key))
+ assertEquals(entry, PendingFileForward.peek(key))
+ assertEquals(entry, PendingFileForward.take(key))
+ assertNull(PendingFileForward.peek(key))
+ assertNull(PendingFileForward.peek(null))
+ }
+
+ // paths
+
+ @Test
+ fun remoteSharePath_hasExactlyOneLeadingSlash() {
+ assertEquals("/Talk/a.jpg", remoteSharePath("Talk/a.jpg"))
+ assertEquals("/Talk/a.jpg", remoteSharePath("/Talk/a.jpg"))
+ assertEquals("/Talk/a.jpg", remoteSharePath("//Talk/a.jpg"))
+ }
+
+ @Test
+ fun insideDirectory_acceptsNestedFiles() {
+ val dir = temp.newFolder("shared_attachments")
+ val nested = File(File(dir, "42").also { it.mkdirs() }, "a.jpg").also { it.writeText("x") }
+ assertTrue(isInsideDirectory(dir, nested))
+ }
+
+ @Test
+ fun insideDirectory_rejectsTraversalAndOutsideFiles() {
+ val dir = temp.newFolder("shared_attachments")
+ val outside = temp.newFile("secret.txt")
+ assertFalse(isInsideDirectory(dir, outside))
+ assertFalse(isInsideDirectory(dir, File(dir, "../secret.txt")))
+ assertFalse(isInsideDirectory(dir, dir))
+ }
+}