Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
104 commits
Select commit Hold shift + click to select a range
151a7ce
fix(chat): measure the minimum voice message length from the recordin…
ToteMeiSter Oct 5, 2026
032345c
fix(chat): release a locked video recording only when CameraX has fin…
ToteMeiSter Oct 5, 2026
24d4527
fix(chat): dim the chat and fit the video message preview to the reco…
ToteMeiSter Oct 5, 2026
9bd50e4
fix(chat): center the video preview by gravity, follow the screen ori…
ToteMeiSter Oct 5, 2026
950eb2a
fix(chat): one-line recording panel for video messages, larger preview
ToteMeiSter Oct 5, 2026
10660fb
fix(chat): video recording row review fixes
ToteMeiSter Oct 5, 2026
251213a
fix(chat): defer video result until chat is loaded, video a11y labels
ToteMeiSter Oct 5, 2026
a6fda72
feat(chat): record video messages from the message input
ToteMeiSter Oct 4, 2026
1687ca2
fix(chat): harden video recording against review findings
ToteMeiSter Oct 18, 2026
6186c74
fix(chat): start recording only on hold and show the record hint as a…
ToteMeiSter Oct 4, 2026
881260d
fix(chat): keep the locked recording panel when the chat finishes loa…
ToteMeiSter Oct 18, 2026
ae71b02
fix(chat): keep a video recording over a screen rotation
ToteMeiSter Oct 4, 2026
1f46b9a
fix(chat): do not ask for media access before the camera photo preview
ToteMeiSter Oct 18, 2026
64fbec6
fix(chat): block taps and TalkBack focus on the video recording preview
ToteMeiSter Oct 4, 2026
4fddff2
fix(chat): do not let the record button click fire after the release
ToteMeiSter Oct 18, 2026
fbd9aa0
fix(chat): measure the minimum voice message length from the recordin…
ToteMeiSter Oct 5, 2026
d9d8009
fix(chat): release a locked video recording only when CameraX has fin…
ToteMeiSter Oct 5, 2026
839b24f
fix(chat): dim the chat and fit the video message preview to the reco…
ToteMeiSter Oct 5, 2026
1282729
fix(chat): center the video preview by gravity, follow the screen ori…
ToteMeiSter Oct 5, 2026
1d7e938
fix(chat): one-line recording panel for video messages, larger preview
ToteMeiSter Oct 5, 2026
b6ba6a6
fix(chat): video recording row review fixes
ToteMeiSter Oct 5, 2026
6ceba02
fix(chat): ask for the selected-photos permission and judge the resul…
ToteMeiSter Oct 5, 2026
6dc8886
fix(chat): defer video result until chat is loaded, video a11y labels
ToteMeiSter Oct 5, 2026
7889017
fix(attachment-sheet): offer "select more" for mixed media grants on …
ToteMeiSter Oct 5, 2026
297d509
fix(attachment-sheet): re-read media grants when the permission reque…
ToteMeiSter Oct 5, 2026
763d284
fix(chat): harden video recording against review findings
ToteMeiSter Oct 18, 2026
acb551b
fix(chat): keep the locked recording panel when the chat finishes loa…
ToteMeiSter Oct 18, 2026
7c968ab
fix(attachment-sheet): honor partial media access in the storage requ…
ToteMeiSter Oct 18, 2026
0e01ff3
feat(chat): replace attachment menu with a media bottom sheet
ToteMeiSter Oct 4, 2026
946d9a4
fix(chat): do not ask for media access before the camera photo preview
ToteMeiSter Oct 18, 2026
b56b95f
fix(chat): count partial media access as files access on Android 14
ToteMeiSter Oct 4, 2026
9bb514b
fix(chat): do not let the record button click fire after the release
ToteMeiSter Oct 18, 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
86493e1
fix(chat): wait for the music of another app to stop before a video m…
ToteMeiSter Oct 18, 2026
0b9f7ea
fix(chat): wait for the music of another app to stop before a video m…
ToteMeiSter Oct 18, 2026
690e34e
fix(chat): wait for the music of another app to stop before a video m…
ToteMeiSter Oct 18, 2026
a0231f1
fix(chat): wait for the music of another app to stop before a video m…
ToteMeiSter Oct 18, 2026
1b9830a
fix(attachment-sheet): keep recent media when the provider refuses on…
ToteMeiSter Oct 19, 2026
c5971e6
fix(attachment-sheet): keep recent media when the provider refuses on…
ToteMeiSter Oct 19, 2026
33908de
fix(attachment-sheet): honor partial media access in the storage requ…
ToteMeiSter Oct 18, 2026
c1c02e1
Merge remote-tracking branch 'upstream/master' into prep/6818-merge-m…
ToteMeiSter Oct 26, 2026
d4422a5
Merge remote-tracking branch 'upstream/master' into prep/6819-merge-m…
ToteMeiSter Oct 26, 2026
d920f75
Merge remote-tracking branch 'upstream/master' into prep/6820-merge-m…
ToteMeiSter Oct 26, 2026
cdde4c8
Merge remote-tracking branch 'upstream/master' into prep/6823-merge-m…
ToteMeiSter Oct 26, 2026
9290fd6
Merge remote-tracking branch 'upstream/master' into prep/6818-merge-m…
ToteMeiSter Oct 27, 2026
c1da139
Merge remote-tracking branch 'upstream/master' into prep/6819-merge-m…
ToteMeiSter Oct 27, 2026
385cacb
Merge remote-tracking branch 'upstream/master' into prep/6820-merge-m…
ToteMeiSter Oct 27, 2026
f29d113
Merge remote-tracking branch 'upstream/master' into prep/6823-merge-m…
ToteMeiSter Oct 27, 2026
f46852b
fix(chat): clear lint findings of the video message recording
ToteMeiSter Oct 27, 2026
7fb9a45
fix(chat): clear lint findings of the video message recording
ToteMeiSter Oct 27, 2026
21dbcf1
fix(chat): clear lint findings of the video message recording
ToteMeiSter Oct 27, 2026
1220e45
fix(chat): clear lint findings of the video message recording
ToteMeiSter Oct 27, 2026
2f9ca57
fix(chat): name the media permissions by string for lint
ToteMeiSter Oct 27, 2026
f4132ef
fix(chat): name the media permissions by string for lint
ToteMeiSter Oct 27, 2026
9b34e0d
fix(chat): name the media permissions by string for lint
ToteMeiSter Oct 27, 2026
ca11ac6
fix(chat): clear lint findings of the attachment preview and camera
ToteMeiSter Oct 27, 2026
ed4f279
fix(chat): clear lint findings of the attachment preview and camera
ToteMeiSter Oct 27, 2026
a4c913e
fix(mediaviewer): do not keep a Context in the media viewer view model
ToteMeiSter Oct 27, 2026
3703eca
Restructure record hint and video recording layouts for lint
ToteMeiSter Oct 27, 2026
ee21c1e
Fix lint DiscouragedApi: set capture orientation in code
ToteMeiSter Oct 27, 2026
623e76a
Restructure record hint and video recording layouts for lint
ToteMeiSter Oct 27, 2026
996829b
Restructure record hint and video recording layouts for lint
ToteMeiSter Oct 27, 2026
25182eb
Restructure record hint and video recording layouts for lint
ToteMeiSter Oct 27, 2026
81f79e1
Fix lint DiscouragedApi: set capture orientation in code
ToteMeiSter Oct 27, 2026
68c940b
Merge prep/6818 lint fixes into prep/6819
ToteMeiSter Oct 27, 2026
2971bed
Merge prep/6819 lint fixes into prep/6820
ToteMeiSter Oct 27, 2026
3d15c7c
Merge prep/6820 lint fixes into prep/6823
ToteMeiSter Oct 27, 2026
81b2b5d
Remove duplicate recording start after stack merge
ToteMeiSter Oct 27, 2026
12763ed
Merge recorder cleanup from prep/6820 into prep/6823
ToteMeiSter Oct 27, 2026
b5a6400
Keep recorder function count at master baseline
ToteMeiSter Oct 27, 2026
980d669
Merge recorder function cleanup from prep/6820
ToteMeiSter Oct 27, 2026
491c615
Merge upstream/master into prep/6818-merge-master
ToteMeiSter Oct 30, 2026
41975f1
Merge prep/6818-merge-master (with upstream/master) into prep/6819-me…
ToteMeiSter Oct 30, 2026
972799b
Merge prep/6819-merge-master (with upstream/master) into prep/6820-me…
ToteMeiSter Oct 30, 2026
8e6e835
Merge prep/6820-merge-master (with upstream/master) into prep/6823-me…
ToteMeiSter Oct 30, 2026
964fac3
Merge remote-tracking branch 'upstream/master' into stack/6818
ToteMeiSter Oct 30, 2026
422d02e
Merge branch 'stack/6818' into stack/6819
ToteMeiSter Oct 30, 2026
37e490e
Merge branch 'stack/6819' into stack/6820
ToteMeiSter Oct 30, 2026
7419138
Merge branch 'stack/6820' into stack/6823
ToteMeiSter Oct 30, 2026
67fcbaf
build: verify stack dependency metadata
ToteMeiSter Oct 30, 2026
fddb13f
Merge branch 'stack/6818' into stack/6819
ToteMeiSter Oct 30, 2026
5a430fc
Merge branch 'stack/6819' into stack/6820
ToteMeiSter Oct 30, 2026
eb16fbf
Merge branch 'stack/6820' into stack/6823
ToteMeiSter Oct 30, 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 @@ -41,6 +41,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
7 changes: 7 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,12 @@
</intent-filter>
</activity>

<activity
android:name=".camera.CaptureActivity"
android:configChanges="orientation|keyboardHidden|screenSize|screenLayout"
android:exported="false"
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