Merge branch 'develop' into dla/feature/custom_room_notification_settings_list

This commit is contained in:
David Langley 2023-10-24 17:20:15 +01:00 committed by GitHub
commit e388ea21b6
420 changed files with 7206 additions and 746 deletions

View file

@ -21,6 +21,7 @@ import android.content.ContextWrapper
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.di.DaggerComponentOwner
inline fun <reified T : Any> Node.optionalBindings() = optionalBindings(T::class.java)
inline fun <reified T : Any> Node.bindings() = bindings(T::class.java)
inline fun <reified T : Any> Context.bindings() = bindings(T::class.java)
@ -36,7 +37,7 @@ fun <T : Any> Context.bindings(klass: Class<T>): T {
?: error("Unable to find bindings for ${klass.name}")
}
fun <T : Any> Node.bindings(klass: Class<T>): T {
fun <T : Any> Node.optionalBindings(klass: Class<T>): T? {
// search dagger components in node hierarchy
return generateSequence(this, Node::parent)
.filterIsInstance<DaggerComponentOwner>()
@ -44,5 +45,8 @@ fun <T : Any> Node.bindings(klass: Class<T>): T {
.flatMap { if (it is Collection<*>) it else listOf(it) }
.filterIsInstance(klass)
.firstOrNull()
?: error("Unable to find bindings for ${klass.name}")
}
fun <T : Any> Node.bindings(klass: Class<T>): T {
return optionalBindings(klass) ?: error("Unable to find bindings for ${klass.name}")
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.core.hash
import java.security.MessageDigest
import java.util.Locale
/**
* Compute a Hash of a String, using md5 algorithm.
*/
fun String.md5() = try {
val digest = MessageDigest.getInstance("md5")
val locale = Locale.ROOT
digest.update(toByteArray())
digest.digest()
.joinToString("") { String.format(locale, "%02X", it) }
.lowercase(locale)
} catch (exc: Exception) {
// Should not happen, but just in case
hashCode().toString()
}

View file

@ -27,9 +27,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.airbnb.android.showkase.annotation.ShowkaseComposable
import io.element.android.libraries.designsystem.components.list.TextFieldListItem
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.DialogPreview
import io.element.android.libraries.designsystem.theme.components.ListSupportingText
import io.element.android.libraries.designsystem.theme.components.SimpleAlertDialogContent
@ -45,6 +45,7 @@ fun ListDialog(
subtitle: String? = null,
cancelText: String = stringResource(CommonStrings.action_cancel),
submitText: String = stringResource(CommonStrings.action_ok),
enabled: Boolean = true,
listItems: LazyListScope.() -> Unit,
) {
val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let {
@ -66,6 +67,7 @@ fun ListDialog(
submitText = submitText,
onDismissRequest = onDismissRequest,
onSubmitClicked = onSubmit,
enabled = enabled,
listItems = listItems,
)
}
@ -80,6 +82,7 @@ private fun ListDialogContent(
submitText: String,
modifier: Modifier = Modifier,
title: String? = null,
enabled: Boolean = true,
subtitle: @Composable (() -> Unit)? = null,
) {
SimpleAlertDialogContent(
@ -90,6 +93,7 @@ private fun ListDialogContent(
submitText = submitText,
onCancelClicked = onDismissRequest,
onSubmitClicked = onSubmitClicked,
enabled = enabled,
applyPaddingToContents = false,
) {
LazyColumn(

View file

@ -16,10 +16,13 @@
package io.element.android.libraries.designsystem.components.list
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@ -29,24 +32,68 @@ import io.element.android.libraries.theme.ElementTheme
@Composable
fun TextFieldListItem(
placeholder: String,
placeholder: String?,
text: String,
onTextChanged: (String) -> Unit,
modifier: Modifier = Modifier,
error: String? = null,
maxLines: Int = 1,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
val textFieldStyle = ElementTheme.materialTypography.bodyLarge
OutlinedTextField(
value = text,
onValueChange = onTextChanged,
placeholder = { Text(placeholder) },
onValueChange = { onTextChanged(it) },
placeholder = placeholder?.let { @Composable { Text(it) } },
colors = OutlinedTextFieldDefaults.colors(
disabledBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
isError = error != null,
supportingText = error?.let { @Composable { Text(it) } },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
textStyle = textFieldStyle,
maxLines = maxLines,
singleLine = maxLines == 1,
modifier = modifier,
)
}
@Composable
fun TextFieldListItem(
placeholder: String?,
text: TextFieldValue,
onTextChanged: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
error: String? = null,
maxLines: Int = 1,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
val textFieldStyle = ElementTheme.materialTypography.bodyLarge
OutlinedTextField(
value = text,
onValueChange = { onTextChanged(it) },
placeholder = placeholder?.let { @Composable { Text(it) } },
colors = OutlinedTextFieldDefaults.colors(
disabledBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
isError = error != null,
supportingText = error?.let { @Composable { Text(it) } },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
textStyle = textFieldStyle,
maxLines = maxLines,
singleLine = maxLines == 1,
modifier = modifier,
)
}
@ -74,3 +121,15 @@ internal fun TextFieldListItemPreview() {
)
}
}
@Preview("Text field List item - textfieldvalue", group = PreviewGroup.ListItems)
@Composable
internal fun TextFieldListItemTextFieldValuePreview() {
ElementThemedPreview {
TextFieldListItem(
placeholder = "Placeholder",
text = TextFieldValue("Text field value"),
onTextChanged = {},
)
}
}

View file

@ -0,0 +1,141 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components.preferences
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.list.TextFieldListItem
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun PreferenceTextField(
headline: String,
onChange: (String?) -> Unit,
modifier: Modifier = Modifier,
placeholder: String? = null,
value: String? = null,
supportingText: String? = null,
displayValue: (String?) -> Boolean = { !it.isNullOrBlank() },
trailingContent: ListItemContent? = null,
validation: (String?) -> Boolean = { true },
onValidationErrorMessage: String? = null,
enabled: Boolean = true,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
style: ListItemStyle = ListItemStyle.Default,
) {
var displayTextFieldDialog by rememberSaveable { mutableStateOf(false) }
val valueToDisplay = if (displayValue(value)) { value } else supportingText
ListItem(
modifier = modifier,
headlineContent = { Text(headline) },
supportingContent = valueToDisplay?.let { @Composable { Text(it) } },
trailingContent = trailingContent,
style = style,
enabled = enabled,
onClick = { displayTextFieldDialog = true }
)
if (displayTextFieldDialog) {
TextFieldDialog(
title = headline,
onSubmit = {
onChange(it.takeIf { it.isNotBlank() })
displayTextFieldDialog = false
},
onDismissRequest = { displayTextFieldDialog = false },
placeholder = placeholder.orEmpty(),
value = value.orEmpty(),
validation = validation,
onValidationErrorMessage = onValidationErrorMessage,
keyboardOptions = keyboardOptions,
)
}
}
@Composable
private fun TextFieldDialog(
title: String,
onSubmit: (String) -> Unit,
onDismissRequest: () -> Unit,
value: String?,
placeholder: String?,
modifier: Modifier = Modifier,
validation: (String?) -> Boolean = { true },
onValidationErrorMessage: String? = null,
autoSelectOnDisplay: Boolean = true,
maxLines: Int = 1,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
) {
val focusRequester = remember { FocusRequester() }
var textFieldContents by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue(value.orEmpty(), selection = TextRange(value.orEmpty().length)))
}
var error by rememberSaveable { mutableStateOf<String?>(null) }
val canSubmit by remember { derivedStateOf { validation(textFieldContents.text) } }
ListDialog(
title = title,
onSubmit = { onSubmit(textFieldContents.text) },
onDismissRequest = onDismissRequest,
enabled = canSubmit,
modifier = modifier,
) {
item {
TextFieldListItem(
placeholder = placeholder.orEmpty(),
text = textFieldContents,
onTextChanged = {
error = if (!validation(it.text)) onValidationErrorMessage else null
textFieldContents = it
},
error = error,
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(onAny = {
if (validation(textFieldContents.text)) {
onSubmit(textFieldContents.text)
}
}),
maxLines = maxLines,
modifier = Modifier.focusRequester(focusRequester),
)
}
}
if (autoSelectOnDisplay) {
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}

View file

@ -98,6 +98,16 @@ val SemanticColors.bgSubtleTertiary
val SemanticColors.temporaryColorBgSpecial
get() = if (isLight) Color(0xFFE4E8F0) else Color(0xFF3A4048)
// This color is not present in Semantic color, so put hard-coded value for now
val SemanticColors.pinDigitBg
get() = if (isLight) {
// We want LightDesignTokens.colorGray300
Color(0xFFF0F2F5)
} else {
// We want DarkDesignTokens.colorGray400
Color(0xFF26282D)
}
@PreviewsDayNight
@Composable
internal fun ColorAliasesPreview() = ElementPreview {

View file

@ -96,6 +96,7 @@ internal fun SimpleAlertDialogContent(
thirdButtonText: String? = null,
onThirdButtonClicked: () -> Unit = {},
applyPaddingToContents: Boolean = true,
enabled: Boolean = true,
icon: @Composable (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
@ -122,6 +123,7 @@ internal fun SimpleAlertDialogContent(
if (submitText != null) {
Button(
text = submitText,
enabled = enabled,
size = ButtonSize.Medium,
onClick = onSubmitClicked,
)

View file

@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessa
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
@ -129,8 +130,12 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
is AudioMessageType -> {
sp.getString(CommonStrings.common_audio)
}
is OtherMessageType -> {
messageType.body
}
UnknownMessageType -> {
// Display the body as a fallback
// Display the body as a fallback, but should not happen anymore
// (we have `OtherMessageType` now)
messageContent.body
}
is NoticeMessageType -> {

View file

@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.MembershipCha
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileTimelineDetails
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
@ -204,6 +205,7 @@ class DefaultRoomLastMessageFormatterTest {
is EmoteMessageType -> "* $senderName ${type.body}"
is TextMessageType,
is NoticeMessageType,
is OtherMessageType,
UnknownMessageType -> body
}
Truth.assertWithMessage("$type was not properly handled for DM").that(result).isEqualTo(expectedResult)
@ -220,6 +222,7 @@ class DefaultRoomLastMessageFormatterTest {
is LocationMessageType -> "$senderName: Shared location"
is TextMessageType,
is NoticeMessageType,
is OtherMessageType,
UnknownMessageType -> "$senderName: $body"
is EmoteMessageType -> "* $senderName ${type.body}"
}
@ -231,6 +234,7 @@ class DefaultRoomLastMessageFormatterTest {
is LocationMessageType -> false
is EmoteMessageType -> false
is TextMessageType, is NoticeMessageType -> true
is OtherMessageType -> true
UnknownMessageType -> true
}
if (shouldCreateAnnotatedString) {

View file

@ -55,4 +55,10 @@ enum class FeatureFlags(
description = "Allow user to lock/unlock the app with a pin code or biometrics",
defaultValue = false,
),
InRoomCalls(
key = "feature.elementcall",
title = "Element call in rooms",
description = "Allow user to start or join a call in a room",
defaultValue = false,
)
}

View file

@ -37,6 +37,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
FeatureFlags.NotificationSettings -> true
FeatureFlags.VoiceMessages -> false
FeatureFlags.PinUnlock -> false
FeatureFlags.InRoomCalls -> false
}
} else {
false

View file

@ -34,6 +34,7 @@ anvil {
}
dependencies {
implementation(projects.appconfig)
implementation(projects.libraries.di)
implementation(libs.dagger)
implementation(projects.libraries.core)

View file

@ -16,4 +16,7 @@
package io.element.android.libraries.matrix.api.core
/**
* The [UserId] of the currently logged in user.
*/
typealias SessionId = UserId

View file

@ -19,6 +19,11 @@ package io.element.android.libraries.matrix.api.core
import io.element.android.libraries.matrix.api.BuildConfig
import java.io.Serializable
/**
* A [String] holding a valid Matrix user ID.
*
* https://spec.matrix.org/v1.8/appendices/#user-identifiers
*/
@JvmInline
value class UserId(val value: String) : Serializable {

View file

@ -14,9 +14,15 @@
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.config
package io.element.android.libraries.matrix.api.encryption
object MatrixConfiguration {
const val matrixToPermalinkBaseUrl: String = "https://matrix.to/#/"
val clientPermalinkBaseUrl: String? = null
enum class BackupState {
UNKNOWN,
CREATING,
ENABLING,
RESUMING,
ENABLED,
DOWNLOADING,
DISABLING,
DISABLED;
}

View file

@ -17,7 +17,7 @@
package io.element.android.libraries.matrix.api.permalink
import android.net.Uri
import io.element.android.libraries.matrix.api.config.MatrixConfiguration
import io.element.android.appconfig.MatrixConfiguration
/**
* Mapping of an input URI to a matrix.to compliant URI.

View file

@ -16,7 +16,7 @@
package io.element.android.libraries.matrix.api.permalink
import io.element.android.libraries.matrix.api.config.MatrixConfiguration
import io.element.android.appconfig.MatrixConfiguration
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId

View file

@ -30,6 +30,8 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import kotlinx.coroutines.flow.StateFlow
import java.io.Closeable
import java.io.File
@ -192,5 +194,27 @@ interface MatrixRoom : Closeable {
progressCallback: ProgressCallback?
): Result<MediaUploadHandler>
/**
* Generates a Widget url to display in a [android.webkit.WebView] given the provided parameters.
* @param widgetSettings The widget settings to use.
* @param clientId The client id to use. It should be unique per app install.
* @param languageTag The language tag to use. If null, the default language will be used.
* @param theme The theme to use. If null, the default theme will be used.
* @return The resulting url, or a failure.
*/
suspend fun generateWidgetWebViewUrl(
widgetSettings: MatrixWidgetSettings,
clientId: String,
languageTag: String? = null,
theme: String? = null,
): Result<String>
/**
* Get a [MatrixWidgetDriver] for the provided [widgetSettings].
* @param widgetSettings The widget settings to use.
* @return The resulting [MatrixWidgetDriver], or a failure.
*/
fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver>
override fun close() = destroy()
}

View file

@ -73,3 +73,8 @@ data class TextMessageType(
val body: String,
val formatted: FormattedBody?
) : MessageType
data class OtherMessageType(
val msgType: String,
val body: String,
) : MessageType

View file

@ -28,6 +28,7 @@ data class TracingFilterConfiguration(
Target.MATRIX_SDK_HTTP_CLIENT to LogLevel.DEBUG,
Target.MATRIX_SDK_SLIDING_SYNC to LogLevel.TRACE,
Target.MATRIX_SDK_BASE_SLIDING_SYNC to LogLevel.TRACE,
Target.MATRIX_SDK_UI_TIMELINE to LogLevel.TRACE,
)
fun getLogLevel(target: Target): LogLevel {

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.widget
import java.util.UUID
interface CallWidgetSettingsProvider {
fun provide(
baseUrl: String,
widgetId: String = UUID.randomUUID().toString()
): MatrixWidgetSettings
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.widget
import kotlinx.coroutines.flow.Flow
interface MatrixWidgetDriver : AutoCloseable {
val id: String
val incomingMessages: Flow<String>
suspend fun run()
suspend fun send(message: String)
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.widget
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
class MatrixWidgetSettings(
val id: String,
val initAfterContentLoad: Boolean,
val rawUrl: String,
) : Parcelable {
companion object
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.encryption
import io.element.android.libraries.matrix.api.encryption.BackupState
import org.matrix.rustcomponents.sdk.BackupState as RustBackupState
class BackupStateMapper {
fun map(backupState: RustBackupState): BackupState {
return when (backupState) {
RustBackupState.UNKNOWN -> BackupState.UNKNOWN
RustBackupState.CREATING -> BackupState.CREATING
RustBackupState.ENABLING -> BackupState.ENABLING
RustBackupState.RESUMING -> BackupState.RESUMING
RustBackupState.ENABLED -> BackupState.ENABLED
RustBackupState.DOWNLOADING -> BackupState.DOWNLOADING
RustBackupState.DISABLING -> BackupState.DISABLING
RustBackupState.DISABLED -> BackupState.DISABLED
}
}
}

View file

@ -40,6 +40,8 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import io.element.android.libraries.matrix.impl.core.toProgressWatcher
import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
import io.element.android.libraries.matrix.impl.media.map
@ -48,6 +50,8 @@ import io.element.android.libraries.matrix.impl.poll.toInner
import io.element.android.libraries.matrix.impl.room.location.toInner
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.libraries.matrix.impl.util.destroyAll
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
import io.element.android.libraries.matrix.impl.widget.generateWidgetWebViewUrl
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CancellationException
@ -65,6 +69,8 @@ import org.matrix.rustcomponents.sdk.RoomListItem
import org.matrix.rustcomponents.sdk.RoomMember
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.WidgetCapabilities
import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import timber.log.Timber
@ -478,6 +484,27 @@ class RustMatrixRoom(
)
}
override suspend fun generateWidgetWebViewUrl(
widgetSettings: MatrixWidgetSettings,
clientId: String,
languageTag: String?,
theme: String?,
) = runCatching {
widgetSettings.generateWidgetWebViewUrl(innerRoom, clientId, languageTag, theme)
}
override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver> = runCatching {
RustWidgetDriver(
widgetSettings = widgetSettings,
room = innerRoom,
widgetCapabilitiesProvider = object : WidgetCapabilitiesProvider {
override fun acquireCapabilities(capabilities: WidgetCapabilities): WidgetCapabilities {
return capabilities
}
},
)
}
private suspend fun sendAttachment(files: List<File>, handle: () -> SendAttachmentJoinHandle): Result<MediaUploadHandler> {
return runCatching {
MediaUploadHandlerImpl(files, handle())

View file

@ -28,11 +28,13 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessa
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.impl.media.map
import org.matrix.rustcomponents.sdk.Message
import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.ProfileDetails
import org.matrix.rustcomponents.sdk.RepliedToEventDetails
import org.matrix.rustcomponents.sdk.use
@ -104,6 +106,9 @@ class EventMessageMapper {
is RustMessageType.Location -> {
LocationMessageType(type.content.body, type.content.geoUri, type.content.description)
}
is MessageType.Other -> {
OtherMessageType(type.msgtype, type.body)
}
null -> UnknownMessageType
}
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.widget
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import org.matrix.rustcomponents.sdk.VirtualElementCallWidgetOptions
import org.matrix.rustcomponents.sdk.newVirtualElementCallWidget
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultCallWidgetSettingsProvider @Inject constructor() : CallWidgetSettingsProvider {
override fun provide(baseUrl: String, widgetId: String): MatrixWidgetSettings {
val options = VirtualElementCallWidgetOptions(
elementCallUrl = baseUrl,
widgetId = widgetId,
parentUrl = null,
hideHeader = null,
preload = null,
fontScale = null,
appPrompt = false,
skipLobby = true,
confineToRoom = true,
font = null,
analyticsId = null
)
val rustWidgetSettings = newVirtualElementCallWidget(options)
return MatrixWidgetSettings.fromRustWidgetSettings(rustWidgetSettings)
}
}

View file

@ -0,0 +1,50 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.widget
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import org.matrix.rustcomponents.sdk.ClientProperties
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.WidgetSettings
import org.matrix.rustcomponents.sdk.generateWebviewUrl
fun MatrixWidgetSettings.toRustWidgetSettings() = WidgetSettings(
widgetId = this.id,
initAfterContentLoad = this.initAfterContentLoad,
rawUrl = this.rawUrl,
)
fun MatrixWidgetSettings.Companion.fromRustWidgetSettings(widgetSettings: WidgetSettings) = MatrixWidgetSettings(
id = widgetSettings.widgetId,
initAfterContentLoad = widgetSettings.initAfterContentLoad,
rawUrl = widgetSettings.rawUrl,
)
suspend fun MatrixWidgetSettings.generateWidgetWebViewUrl(
room: Room,
clientId: String,
languageTag: String? = null,
theme: String? = null
) = generateWebviewUrl(
widgetSettings = this.toRustWidgetSettings(),
room = room,
props = ClientProperties(
clientId = clientId,
languageTag = languageTag,
theme = theme,
)
)

View file

@ -0,0 +1,78 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.widget
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider
import org.matrix.rustcomponents.sdk.makeWidgetDriver
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.coroutineContext
class RustWidgetDriver(
widgetSettings: MatrixWidgetSettings,
private val room: Room,
private val widgetCapabilitiesProvider: WidgetCapabilitiesProvider,
): MatrixWidgetDriver {
override val incomingMessages = MutableSharedFlow<String>()
private val driverAndHandle = makeWidgetDriver(widgetSettings.toRustWidgetSettings())
private var receiveMessageJob: Job? = null
private var isRunning = AtomicBoolean(false)
override val id: String = widgetSettings.id
override suspend fun run() {
// Don't run the driver if it's already running
if (!isRunning.compareAndSet(false, true)) {
return
}
val coroutineScope = CoroutineScope(coroutineContext)
coroutineScope.launch {
// This call will suspend the coroutine while the driver is running, so it needs to be launched separately
driverAndHandle.driver.run(room, widgetCapabilitiesProvider)
}
receiveMessageJob = coroutineScope.launch(Dispatchers.IO) {
try {
while (isActive) {
driverAndHandle.handle.recv()?.let { incomingMessages.emit(it) }
}
} finally {
driverAndHandle.handle.close()
}
}
}
override suspend fun send(message: String) {
driverAndHandle.handle.send(message)
}
override fun close() {
receiveMessageJob?.cancel()
driverAndHandle.driver.close()
}
}

View file

@ -208,8 +208,12 @@ class FakeMatrixClient(
findDmResult = result
}
fun givenGetRoomResult(roomId: RoomId, result: MatrixRoom) {
getRoomResults[roomId] = result
fun givenGetRoomResult(roomId: RoomId, result: MatrixRoom?) {
if (result == null) {
getRoomResults.remove(roomId)
} else {
getRoomResults[roomId] = result
}
}
fun givenSearchUsersResult(searchTerm: String, result: Result<MatrixSearchUserResults>) {

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.test
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
class FakeMatrixClientProvider(
private val getClient: (SessionId) -> Result<MatrixClient> = { Result.success(FakeMatrixClient()) }
) : MatrixClientProvider {
override suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> = getClient(sessionId)
}

View file

@ -43,9 +43,11 @@ val A_USER_ID_10 = UserId("@walter:server.org")
val A_SESSION_ID: SessionId = A_USER_ID
val A_SESSION_ID_2: SessionId = A_USER_ID_2
val A_SPACE_ID = SpaceId("!aSpaceId:domain")
val A_SPACE_ID_2 = SpaceId("!aSpaceId2:domain")
val A_ROOM_ID = RoomId("!aRoomId:domain")
val A_ROOM_ID_2 = RoomId("!aRoomId2:domain")
val A_THREAD_ID = ThreadId("\$aThreadId")
val A_THREAD_ID_2 = ThreadId("\$aThreadId2")
val AN_EVENT_ID = EventId("\$anEventId")
val AN_EVENT_ID_2 = EventId("\$anEventId2")
val A_TRANSACTION_ID = TransactionId("aTransactionId")

View file

@ -36,11 +36,14 @@ import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@ -92,6 +95,8 @@ class FakeMatrixRoom(
private var sendPollResponseResult = Result.success(Unit)
private var endPollResult = Result.success(Unit)
private var progressCallbackValues = emptyList<Pair<Long, Long>>()
private var generateWidgetWebViewUrlResult = Result.success("https://call.element.io")
private var getWidgetDriverResult: Result<MatrixWidgetDriver> = Result.success(FakeWidgetDriver())
val editMessageCalls = mutableListOf<Pair<String, String?>>()
var sendMediaCount = 0
@ -368,6 +373,15 @@ class FakeMatrixRoom(
progressCallback: ProgressCallback?
): Result<MediaUploadHandler> = fakeSendMedia(progressCallback)
override suspend fun generateWidgetWebViewUrl(
widgetSettings: MatrixWidgetSettings,
clientId: String,
languageTag: String?,
theme: String?,
): Result<String> = generateWidgetWebViewUrlResult
override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver> = getWidgetDriverResult
fun givenLeaveRoomError(throwable: Throwable?) {
this.leaveRoomError = throwable
}
@ -475,6 +489,14 @@ class FakeMatrixRoom(
fun givenProgressCallbackValues(values: List<Pair<Long, Long>>) {
progressCallbackValues = values
}
fun givenGenerateWidgetWebViewUrlResult(result: Result<String>) {
generateWidgetWebViewUrlResult = result
}
fun givenGetWidgetDriverResult(result: Result<MatrixWidgetDriver>) {
getWidgetDriverResult = result
}
}
data class SendLocationInvocation(

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.test.widget
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
class FakeCallWidgetSettingsProvider(
private val provideFn: (String, String) -> MatrixWidgetSettings = { _, _ -> MatrixWidgetSettings("id", true, "url") }
) : CallWidgetSettingsProvider {
val providedBaseUrls = mutableListOf<String>()
override fun provide(baseUrl: String, widgetId: String): MatrixWidgetSettings {
providedBaseUrls += baseUrl
return provideFn(baseUrl, widgetId)
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.test.widget
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import kotlinx.coroutines.flow.MutableSharedFlow
import java.util.UUID
class FakeWidgetDriver(
override val id: String = UUID.randomUUID().toString(),
) : MatrixWidgetDriver {
private val _sentMessages = mutableListOf<String>()
val sentMessages: List<String> = _sentMessages
var runCalledCount = 0
private set
var closeCalledCount = 0
private set
override val incomingMessages = MutableSharedFlow<String>(extraBufferCapacity = 1)
override suspend fun run() {
runCalledCount++
}
override suspend fun send(message: String) {
_sentMessages.add(message)
}
override fun close() {
closeCalledCount++
}
fun givenIncomingMessage(message: String) {
incomingMessages.tryEmit(message)
}
}

View file

@ -50,16 +50,43 @@ class MediaSender @Inject constructor(
.flatMapCatching { info ->
room.sendMedia(info, progressCallback)
}
.onFailure { error ->
val job = ongoingUploadJobs.remove(Job)
if (error !is CancellationException) {
job?.cancel()
}
}
.onSuccess {
ongoingUploadJobs.remove(Job)
}
.handleSendResult()
}
suspend fun sendVoiceMessage(
uri: Uri,
mimeType: String,
waveForm: List<Int>,
progressCallback: ProgressCallback? = null
): Result<Unit> {
return preProcessor
.process(
uri = uri,
mimeType = mimeType,
deleteOriginal = true,
compressIfPossible = false
)
.flatMapCatching { info ->
val audioInfo = (info as MediaUploadInfo.Audio).audioInfo
val newInfo = MediaUploadInfo.VoiceMessage(
file = info.file,
audioInfo = audioInfo,
waveform = waveForm,
)
room.sendMedia(newInfo, progressCallback)
}
.handleSendResult()
}
private fun Result<Unit>.handleSendResult() = this
.onFailure { error ->
val job = ongoingUploadJobs.remove(Job)
if (error !is CancellationException) {
job?.cancel()
}
}
.onSuccess {
ongoingUploadJobs.remove(Job)
}
private suspend fun MatrixRoom.sendMedia(
uploadInfo: MediaUploadInfo,
@ -90,7 +117,14 @@ class MediaSender @Inject constructor(
progressCallback = progressCallback
)
}
is MediaUploadInfo.VoiceMessage -> {
sendVoiceMessage(
file = uploadInfo.file,
audioInfo = uploadInfo.audioInfo,
waveform = uploadInfo.waveform,
progressCallback = progressCallback
)
}
is MediaUploadInfo.AnyFile -> {
sendFile(
file = uploadInfo.file,

View file

@ -29,5 +29,6 @@ sealed interface MediaUploadInfo {
data class Image(override val file: File, val imageInfo: ImageInfo, val thumbnailFile: File) : MediaUploadInfo
data class Video(override val file: File, val videoInfo: VideoInfo, val thumbnailFile: File) : MediaUploadInfo
data class Audio(override val file: File, val audioInfo: AudioInfo) : MediaUploadInfo
data class VoiceMessage(override val file: File, val audioInfo: AudioInfo, val waveform: List<Int>) : MediaUploadInfo
data class AnyFile(override val file: File, val fileInfo: FileInfo) : MediaUploadInfo
}

View file

@ -118,6 +118,7 @@ class AndroidMediaPreProcessor @Inject constructor(
is MediaUploadInfo.Audio -> copy(file = renamedFile)
is MediaUploadInfo.Image -> copy(file = renamedFile)
is MediaUploadInfo.Video -> copy(file = renamedFile)
is MediaUploadInfo.VoiceMessage -> copy(file = renamedFile)
}
}

View file

@ -17,11 +17,14 @@
package io.element.android.libraries.mediaupload.test
import android.net.Uri
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.tests.testutils.simulateLongTask
import java.io.File
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
class FakeMediaPreProcessor : MediaPreProcessor {
@ -53,4 +56,19 @@ class FakeMediaPreProcessor : MediaPreProcessor {
fun givenResult(value: Result<MediaUploadInfo>) {
this.result = value
}
fun givenAudioResult() {
givenResult(
Result.success(
MediaUploadInfo.Audio(
file = File("audio.ogg"),
audioInfo = AudioInfo(
duration = 1000.seconds.toJavaDuration(),
size = 1000,
mimetype = "audio/ogg",
),
)
)
)
}
}

View file

@ -31,10 +31,11 @@ open class PermissionsStateProvider : PreviewParameterProvider<PermissionsState>
fun aPermissionsState(
showDialog: Boolean,
permission: String = Manifest.permission.POST_NOTIFICATIONS
permission: String = Manifest.permission.POST_NOTIFICATIONS,
permissionGranted: Boolean = false,
) = PermissionsState(
permission = permission,
permissionGranted = false,
permissionGranted = permissionGranted,
shouldShowRationale = false,
showDialog = showDialog,
permissionAlreadyAsked = false,

View file

@ -25,5 +25,8 @@ interface PreferencesStore {
suspend fun setDeveloperModeEnabled(enabled: Boolean)
fun isDeveloperModeEnabledFlow(): Flow<Boolean>
suspend fun setCustomElementCallBaseUrl(string: String?)
fun getCustomElementCallBaseUrlFlow(): Flow<String?>
suspend fun reset()
}

View file

@ -21,6 +21,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.preferences.api.store.PreferencesStore
@ -37,6 +38,7 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
private val richTextEditorKey = booleanPreferencesKey("richTextEditor")
private val developerModeKey = booleanPreferencesKey("developerMode")
private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl")
@ContributesBinding(AppScope::class)
class DefaultPreferencesStore @Inject constructor(
@ -71,6 +73,22 @@ class DefaultPreferencesStore @Inject constructor(
}
}
override suspend fun setCustomElementCallBaseUrl(string: String?) {
store.edit { prefs ->
if (string != null) {
prefs[customElementCallBaseUrlKey] = string
} else {
prefs.remove(customElementCallBaseUrlKey)
}
}
}
override fun getCustomElementCallBaseUrlFlow(): Flow<String?> {
return store.data.map { prefs ->
prefs[customElementCallBaseUrlKey]
}
}
override suspend fun reset() {
store.edit { it.clear() }
}

View file

@ -23,9 +23,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
class InMemoryPreferencesStore(
isRichTextEditorEnabled: Boolean = false,
isDeveloperModeEnabled: Boolean = false,
customElementCallBaseUrl: String? = null,
) : PreferencesStore {
private var _isRichTextEditorEnabled = MutableStateFlow(isRichTextEditorEnabled)
private var _isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled)
private var _customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl)
override suspend fun setRichTextEditorEnabled(enabled: Boolean) {
_isRichTextEditorEnabled.value = enabled
@ -43,6 +45,14 @@ class InMemoryPreferencesStore(
return _isDeveloperModeEnabled
}
override suspend fun setCustomElementCallBaseUrl(string: String?) {
_customElementCallBaseUrl.tryEmit(string)
}
override fun getCustomElementCallBaseUrlFlow(): Flow<String?> {
return _customElementCallBaseUrl
}
override suspend fun reset() {
// No op
}

View file

@ -18,6 +18,7 @@ package io.element.android.libraries.push.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
@ -28,7 +29,6 @@ import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyReque
import io.element.android.libraries.pushproviders.api.PusherSubscriber
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
import io.element.android.services.toolbox.api.appname.AppNameProvider
import timber.log.Timber
import javax.inject.Inject
@ -39,7 +39,7 @@ private val loggerTag = LoggerTag("PushersManager", LoggerTag.PushLoggerTag)
@ContributesBinding(AppScope::class)
class PushersManager @Inject constructor(
// private val localeProvider: LocaleProvider,
private val appNameProvider: AppNameProvider,
private val buildMeta: BuildMeta,
// private val getDeviceInfoUseCase: GetDeviceInfoUseCase,
private val pushGatewayNotifyRequest: PushGatewayNotifyRequest,
private val pushClientSecret: PushClientSecret,
@ -88,7 +88,7 @@ class PushersManager @Inject constructor(
appId = PushConfig.pusher_app_id,
profileTag = DEFAULT_PUSHER_FILE_TAG + "_" /* TODO + abs(activeSessionHolder.getActiveSession().myUserId.hashCode())*/,
lang = "en", // TODO localeProvider.current().language,
appDisplayName = appNameProvider.getAppName(),
appDisplayName = buildMeta.applicationName,
deviceDisplayName = "MyDevice", // TODO getDeviceInfoUseCase.execute().displayName().orEmpty(),
url = gateway,
defaultPayload = createDefaultPayload(pushClientSecret.getSecretForUser(userId))

View file

@ -32,6 +32,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageTy
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
@ -216,6 +217,7 @@ class NotifiableEventResolver @Inject constructor(
is TextMessageType -> messageType.body
is VideoMessageType -> messageType.body
is LocationMessageType -> messageType.body
is OtherMessageType -> messageType.body
is UnknownMessageType -> stringProvider.getString(CommonStrings.common_unsupported_event)
}
}

View file

@ -32,6 +32,7 @@ dependencies {
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiUtils)
implementation(libs.matrix.richtexteditor)
api(libs.matrix.richtexteditor.compose)

View file

@ -50,6 +50,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.applyScaleUp
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
@ -64,7 +65,8 @@ import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.textcomposer.components.ComposerOptionsButton
import io.element.android.libraries.textcomposer.components.DismissTextFormattingButton
import io.element.android.libraries.textcomposer.components.RecordButton
import io.element.android.libraries.textcomposer.components.RecordingProgress
import io.element.android.libraries.textcomposer.components.VoiceMessagePreview
import io.element.android.libraries.textcomposer.components.VoiceMessageRecording
import io.element.android.libraries.textcomposer.components.SendButton
import io.element.android.libraries.textcomposer.components.TextFormatting
import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape
@ -78,6 +80,7 @@ import io.element.android.wysiwyg.compose.RichTextEditor
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlin.time.Duration.Companion.seconds
@Composable
fun TextComposer(
@ -95,6 +98,7 @@ fun TextComposer(
onAddAttachment: () -> Unit = {},
onDismissTextFormatting: () -> Unit = {},
onVoiceRecordButtonEvent: (PressEvent) -> Unit = {},
onSendVoiceMessage: () -> Unit = {},
onError: (Throwable) -> Unit = {},
) {
val onSendClicked = {
@ -137,24 +141,50 @@ fun TextComposer(
composerMode = composerMode,
)
}
val recordButton = @Composable {
val recordVoiceButton = @Composable {
RecordButton(
onPressStart = { onVoiceRecordButtonEvent(PressEvent.PressStart) },
onLongPressEnd = { onVoiceRecordButtonEvent(PressEvent.LongPressEnd) },
onTap = { onVoiceRecordButtonEvent(PressEvent.Tapped) },
)
}
val sendVoiceButton = @Composable {
SendButton(
canSendMessage = voiceMessageState is VoiceMessageState.Preview,
onClick = { onSendVoiceMessage() },
composerMode = composerMode,
)
}
val uploadVoiceProgress = @Composable {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
)
}
val textFormattingOptions = @Composable { TextFormatting(state = state) }
val sendOrRecordButton = if (canSendMessage || !enableVoiceMessages) {
sendButton
} else {
recordButton
val sendOrRecordButton = when {
enableVoiceMessages && !canSendMessage ->
when (voiceMessageState) {
VoiceMessageState.Idle,
is VoiceMessageState.Recording -> recordVoiceButton
is VoiceMessageState.Preview -> sendVoiceButton
is VoiceMessageState.Sending -> uploadVoiceProgress
}
else ->
sendButton
}
val recordingProgress = @Composable {
RecordingProgress()
val voiceRecording = @Composable {
when(voiceMessageState) {
VoiceMessageState.Preview ->
VoiceMessagePreview(isInteractive = true)
VoiceMessageState.Sending ->
VoiceMessagePreview(isInteractive = false)
is VoiceMessageState.Recording ->
VoiceMessageRecording(voiceMessageState.level, voiceMessageState.duration)
VoiceMessageState.Idle -> {}
}
}
if (showTextFormatting) {
@ -170,11 +200,12 @@ fun TextComposer(
} else {
StandardLayout(
voiceMessageState = voiceMessageState,
enableVoiceMessages = enableVoiceMessages,
modifier = layoutModifier,
composerOptionsButton = composerOptionsButton,
textInput = textInput,
endButton = sendOrRecordButton,
recordingProgress = recordingProgress,
voiceRecording = voiceRecording,
)
}
@ -190,9 +221,10 @@ fun TextComposer(
@Composable
private fun StandardLayout(
voiceMessageState: VoiceMessageState,
enableVoiceMessages: Boolean,
textInput: @Composable () -> Unit,
composerOptionsButton: @Composable () -> Unit,
recordingProgress: @Composable () -> Unit,
voiceRecording: @Composable () -> Unit,
endButton: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
@ -200,13 +232,13 @@ private fun StandardLayout(
modifier = modifier,
verticalAlignment = Alignment.Bottom,
) {
if (voiceMessageState is VoiceMessageState.Recording) {
if (enableVoiceMessages && voiceMessageState !is VoiceMessageState.Idle) {
Box(
modifier = Modifier
.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
.weight(1f)
) {
recordingProgress()
voiceRecording()
}
} else {
Box(
@ -226,6 +258,8 @@ private fun StandardLayout(
Box(
Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp)
.size(48.dp.applyScaleUp()),
contentAlignment = Alignment.Center,
) {
endButton()
}
@ -483,7 +517,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
RichTextEditorState("", initialFocus = true),
voiceMessageState = VoiceMessageState.Idle,
onSendMessage = {},
composerMode = MessageComposerMode.Normal(""),
composerMode = MessageComposerMode.Normal,
onResetComposerMode = {},
enableTextFormatting = true,
enableVoiceMessages = true,
@ -493,7 +527,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
RichTextEditorState("A message", initialFocus = true),
voiceMessageState = VoiceMessageState.Idle,
onSendMessage = {},
composerMode = MessageComposerMode.Normal(""),
composerMode = MessageComposerMode.Normal,
onResetComposerMode = {},
enableTextFormatting = true,
enableVoiceMessages = true,
@ -506,7 +540,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
),
voiceMessageState = VoiceMessageState.Idle,
onSendMessage = {},
composerMode = MessageComposerMode.Normal(""),
composerMode = MessageComposerMode.Normal,
onResetComposerMode = {},
enableTextFormatting = true,
enableVoiceMessages = true,
@ -516,7 +550,7 @@ internal fun TextComposerSimplePreview() = ElementPreview {
RichTextEditorState("A message without focus", initialFocus = false),
voiceMessageState = VoiceMessageState.Idle,
onSendMessage = {},
composerMode = MessageComposerMode.Normal(""),
composerMode = MessageComposerMode.Normal,
onResetComposerMode = {},
enableTextFormatting = true,
enableVoiceMessages = true,
@ -533,7 +567,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
RichTextEditorState("", initialFocus = false),
voiceMessageState = VoiceMessageState.Idle,
showTextFormatting = true,
composerMode = MessageComposerMode.Normal(""),
composerMode = MessageComposerMode.Normal,
enableTextFormatting = true,
enableVoiceMessages = true,
)
@ -542,7 +576,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
RichTextEditorState("A message", initialFocus = false),
voiceMessageState = VoiceMessageState.Idle,
showTextFormatting = true,
composerMode = MessageComposerMode.Normal(""),
composerMode = MessageComposerMode.Normal,
enableTextFormatting = true,
enableVoiceMessages = true,
)
@ -551,7 +585,7 @@ internal fun TextComposerFormattingPreview() = ElementPreview {
RichTextEditorState("A message\nWith several lines\nTo preview larger textfields and long lines with overflow", initialFocus = false),
voiceMessageState = VoiceMessageState.Idle,
showTextFormatting = true,
composerMode = MessageComposerMode.Normal(""),
composerMode = MessageComposerMode.Normal,
enableTextFormatting = true,
enableVoiceMessages = true,
)
@ -702,6 +736,30 @@ internal fun TextComposerReplyPreview() = ElementPreview {
)
}
@PreviewsDayNight
@Composable
internal fun TextComposerVoicePreview() = ElementPreview {
@Composable
fun VoicePreview(
voiceMessageState: VoiceMessageState
) = TextComposer(
RichTextEditorState("", initialFocus = true),
voiceMessageState = voiceMessageState,
onSendMessage = {},
composerMode = MessageComposerMode.Normal,
onResetComposerMode = {},
enableTextFormatting = true,
enableVoiceMessages = true,
)
PreviewColumn(items = persistentListOf({
VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, 0.5))
}, {
VoicePreview(voiceMessageState = VoiceMessageState.Preview)
}, {
VoicePreview(voiceMessageState = VoiceMessageState.Sending)
}))
}
@Composable
private fun PreviewColumn(
items: ImmutableList<@Composable () -> Unit>,

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
import io.element.android.libraries.designsystem.components.list.TextFieldListItem
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
@ -200,7 +201,7 @@ private fun EditLinkDialog(
@PreviewsDayNight
@Composable
internal fun TextComposerLinkDialogCreateLinkPreview() {
internal fun TextComposerLinkDialogCreateLinkPreview() = ElementPreview {
TextComposerLinkDialog(
onDismissRequest = {},
linkAction = LinkAction.InsertLink,
@ -212,7 +213,7 @@ internal fun TextComposerLinkDialogCreateLinkPreview() {
@PreviewsDayNight
@Composable
internal fun TextComposerLinkDialogCreateLinkWithoutTextPreview() {
internal fun TextComposerLinkDialogCreateLinkWithoutTextPreview() = ElementPreview {
TextComposerLinkDialog(
onDismissRequest = {},
linkAction = LinkAction.SetLink(null),
@ -224,7 +225,7 @@ internal fun TextComposerLinkDialogCreateLinkWithoutTextPreview() {
@PreviewsDayNight
@Composable
internal fun TextComposerLinkDialogEditLinkPreview() {
internal fun TextComposerLinkDialogEditLinkPreview() = ElementPreview {
TextComposerLinkDialog(
onDismissRequest = {},
linkAction = LinkAction.SetLink("https://element.io"),

View file

@ -93,7 +93,7 @@ internal fun SendButton(
@PreviewsDayNight
@Composable
internal fun SendButtonPreview() = ElementPreview {
val normalMode = MessageComposerMode.Normal("")
val normalMode = MessageComposerMode.Normal
val editMode = MessageComposerMode.Edit(null, "", null)
Row {
SendButton(canSendMessage = true, onClick = {}, composerMode = normalMode)

View file

@ -17,25 +17,24 @@
package io.element.android.libraries.textcomposer.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
@Composable
internal fun RecordingProgress(
internal fun VoiceMessagePreview(
isInteractive: Boolean,
modifier: Modifier = Modifier,
) {
Row(
@ -46,22 +45,17 @@ internal fun RecordingProgress(
shape = MaterialTheme.shapes.medium,
)
.padding(start = 12.dp, end = 20.dp, top = 8.dp, bottom = 8.dp)
.heightIn(26.dp)
,
.heightIn(26.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(8.dp)
.background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape)
)
Spacer(Modifier.size(8.dp))
// TODO Replace with timer UI
// TODO Replace with recording preview UI
Text(
text = "Recording...", // Not localized because it is a placeholder
color = ElementTheme.colors.textSecondary,
text = "Finished recording", // Not localized because it is a placeholder
color = if (isInteractive) {
ElementTheme.colors.textSecondary
} else {
ElementTheme.colors.textDisabled
},
style = ElementTheme.typography.fontBodySmMedium
)
}
@ -69,6 +63,9 @@ internal fun RecordingProgress(
@PreviewsDayNight
@Composable
internal fun RecordingProgressPreview() {
RecordingProgress()
internal fun VoiceMessagePreviewPreview() = ElementPreview {
Column {
VoiceMessagePreview(isInteractive = true)
VoiceMessagePreview(isInteractive = false)
}
}

View file

@ -0,0 +1,112 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.textcomposer.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.utils.time.formatShort
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@Composable
internal fun VoiceMessageRecording(
level: Double,
duration: Duration,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = MaterialTheme.shapes.medium,
)
.padding(start = 12.dp, end = 20.dp, top = 8.dp, bottom = 8.dp)
.heightIn(26.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RedRecordingDot()
Spacer(Modifier.size(8.dp))
// Timer
Text(
text = duration.formatShort(),
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmMedium
)
Spacer(Modifier.size(20.dp))
// TODO Replace with waveform UI
DebugAudioLevel(
modifier = Modifier.weight(1f), level = level
)
}
}
@Composable
private fun DebugAudioLevel(
level: Double,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.height(26.dp)
) {
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxWidth(level.toFloat())
.background(ElementTheme.colors.iconQuaternary, shape = MaterialTheme.shapes.small)
.fillMaxHeight()
)
}
}
@Composable
private fun RedRecordingDot(
modifier: Modifier = Modifier,
) = Box(
modifier = modifier
.size(8.dp)
.background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape)
)
@PreviewsDayNight
@Composable
internal fun VoiceMessageRecordingPreview() = ElementPreview {
VoiceMessageRecording(0.5, 0.seconds)
}

View file

@ -24,7 +24,7 @@ import kotlinx.parcelize.Parcelize
sealed interface MessageComposerMode : Parcelable {
@Parcelize
data class Normal(val content: CharSequence?) : MessageComposerMode
data object Normal: MessageComposerMode
sealed class Special(open val eventId: EventId?, open val defaultContent: String) :
MessageComposerMode

View file

@ -16,7 +16,15 @@
package io.element.android.libraries.textcomposer.model
import kotlin.time.Duration
sealed class VoiceMessageState {
data object Idle: VoiceMessageState()
data object Recording: VoiceMessageState()
data object Preview: VoiceMessageState()
data object Sending: VoiceMessageState()
data class Recording(
val duration: Duration,
val level: Double,
): VoiceMessageState()
}

View file

@ -131,7 +131,6 @@
<string name="common_room_name_placeholder">"např. název vašeho projektu"</string>
<string name="common_search_for_someone">"Hledat někoho"</string>
<string name="common_search_results">"Výsledky hledání"</string>
<string name="common_secure_backup">"Zabezpečená záloha"</string>
<string name="common_security">"Zabezpečení"</string>
<string name="common_sending">"Odesílání…"</string>
<string name="common_server_not_supported">"Server není podporován"</string>

View file

@ -1,13 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="a11y_delete">"Удалить"</string>
<string name="a11y_hide_password">"Скрыть пароль"</string>
<string name="a11y_notifications_mentions_only">"Только упоминания"</string>
<string name="a11y_notifications_muted">"Звук отключен"</string>
<string name="a11y_pause">"Приостановить"</string>
<string name="a11y_play">"Воспроизвести"</string>
<string name="a11y_poll">"Опрос"</string>
<string name="a11y_poll_end">"Опрос завершен"</string>
<string name="a11y_send_files">"Отправить файлы"</string>
<string name="a11y_show_password">"Показать пароль"</string>
<string name="a11y_user_menu">"Меню пользователя"</string>
<string name="a11y_voice_message_record">"Запишите голосовое сообщение. Дважды нажмите и удерживайте, чтобы записать. Отпустите, чтобы закончить запись."</string>
<string name="action_accept">"Разрешить"</string>
<string name="action_add_to_timeline">"Добавить в хронологию"</string>
<string name="action_back">"Назад"</string>
@ -90,6 +94,7 @@
<string name="common_emote">"%1$s%2$s"</string>
<string name="common_encryption_enabled">"Шифрование включено"</string>
<string name="common_error">"Ошибка"</string>
<string name="common_everyone">"Для всех"</string>
<string name="common_file">"Файл"</string>
<string name="common_file_saved_on_disk_android">"Файл сохранен в «Загрузки»"</string>
<string name="common_forward_message">"Переслать сообщение"</string>
@ -126,7 +131,6 @@
<string name="common_room_name_placeholder">"например, название вашего проекта"</string>
<string name="common_search_for_someone">"Поиск человека"</string>
<string name="common_search_results">"Результаты поиска"</string>
<string name="common_secure_backup">"Безопасное резервное копирование"</string>
<string name="common_security">"Безопасность"</string>
<string name="common_sending">"Отправка…"</string>
<string name="common_server_not_supported">"Сервер не поддерживается"</string>

View file

@ -5,6 +5,7 @@
<string name="a11y_notifications_mentions_only">"Iba zmienky"</string>
<string name="a11y_notifications_muted">"Stlmené"</string>
<string name="a11y_pause">"Pozastaviť"</string>
<string name="a11y_pin_field">"Pole PIN"</string>
<string name="a11y_play">"Prehrať"</string>
<string name="a11y_poll">"Anketa"</string>
<string name="a11y_poll_end">"Ukončená anketa"</string>
@ -69,12 +70,15 @@
<string name="action_share">"Zdieľať"</string>
<string name="action_share_link">"Zdieľať odkaz"</string>
<string name="action_sign_in_again">"Prihláste sa znova"</string>
<string name="action_signout">"Odhlásiť sa"</string>
<string name="action_signout_anyway">"Napriek tomu sa odhlásiť"</string>
<string name="action_skip">"Preskočiť"</string>
<string name="action_start">"Spustiť"</string>
<string name="action_start_chat">"Začať konverzáciu"</string>
<string name="action_start_verification">"Spustiť overovanie"</string>
<string name="action_static_map_load">"Ťuknutím načítate mapu"</string>
<string name="action_take_photo">"Urobiť fotku"</string>
<string name="action_try_again">"Skúste to znova"</string>
<string name="action_view_source">"Zobraziť zdroj"</string>
<string name="action_yes">"Áno"</string>
<string name="action_edit_poll">"Upraviť anketu"</string>
@ -84,6 +88,7 @@
<string name="common_analytics">"Analytika"</string>
<string name="common_audio">"Zvuk"</string>
<string name="common_bubbles">"Bubliny"</string>
<string name="common_chat_backup">"Záloha konverzácie"</string>
<string name="common_copyright">"Autorské práva"</string>
<string name="common_creating_room">"Vytváranie miestnosti…"</string>
<string name="common_current_user_left_room">"Opustil/a miestnosť"</string>
@ -93,6 +98,7 @@
<string name="common_editing">"Upravuje sa"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption_enabled">"Šifrovanie zapnuté"</string>
<string name="common_enter_your_pin">"Zadajte svoj PIN"</string>
<string name="common_error">"Chyba"</string>
<string name="common_everyone">"Všetci"</string>
<string name="common_file">"Súbor"</string>
@ -122,6 +128,7 @@
<string name="common_privacy_policy">"Zásady ochrany osobných údajov"</string>
<string name="common_reaction">"Reakcia"</string>
<string name="common_reactions">"Reakcie"</string>
<string name="common_recovery_key">"Kľúč na obnovenie"</string>
<string name="common_refreshing">"Obnovuje sa…"</string>
<string name="common_replying_to">"Odpoveď na %1$s"</string>
<string name="common_report_a_bug">"Nahlásiť chybu"</string>
@ -129,9 +136,9 @@
<string name="common_rich_text_editor">"Rozšírený textový editor"</string>
<string name="common_room_name">"Názov miestnosti"</string>
<string name="common_room_name_placeholder">"napr. názov vášho projektu"</string>
<string name="common_screen_lock">"Zámok obrazovky"</string>
<string name="common_search_for_someone">"Vyhľadať niekoho"</string>
<string name="common_search_results">"Výsledky hľadania"</string>
<string name="common_secure_backup">"Bezpečné zálohovanie"</string>
<string name="common_security">"Bezpečnosť"</string>
<string name="common_sending">"Odosiela sa…"</string>
<string name="common_server_not_supported">"Server nie je podporovaný"</string>
@ -151,6 +158,7 @@
<string name="common_unable_to_decrypt">"Nie je možné dešifrovať"</string>
<string name="common_unable_to_invite_message">"Pozvánky nebolo možné odoslať jednému alebo viacerým používateľom."</string>
<string name="common_unable_to_invite_title">"Nie je možné odoslať pozvánku/ky"</string>
<string name="common_unlock">"Odomknúť"</string>
<string name="common_unmute">"Zrušiť stlmenie zvuku"</string>
<string name="common_unsupported_event">"Nepodporovaná udalosť"</string>
<string name="common_username">"Používateľské meno"</string>
@ -175,8 +183,10 @@
<string name="error_failed_loading_map">"%1$s nedokázal načítať mapu. Skúste to prosím neskôr."</string>
<string name="error_failed_loading_messages">"Načítanie správ zlyhalo"</string>
<string name="error_failed_locating_user">"%1$s nemohol získať prístup k vašej polohe. Skúste to prosím neskôr."</string>
<string name="error_failed_uploading_voice_message">"Nepodarilo sa nahrať hlasovú správu."</string>
<string name="error_missing_location_auth_android">"%1$s nemá povolenie na prístup k vašej polohe. Prístup môžete zapnúť v Nastaveniach."</string>
<string name="error_missing_location_rationale_android">"%1$s nemá povolenie na prístup k vašej polohe. Povoľte prístup nižšie."</string>
<string name="error_missing_microphone_voice_rationale_android">"%1$s nemá povolenie na prístup k vášmu mikrofónu. Povoľte prístup na nahrávanie hlasovej správy."</string>
<string name="error_some_messages_have_not_been_sent">"Niektoré správy neboli odoslané"</string>
<string name="error_unknown">"Prepáčte, vyskytla sa chyba"</string>
<string name="invite_friends_rich_title">"🔐️ Pripojte sa ku mne na %1$s"</string>
@ -185,6 +195,11 @@
<string name="leave_room_alert_private_subtitle">"Ste si istí, že chcete opustiť túto miestnosť? Táto miestnosť nie je verejná a bez pozvania sa do nej nebudete môcť vrátiť."</string>
<string name="leave_room_alert_subtitle">"Ste si istí, že chcete opustiť miestnosť?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<plurals name="a11y_digits_entered">
<item quantity="one">"%1$d zadaná číslica"</item>
<item quantity="few">"%1$d zadané číslice"</item>
<item quantity="other">"%1$d zadaných číslic"</item>
</plurals>
<plurals name="common_member_count">
<item quantity="one">"%1$d člen"</item>
<item quantity="few">"%1$d členovia"</item>
@ -202,6 +217,22 @@
<string name="room_timeline_beginning_of_room_no_name">"Toto je začiatok tejto konverzácie."</string>
<string name="room_timeline_read_marker_title">"Nové"</string>
<string name="screen_analytics_settings_share_data">"Zdieľať analytické údaje"</string>
<string name="screen_chat_backup_key_backup_action_disable">"Vypnúť zálohovanie"</string>
<string name="screen_chat_backup_key_backup_action_enable">"Zapnúť zálohovanie"</string>
<string name="screen_chat_backup_key_backup_description">"Zálohovanie zaisťuje, že nestratíte históriu správ. %1$s."</string>
<string name="screen_chat_backup_key_backup_title">"Zálohovanie"</string>
<string name="screen_chat_backup_recovery_action_change">"Zmeniť kľúč na obnovenie"</string>
<string name="screen_chat_backup_recovery_action_confirm">"Potvrdiť kľúč na obnovenie"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Vaša záloha konverzácie nie je momentálne synchronizovaná."</string>
<string name="screen_chat_backup_recovery_action_setup">"Nastaviť obnovovanie"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Získajte prístup k vašim šifrovaným správam aj keď stratíte všetky svoje zariadenia alebo sa odhlásite zo všetkých %1$s zariadení."</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Vypnúť"</string>
<string name="screen_key_backup_disable_confirmation_description">"Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite zo všetkých zariadení"</string>
<string name="screen_key_backup_disable_confirmation_title">"Ste si istí, že chcete vypnúť zálohovanie?"</string>
<string name="screen_key_backup_disable_description">"Vypnutím zálohovania sa odstráni aktuálna záloha šifrovacích kľúčov a vypnú sa ďalšie bezpečnostné funkcie. V tomto prípade:"</string>
<string name="screen_key_backup_disable_description_point_1">"Na nových zariadeniach nebudete mať zašifrovanú históriu správ"</string>
<string name="screen_key_backup_disable_description_point_2">"Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite zo všetkých %1$s zariadení"</string>
<string name="screen_key_backup_disable_title">"Ste si istí, že chcete vypnúť zálohovanie?"</string>
<string name="screen_media_picker_error_failed_selection">"Nepodarilo sa vybrať médium, skúste to prosím znova."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Nepodarilo sa nahrať médiá, skúste to prosím znova."</string>
@ -232,6 +263,27 @@ Ak budete pokračovať, niektoré z vašich nastavení sa môžu zmeniť."</stri
<string name="screen_notification_settings_system_notifications_action_required_content_link">"nastavenia systému"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"Systémové oznámenia sú vypnuté"</string>
<string name="screen_notification_settings_title">"Oznámenia"</string>
<string name="screen_recovery_key_change_description">"Získajte nový kľúč na obnovenie, ak ste stratili svoj existujúci. Po zmene kľúča na obnovenie už starý kľúč nebude fungovať."</string>
<string name="screen_recovery_key_change_generate_key">"Vygenerovať nový kľúč na obnovenie"</string>
<string name="screen_recovery_key_change_generate_key_description">"Uistite sa, že kľúč na obnovenie môžete uložiť niekde v bezpečí"</string>
<string name="screen_recovery_key_change_success">"Kľúč na obnovenie bol zmenený"</string>
<string name="screen_recovery_key_change_title">"Zmeniť kľúč na obnovenie?"</string>
<string name="screen_recovery_key_confirm_description">"Zadajte kľúč na obnovenie a potvrďte prístup k zálohe konverzácie."</string>
<string name="screen_recovery_key_confirm_key_description">"Zadajte 48-znakový kód."</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Zadať…"</string>
<string name="screen_recovery_key_confirm_success">"Kľúč na obnovu potvrdený"</string>
<string name="screen_recovery_key_confirm_title">"Potvrďte kľúč na obnovenie"</string>
<string name="screen_recovery_key_save_action">"Uložiť kľúč na obnovenie"</string>
<string name="screen_recovery_key_save_description">"Zapíšte si kľúč na obnovenie na bezpečné miesto alebo ho uložte do správcu hesiel."</string>
<string name="screen_recovery_key_save_key_description">"Ťuknutím skopírujte kľúč na obnovenie"</string>
<string name="screen_recovery_key_save_title">"Uložte svoj kľúč na obnovenie"</string>
<string name="screen_recovery_key_setup_confirmation_description">"Po tomto kroku nebudete mať prístup k novému kľúču na obnovenie."</string>
<string name="screen_recovery_key_setup_confirmation_title">"Uložili ste kľúč na obnovenie?"</string>
<string name="screen_recovery_key_setup_description">"Vaša záloha konverzácie je chránená kľúčom na obnovenie. Ak potrebujete nový kľúč na obnovenie, po nastavení si ho môžete znova vytvoriť výberom položky „Zmeniť kľúč na obnovenie“."</string>
<string name="screen_recovery_key_setup_generate_key">"Vygenerujte si váš kľúč na obnovenie"</string>
<string name="screen_recovery_key_setup_generate_key_description">"Uistite sa, že kľúč na obnovenie môžete uložiť niekde v bezpečí"</string>
<string name="screen_recovery_key_setup_success">"Úspešné nastavenie obnovy"</string>
<string name="screen_recovery_key_setup_title">"Nastaviť obnovenie"</string>
<string name="screen_report_content_block_user_hint">"Označte, či chcete skryť všetky aktuálne a budúce správy od tohto používateľa"</string>
<string name="screen_share_location_title">"Zdieľať polohu"</string>
<string name="screen_share_my_location_action">"Zdieľať moju polohu"</string>

View file

@ -25,6 +25,7 @@
<string name="action_copy_link">"複製連結"</string>
<string name="action_create">"建立"</string>
<string name="action_create_a_room">"建立聊天室"</string>
<string name="action_decline">"拒絕"</string>
<string name="action_disable">"停用"</string>
<string name="action_done">"完成"</string>
<string name="action_edit">"編輯"</string>

View file

@ -5,6 +5,7 @@
<string name="a11y_notifications_mentions_only">"Mentions only"</string>
<string name="a11y_notifications_muted">"Muted"</string>
<string name="a11y_pause">"Pause"</string>
<string name="a11y_pin_field">"PIN field"</string>
<string name="a11y_play">"Play"</string>
<string name="a11y_poll">"Poll"</string>
<string name="a11y_poll_end">"Ended poll"</string>
@ -69,12 +70,15 @@
<string name="action_share">"Share"</string>
<string name="action_share_link">"Share link"</string>
<string name="action_sign_in_again">"Sign in again"</string>
<string name="action_signout">"Sign out"</string>
<string name="action_signout_anyway">"Sign out anyway"</string>
<string name="action_skip">"Skip"</string>
<string name="action_start">"Start"</string>
<string name="action_start_chat">"Start chat"</string>
<string name="action_start_verification">"Start verification"</string>
<string name="action_static_map_load">"Tap to load map"</string>
<string name="action_take_photo">"Take photo"</string>
<string name="action_try_again">"Try again"</string>
<string name="action_view_source">"View source"</string>
<string name="action_yes">"Yes"</string>
<string name="action_edit_poll">"Edit poll"</string>
@ -84,6 +88,7 @@
<string name="common_analytics">"Analytics"</string>
<string name="common_audio">"Audio"</string>
<string name="common_bubbles">"Bubbles"</string>
<string name="common_chat_backup">"Chat backup"</string>
<string name="common_copyright">"Copyright"</string>
<string name="common_creating_room">"Creating room…"</string>
<string name="common_current_user_left_room">"Left room"</string>
@ -93,6 +98,7 @@
<string name="common_editing">"Editing"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption_enabled">"Encryption enabled"</string>
<string name="common_enter_your_pin">"Enter your PIN"</string>
<string name="common_error">"Error"</string>
<string name="common_everyone">"Everyone"</string>
<string name="common_file">"File"</string>
@ -122,6 +128,7 @@
<string name="common_privacy_policy">"Privacy policy"</string>
<string name="common_reaction">"Reaction"</string>
<string name="common_reactions">"Reactions"</string>
<string name="common_recovery_key">"Recovery key"</string>
<string name="common_refreshing">"Refreshing…"</string>
<string name="common_replying_to">"Replying to %1$s"</string>
<string name="common_report_a_bug">"Report a bug"</string>
@ -129,9 +136,9 @@
<string name="common_rich_text_editor">"Rich text editor"</string>
<string name="common_room_name">"Room name"</string>
<string name="common_room_name_placeholder">"e.g. your project name"</string>
<string name="common_screen_lock">"Screen lock"</string>
<string name="common_search_for_someone">"Search for someone"</string>
<string name="common_search_results">"Search results"</string>
<string name="common_secure_backup">"Secure backup"</string>
<string name="common_security">"Security"</string>
<string name="common_sending">"Sending…"</string>
<string name="common_server_not_supported">"Server not supported"</string>
@ -151,6 +158,7 @@
<string name="common_unable_to_decrypt">"Unable to decrypt"</string>
<string name="common_unable_to_invite_message">"Invites couldn\'t be sent to one or more users."</string>
<string name="common_unable_to_invite_title">"Unable to send invite(s)"</string>
<string name="common_unlock">"Unlock"</string>
<string name="common_unmute">"Unmute"</string>
<string name="common_unsupported_event">"Unsupported event"</string>
<string name="common_username">"Username"</string>
@ -175,8 +183,10 @@
<string name="error_failed_loading_map">"%1$s could not load the map. Please try again later."</string>
<string name="error_failed_loading_messages">"Failed loading messages"</string>
<string name="error_failed_locating_user">"%1$s could not access your location. Please try again later."</string>
<string name="error_failed_uploading_voice_message">"Failed to upload your voice message."</string>
<string name="error_missing_location_auth_android">"%1$s does not have permission to access your location. You can enable access in Settings."</string>
<string name="error_missing_location_rationale_android">"%1$s does not have permission to access your location. Enable access below."</string>
<string name="error_missing_microphone_voice_rationale_android">"%1$s does not have permission to access your microphone. Enable access to record a voice message."</string>
<string name="error_some_messages_have_not_been_sent">"Some messages have not been sent"</string>
<string name="error_unknown">"Sorry, an error occurred"</string>
<string name="invite_friends_rich_title">"🔐️ Join me on %1$s"</string>
@ -185,6 +195,10 @@
<string name="leave_room_alert_private_subtitle">"Are you sure that you want to leave this room? This room is not public and you won\'t be able to rejoin without an invite."</string>
<string name="leave_room_alert_subtitle">"Are you sure that you want to leave the room?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<plurals name="a11y_digits_entered">
<item quantity="one">"%1$d digit entered"</item>
<item quantity="other">"%1$d digits entered"</item>
</plurals>
<plurals name="common_member_count">
<item quantity="one">"%1$d member"</item>
<item quantity="other">"%1$d members"</item>
@ -200,6 +214,22 @@
<string name="room_timeline_beginning_of_room_no_name">"This is the beginning of this conversation."</string>
<string name="room_timeline_read_marker_title">"New"</string>
<string name="screen_analytics_settings_share_data">"Share analytics data"</string>
<string name="screen_chat_backup_key_backup_action_disable">"Turn off backup"</string>
<string name="screen_chat_backup_key_backup_action_enable">"Turn on backup"</string>
<string name="screen_chat_backup_key_backup_description">"Backup ensures that you don\'t lose your message history. %1$s."</string>
<string name="screen_chat_backup_key_backup_title">"Backup"</string>
<string name="screen_chat_backup_recovery_action_change">"Change recovery key"</string>
<string name="screen_chat_backup_recovery_action_confirm">"Confirm recovery key"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Your chat backup is currently out of sync."</string>
<string name="screen_chat_backup_recovery_action_setup">"Set up recovery"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Get access to your encrypted messages if you lose all your devices or are signed out of %1$s everywhere."</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Turn off"</string>
<string name="screen_key_backup_disable_confirmation_description">"You will lose your encrypted messages if you are signed out of all devices."</string>
<string name="screen_key_backup_disable_confirmation_title">"Are you sure you want to turn off backup?"</string>
<string name="screen_key_backup_disable_description">"Turning off backup will remove your current encryption key backup and turn off other security features. In this case, you will:"</string>
<string name="screen_key_backup_disable_description_point_1">"Not have encrypted message history on new devices"</string>
<string name="screen_key_backup_disable_description_point_2">"Lose access to your encrypted messages if you are signed out of %1$s everywhere"</string>
<string name="screen_key_backup_disable_title">"Are you sure you want to turn off backup?"</string>
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
@ -228,6 +258,27 @@ If you proceed, some of your settings may change."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"system settings"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"System notifications turned off"</string>
<string name="screen_notification_settings_title">"Notifications"</string>
<string name="screen_recovery_key_change_description">"Get a new recovery key if you\'ve lost your existing one. After changing your recovery key, your old one will no longer work."</string>
<string name="screen_recovery_key_change_generate_key">"Generate a new recovery key"</string>
<string name="screen_recovery_key_change_generate_key_description">"Make sure you can store your recovery key somewhere safe"</string>
<string name="screen_recovery_key_change_success">"Recovery key changed"</string>
<string name="screen_recovery_key_change_title">"Change recovery key?"</string>
<string name="screen_recovery_key_confirm_description">"Enter your recovery key to confirm access to your chat backup."</string>
<string name="screen_recovery_key_confirm_key_description">"Enter the 48 character code."</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Enter…"</string>
<string name="screen_recovery_key_confirm_success">"Recovery key confirmed"</string>
<string name="screen_recovery_key_confirm_title">"Confirm your recovery key"</string>
<string name="screen_recovery_key_save_action">"Save recovery key"</string>
<string name="screen_recovery_key_save_description">"Write down your recovery key somewhere safe or save it in a password manager."</string>
<string name="screen_recovery_key_save_key_description">"Tap to copy recovery key"</string>
<string name="screen_recovery_key_save_title">"Save your recovery key"</string>
<string name="screen_recovery_key_setup_confirmation_description">"You will not be able to access your new recovery key after this step."</string>
<string name="screen_recovery_key_setup_confirmation_title">"Have you saved your recovery key?"</string>
<string name="screen_recovery_key_setup_description">"Your chat backup is protected by a recovery key. If you need a new recovery key after setup you can recreate by selecting Change recovery key."</string>
<string name="screen_recovery_key_setup_generate_key">"Generate your recovery key"</string>
<string name="screen_recovery_key_setup_generate_key_description">"Make sure you can store your recovery key somewhere safe"</string>
<string name="screen_recovery_key_setup_success">"Recovery setup successful"</string>
<string name="screen_recovery_key_setup_title">"Set up recovery"</string>
<string name="screen_report_content_block_user_hint">"Check if you want to hide all current and future messages from this user"</string>
<string name="screen_share_location_title">"Share location"</string>
<string name="screen_share_my_location_action">"Share my location"</string>

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.ui.utils"
dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.ui.utils.time
import kotlin.time.Duration
/**
* Format a duration as minutes:seconds.
*
* For example,
* - 0 seconds will be formatted as "0:00".
* - 65 seconds will be formatted as "1:05".
* - 2 hours will be formatted as "120:00".
* - negative 10 seconds will be formatted as "-0:10".
*
* @return the formatted duration.
*/
fun Duration.formatShort(): String {
// Format as minutes:seconds
val seconds = (absoluteValue.inWholeSeconds % 60)
.toString()
.padStart(2, '0')
val sign = isNegative().let { if (it) "-" else "" }
return "$sign${absoluteValue.inWholeMinutes}:$seconds"
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.ui.utils.time
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import kotlin.time.Duration.Companion.seconds
@RunWith(value = Parameterized::class)
class DurationFormatTest(
private val seconds: Double,
private val output: String,
) {
companion object {
@Parameterized.Parameters(name = "{index}: format({0})={1}")
@JvmStatic
fun data(): Iterable<Array<Any>> {
return arrayListOf(
arrayOf<Any>(0, "0:00"),
arrayOf<Any>(1, "0:01"),
arrayOf<Any>(10, "0:10"),
arrayOf<Any>(59.9, "0:59"),
arrayOf<Any>(60, "1:00"),
arrayOf<Any>(61, "1:01"),
arrayOf<Any>(60 * 60, "60:00"),
arrayOf<Any>(-60, "-1:00"),
arrayOf<Any>(-1, "-0:01"),
).toList()
}
}
@Test
fun formatShort() {
assertEquals(output, seconds.seconds.formatShort())
}
}

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
alias(libs.plugins.anvil)
}
android {
namespace = "io.element.android.libraries.voicerecorder.api"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
implementation(libs.androidx.annotationjvm)
implementation(libs.coroutines.core)
}

View file

@ -0,0 +1,55 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.api
import android.Manifest
import androidx.annotation.RequiresPermission
import kotlinx.coroutines.flow.StateFlow
/**
* Audio recorder which records audio to opus/ogg files.
*/
interface VoiceRecorder {
/**
* Start a recording.
*
* Call [stopRecord] to stop the recording and release resources.
*/
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
suspend fun startRecord()
/**
* Stop the current recording.
*
* Call [deleteRecording] to delete any recorded audio.
*
* @param cancelled If true, the recording is deleted.
*/
suspend fun stopRecord(
cancelled: Boolean = false
)
/**
* Stop the current recording and delete the output file.
*/
suspend fun deleteRecording()
/**
* The current state of the recorder.
*/
val state: StateFlow<VoiceRecorderState>
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.api
import java.io.File
import kotlin.time.Duration
sealed class VoiceRecorderState {
/**
* The recorder is idle and not recording.
*/
data object Idle : VoiceRecorderState()
/**
* The recorder is currently recording.
*
* @property elapsedTime The elapsed time since the recording started.
* @property level The current audio level of the recording as a fraction of 1.
*/
data class Recording(val elapsedTime: Duration, val level: Double) : VoiceRecorderState()
/**
* The recorder has finished recording.
*
* @property file The recorded file.
* @property mimeType The mime type of the file.
*/
data class Finished(
val file: File,
val mimeType: String,
) : VoiceRecorderState()
}

View file

@ -0,0 +1,48 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
alias(libs.plugins.anvil)
}
android {
namespace = "io.element.android.libraries.voicerecorder.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
api(projects.libraries.voicerecorder.api)
api(libs.opusencoder)
implementation(libs.dagger)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(libs.androidx.annotationjvm)
implementation(libs.coroutines.core)
testImplementation(projects.tests.testutils)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.mockk)
testImplementation(libs.test.turbine)
testImplementation(libs.coroutines.core)
testImplementation(libs.coroutines.test)
}

View file

@ -0,0 +1,147 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.impl
import android.Manifest
import androidx.annotation.RequiresPermission
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import io.element.android.libraries.voicerecorder.impl.audio.Audio
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
import io.element.android.libraries.voicerecorder.impl.audio.AudioLevelCalculator
import io.element.android.libraries.voicerecorder.impl.audio.AudioReader
import io.element.android.libraries.voicerecorder.impl.audio.Encoder
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import timber.log.Timber
import java.io.File
import java.util.UUID
import javax.inject.Inject
import kotlin.time.Duration.Companion.minutes
import kotlin.time.TimeSource
@SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class)
class VoiceRecorderImpl @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val timeSource: TimeSource,
private val audioReaderFactory: AudioReader.Factory,
private val encoder: Encoder,
private val fileManager: VoiceFileManager,
private val config: AudioConfig,
private val fileConfig: VoiceFileConfig,
private val audioLevelCalculator: AudioLevelCalculator,
appCoroutineScope: CoroutineScope,
) : VoiceRecorder {
private val voiceCoroutineScope by lazy {
appCoroutineScope.childScope(dispatchers.io, "VoiceRecorder-${UUID.randomUUID()}")
}
private var outputFile: File? = null
private var audioReader: AudioReader? = null
private var recordingJob: Job? = null
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
override val state: StateFlow<VoiceRecorderState> = _state
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
override suspend fun startRecord() {
Timber.i("Voice recorder started recording")
outputFile = fileManager.createFile()
.also(encoder::init)
val audioRecorder = audioReaderFactory.create(config, dispatchers).also { audioReader = it }
recordingJob = voiceCoroutineScope.launch {
val startedAt = timeSource.markNow()
audioRecorder.record { audio ->
yield()
val elapsedTime = startedAt.elapsedNow()
if (elapsedTime >= 30.minutes) {
Timber.w("Voice message time limit reached")
stopRecord(false)
return@record
}
when (audio) {
is Audio.Data -> {
val audioLevel = audioLevelCalculator.calculateAudioLevel(audio.buffer)
_state.emit(VoiceRecorderState.Recording(elapsedTime, audioLevel))
encoder.encode(audio.buffer, audio.readSize)
}
is Audio.Error -> {
Timber.e("Voice message error: code=${audio.audioRecordErrorCode}")
_state.emit(VoiceRecorderState.Recording(elapsedTime, 0.0))
}
}
}
}
}
/**
* Stop the current recording.
*
* Call [deleteRecording] to delete any recorded audio.
*/
override suspend fun stopRecord(
cancelled: Boolean
) {
recordingJob?.cancel()?.also {
Timber.i("Voice recorder stopped recording")
}
recordingJob = null
audioReader?.stop()
audioReader = null
encoder.release()
if (cancelled) {
deleteRecording()
}
_state.emit(
when (val file = outputFile) {
null -> VoiceRecorderState.Idle
else -> VoiceRecorderState.Finished(file, fileConfig.mimeType)
}
)
}
/**
* Stop the current recording and delete the output file.
*/
override suspend fun deleteRecording() {
outputFile?.let(fileManager::deleteFile)?.also {
Timber.i("Voice recorder deleted recording")
}
outputFile = null
_state.emit(VoiceRecorderState.Idle)
}
}

View file

@ -0,0 +1,139 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.impl.audio
import android.Manifest
import android.media.AudioRecord
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
import androidx.annotation.RequiresPermission
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.di.RoomScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
class AndroidAudioReader
@RequiresPermission(Manifest.permission.RECORD_AUDIO) private constructor(
private val config: AudioConfig,
private val dispatchers: CoroutineDispatchers,
) : AudioReader {
private val audioRecord: AudioRecord
private var noiseSuppressor: NoiseSuppressor? = null
private var automaticGainControl: AutomaticGainControl? = null
private val outputBuffer: ShortArray
init {
outputBuffer = createOutputBuffer(config.sampleRate)
audioRecord = AudioRecord.Builder().setAudioSource(config.source).setAudioFormat(config.format).setBufferSizeInBytes(outputBuffer.sizeInBytes()).build()
noiseSuppressor = requestNoiseSuppressor(audioRecord)
automaticGainControl = requestAutomaticGainControl(audioRecord)
}
/**
* Record audio data continuously.
*
* @param onAudio callback when audio is read.
*/
override suspend fun record(
onAudio: suspend (Audio) -> Unit,
) {
audioRecord.startRecording()
withContext(dispatchers.io) {
while (isActive) {
if (audioRecord.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
break
}
onAudio(read())
}
}
}
private fun read(): Audio {
val result = audioRecord.read(outputBuffer, 0, outputBuffer.size)
if (isAudioRecordErrorResult(result)) {
return Audio.Error(result)
}
return Audio.Data(
result,
outputBuffer,
)
}
override fun stop() {
if (audioRecord.state == AudioRecord.STATE_INITIALIZED) {
audioRecord.stop()
}
audioRecord.release()
noiseSuppressor?.release()
noiseSuppressor = null
automaticGainControl?.release()
automaticGainControl = null
}
private fun createOutputBuffer(sampleRate: SampleRate): ShortArray {
val bufferSizeInShorts = AudioRecord.getMinBufferSize(
sampleRate.hz,
config.format.channelMask,
config.format.encoding
)
return ShortArray(bufferSizeInShorts)
}
private fun requestNoiseSuppressor(audioRecord: AudioRecord): NoiseSuppressor? {
if (!NoiseSuppressor.isAvailable()) {
return null
}
return tryOrNull {
NoiseSuppressor.create(audioRecord.audioSessionId).apply {
enabled = true
}
}
}
private fun requestAutomaticGainControl(audioRecord: AudioRecord): AutomaticGainControl? {
if (!AutomaticGainControl.isAvailable()) {
return null
}
return tryOrNull {
AutomaticGainControl.create(audioRecord.audioSessionId).apply {
enabled = true
}
}
}
@ContributesBinding(RoomScope::class)
companion object Factory : AudioReader.Factory {
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
override fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AndroidAudioReader {
return AndroidAudioReader(config, dispatchers)
}
}
}
private fun isAudioRecordErrorResult(result: Int): Boolean {
return result < 0
}
private fun ShortArray.sizeInBytes(): Int = size * Short.SIZE_BYTES

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.impl.audio
sealed class Audio {
class Data(
val readSize: Int,
val buffer: ShortArray,
) : Audio()
data class Error(
val audioRecordErrorCode: Int
) : Audio()
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.impl.audio
import android.media.AudioFormat
import android.media.MediaRecorder.AudioSource
/**
* Audio configuration for voice recording.
*
* @property source the audio source to use, see constants in [AudioSource]
* @property format the audio format to use, see [AudioFormat]
* @property sampleRate the sample rate to use. Ensure this matches the value set in [format].
* @property bitRate the bitrate in bps
*/
data class AudioConfig(
val source: Int,
val format: AudioFormat,
val sampleRate: SampleRate,
val bitRate: Int,
)

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.impl.audio
interface AudioLevelCalculator {
/**
* Calculate the audio level of the audio buffer.
*
* @param buffer The audio buffer containing raw audio data.
*
* @return A value between 0 and 1.
*/
fun calculateAudioLevel(buffer: ShortArray): Double
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.impl.audio
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
interface AudioReader {
/**
* Record audio data continuously.
*
* @param onAudio callback when audio is read.
*/
suspend fun record(
onAudio: suspend (Audio) -> Unit,
)
fun stop()
interface Factory {
fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AudioReader
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.impl.audio
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.RoomScope
import javax.inject.Inject
import kotlin.math.log10
import kotlin.math.min
import kotlin.math.sqrt
@ContributesBinding(RoomScope::class)
class DecibelAudioLevelCalculator @Inject constructor() : AudioLevelCalculator {
companion object {
private const val REFERENCE_DB = 50.0 // Reference dB for normal conversation
}
override fun calculateAudioLevel(buffer: ShortArray): Double {
val rms = buffer.rootMeanSquare()
// Convert to decibels and clip
val db = 20 * log10(rms / REFERENCE_DB)
val clipped = min(db, REFERENCE_DB)
// Scale to the range [0.0, 1.0]
return clipped / REFERENCE_DB
}
private fun ShortArray.rootMeanSquare(): Double {
// Use Double to avoid overflow
val sumOfSquares: Double = sumOf { it.toDouble() * it.toDouble() }
val avgSquare = sumOfSquares / size.toDouble()
return sqrt(avgSquare)
}
}

View file

@ -0,0 +1,63 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.impl.audio
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.RoomScope
import io.element.android.opusencoder.OggOpusEncoder
import timber.log.Timber
import java.io.File
import javax.inject.Inject
import javax.inject.Provider
/**
* Safe wrapper for OggOpusEncoder.
*/
@ContributesBinding(RoomScope::class)
class DefaultEncoder @Inject constructor(
private val encoderProvider: Provider<OggOpusEncoder>,
config: AudioConfig,
) : Encoder {
private val bitRate = config.bitRate
private val sampleRate = config.sampleRate.asEncoderModel()
private var encoder: OggOpusEncoder? = null
override fun init(
file: File,
) {
encoder?.release()
encoder = encoderProvider.get().apply {
init(file.absolutePath, sampleRate)
setBitrate(bitRate)
// TODO check encoder application: 2048 (voice, default is typically 2049 as audio)
}
}
override fun encode(
buffer: ShortArray,
readSize: Int,
) {
encoder?.encode(buffer, readSize)
?: Timber.w("Can't encode when encoder not initialized")
}
override fun release() {
encoder?.release()
?: Timber.w("Can't release encoder that is not initialized")
encoder = null
}
}

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.impl.audio
import java.io.File
interface Encoder {
fun init(file: File)
fun encode(buffer: ShortArray, readSize: Int)
fun release()
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.impl.audio
import io.element.android.opusencoder.configuration.SampleRate as LibOpusOggSampleRate
data object SampleRate {
const val hz = 48_000
fun asEncoderModel() = LibOpusOggSampleRate.Rate48kHz
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.impl.di
import android.media.AudioFormat
import android.media.MediaRecorder
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
import io.element.android.libraries.voicerecorder.impl.audio.SampleRate
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig
import io.element.android.opusencoder.OggOpusEncoder
@Module
@ContributesTo(RoomScope::class)
object VoiceRecorderModule {
@Provides
fun provideAudioConfig(): AudioConfig {
val sampleRate = SampleRate
return AudioConfig(
format = AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate.hz)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build(),
bitRate = 24_000, // 24 kbps
sampleRate = sampleRate,
source = MediaRecorder.AudioSource.MIC,
)
}
@Provides
fun provideVoiceFileConfig(): VoiceFileConfig =
VoiceFileConfig(
cacheSubdir = "voice_recordings",
fileExt = "ogg",
mimeType = "audio/ogg",
)
@Provides
fun provideOggOpusEncoder(): OggOpusEncoder = OggOpusEncoder.create()
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.impl.file
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.hash.md5
import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import java.io.File
import java.util.UUID
import javax.inject.Inject
@ContributesBinding(RoomScope::class)
class DefaultVoiceFileManager @Inject constructor(
@CacheDirectory private val cacheDir: File,
private val config: VoiceFileConfig,
room: MatrixRoom,
) : VoiceFileManager {
private val roomId: RoomId = room.roomId
override fun createFile(): File {
val fileName = "${UUID.randomUUID()}.${config.fileExt}"
val outputDirectory = File(cacheDir, config.cacheSubdir)
val roomDir = File(outputDirectory, roomId.value.md5())
.apply(File::mkdirs)
return File(roomDir, fileName)
}
override fun deleteFile(file: File) {
file.delete()
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.impl.file
/**
* File configuration for voice recording.
*
* @property cacheSubdir the subdirectory in the cache dir to use.
* @property fileExt the file extension for audio files.
* @property mimeType the mime type of audio files.
*/
data class VoiceFileConfig(
val cacheSubdir: String,
val fileExt: String,
val mimeType: String,
)

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.impl.file
import java.io.File
interface VoiceFileManager {
fun createFile(): File
fun deleteFile(file: File)
}

View file

@ -0,0 +1,157 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.impl
import android.media.AudioFormat
import android.media.MediaRecorder
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import io.element.android.libraries.voicerecorder.impl.audio.Audio
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
import io.element.android.libraries.voicerecorder.impl.audio.SampleRate
import io.element.android.libraries.voicerecorder.impl.di.VoiceRecorderModule
import io.element.android.libraries.voicerecorder.test.FakeAudioLevelCalculator
import io.element.android.libraries.voicerecorder.test.FakeAudioRecorderFactory
import io.element.android.libraries.voicerecorder.test.FakeEncoder
import io.element.android.libraries.voicerecorder.test.FakeFileSystem
import io.element.android.libraries.voicerecorder.test.FakeVoiceFileManager
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.BeforeClass
import org.junit.Test
import java.io.File
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TestTimeSource
class VoiceRecorderImplTest {
private val fakeFileSystem = FakeFileSystem()
private val timeSource = TestTimeSource()
@Test
fun `it emits the initial state`() = runTest {
val voiceRecorder = createVoiceRecorder()
voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
}
}
@Test
fun `when recording, it emits the recording state`() = runTest {
val voiceRecorder = createVoiceRecorder()
voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
voiceRecorder.startRecord()
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.seconds, 1.0))
timeSource += 1.seconds
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.seconds,0.0))
timeSource += 1.seconds
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(2.seconds, 1.0))
}
}
@Test
fun `when elapsed time reaches 30 minutes, it stops recording`() = runTest {
val voiceRecorder = createVoiceRecorder()
voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
voiceRecorder.startRecord()
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.minutes, 1.0))
timeSource += 29.minutes
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(29.minutes, 0.0))
timeSource += 1.minutes
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Finished(File(FILE_PATH), "audio/ogg"))
}
}
@Test
fun `when stopped, it provides a file`() = runTest {
val voiceRecorder = createVoiceRecorder()
voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
voiceRecorder.startRecord()
skipItems(3)
voiceRecorder.stopRecord()
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Finished(File(FILE_PATH), "audio/ogg"))
assertThat(fakeFileSystem.files[File(FILE_PATH)]).isEqualTo(ENCODED_DATA)
}
}
@Test
fun `when cancelled, it deletes the file`() = runTest {
val voiceRecorder = createVoiceRecorder()
voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
voiceRecorder.startRecord()
skipItems(3)
voiceRecorder.stopRecord(cancelled = true)
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
assertThat(fakeFileSystem.files[File(FILE_PATH)]).isNull()
}
}
private fun TestScope.createVoiceRecorder(): VoiceRecorderImpl {
val fileConfig = VoiceRecorderModule.provideVoiceFileConfig()
return VoiceRecorderImpl(
dispatchers = testCoroutineDispatchers(),
timeSource = timeSource,
audioReaderFactory = FakeAudioRecorderFactory(
audio = AUDIO,
),
encoder = FakeEncoder(fakeFileSystem),
config = AudioConfig(
format = AUDIO_FORMAT,
bitRate = 24_000, // 24 kbps
sampleRate = SampleRate,
source = MediaRecorder.AudioSource.MIC,
),
fileConfig = fileConfig,
fileManager = FakeVoiceFileManager(fakeFileSystem, fileConfig, FILE_ID),
audioLevelCalculator = FakeAudioLevelCalculator(),
appCoroutineScope = backgroundScope,
)
}
companion object {
const val FILE_ID: String = "recording"
const val FILE_PATH = "voice_recordings/${FILE_ID}.ogg"
private lateinit var AUDIO_FORMAT: AudioFormat
// FakeEncoder doesn't actually encode, it just writes the data to the file
private const val ENCODED_DATA = "[32767, 32767, 32767][32767, 32767, 32767]"
private const val MAX_AMP = Short.MAX_VALUE
private val AUDIO = listOf(
Audio.Data(3, shortArrayOf(MAX_AMP, MAX_AMP, MAX_AMP)),
Audio.Error(-1),
Audio.Data(3, shortArrayOf(MAX_AMP, MAX_AMP, MAX_AMP)),
)
@BeforeClass
@JvmStatic
fun initAudioFormat() {
AUDIO_FORMAT = mockk()
}
}
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.impl.audio
import org.junit.Test
class DecibelAudioLevelCalculatorTest {
@Test
fun `given max values, it returns values within range`() {
val calculator = DecibelAudioLevelCalculator()
val buffer = ShortArray(100) { Short.MAX_VALUE }
val level = calculator.calculateAudioLevel(buffer)
assert(level in 0.0..1.0)
}
@Test
fun `given mixed values, it returns values within range`() {
val calculator = DecibelAudioLevelCalculator()
val buffer = shortArrayOf(Short.MAX_VALUE, Short.MIN_VALUE, -1, 1)
val level = calculator.calculateAudioLevel(buffer)
assert(level in 0.0..1.0)
}
@Test
fun `given min values, it returns values within range`() {
val calculator = DecibelAudioLevelCalculator()
val buffer = ShortArray(100) { Short.MIN_VALUE }
val level = calculator.calculateAudioLevel(buffer)
assert(level in 0.0..1.0)
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.test
import io.element.android.libraries.voicerecorder.impl.audio.AudioLevelCalculator
import kotlin.math.abs
class FakeAudioLevelCalculator: AudioLevelCalculator {
override fun calculateAudioLevel(buffer: ShortArray): Double {
return buffer.map { abs(it.toDouble()) }.average() / Short.MAX_VALUE
}
}

View file

@ -0,0 +1,50 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.test
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.voicerecorder.impl.audio.Audio
import io.element.android.libraries.voicerecorder.impl.audio.AudioReader
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
class FakeAudioReader(
private val dispatchers: CoroutineDispatchers,
private val audio: List<Audio>,
) : AudioReader {
private var isRecording = false
override suspend fun record(onAudio: suspend (Audio) -> Unit) {
isRecording = true
withContext(dispatchers.io) {
val audios = audio.iterator()
while (audios.hasNext()) {
if (!isRecording) break
onAudio(audios.next())
yield()
}
while (isActive) {
// do not return from the coroutine until it is cancelled
yield()
}
}
}
override fun stop() {
isRecording = false
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.test
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.voicerecorder.impl.audio.Audio
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
import io.element.android.libraries.voicerecorder.impl.audio.AudioReader
class FakeAudioRecorderFactory(
private val audio: List<Audio>
): AudioReader.Factory {
override fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AudioReader {
return FakeAudioReader(dispatchers, audio)
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.test
import io.element.android.libraries.voicerecorder.impl.audio.Encoder
import java.io.File
class FakeEncoder(
private val fakeFileSystem: FakeFileSystem
) : Encoder {
private var curFile: File? = null
override fun init(file: File) {
curFile = file
}
override fun encode(buffer: ShortArray, readSize: Int) {
val file = curFile
?: error("Encoder not initialized")
fakeFileSystem.appendToFile(file, buffer, readSize)
}
override fun release() {
curFile = null
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.test
import java.io.File
class FakeFileSystem {
// Map of file to file content
val files = mutableMapOf<File, String>()
fun createFile(file: File) {
if(files.containsKey(file)) {
return
}
files[file] = ""
}
fun appendToFile(file: File, buffer: ShortArray, readSize: Int) {
val content = files[file]
?: error("File ${file.path} does not exist")
files[file] = content + buffer.sliceArray(0 until readSize).contentToString()
}
fun deleteFile(file: File) {
files.remove(file)
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.test
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileManager
import java.io.File
class FakeVoiceFileManager(
private val fakeFileSystem: FakeFileSystem,
private val config: VoiceFileConfig,
private val fileId: String,
) : VoiceFileManager {
override fun createFile(): File {
val file = File("${config.cacheSubdir}/$fileId.${config.fileExt}")
fakeFileSystem.createFile(file)
return file
}
override fun deleteFile(file: File) {
fakeFileSystem.deleteFile(file)
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.voicerecorder.test"
}
dependencies {
api(projects.libraries.voicerecorder.api)
implementation(projects.tests.testutils)
implementation(libs.coroutines.test)
}

View file

@ -0,0 +1,81 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.voicerecorder.test
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.io.File
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TestTimeSource
class FakeVoiceRecorder(
private val timeSource: TestTimeSource = TestTimeSource(),
private val recordingDuration: Duration = 0.seconds,
private val levels: List<Double> = listOf(0.1, 0.2)
) : VoiceRecorder {
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
override val state: StateFlow<VoiceRecorderState> = _state
private var curRecording: File? = null
private var securityException: SecurityException? = null
override suspend fun startRecord() {
val startedAt = timeSource.markNow()
securityException?.let { throw it }
if (curRecording != null) {
error("Previous recording was not cleared")
}
curRecording = File("file.ogg")
timeSource += recordingDuration
levels.forEach {
_state.emit(VoiceRecorderState.Recording(startedAt.elapsedNow(), it))
}
}
override suspend fun stopRecord(
cancelled: Boolean
) {
if (cancelled) {
deleteRecording()
}
_state.emit(
when (curRecording) {
null -> VoiceRecorderState.Idle
else -> VoiceRecorderState.Finished(curRecording!!, "audio/ogg")
}
)
}
override suspend fun deleteRecording() {
curRecording = null
_state.emit(
VoiceRecorderState.Idle
)
}
fun givenThrowsSecurityException(exception: SecurityException) {
this.securityException = exception
}
}