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