Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,8 @@ dependencies {
implementation("com.google.code.gson:gson:2.14.0")

implementation("androidx.media3:media3-exoplayer:$media3Version")
implementation("androidx.media3:media3-datasource-okhttp:$media3Version")
implementation("androidx.media3:media3-database:$media3Version")
implementation("androidx.media3:media3-ui:$media3Version")
implementation("androidx.media3:media3-transformer:$media3Version")
implementation("androidx.media3:media3-effect:$media3Version")
Expand Down
428 changes: 124 additions & 304 deletions app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* 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.audio

/**
* Decides whether the credentials of an account may be attached to an audio request. Only requests for the WebDAV
* files of that account on its own server qualify, so credentials are never sent to any other host, whatever URL
* ends up in the player.
*/
object ChatAudioCredentials {
private const val USER_FILES_PATH = "/remote.php/dav/files/"

/**
* The prefix of every file URL of the account, built the same way as
* [com.nextcloud.talk.utils.ApiUtils.getUrlForFileDownload].
*/
fun userFilesPrefix(baseUrl: String, userId: String): String = "$baseUrl$USER_FILES_PATH$userId/"

fun belongsToUser(url: String, baseUrl: String?, userId: String?): Boolean =
!baseUrl.isNullOrEmpty() && !userId.isNullOrEmpty() && url.startsWith(userFilesPrefix(baseUrl, userId))
}
Original file line number Diff line number Diff line change
@@ -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.audio

import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import com.nextcloud.talk.users.UserManager
import com.nextcloud.talk.utils.ApiUtils
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton

/**
* Data sources for chat audio. Files are streamed through the app's HTTP client, so proxy settings and user
* accepted certificates apply, and are kept in a size limited disk cache that is shared by the player and the
* waveform extraction. There must be only one instance per process, as a cache directory can only be used once.
*/
@OptIn(UnstableApi::class)
@Singleton
class ChatAudioDataSources @Inject constructor(
context: Context,
okHttpClient: OkHttpClient,
private val userManager: UserManager
) {
private val cache = SimpleCache(
File(context.cacheDir, CACHE_DIRECTORY),
LeastRecentlyUsedCacheEvictor(MAX_CACHE_SIZE_BYTES),
StandaloneDatabaseProvider(context)
)

val cacheDataSourceFactory: CacheDataSource.Factory = CacheDataSource.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(
DefaultDataSource.Factory(
context,
ResolvingDataSource.Factory(OkHttpDataSource.Factory(okHttpClient)) { dataSpec ->
authenticate(dataSpec)
}
)
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)

private fun authenticate(dataSpec: DataSpec): DataSpec {
val credentials = credentialsFor(dataSpec.uri.toString())
return if (credentials == null) {
dataSpec
} else {
dataSpec.withAdditionalHeaders(mapOf(AUTHORIZATION_HEADER to credentials))
}
}

private fun credentialsFor(url: String): String? =
runBlocking { userManager.getUsers() }
.firstOrNull { user -> ChatAudioCredentials.belongsToUser(url, user.baseUrl, user.userId) }
?.let { user -> ApiUtils.getCredentials(user.username, user.token) }

companion object {
private const val CACHE_DIRECTORY = "chat_audio"
private const val MAX_CACHE_SIZE_BYTES = 200L * 1024 * 1024
private const val AUTHORIZATION_HEADER = "Authorization"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* 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.audio

import android.content.Context
import android.content.Intent
import android.os.Bundle
import com.nextcloud.talk.activities.MainActivity
import com.nextcloud.talk.utils.bundle.BundleKeys

/**
* Opens the conversation of the audio message at the message, switching to its account if needed. A message of
* the thread [threadId] is opened in that thread.
*/
fun ChatAudioKey.openMessageIntent(context: Context, threadId: Long?): Intent =
Intent(context, MainActivity::class.java).apply {
putExtra(BundleKeys.KEY_INTERNAL_USER_ID, internalUserId)
putExtra(BundleKeys.KEY_ROOM_TOKEN, roomToken)
putExtras(openMessageExtras(threadId))
}

/** The extras that make the chat jump to the audio message, in the thread [threadId] if it belongs to one. */
fun ChatAudioKey.openMessageExtras(threadId: Long?): Bundle =
Bundle().apply {
putString(BundleKeys.KEY_MESSAGE_ID, messageId.toString())
threadId?.let { putLong(BundleKeys.KEY_THREAD_ID, it) }
}
51 changes: 51 additions & 0 deletions app/src/main/java/com/nextcloud/talk/chat/audio/ChatAudioKey.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* 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.audio

/**
* Identifies an audio message in a conversation of one account. [mediaId] is the media id of the corresponding
* item in the playback service, so the key of whatever the service currently plays can always be restored.
*/
data class ChatAudioKey(val internalUserId: Long, val roomToken: String, val messageId: Int) {

val mediaId: String
get() = "${mediaIdPrefixOf(internalUserId)}$roomToken$SEPARATOR$messageId"

companion object {
private const val MEDIA_ID_PREFIX = "talk-audio:"
private const val SEPARATOR = "/"
private const val PART_COUNT = 3

/** The start of the media ids of all audio messages of the account [internalUserId]. */
fun mediaIdPrefixOf(internalUserId: Long): String = "$MEDIA_ID_PREFIX$internalUserId$SEPARATOR"

fun fromMediaId(mediaId: String?): ChatAudioKey? {
val parts = mediaId
?.takeIf { it.startsWith(MEDIA_ID_PREFIX) }
?.removePrefix(MEDIA_ID_PREFIX)
?.split(SEPARATOR)
?.takeIf { it.size == PART_COUNT && it[1].isNotEmpty() }
val internalUserId = parts?.get(0)?.toLongOrNull()
val messageId = parts?.get(2)?.toIntOrNull()
return if (parts != null && internalUserId != null && messageId != null) {
ChatAudioKey(internalUserId, parts[1], messageId)
} else {
null
}
}
}
}

/**
* The kinds of chat audio. A queue only contains messages of one kind, and each kind is played with its own
* audio attributes and playback speed.
*/
enum class ChatAudioKind {
VOICE_MESSAGE,
AUDIO_FILE
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* 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.audio

import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.IOException

/**
* What is known about an audio message beyond the chat message itself: its duration, the position to resume
* from and, for voice messages, the waveform.
*/
data class ChatAudioMetadata(val durationMs: Long = 0L, val positionMs: Long = 0L, val waveform: List<Float>? = null) {

/** Only long recordings keep their position across app restarts, short ones start from the beginning again. */
val persistablePosition: Long
get() = if (durationMs >= LONG_AUDIO_DURATION_MS) positionMs else 0L

/** The position playback should start from, or 0 if the message was (almost) played to the end. */
val resumePosition: Long
get() = if (positionMs > 0L && (durationMs <= 0L || positionMs < durationMs - END_TOLERANCE_MS)) {
positionMs
} else {
0L
}

companion object {
const val LONG_AUDIO_DURATION_MS = 5 * 60 * 1000L
const val END_TOLERANCE_MS = 1000L
}
}

/** Binary representation of [ChatAudioMetadata] used by the small on-disk metadata cache. */
object ChatAudioMetadataCodec {
private const val VERSION = 1
private const val MAX_WAVEFORM_SIZE = 1024

fun encode(metadata: ChatAudioMetadata): ByteArray {
val bytes = ByteArrayOutputStream()
DataOutputStream(bytes).use { output ->
val waveform = metadata.waveform.orEmpty().take(MAX_WAVEFORM_SIZE)
output.writeInt(VERSION)
output.writeLong(metadata.durationMs)
output.writeLong(metadata.persistablePosition)
output.writeInt(waveform.size)
waveform.forEach { output.writeFloat(it) }
}
return bytes.toByteArray()
}

fun decode(bytes: ByteArray): ChatAudioMetadata? =
try {
DataInputStream(ByteArrayInputStream(bytes)).use { input ->
require(input.readInt() == VERSION)
val durationMs = input.readLong()
val positionMs = input.readLong()
val waveformSize = input.readInt()
require(waveformSize in 0..MAX_WAVEFORM_SIZE)
val waveform = List(waveformSize) { input.readFloat() }
ChatAudioMetadata(durationMs, positionMs, waveform.takeIf { it.isNotEmpty() })
}
} catch (e: IOException) {
null
} catch (e: IllegalArgumentException) {
null
}
}
Original file line number Diff line number Diff line change
@@ -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.audio

/** Why the current audio message could not be played. */
enum class ChatAudioError {
NETWORK,
UNAVAILABLE,
UNSUPPORTED,
UNKNOWN
}

/** Repeat modes of audio files, in the order a repeat button switches through them. */
enum class ChatAudioRepeatMode {
OFF,
ALL,
ONE;

fun next(): ChatAudioRepeatMode = entries[(ordinal + 1) % entries.size]

companion object {
fun fromName(name: String?): ChatAudioRepeatMode = entries.firstOrNull { it.name == name } ?: OFF
}
}

/**
* State of the chat audio playback as seen by the UI. It only describes the item that is loaded in the playback
* service; other messages are described by their [ChatAudioMetadata].
*
* @param currentThreadId the thread [currentKey] was played in, or null if it was played in the conversation
* @param isPlaying true while playback is requested, including while it waits for data
* @param isBuffering true while playback is requested but waits for data
* @param hasNext whether the queue has an item after the current one
*/
data class ChatAudioPlaybackState(
val currentKey: ChatAudioKey? = null,
val currentThreadId: Long? = null,
val kind: ChatAudioKind? = null,
val title: String = "",
val subtitle: String = "",
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val positionMs: Long = 0L,
val durationMs: Long = 0L,
val speed: Float = 1f,
val error: ChatAudioError? = null,
val shuffleEnabled: Boolean = false,
val repeatMode: ChatAudioRepeatMode = ChatAudioRepeatMode.OFF,
val hasNext: Boolean = false,
val queue: List<ChatAudioQueueItem> = emptyList()
) {
val isActive: Boolean
get() = currentKey != null

/** The state of the message with the given [key] for its bubble in the chat. */
fun messageState(key: ChatAudioKey?, metadata: ChatAudioMetadata?): ChatAudioMessageState =
if (key != null && key == currentKey) {
ChatAudioMessageState(
isCurrent = true,
isPlaying = isPlaying,
isBuffering = isBuffering,
positionMs = positionMs,
durationMs = durationMs.takeIf { it > 0L } ?: metadata?.durationMs ?: 0L,
error = error
)
} else {
ChatAudioMessageState(
positionMs = metadata?.resumePosition ?: 0L,
durationMs = metadata?.durationMs ?: 0L
)
}
}

/** An item of the loaded queue; [index] is its position in the queue, not in the shuffled playback order. */
data class ChatAudioQueueItem(val index: Int, val key: ChatAudioKey, val title: String, val subtitle: String)

/** What the bubble of a single audio message shows. */
data class ChatAudioMessageState(
val isCurrent: Boolean = false,
val isPlaying: Boolean = false,
val isBuffering: Boolean = false,
val positionMs: Long = 0L,
val durationMs: Long = 0L,
val error: ChatAudioError? = null
) {
val progress: Float
get() = if (durationMs > 0L) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f

val canSeek: Boolean
get() = durationMs > 0L

/** The elapsed time while the message is played or paused in between, otherwise its duration. */
val displayedTimeMs: Long?
get() = when {
isCurrent || positionMs > 0L -> positionMs
durationMs > 0L -> durationMs
else -> null
}
}
Loading