Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a6fda72
feat(chat): record video messages from the message input
ToteMeiSter Oct 4, 2026
6186c74
fix(chat): start recording only on hold and show the record hint as a…
ToteMeiSter Oct 4, 2026
ae71b02
fix(chat): keep a video recording over a screen rotation
ToteMeiSter Oct 4, 2026
64fbec6
fix(chat): block taps and TalkBack focus on the video recording preview
ToteMeiSter Oct 4, 2026
0e01ff3
feat(chat): replace attachment menu with a media bottom sheet
ToteMeiSter Oct 4, 2026
b56b95f
fix(chat): count partial media access as files access on Android 14
ToteMeiSter Oct 4, 2026
05e19c4
feat(chat): take photos with an in-app camera instead of the system one
ToteMeiSter Oct 4, 2026
0cfcf9d
feat(chat): full-screen attachment preview with photo editing
ToteMeiSter Oct 4, 2026
00529b8
feat(camera): switch between fast and maximum quality shutter
ToteMeiSter Oct 4, 2026
4d9b1c1
fix(chat): measure the minimum voice message length from the recordin…
ToteMeiSter Oct 5, 2026
4ded289
fix(chat): release a locked video recording only when CameraX has fin…
ToteMeiSter Oct 5, 2026
80df833
fix(chat): dim the chat and fit the video message preview to the reco…
ToteMeiSter Oct 5, 2026
1e8b515
fix(chat): center the video preview by gravity, follow the screen ori…
ToteMeiSter Oct 5, 2026
6f6bc39
fix(chat): one-line recording panel for video messages, larger preview
ToteMeiSter Oct 5, 2026
57655a6
fix(chat): video recording row review fixes
ToteMeiSter Oct 5, 2026
b68804c
fix(chat): ask for the selected-photos permission and judge the resul…
ToteMeiSter Oct 5, 2026
15291f1
fix(chat): defer video result until chat is loaded, video a11y labels
ToteMeiSter Oct 5, 2026
fe2dcff
fix(chat): open the in-app camera after the camera permission is granted
ToteMeiSter Oct 5, 2026
23b121d
fix(chat): hide the photo entry of the attachment sheet without a camera
ToteMeiSter Oct 5, 2026
0721680
fix(camera): keep the photo rotation until ImageCapture exists
ToteMeiSter Oct 5, 2026
3c98f3b
fix(attachment-sheet): offer "select more" for mixed media grants on …
ToteMeiSter Oct 5, 2026
51dd906
fix(attachment-preview): reserve a unique file for every edit output
ToteMeiSter Oct 5, 2026
d4fafe8
fix(attachment-sheet): re-read media grants when the permission reque…
ToteMeiSter Oct 5, 2026
ecd5a44
fix(attachment-preview): do not ask the content provider for the type…
ToteMeiSter Oct 12, 2026
c4c2e6c
fix(attachment-preview): send with the compression option as chosen
ToteMeiSter Oct 18, 2026
4710065
feat(mediaviewer): show the position, add forward and draw actions to…
ToteMeiSter Oct 5, 2026
5300d72
fix(chat): run the media viewer request only once the capabilities ar…
ToteMeiSter Oct 5, 2026
0898339
fix(chat): open the "show in chat" target when the conversation loads…
ToteMeiSter Oct 5, 2026
a0231f1
fix(chat): wait for the music of another app to stop before a video m…
ToteMeiSter Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />

<uses-permission android:name="android.permission.WAKE_LOCK" />
Expand Down Expand Up @@ -191,6 +192,13 @@
</intent-filter>
</activity>

<activity
android:name=".camera.CaptureActivity"
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout"
android:exported="false"
android:screenOrientation="nosensor"
android:theme="@style/FullScreenImageTheme" />

<activity
android:name=".chat.ChatActivity"
android:theme="@style/AppTheme.DisplayCutout" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.attachmentpreview

import kotlin.math.ceil

/*
* Pure, Android-free helpers behind the attachment preview editing features, kept apart from the
* UI so they can be covered by plain JVM unit tests.
*/

private const val EDITED_SUFFIX = "_edited_"
private const val SHARED_URI_SEGMENT = "shared_attachments"
private val editedSuffixRegex = Regex("${EDITED_SUFFIX}[0-9-]+$")
private const val MAX_DECODE_PIXELS = 20_000_000L

/** A point on the displayed image, in 0..1 of the image's width/height (resolution independent). */
internal data class NormalizedPoint(val x: Float, val y: Float)

/** One finger-down..finger-up brush stroke. [widthFraction] is the brush width relative to the image width. */
internal data class DrawStroke(val colorArgb: Int, val widthFraction: Float, val points: List<NormalizedPoint>)

/** Where an image of a given aspect ratio lands when fitted (letterboxed) into a view, in view pixels. */
internal data class FitRect(val left: Float, val top: Float, val width: Float, val height: Float)

