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)) + } +}