diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 1d9b04b0e2..13f1a99bb0 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 4c27ccc2c8..530b0e0dbb 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -65,6 +65,7 @@
android:maxSdkVersion="32" />
+
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 0000000000..e81ebd475c
--- /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.PICTURE_FROM_CAM,
+ AttachmentAction.FILE_FROM_LOCAL,
+ AttachmentAction.FILE_FROM_CLOUD,
+ AttachmentAction.SHARE_CONTACT -> !input.isRemoteConversation
+
+ 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 0000000000..e363ce42f3
--- /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 0000000000..6820db600f
--- /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 0000000000..45507becf2
--- /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 0000000000..ee2f1ac5b7
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt
@@ -0,0 +1,115 @@
+/*
+ * 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 shows only the files of the app itself:
+ * the picked items do not apply once one per-type permission is granted. The action opens the request again, where
+ * "allow all" is offered.
+ */
+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)
+ }
+
+/**
+ * Permissions asked for when a file that is already at hand (camera photo, upload preview) needs access to the
+ * files of the device. Android 13 and newer ask for the media permissions, with the user-selected grant on Android 14,
+ * so that a partial grant is possible and counts; before that the storage permission that [resolveMediaAccess]
+ * and the permission check of the platform look at.
+ */
+fun storagePermissionsToRequest(sdkInt: Int): Array =
+ when {
+ sdkInt >= Build.VERSION_CODES.TIRAMISU -> shareFilePermissionsToRequest(sdkInt)
+ sdkInt > Build.VERSION_CODES.Q -> arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
+ else -> arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ }
+
+/**
+ * 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 0000000000..492a22ee36
--- /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 0000000000..d5fd5e5a6e
--- /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 0000000000..b4ed5e52be
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/RecentMediaLoader.kt
@@ -0,0 +1,125 @@
+/*
+ * 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. On AOSP a query without read access (or with partial access)
+ * returns only the files of the app itself, so both types are always asked for and no permission check happens
+ * here. A provider of a device may refuse instead; the refusal of one type must not drop the other one, because
+ * the caller has no catch.
+ */
+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()
+ try {
+ 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
+ )
+ }
+ }
+ } catch (e: SecurityException) {
+ Log.w(TAG, "recent ${if (isVideo) "videos" else "photos"} not readable", e)
+ result.clear()
+ }
+ return result
+ }
+}
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 0000000000..b9176f4700
--- /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 30bbf4d565..c63b15eced 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,14 @@ 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.chat.data.io.VoiceMessageMediaService
import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.chat.data.model.FileParameters
@@ -268,6 +282,8 @@ class ChatActivity :
private lateinit var binding: ActivityChatBinding
+ private val orientationTracker by lazy { DeviceOrientationTracker(this) }
+
@Inject
lateinit var ncApi: NcApi
@@ -422,6 +438,7 @@ class ChatActivity :
val participantPermissionsFlow: StateFlow = _participantPermissionsFlow.asStateFlow()
private var pendingCameraUri: Uri? = null
+ private var attachmentSheetModel by mutableStateOf(null)
private var pendingTargetMessageId: Long? = null
private var pendingTargetThreadId: Long? = null
private var pendingTargetSearchQuery: String? = null
@@ -668,6 +685,7 @@ class ChatActivity :
messageInputViewModel.setData(chatViewModel.getChatRepository())
initObservers()
+ resumeRecordingAfterRecreation()
pendingTargetMessageId?.let { messageId ->
lifecycleScope.launch {
@@ -1079,6 +1097,8 @@ class ChatActivity :
)
}
}
+
+ AttachmentSheetHost()
}
}
}
@@ -1418,6 +1438,7 @@ class ChatActivity :
override fun onStart() {
super.onStart()
active = true
+ orientationTracker.enable()
this.lifecycle.addObserver(AudioUtils)
this.lifecycle.addObserver(chatViewModel)
@@ -1458,8 +1479,16 @@ class ChatActivity :
}
override fun onStop() {
+ val changingConfigurations = isChangingConfigurations
super.onStop()
active = false
+ orientationTracker.disable()
+ // reopened on the next start, so that the live camera preview follows the call state of that moment
+ attachmentSheetModel = null
+ 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)
@@ -1605,11 +1634,16 @@ class ChatActivity :
chatApiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1))
_participantPermissionsFlow.value =
ParticipantPermissions(spreedCapabilities, state.conversationModel!!)
+ // a locked recording keeps its panel: the observer of the lock does not run again
+ val recordingLocked = chatViewModel.getVoiceRecordingLocked.value == true
supportFragmentManager.commit {
setReorderingAllowed(true) // optimizes out redundant replace operations
- replace(R.id.fragment_container_activity_chat, messageInputFragment)
+ replace(
+ R.id.fragment_container_activity_chat,
+ if (recordingLocked) MessageInputVoiceRecordingFragment() else messageInputFragment
+ )
runOnCommit {
- if (focusInput) {
+ if (focusInput && !recordingLocked) {
messageInputFragment.binding.fragmentMessageInputView.requestFocus()
}
}
@@ -1617,6 +1651,11 @@ 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()
@@ -2466,6 +2505,250 @@ 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) {
+ // captured by value: it outlives this activity when the chat is left before the file is finalized
+ val viewModel = chatViewModel
+ val room = roomToken
+ val replyTo = getReplyToMessageId()
+ val displayName = currentConversation!!.displayName
+ val appContext = context.applicationContext
+ viewModel.activeVideoMessageRecorder?.stopAndSend { file ->
+ viewModel.uploadFile(
+ fileUri = FileProvider.getUriForFile(appContext, appContext.packageName, file).toString(),
+ isVoiceMessage = false,
+ roomToken = room,
+ replyToMessageId = replyTo,
+ displayName = displayName,
+ uploadId = UUID.randomUUID().toString()
+ )
+ }
+ } else {
+ chatViewModel.stopAndSendAudioRecording(
+ roomToken = roomToken,
+ replyToMessageId = getReplyToMessageId(),
+ displayName = currentConversation!!.displayName
+ )
+ }
+ }
+
+ fun stopAndDiscardRecording() {
+ if (chatViewModel.activeRecordingMode == RecordInputMode.VIDEO) {
+ chatViewModel.activeVideoMessageRecorder?.cancel()
+ } else {
+ chatViewModel.stopAndDiscardAudioRecording()
+ }
+ }
+
+ // the result of a recording is uploaded or previewed with the conversation and the capabilities
+ private fun isChatReady(): Boolean = currentConversation != null && ::spreedCapabilities.isInitialized
+
+ 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)
+ }
+ if (!isChatReady()) {
+ chatViewModel.activeVideoMessageRecorder?.holdResult(outcome, file)
+ return
+ }
+ 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,
+ chatReady = isChatReady()
+ )
+ 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
+ }
+
private fun requestCameraPermissions() {
requestPermissions(
arrayOf(
@@ -2485,23 +2768,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() {
@@ -2724,11 +2991,9 @@ class ChatActivity :
error("Failed to get data from intent and uri")
}
- if (permissionUtil.isFilesPermissionGranted()) {
- showFileAttachmentPreview(filesToUpload)
- } else {
- UploadAndShareFilesWorker.requestStoragePermission(this)
- }
+ // The photo lives in the app cache and is handed over through the own file provider,
+ // so it needs no access to the media.
+ showFileAttachmentPreview(filesToUpload)
} catch (e: IllegalStateException) {
Snackbar.make(
binding.root,
@@ -2778,18 +3043,14 @@ class ChatActivity :
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == UploadAndShareFilesWorker.REQUEST_PERMISSION) {
- if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
- Log.d(TAG, "showing upload preview after permissions were granted")
- if (filesToUpload.isNotEmpty()) {
- showFileAttachmentPreview(filesToUpload)
- }
- } else {
+ // Only the voice recording asks for this permission; the user taps the microphone again.
+ if (!isShareFileRequestGranted(grantResults, permissionUtil.isFilesPermissionGranted())) {
Snackbar
.make(binding.root, context.getString(R.string.read_storage_no_permission), Snackbar.LENGTH_LONG)
.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,6 +3080,14 @@ 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) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Snackbar
@@ -2875,7 +3144,7 @@ class ChatActivity :
)
}
- fun sendSelectLocalFileIntent() {
+ private fun sendSelectLocalFileIntent() {
if (!permissionUtil.isFilesPermissionGranted()) {
requestReadFilesPermissions()
} else {
@@ -2883,17 +3152,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
@@ -4053,27 +4322,78 @@ class ChatActivity :
}
}
- fun sendPictureFromCamIntent() {
+ @Composable
+ private fun AttachmentSheetHost() {
+ val model = attachmentSheetModel ?: return
+ val callbacks = remember {
+ AttachmentSheetCallbacks(
+ onAction = { runAttachmentAction(it) },
+ onTakePhoto = {
+ attachmentSheetModel = null
+ sendPictureFromCamIntent()
+ },
+ 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 -> sendPictureFromCamIntent()
+ 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 sendPictureFromCamIntent() {
if (!permissionUtil.isCameraPermissionGranted()) {
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
- }
+ val photoFile = createAttachmentFile(R.string.nc_picture_filename, PICTURE_SUFFIX)
photoFile?.also {
pendingCameraUri = FileProvider.getUriForFile(context, context.packageName, it)
@@ -4085,27 +4405,13 @@ class ChatActivity :
}
}
- fun sendVideoFromCamIntent() {
+ private fun sendVideoFromCamIntent() {
if (!permissionUtil.isCameraPermissionGranted()) {
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 +4423,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 +4541,7 @@ class ChatActivity :
}
companion object {
+ private const val VIDEO_PREVIEW_MAX_WIDTH_FRACTION = 0.75f
val TAG = ChatActivity::class.java.simpleName
/**
@@ -4266,6 +4573,7 @@ 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"
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 a5d3fa86c7..933ff4886c 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.setOnClickListener { toggleRecordInputMode() }
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
@@ -685,7 +700,104 @@ class MessageInputFragment : Fragment() {
}
}
}
- v?.onTouchEvent(event) != false
+ v != null && event != null && forwardRecordButtonTouch(v, event)
+ }
+ }
+
+ 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
+ when (
+ resolveRecordStart(
+ isVideo = isVideo,
+ videoRecorderActive = chatActivity.chatViewModel.activeVideoMessageRecorder?.isActive == true,
+ videoPermissionGranted = chatActivity.isVideoRecordPermissionGranted(),
+ audioPermissionGranted = chatActivity.isRecordAudioPermissionGranted(),
+ filesPermissionGranted = chatActivity.permissionUtil.isFilesPermissionGranted()
+ )
+ ) {
+ RecordStartStep.BUSY -> Unit
+ RecordStartStep.REQUEST_VIDEO_PERMISSIONS -> chatActivity.requestVideoRecordPermissions()
+ RecordStartStep.REQUEST_AUDIO_PERMISSION -> chatActivity.requestRecordAudioPermissions()
+ RecordStartStep.REQUEST_FILES_PERMISSION ->
+ UploadAndShareFilesWorker.requestStoragePermission(chatActivity)
+ RecordStartStep.START -> 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
+ )
}
}
@@ -820,6 +932,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 6832cfcf70..7523a60f06 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 0000000000..6a0a101ac0
--- /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 0000000000..c2163dd9e8
--- /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 0000000000..aac7379d92
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/chat/RecordInputMode.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.MotionEvent
+import android.view.View
+
+/**
+ * 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
+ }
+}
+
+/**
+ * What the record button does once the hold threshold has passed.
+ */
+enum class RecordStartStep {
+ BUSY,
+ REQUEST_VIDEO_PERMISSIONS,
+ REQUEST_AUDIO_PERMISSION,
+ REQUEST_FILES_PERMISSION,
+ START
+}
+
+/**
+ * Decides how a hold on the record button continues. No recording of either kind starts while a video recorder is
+ * still active: a video which was released is not over before CameraX has finalized the file, and its end would
+ * clear the state of a recording that started meanwhile. The video is written to the app cache and uploaded through
+ * the own file provider, so only the voice recording asks for access to the files.
+ */
+fun resolveRecordStart(
+ isVideo: Boolean,
+ videoRecorderActive: Boolean,
+ videoPermissionGranted: Boolean,
+ audioPermissionGranted: Boolean,
+ filesPermissionGranted: Boolean
+): RecordStartStep =
+ when {
+ videoRecorderActive -> RecordStartStep.BUSY
+ isVideo && !videoPermissionGranted -> RecordStartStep.REQUEST_VIDEO_PERMISSIONS
+ !isVideo && !audioPermissionGranted -> RecordStartStep.REQUEST_AUDIO_PERMISSION
+ !isVideo && !filesPermissionGranted -> RecordStartStep.REQUEST_FILES_PERMISSION
+ else -> RecordStartStep.START
+ }
+
+/**
+ * Whether clearing the view model has to give the audio focus of a video recording back. The released recorder
+ * reports nothing, so the end of the recording is never signalled otherwise.
+ */
+fun releasesVideoAudioFocusOnClear(activeMode: RecordInputMode, recordingInProgress: Boolean): Boolean =
+ activeMode == RecordInputMode.VIDEO && recordingInProgress
+
+/**
+ * Lets the record button process a touch event, e.g. for its pressed state, without the click it would generate. The
+ * touch listener switches the mode itself, and the view posts its click only after the touch has been handled, so the
+ * release is consumed here and never reaches the view. A click which does not come from a touch (TalkBack, Switch
+ * Access, keyboard) is not affected and switches the mode through the click listener.
+ */
+fun forwardRecordButtonTouch(view: View, event: MotionEvent): Boolean =
+ if (event.action == MotionEvent.ACTION_UP) {
+ view.isPressed = false
+ true
+ } else {
+ view.onTouchEvent(event)
+ }
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 0000000000..ac584bf336
--- /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 0000000000..d7f8d2780e
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt
@@ -0,0 +1,564 @@
+/*
+ * 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.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
+ internal var sendAfterRelease: ((File) -> Unit)? = null
+
+ 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
+ internal 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)
+ }
+ }
+
+ /**
+ * Keeps the result of a recording which ended while the chat of the attached activity is not loaded yet. The
+ * next [attach] delivers it.
+ */
+ fun holdResult(outcome: Outcome, file: File?) {
+ if (released) {
+ file?.delete()
+ } else {
+ pendingResult = Finished(outcome, 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.
+ * A recording which was already stopped to be sent is the exception: it is finalized and handed to the
+ * `sendAfterRelease` given to [stopAndSend], which must not depend on the activity or the chat.
+ */
+ fun release() {
+ released = true
+ detach()
+ pendingResult?.file?.delete()
+ pendingResult = null
+ cancel()
+ // The camera of an accepted send stays bound until the file is finalized. 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 (!isSendAccepted && !stopCoordinator.settling) releaseCamera()
+ }
+
+ private val isSendAccepted: Boolean
+ get() = state == State.RECORDING && stopCoordinator.action == StopAction.SEND && sendAfterRelease != null
+
+ /**
+ * @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()
+ sendAfterRelease = null
+ 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)
+ }
+ }
+ }
+
+ /**
+ * @param sendAfterRelease sends the finalized file if the recorder is released before that happens
+ */
+ fun stopAndSend(sendAfterRelease: ((File) -> Unit)? = null) {
+ if (state == State.RECORDING && stopCoordinator.action == null) {
+ this.sendAfterRelease = sendAfterRelease
+ }
+ 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)
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ @androidx.annotation.OptIn(ExperimentalPersistentRecording::class)
+ 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()
+ val videoUseCase = VideoCapture.Builder(recorder).setTargetRotation(videoRotation).build()
+ videoCapture = videoUseCase
+ preview = Preview.Builder()
+ .setTargetRotation(previewView?.let { displayRotation(it, videoRotation) } ?: videoRotation)
+ .build()
+ .also { it.surfaceProvider = previewView?.surfaceProvider }
+
+ val file = outputFile
+ if (file == null || !bindUseCases(provider)) {
+ finish(Outcome.FAILED, null)
+ 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 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
+ }
+
+ 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)
+ }
+ }
+
+ internal fun finish(outcome: Outcome, fileToSend: File?) {
+ val file = outputFile
+ musicSilenceGate.cancel()
+ releaseCamera()
+ recording = null
+ outputFile = null
+ state = State.IDLE
+ val sendReleased = sendAfterRelease
+ sendAfterRelease = null
+ if (released) {
+ if (outcome == Outcome.SEND && fileToSend != null && sendReleased != null) {
+ sendReleased(fileToSend)
+ } else {
+ file?.delete()
+ }
+ return
+ }
+ if (fileToSend == null) {
+ file?.delete()
+ }
+
+ 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
+ }
+}
+
+private const val ORIENTATION_QUARTER = 90
+private const val ORIENTATION_EIGHTH = 45
+private const val FULL_CIRCLE = 360
+
+// 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
+)
+
+/**
+ * Maps the device orientation reported by OrientationEventListener (degrees, clockwise from upright) to the
+ * [Surface] rotation, 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]
+}
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 0000000000..6461b9e67d
--- /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 0000000000..b9db99ab77
--- /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/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt
index 140f2f8b0e..b4991c7944 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,9 @@ 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.releasesVideoAudioFocusOnClear
+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 +2383,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 +2416,70 @@ 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?.let {
+ // the released recorder reports nothing, so nobody else gives the audio focus back
+ if (releasesVideoAudioFocusOnClear(activeRecordingMode, _getVoiceRecordingInProgress.value == true)) {
+ onVideoRecordingEnded()
+ }
+ it.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/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt
index f03d2abe10..f24536d921 100644
--- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt
+++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt
@@ -51,6 +51,7 @@ import com.nextcloud.talk.activities.CallActivity
import com.nextcloud.talk.activities.MainActivity
import com.nextcloud.talk.api.NcApiCoroutines
import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.attachmentsheet.isShareFileRequestGranted
import com.nextcloud.talk.chat.ChatActivity
import com.nextcloud.talk.contacts.ContactsActivity
import com.nextcloud.talk.contacts.ContactsViewModel
@@ -1057,7 +1058,7 @@ class ConversationsListActivity : BaseActivity() {
when (requestCode) {
UploadAndShareFilesWorker.REQUEST_PERMISSION -> {
- if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ if (isShareFileRequestGranted(grantResults, platformPermissionUtil.isFilesPermissionGranted())) {
Log.d(TAG, "upload starting after permissions were granted")
showSendFilesConfirmDialog()
} else {
diff --git a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt
index 0fdda7cb24..855584b1e8 100644
--- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt
+++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt
@@ -7,7 +7,6 @@
*/
package com.nextcloud.talk.jobs
-import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.NotificationManager
@@ -33,6 +32,7 @@ import com.nextcloud.talk.R
import com.nextcloud.talk.api.NcApi
import com.nextcloud.talk.api.NcApiCoroutines
import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.attachmentsheet.storagePermissionsToRequest
import com.nextcloud.talk.data.database.dao.ChatMessagesDao
import com.nextcloud.talk.data.database.model.SendStatus
import com.nextcloud.talk.data.user.model.User
@@ -508,37 +508,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa
}
fun requestStoragePermission(activity: Activity) {
- when {
- Build.VERSION
- .SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
- activity.requestPermissions(
- arrayOf(
- Manifest.permission.READ_MEDIA_IMAGES,
- Manifest.permission.READ_MEDIA_VIDEO,
- Manifest.permission.READ_MEDIA_AUDIO
- ),
- REQUEST_PERMISSION
- )
- }
-
- Build.VERSION.SDK_INT > Build.VERSION_CODES.Q -> {
- activity.requestPermissions(
- arrayOf(
- Manifest.permission.READ_EXTERNAL_STORAGE
- ),
- REQUEST_PERMISSION
- )
- }
-
- else -> {
- activity.requestPermissions(
- arrayOf(
- Manifest.permission.WRITE_EXTERNAL_STORAGE
- ),
- REQUEST_PERMISSION
- )
- }
- }
+ activity.requestPermissions(storagePermissionsToRequest(Build.VERSION.SDK_INT), REQUEST_PERMISSION)
}
@Suppress("LongParameterList")
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 a1cdb1e73c..0000000000
--- 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/utils/permissions/PlatformPermissionUtilImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/permissions/PlatformPermissionUtilImpl.kt
index f29ef0e4bc..6f3e8eff51 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 440a83f37e..b9e6324e7b 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,9 @@ public interface AppPreferences {
long getConversationListLastUserId();
+ 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 ccda4e3e03..3880a57752 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,18 @@ class AppPreferencesImpl(val context: Context) : AppPreferences {
}
}
+ 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 +720,7 @@ 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 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 0000000000..f3958f677d
--- /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_record_hint_arrow.xml b/app/src/main/res/drawable/ic_record_hint_arrow.xml
new file mode 100644
index 0000000000..cbcbb257a7
--- /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 0000000000..5215a6ff42
--- /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 16a10d70a5..72522c2f22 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 ad6fd24795..6753223994 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 3c80902ce0..4c5949b231 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 ba77a21a5e..3ae7d9f45b 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 5ef9f26e22..8ba997aeb0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -696,6 +696,10 @@ How to translate with transifex:
Add to conversation
+ Select more photos and videos
+ Photo
+ Video, %1$s
+ Send (%1$d)
Take photo
Take video
Create poll
@@ -744,6 +748,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
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 0000000000..5e0fe503d4
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/AttachmentActionTest.kt
@@ -0,0 +1,81 @@
+/*
+ * 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.assertTrue
+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 missingCameraHidesOnlyVideo() {
+ val actions = resolveAttachmentActions(everything.copy(hasCamera = false))
+ assertFalse(AttachmentAction.VIDEO_FROM_CAM in actions)
+ assertTrue(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 0000000000..35829b7e35
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/CanSelectMoreMediaTest.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.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 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)))
+ }
+
+ @Test
+ fun selectMoreIsHiddenOnAndroid13AndOlderWithoutPartialAccess() {
+ assertFalse(canSelectMoreMedia(Build.VERSION_CODES.TIRAMISU, setOf(images, video)))
+ assertFalse(canSelectMoreMedia(Build.VERSION_CODES.TIRAMISU, emptySet()))
+ assertFalse(canSelectMoreMedia(Build.VERSION_CODES.S, emptySet()))
+ }
+
+ @Test
+ fun mediaPermissionsGiveFullAccessEvenWhenSelectMoreIsOffered() {
+ assertEquals(MediaAccess.FULL, resolveMediaAccess(Build.VERSION_CODES.TIRAMISU, setOf(images)))
+ assertEquals(MediaAccess.FULL, resolveMediaAccess(Build.VERSION_CODES.TIRAMISU, setOf(images, video)))
+ val sdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE
+ assertEquals(MediaAccess.FULL, resolveMediaAccess(sdk, setOf(images, selected)))
+ assertEquals(MediaAccess.FULL, resolveMediaAccess(sdk, setOf(video, selected)))
+ }
+}
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 0000000000..8acca06664
--- /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 0000000000..dc5f338a65
--- /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/RecentMediaLoaderTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentsheet/RecentMediaLoaderTest.kt
new file mode 100644
index 0000000000..b6d1a99239
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/RecentMediaLoaderTest.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.attachmentsheet
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import android.provider.MediaStore
+import androidx.test.core.app.ApplicationProvider
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33])
+class RecentMediaLoaderTest {
+
+ class FakeMediaProvider : ContentProvider() {
+ override fun onCreate(): Boolean = true
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?
+ ): Cursor {
+ val isVideo = uri.toString().startsWith(MediaStore.Video.Media.EXTERNAL_CONTENT_URI.toString())
+ if (isVideo && refuseVideos || !isVideo && refuseImages) {
+ throw SecurityException("refused")
+ }
+ val cursor = MatrixCursor(projection!!)
+ (if (isVideo) videoRows else imageRows).forEach { (id, date) ->
+ cursor.addRow(projection.map { if (it == MediaStore.MediaColumns._ID) id else date })
+ }
+ return cursor
+ }
+
+ override fun getType(uri: Uri): String? = null
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0
+
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int =
+ 0
+
+ companion object {
+ var refuseImages = false
+ var refuseVideos = false
+ var imageRows = listOf(1L to 100L, 3L to 300L)
+ var videoRows = listOf(2L to 200L)
+ }
+ }
+
+ @Before
+ fun setUp() {
+ FakeMediaProvider.refuseImages = false
+ FakeMediaProvider.refuseVideos = false
+ Robolectric.setupContentProvider(FakeMediaProvider::class.java, MediaStore.AUTHORITY)
+ }
+
+ @After
+ fun tearDown() {
+ FakeMediaProvider.refuseImages = false
+ FakeMediaProvider.refuseVideos = false
+ }
+
+ private fun load(): List {
+ val resolver = ApplicationProvider.getApplicationContext().contentResolver
+ return runBlocking { RecentMediaLoader.load(resolver) }
+ }
+
+ @Test
+ fun bothTypesAreMergedNewestFirst() {
+ assertEquals(listOf(3L, 2L, 1L), load().map { it.id })
+ }
+
+ @Test
+ fun refusedVideosKeepThePhotos() {
+ FakeMediaProvider.refuseVideos = true
+ assertEquals(listOf(3L, 1L), load().map { it.id })
+ }
+
+ @Test
+ fun refusedPhotosKeepTheVideos() {
+ FakeMediaProvider.refuseImages = true
+ assertEquals(listOf(2L), load().map { it.id })
+ }
+
+ @Test
+ fun bothRefusedGiveAnEmptyList() {
+ FakeMediaProvider.refuseImages = true
+ FakeMediaProvider.refuseVideos = true
+ assertEquals(emptyList(), load())
+ }
+}
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 0000000000..322fee064c
--- /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 0000000000..3107f105d9
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/ShareFilePermissionTest.kt
@@ -0,0 +1,73 @@
+/*
+ * 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 storageRequestOfAFileAtHandOffersTheSelectedPhotosFromAndroid14() {
+ val selected = READ_MEDIA_VISUAL_USER_SELECTED
+ assertTrue(selected in storagePermissionsToRequest(Build.VERSION_CODES.UPSIDE_DOWN_CAKE))
+ assertFalse(selected in storagePermissionsToRequest(Build.VERSION_CODES.TIRAMISU))
+ assertTrue(Manifest.permission.READ_MEDIA_IMAGES in storagePermissionsToRequest(Build.VERSION_CODES.TIRAMISU))
+ }
+
+ @Test
+ fun storageRequestBeforeAndroid13KeepsTheStoragePermissionTheCheckLooksAt() {
+ assertEquals(
+ listOf(Manifest.permission.READ_EXTERNAL_STORAGE),
+ storagePermissionsToRequest(Build.VERSION_CODES.R).toList()
+ )
+ assertEquals(
+ listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
+ storagePermissionsToRequest(Build.VERSION_CODES.Q).toList()
+ )
+ }
+
+ @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/chat/RecordButtonGestureTest.kt b/app/src/test/java/com/nextcloud/talk/chat/RecordButtonGestureTest.kt
new file mode 100644
index 0000000000..feeb5cff55
--- /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/RecordButtonTouchTest.kt b/app/src/test/java/com/nextcloud/talk/chat/RecordButtonTouchTest.kt
new file mode 100644
index 0000000000..88bc3c391c
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/chat/RecordButtonTouchTest.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 android.app.Application
+import android.os.Looper
+import android.os.SystemClock
+import android.view.MotionEvent
+import android.widget.ImageButton
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [33])
+class RecordButtonTouchTest {
+ private var clicks = 0
+ private lateinit var button: ImageButton
+
+ @Before
+ fun setUp() {
+ clicks = 0
+ button = ImageButton(ApplicationProvider.getApplicationContext())
+ button.setOnClickListener { clicks++ }
+ button.setOnTouchListener { v, event -> forwardRecordButtonTouch(v, event) }
+ }
+
+ private fun touch(action: Int) {
+ val now = SystemClock.uptimeMillis()
+ val event = MotionEvent.obtain(now, now, action, 1f, 1f, 0)
+ button.dispatchTouchEvent(event)
+ event.recycle()
+ }
+
+ @Test
+ fun releaseDoesNotGenerateAClickEvenAfterThePostedWorkRuns() {
+ touch(MotionEvent.ACTION_DOWN)
+ touch(MotionEvent.ACTION_UP)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(0, clicks)
+ assertFalse(button.isPressed)
+ }
+
+ @Test
+ fun cancelDoesNotGenerateAClickAndClearsPressed() {
+ touch(MotionEvent.ACTION_DOWN)
+ touch(MotionEvent.ACTION_CANCEL)
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertEquals(0, clicks)
+ assertFalse(button.isPressed)
+ }
+
+ @Test
+ fun accessibilityClickStillReachesTheClickListener() {
+ button.performClick()
+
+ assertEquals(1, clicks)
+ }
+}
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 0000000000..e6bc2584e0
--- /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/RecordStartTest.kt b/app/src/test/java/com/nextcloud/talk/chat/RecordStartTest.kt
new file mode 100644
index 0000000000..b732e91283
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/chat/RecordStartTest.kt
@@ -0,0 +1,50 @@
+/*
+ * 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 RecordStartTest {
+
+ private fun resolve(
+ isVideo: Boolean,
+ active: Boolean = false,
+ video: Boolean = true,
+ audio: Boolean = true,
+ files: Boolean = true
+ ) = resolveRecordStart(isVideo, active, video, audio, files)
+
+ @Test
+ fun noRecordingStartsWhileAVideoIsStillFinalizing() {
+ assertEquals(RecordStartStep.BUSY, resolve(isVideo = false, active = true))
+ assertEquals(RecordStartStep.BUSY, resolve(isVideo = true, active = true))
+ }
+
+ @Test
+ fun videoNeedsCameraAndMicrophoneButNoAccessToFiles() {
+ assertEquals(RecordStartStep.START, resolve(isVideo = true, files = false))
+ assertEquals(RecordStartStep.REQUEST_VIDEO_PERMISSIONS, resolve(isVideo = true, video = false))
+ }
+
+ @Test
+ fun voiceStillNeedsAccessToFiles() {
+ assertEquals(RecordStartStep.REQUEST_FILES_PERMISSION, resolve(isVideo = false, files = false))
+ assertEquals(RecordStartStep.REQUEST_AUDIO_PERMISSION, resolve(isVideo = false, audio = false))
+ assertEquals(RecordStartStep.START, resolve(isVideo = false))
+ }
+
+ @Test
+ fun clearingGivesTheAudioFocusOfAVideoRecordingBack() {
+ assertTrue(releasesVideoAudioFocusOnClear(RecordInputMode.VIDEO, recordingInProgress = true))
+ assertFalse(releasesVideoAudioFocusOnClear(RecordInputMode.VIDEO, recordingInProgress = false))
+ assertFalse(releasesVideoAudioFocusOnClear(RecordInputMode.VOICE, recordingInProgress = true))
+ }
+}
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 0000000000..a4c8d6f0fd
--- /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 0000000000..17c91c18b0
--- /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 0000000000..ab4cdaa165
--- /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 0000000000..584ae09f21
--- /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 0000000000..c88301fb80
--- /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 0000000000..c4ec2ec9df
--- /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/VideoRecordingCopilotFixesTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingCopilotFixesTest.kt
new file mode 100644
index 0000000000..ff64fd3241
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingCopilotFixesTest.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.app.Application
+import androidx.camera.view.PreviewView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.test.core.app.ApplicationProvider
+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 org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.File
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33])
+class VideoRecordingCopilotFixesTest {
+
+ @get:Rule
+ val folder = TemporaryFolder()
+
+ private val context: Application = ApplicationProvider.getApplicationContext()
+
+ private fun recorder() = VideoMessageRecorder(context)
+
+ private fun file(): File = folder.newFile("video.mp4")
+
+ private class TestOwner : LifecycleOwner {
+ private val registry = LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.STARTED }
+ override val lifecycle: Lifecycle get() = registry
+ }
+
+ @Test
+ fun heldResultIsDeliveredByTheNextAttachOnly() {
+ val recorder = recorder()
+ val file = file()
+ recorder.holdResult(VideoMessageRecorder.Outcome.SEND, file)
+ assertTrue(recorder.hasPendingResult)
+
+ var delivered: Pair? = null
+ recorder.attach(TestOwner(), PreviewView(context), { outcome, f -> delivered = outcome to f })
+
+ assertEquals(VideoMessageRecorder.Outcome.SEND to file, delivered)
+ assertFalse(recorder.hasPendingResult)
+ assertTrue(file.exists())
+ }
+
+ @Test
+ fun resultHeldAfterReleaseIsDropped() {
+ val recorder = recorder()
+ val file = file()
+ recorder.release()
+
+ recorder.holdResult(VideoMessageRecorder.Outcome.SEND, file)
+
+ assertFalse(recorder.hasPendingResult)
+ assertFalse(file.exists())
+ }
+
+ @Test
+ fun acceptedSendIsHandedOverWhenTheRecorderWasReleasedBeforeFinalization() {
+ val recorder = recorder()
+ val file = file()
+ var sent: File? = null
+ recorder.outputFile = file
+ recorder.sendAfterRelease = { sent = it }
+ recorder.release()
+
+ recorder.finish(VideoMessageRecorder.Outcome.SEND, file)
+
+ assertEquals(file, sent)
+ assertTrue(file.exists())
+ }
+
+ @Test
+ fun releasedRecorderDeletesTheFileWhenNoSendWasAccepted() {
+ val recorder = recorder()
+ val file = file()
+ recorder.outputFile = file
+ recorder.release()
+
+ recorder.finish(VideoMessageRecorder.Outcome.SEND, file)
+
+ assertFalse(file.exists())
+ }
+
+ @Test
+ fun releasedRecorderDoesNotSendACancelledRecording() {
+ val recorder = recorder()
+ val file = file()
+ var sent: File? = null
+ recorder.sendAfterRelease = { sent = it }
+ recorder.release()
+
+ recorder.finish(VideoMessageRecorder.Outcome.CANCELLED, null)
+
+ assertNull(sent)
+ }
+}
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 0000000000..6f4e9650aa
--- /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))
+ }
+}