/** Replaces [oldUri] by [newUri] keeping its position; returns [files] unchanged when [oldUri] is absent. */
internal fun replaceUri(files: List<String>, oldUri: String, newUri: String): List<String> {
val index = files.indexOf(oldUri)
return if (index < 0 || oldUri == newUri) {
files
} else {
files.toMutableList().also { it[index] = newUri }
}
}

/**
* Toggles [uri] in the set of files excluded from sending. At least one file always stays selected,
* so excluding the last selected file is refused (the set is returned unchanged).
*/
internal fun toggleSelection(files: List<String>, unselected: Set<String>, uri: String): Set<String> =
when {
uri !in files -> unselected
uri in unselected -> unselected - uri
files.count { it !in unselected } <= 1 -> unselected
else -> unselected + uri
}

/** The files that will actually be sent, in list order. */
internal fun selectedFiles(files: List<String>, unselected: Set<String>): List<String> =
files.filter { it !in unselected }

/**
* Name for the file an edit produces: the original base name without extension and without an
* earlier edit suffix (so repeated edits don't pile suffixes up), plus a unique [stamp].
*/
internal fun editedFileName(originalName: String, stamp: String, extension: String): String {
val base = originalName.substringBeforeLast('.', originalName).replace(editedSuffixRegex, "")
return "${base.ifBlank { "image" }}$EDITED_SUFFIX$stamp.$extension"
}

/** True for names produced by [editedFileName], i.e. intermediates of an earlier edit. */
internal fun isEditedFileName(fileName: String): Boolean =
editedSuffixRegex.containsMatchIn(fileName.substringBeforeLast('.', fileName))

/**
* The edited file's name when [pathSegments] of a URI under [authority] address exactly
* `shared_attachments/<edit-suffixed name>` in our own FileProvider; null for anything else
* (foreign authority, an original/camera file, a nested path). Only those are ours to delete.
*/
internal fun ownEditedName(authority: String?, ownAuthority: String, pathSegments: List<String>): String? {
val name = pathSegments.getOrNull(1)
val matches = authority == ownAuthority &&
pathSegments.size == 2 &&
pathSegments.first() == SHARED_URI_SEGMENT &&
name != null &&
isEditedFileName(name)
return if (matches) name else null
}

/**
* Whether drawing must turn the pixels upright instead of carrying the EXIF orientation tag over.
* Mirrored orientations (2/4/5/7) are not understood downstream, and PNG is not rotated by EXIF at
* all; only JPEG with the plain rotations 3/6/8 keeps the tag.
*/
internal fun rotatesPixels(png: Boolean, rotationDegrees: Int, flipped: Boolean): Boolean =
flipped || (png && rotationDegrees != 0)

/** Output extension and whether it is PNG (lossless, keeps transparency) for a source MIME type. */
internal fun editOutputIsPng(sourceMimeType: String?): Boolean = sourceMimeType == "image/png"

/** Fits an image of [aspectRatio] (width / height) into the view like `ContentScale.Fit`, centered. */
internal fun fitRect(viewWidth: Float, viewHeight: Float, aspectRatio: Float): FitRect {
val viewRatio = viewWidth / viewHeight
val (width, height) = if (viewRatio > aspectRatio) {
(viewHeight * aspectRatio) to viewHeight
} else {
viewWidth to (viewWidth / aspectRatio)
}
return FitRect((viewWidth - width) / 2f, (viewHeight - height) / 2f, width, height)
}

/** Maps a view-space touch position to the image, clamped so strokes can't leave the picture. */
internal fun toNormalized(x: Float, y: Float, rect: FitRect): NormalizedPoint =
NormalizedPoint(
((x - rect.left) / rect.width).coerceIn(0f, 1f),
((y - rect.top) / rect.height).coerceIn(0f, 1f)
)

/** Maps a normalized point to pixels of a bitmap of the given size. */
internal fun toPixels(point: NormalizedPoint, bitmapWidth: Int, bitmapHeight: Int): Pair<Float, Float> =
(point.x * bitmapWidth) to (point.y * bitmapHeight)

/** Brush width in bitmap pixels; never thinner than one pixel. */
internal fun strokeWidthPixels(stroke: DrawStroke, bitmapWidth: Int): Float =
(stroke.widthFraction * bitmapWidth).coerceAtLeast(1f)

/** Drops the most recent stroke ("undo"). */
internal fun undoLast(strokes: List<DrawStroke>): List<DrawStroke> = strokes.dropLast(1)

/**
* Power-of-two `inSampleSize` that keeps a decoded bitmap under [maxPixels]; 1 means full resolution.
* Only very large sources (beyond ~20 MP) are downsampled, to avoid running out of memory.
*/
internal fun decodeSampleSize(width: Int, height: Int, maxPixels: Long = MAX_DECODE_PIXELS): Int {
var sample = 1
var pixels = width.toLong() * height.toLong()
while (pixels > maxPixels) {
sample *= 2
pixels = ceil(width / sample.toDouble()).toLong() * ceil(height / sample.toDouble()).toLong()
}
return sample
}
Loading
Loading