Merge branch 'develop' into feature/fga/knock_requests_sdk

This commit is contained in:
ganfra 2024-12-18 17:21:16 +01:00
commit 01f7fc20f4
391 changed files with 6199 additions and 1991 deletions

View file

@ -10,10 +10,13 @@ package io.element.android.libraries.androidutils.browser
import android.app.Activity
import android.content.ActivityNotFoundException
import android.net.Uri
import android.os.Bundle
import android.provider.Browser
import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent
import androidx.browser.customtabs.CustomTabsSession
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import java.util.Locale
/**
* Open url in custom tab or, if not available, in the default browser.
@ -51,6 +54,9 @@ fun Activity.openUrlInChromeCustomTab(
intent.putExtra("org.chromium.chrome.browser.customtabs.EXTRA_DISABLE_DOWNLOAD_BUTTON", true)
// Disable bookmark button
intent.putExtra("org.chromium.chrome.browser.customtabs.EXTRA_DISABLE_START_BUTTON", true)
intent.putExtra(Browser.EXTRA_HEADERS, Bundle().apply {
putString("Accept-Language", Locale.getDefault().toLanguageTag())
})
}
.launchUrl(this, Uri.parse(url))
} catch (activityNotFoundException: ActivityNotFoundException) {

View file

@ -7,6 +7,9 @@
package io.element.android.libraries.core.extensions
import java.text.Normalizer
import java.util.Locale
fun Boolean.toOnOff() = if (this) "ON" else "OFF"
fun Boolean.to01() = if (this) "1" else "0"
@ -68,3 +71,21 @@ fun String.replacePrefix(oldPrefix: String, newPrefix: String): String {
fun String.withBrackets(prefix: String = "(", suffix: String = ")"): String {
return "$prefix$this$suffix"
}
/**
* Capitalize the string.
*/
fun String.safeCapitalize(): String {
return replaceFirstChar {
if (it.isLowerCase()) {
it.titlecase(Locale.getDefault())
} else {
it.toString()
}
}
}
fun String.withoutAccents(): String {
return Normalizer.normalize(this, Normalizer.Form.NFD)
.replace("\\p{Mn}+".toRegex(), "")
}

View file

@ -0,0 +1,56 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.api
interface DateFormatter {
fun format(
timestamp: Long?,
mode: DateFormatterMode = DateFormatterMode.Full,
useRelative: Boolean = false,
): String
}
enum class DateFormatterMode {
/**
* Full date and time.
* Example:
* "April 6, 1980 at 6:35 PM"
* Format can be shorter when useRelative is true.
* Example:
* "6:35 PM"
*/
Full,
/**
* Only month and year.
* Example:
* "April 1980"
* "This month" can be returned when useRelative is true.
* Example:
* "This month"
*/
Month,
/**
* Only day.
* Example:
* "Sunday 6 April"
* "Today", "Yesterday" and day of week can be returned when useRelative is true.
*/
Day,
/**
* Time if same day, else date.
*/
TimeOrDate,
/**
* Only time whatever the day.
*/
TimeOnly,
}

View file

@ -1,12 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.api
interface DaySeparatorFormatter {
fun format(timestamp: Long): String
}

View file

@ -1,12 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.api
fun interface LastMessageTimestampFormatter {
fun format(timestamp: Long?): String
}

View file

@ -8,7 +8,7 @@ import extension.setupAnvil
*/
plugins {
id("io.element.android-library")
id("io.element.android-compose-library")
}
setupAnvil()
@ -16,15 +16,30 @@ setupAnvil()
android {
namespace = "io.element.android.libraries.dateformatter.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
dependencies {
implementation(libs.dagger)
implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.di)
implementation(projects.libraries.uiStrings)
implementation(projects.services.toolbox.api)
api(projects.libraries.dateformatter.api)
api(libs.datetime)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.services.toolbox.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
}
}

View file

@ -0,0 +1,57 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.extensions.safeCapitalize
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
interface DateFormatterDay {
fun format(
timestamp: Long,
useRelative: Boolean,
): String
}
@ContributesBinding(AppScope::class)
class DefaultDateFormatterDay @Inject constructor(
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
) : DateFormatterDay {
override fun format(
timestamp: Long,
useRelative: Boolean,
): String {
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
val today = localDateTimeProvider.providesNow()
return if (useRelative) {
val dayDiff = today.date.toEpochDays() - dateToFormat.date.toEpochDays()
when (dayDiff) {
0 -> dateFormatters.getRelativeDay(timestamp, "Today")
1 -> dateFormatters.getRelativeDay(timestamp, "Yesterday")
else -> if (dayDiff < 7) {
dateFormatters.formatDateWithDay(dateToFormat)
} else {
if (today.year == dateToFormat.year) {
dateFormatters.formatDateWithFullFormatNoYear(dateToFormat)
} else {
dateFormatters.formatDateWithFullFormat(dateToFormat)
}
}
}
} else {
if (today.year == dateToFormat.year) {
dateFormatters.formatDateWithFullFormatNoYear(dateToFormat)
} else {
dateFormatters.formatDateWithFullFormat(dateToFormat)
}
}
.safeCapitalize()
}
}

View file

@ -0,0 +1,38 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
class DateFormatterFull @Inject constructor(
private val stringProvider: StringProvider,
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
private val dateFormatterDay: DateFormatterDay,
) {
fun format(
timestamp: Long,
useRelative: Boolean,
): String {
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
val time = dateFormatters.formatTime(dateToFormat)
return if (useRelative) {
val now = localDateTimeProvider.providesNow()
if (now.date == dateToFormat.date) {
time
} else {
val dateStr = dateFormatterDay.format(timestamp, true)
stringProvider.getString(R.string.common_date_date_at_time, dateStr, time)
}
} else {
val dateStr = dateFormatters.formatDateWithFullFormat(dateToFormat)
stringProvider.getString(R.string.common_date_date_at_time, dateStr, time)
}
}
}

View file

@ -0,0 +1,32 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl
import io.element.android.libraries.core.extensions.safeCapitalize
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
class DateFormatterMonth @Inject constructor(
private val stringProvider: StringProvider,
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
) {
fun format(
timestamp: Long,
useRelative: Boolean,
): String {
val today = localDateTimeProvider.providesNow()
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
return if (useRelative && dateToFormat.month == today.month && dateToFormat.year == today.year) {
stringProvider.getString(R.string.common_date_this_month)
} else {
dateFormatters.formatDateWithMonthAndYear(dateToFormat)
}
.safeCapitalize()
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
@ -7,18 +7,16 @@
package io.element.android.libraries.dateformatter.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultLastMessageTimestampFormatter @Inject constructor(
class DateFormatterTime @Inject constructor(
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
) : LastMessageTimestampFormatter {
override fun format(timestamp: Long?): String {
if (timestamp == null) return ""
) {
fun format(
timestamp: Long,
useRelative: Boolean,
): String {
val currentDate = localDateTimeProvider.providesNow()
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
val isSameDay = currentDate.date == dateToFormat.date
@ -30,7 +28,7 @@ class DefaultLastMessageTimestampFormatter @Inject constructor(
dateFormatters.formatDate(
dateToFormat = dateToFormat,
currentDate = currentDate,
useRelative = true
useRelative = useRelative,
)
}
}

View file

@ -0,0 +1,22 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl
import javax.inject.Inject
class DateFormatterTimeOnly @Inject constructor(
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
) {
fun format(
timestamp: Long,
): String {
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
return dateFormatters.formatTime(dateToFormat)
}
}

View file

@ -7,57 +7,64 @@
package io.element.android.libraries.dateformatter.impl
import android.text.format.DateFormat
import android.text.format.DateUtils
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.toInstant
import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime
import timber.log.Timber
import java.time.Period
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
import javax.inject.Inject
import kotlin.math.absoluteValue
@SingleIn(AppScope::class)
class DateFormatters @Inject constructor(
private val locale: Locale,
localeChangeObserver: LocaleChangeObserver,
private val clock: Clock,
private val timeZoneProvider: TimezoneProvider,
) {
private val onlyTimeFormatter: DateTimeFormatter by lazy {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
locale: Locale,
) : LocaleChangeListener {
init {
localeChangeObserver.addListener(this)
}
private val dateWithMonthFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "d MMM") ?: "d MMM"
DateTimeFormatter.ofPattern(pattern, locale)
}
private var dateTimeFormatters: DateTimeFormatters = DateTimeFormatters(locale)
private val dateWithYearFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "dd.MM.yyyy") ?: "dd.MM.yyyy"
DateTimeFormatter.ofPattern(pattern, locale)
}
private val dateWithFullFormatFormatter: DateTimeFormatter by lazy {
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
override fun onLocaleChange() {
Timber.w("Locale changed, updating formatters")
dateTimeFormatters = DateTimeFormatters(Locale.getDefault())
}
internal fun formatTime(localDateTime: LocalDateTime): String {
return onlyTimeFormatter.format(localDateTime.toJavaLocalDateTime())
return dateTimeFormatters.onlyTimeFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDateWithMonthAndYear(localDateTime: LocalDateTime): String {
return dateTimeFormatters.dateWithMonthAndYearFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDateWithMonth(localDateTime: LocalDateTime): String {
return dateWithMonthFormatter.format(localDateTime.toJavaLocalDateTime())
return dateTimeFormatters.dateWithMonthFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDateWithDay(localDateTime: LocalDateTime): String {
return dateTimeFormatters.dateWithDayFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDateWithYear(localDateTime: LocalDateTime): String {
return dateWithYearFormatter.format(localDateTime.toJavaLocalDateTime())
return dateTimeFormatters.dateWithYearFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDateWithFullFormat(localDateTime: LocalDateTime): String {
return dateWithFullFormatFormatter.format(localDateTime.toJavaLocalDateTime())
return dateTimeFormatters.dateWithFullFormatFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDateWithFullFormatNoYear(localDateTime: LocalDateTime): String {
return dateTimeFormatters.dateWithFullFormatNoYearFormatter.format(localDateTime.toJavaLocalDateTime())
}
internal fun formatDate(
@ -75,12 +82,12 @@ class DateFormatters @Inject constructor(
}
}
private fun getRelativeDay(ts: Long): String {
internal fun getRelativeDay(ts: Long, default: String = ""): String {
return DateUtils.getRelativeTimeSpanString(
ts,
clock.now().toEpochMilliseconds(),
DateUtils.DAY_IN_MILLIS,
DateUtils.FORMAT_SHOW_WEEKDAY
)?.toString() ?: ""
)?.toString() ?: default
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl
import android.text.format.DateFormat
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
class DateTimeFormatters(
private val locale: Locale,
) {
val onlyTimeFormatter: DateTimeFormatter by lazy {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
}
val dateWithMonthAndYearFormatter: DateTimeFormatter by lazy {
val pattern = bestDateTimePattern("MMMM YYYY")
DateTimeFormatter.ofPattern(pattern, locale)
}
val dateWithMonthFormatter: DateTimeFormatter by lazy {
val pattern = bestDateTimePattern("d MMM")
DateTimeFormatter.ofPattern(pattern, locale)
}
val dateWithDayFormatter: DateTimeFormatter by lazy {
val pattern = bestDateTimePattern("EEEE")
DateTimeFormatter.ofPattern(pattern, locale)
}
val dateWithYearFormatter: DateTimeFormatter by lazy {
val pattern = bestDateTimePattern("dd.MM.yyyy")
DateTimeFormatter.ofPattern(pattern, locale)
}
val dateWithFullFormatFormatter: DateTimeFormatter by lazy {
DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(locale)
}
val dateWithFullFormatNoYearFormatter: DateTimeFormatter by lazy {
val pattern = DateFormat.getBestDateTimePattern(locale, "EEEE d MMMM") ?: "EEEE d MMMM"
DateTimeFormatter.ofPattern(pattern, locale)
}
private fun bestDateTimePattern(pattern: String): String {
return DateFormat.getBestDateTimePattern(locale, pattern) ?: pattern
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultDateFormatter @Inject constructor(
private val dateFormatterFull: DateFormatterFull,
private val dateFormatterMonth: DateFormatterMonth,
private val dateFormatterDay: DateFormatterDay,
private val dateFormatterTime: DateFormatterTime,
private val dateFormatterTimeOnly: DateFormatterTimeOnly,
) : DateFormatter {
override fun format(
timestamp: Long?,
mode: DateFormatterMode,
useRelative: Boolean,
): String {
timestamp ?: return ""
return when (mode) {
DateFormatterMode.Full -> {
dateFormatterFull.format(timestamp, useRelative)
}
DateFormatterMode.Month -> {
dateFormatterMonth.format(timestamp, useRelative)
}
DateFormatterMode.Day -> {
dateFormatterDay.format(timestamp, useRelative)
}
DateFormatterMode.TimeOrDate -> {
dateFormatterTime.format(timestamp, useRelative)
}
DateFormatterMode.TimeOnly -> {
dateFormatterTimeOnly.format(timestamp)
}
}
}
}

View file

@ -1,25 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultDaySeparatorFormatter @Inject constructor(
private val localDateTimeProvider: LocalDateTimeProvider,
private val dateFormatters: DateFormatters,
) : DaySeparatorFormatter {
override fun format(timestamp: Long): String {
val dateToFormat = localDateTimeProvider.providesFromTimestamp(timestamp)
// TODO use relative formatting once iOS uses it too
return dateFormatters.formatDateWithFullFormat(dateToFormat)
}
}

View file

@ -0,0 +1,56 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import javax.inject.Inject
fun interface LocaleChangeObserver {
fun addListener(listener: LocaleChangeListener)
}
interface LocaleChangeListener {
fun onLocaleChange()
}
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultLocaleChangeObserver @Inject constructor(
@ApplicationContext private val context: Context,
) : LocaleChangeObserver {
init {
registerReceiver(object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
listeners.forEach(LocaleChangeListener::onLocaleChange)
}
})
}
private val listeners = mutableSetOf<LocaleChangeListener>()
override fun addListener(listener: LocaleChangeListener) {
listeners.add(listener)
}
private fun registerReceiver(receiver: BroadcastReceiver) {
val filter = IntentFilter()
filter.addAction(Intent.ACTION_LOCALE_CHANGED)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
filter.addAction(Intent.ACTION_APPLICATION_LOCALE_CHANGED)
}
context.registerReceiver(receiver, filter)
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl.previews
data class DateForPreview(
val semantic: String,
val date: String,
)
val dateForPreviewToday = DateForPreview(
semantic = "Today",
date = "1980-04-06T18:35:24.00Z",
)
val dateForPreviews = listOf(
DateForPreview(
semantic = "Now",
date = dateForPreviewToday.date,
),
DateForPreview(
semantic = "One second ago",
date = "1980-04-06T18:35:23.00Z",
),
DateForPreview(
semantic = "One minute ago",
date = "1980-04-06T18:34:24.00Z",
),
DateForPreview(
semantic = "One hour ago",
date = "1980-04-06T17:35:24.00Z",
),
DateForPreview(
semantic = "One day ago",
date = "1980-04-05T18:35:24.00Z",
),
DateForPreview(
semantic = "Two days ago",
date = "1980-04-04T18:35:24.00Z",
),
DateForPreview(
semantic = "One month ago",
date = "1980-03-06T18:35:24.00Z",
),
DateForPreview(
semantic = "One year ago",
date = "1979-04-06T18:35:24.00Z",
),
)

View file

@ -0,0 +1,16 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl.previews
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.dateformatter.api.DateFormatterMode
class DateFormatterModeProvider : PreviewParameterProvider<DateFormatterMode> {
override val values: Sequence<DateFormatterMode>
get() = DateFormatterMode.entries.asSequence()
}

View file

@ -0,0 +1,124 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl.previews
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.dateformatter.impl.DefaultDateFormatter
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.allBooleans
import kotlinx.datetime.Instant
@Preview
@Composable
internal fun DateFormatterModeViewPreview(
@PreviewParameter(DateFormatterModeProvider::class) dateFormatterMode: DateFormatterMode,
) = ElementPreview {
DateFormatterModeView(dateFormatterMode)
}
@Composable
private fun DateFormatterModeView(
mode: DateFormatterMode,
) {
val context = LocalContext.current
val composeLocale = Locale.current
val dateFormatter = remember {
createFormatter(
context = context,
currentDate = dateForPreviewToday.date,
locale = java.util.Locale.Builder()
.setLanguageTag(composeLocale.toLanguageTag())
.build(),
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Mode $mode / $composeLocale",
style = ElementTheme.typography.fontHeadingSmMedium
)
val today = Instant.parse(dateForPreviewToday.date).toEpochMilliseconds()
Text(
text = "Today is: ${dateFormatter.format(today, DateFormatterMode.Full, useRelative = false)}",
style = ElementTheme.typography.fontHeadingSmMedium,
)
dateForPreviews.forEach { dateForPreview ->
DateForPreviewItem(
dateForPreview = dateForPreview,
dateFormatter = dateFormatter,
mode = mode,
)
}
}
}
@Composable
private fun DateForPreviewItem(
dateForPreview: DateForPreview,
dateFormatter: DefaultDateFormatter,
mode: DateFormatterMode,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(2.dp),
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp),
text = dateForPreview.semantic,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textSecondary,
)
val ts = Instant.parse(dateForPreview.date).toEpochMilliseconds()
Row {
Column {
listOf("Absolute:", "Relative:").forEach { label ->
Text(
text = label,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
Spacer(modifier = Modifier.width(8.dp))
Column {
allBooleans.forEach { useRelative ->
Text(
modifier = Modifier.fillMaxWidth(),
text = dateFormatter.format(ts, mode, useRelative),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
}
}
}

View file

@ -0,0 +1,66 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl.previews
import android.content.Context
import io.element.android.libraries.dateformatter.impl.DateFormatterFull
import io.element.android.libraries.dateformatter.impl.DateFormatterMonth
import io.element.android.libraries.dateformatter.impl.DateFormatterTime
import io.element.android.libraries.dateformatter.impl.DateFormatterTimeOnly
import io.element.android.libraries.dateformatter.impl.DateFormatters
import io.element.android.libraries.dateformatter.impl.DefaultDateFormatter
import io.element.android.libraries.dateformatter.impl.DefaultDateFormatterDay
import io.element.android.libraries.dateformatter.impl.LocalDateTimeProvider
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import java.util.Locale
/**
* Create DefaultDateFormatter and set current time to the provided date.
*/
fun createFormatter(
context: Context,
currentDate: String,
locale: Locale,
): DefaultDateFormatter {
val clock = PreviewClock().apply { givenInstant(Instant.parse(currentDate)) }
val localDateTimeProvider = LocalDateTimeProvider(clock) { TimeZone.UTC }
val dateFormatters = DateFormatters(
localeChangeObserver = {},
clock = clock,
timeZoneProvider = { TimeZone.UTC },
locale = locale,
)
val stringProvider = PreviewStringProvider(context.resources)
val dateFormatterDay = DefaultDateFormatterDay(
localDateTimeProvider = localDateTimeProvider,
dateFormatters = dateFormatters,
)
return DefaultDateFormatter(
dateFormatterFull = DateFormatterFull(
stringProvider = stringProvider,
localDateTimeProvider = localDateTimeProvider,
dateFormatters = dateFormatters,
dateFormatterDay = dateFormatterDay,
),
dateFormatterMonth = DateFormatterMonth(
stringProvider = stringProvider,
localDateTimeProvider = localDateTimeProvider,
dateFormatters = dateFormatters,
),
dateFormatterDay = dateFormatterDay,
dateFormatterTime = DateFormatterTime(
localDateTimeProvider = localDateTimeProvider,
dateFormatters = dateFormatters,
),
dateFormatterTimeOnly = DateFormatterTimeOnly(
localDateTimeProvider = localDateTimeProvider,
dateFormatters = dateFormatters,
),
)
}

View file

@ -0,0 +1,21 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl.previews
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
class PreviewClock : Clock {
private var instant: Instant = Instant.fromEpochMilliseconds(0)
fun givenInstant(instant: Instant) {
this.instant = instant
}
override fun now(): Instant = instant
}

View file

@ -0,0 +1,29 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl.previews
import android.content.res.Resources
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import io.element.android.services.toolbox.api.strings.StringProvider
class PreviewStringProvider(
private val resources: Resources
) : StringProvider {
override fun getString(@StringRes resId: Int): String {
return resources.getString(resId)
}
override fun getString(@StringRes resId: Int, vararg formatArgs: Any?): String {
return resources.getString(resId, *formatArgs)
}
override fun getQuantityString(@PluralsRes resId: Int, quantity: Int, vararg formatArgs: Any?): String {
return resources.getQuantityString(resId, quantity, *formatArgs)
}
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="common_date_date_at_time">"%1$s v %2$s"</string>
<string name="common_date_this_month">"Tento měsíc"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="common_date_this_month">"Αυτό το μήνα"</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="common_date_this_month">"Sel kuul"</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="common_date_date_at_time">"%1$s à %2$s"</string>
<string name="common_date_this_month">"Ce mois-ci"</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="common_date_date_at_time">"%1$s itt: %2$s"</string>
<string name="common_date_this_month">"Ebben a hónapban"</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="common_date_date_at_time">"%1$s at %2$s"</string>
<string name="common_date_this_month">"This month"</string>
</resources>

View file

@ -0,0 +1,260 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import kotlinx.datetime.Instant
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
@Config(qualifiers = "fr")
class DefaultDateFormatterFrTest {
@Test
fun `test null`() {
val now = "1980-04-06T18:35:24.00Z"
val ts: Long? = null
val formatter = createFormatter(now)
assertThat(formatter.format(ts)).isEmpty()
}
@Test
fun `test epoch`() {
val now = "1980-04-06T18:35:24.00Z"
val ts = 0L
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("1 janvier 1970 à 00:00")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Janvier 1970")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("1 janvier 1970")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("01.01.1970")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("00:00")
}
@Test
fun `test epoch relative`() {
val now = "1980-04-06T18:35:24.00Z"
val ts = 0L
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("1 janvier 1970 à 00:00")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Janvier 1970")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("1 janvier 1970")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("01.01.1970")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("00:00")
}
@Test
fun `test now`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("6 avril 1980 à 18:35")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Avril 1980")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Dimanche 6 avril")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("18:35")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("18:35")
}
@Test
fun `test now relative`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("18:35")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Ce mois-ci")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Aujourdhui")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("18:35")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("18:35")
}
@Test
fun `test one second before`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:35:23.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("6 avril 1980 à 18:35")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Avril 1980")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Dimanche 6 avril")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("18:35")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("18:35")
}
@Test
fun `test one second before relative`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:35:23.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("18:35")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Ce mois-ci")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Aujourdhui")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("18:35")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("18:35")
}
@Test
fun `test one minute before`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:34:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("6 avril 1980 à 18:34")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Avril 1980")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Dimanche 6 avril")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("18:34")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("18:34")
}
@Test
fun `test one minute before relative`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:34:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("18:34")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Ce mois-ci")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Aujourdhui")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("18:34")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("18:34")
}
@Test
fun `test one hour before`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T17:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("6 avril 1980 à 17:35")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Avril 1980")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Dimanche 6 avril")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("17:35")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("17:35")
}
@Test
fun `test one hour before relative`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T17:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("17:35")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Ce mois-ci")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Aujourdhui")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("17:35")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("17:35")
}
@Test
fun `test one day before same time`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-05T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("5 avril 1980 à 18:35")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Avril 1980")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Samedi 5 avril")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("5 avr.")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("18:35")
}
@Test
fun `test one day before same time relative`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-05T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("Hier à 18:35")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Ce mois-ci")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Hier")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("Hier")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("18:35")
}
@Test
fun `test two days before same time`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-04T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("4 avril 1980 à 18:35")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Avril 1980")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Vendredi 4 avril")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("4 avr.")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("18:35")
}
@Test
fun `test two days before same time relative`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-04T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("Vendredi à 18:35")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Ce mois-ci")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Vendredi")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("4 avr.")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("18:35")
}
@Test
fun `test one month before same time`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-03-06T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("6 mars 1980 à 18:35")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Mars 1980")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Jeudi 6 mars")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("6 mars")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("18:35")
}
@Test
fun `test one month before same time relative`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-03-06T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("Jeudi 6 mars à 18:35")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Mars 1980")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Jeudi 6 mars")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("6 mars")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("18:35")
}
@Test
fun `test one year before same time`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1979-04-06T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("6 avril 1979 à 18:35")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("Avril 1979")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("6 avril 1979")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("06.04.1979")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("18:35")
}
@Test
fun `test one year before same time relative`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1979-04-06T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("6 avril 1979 à 18:35")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("Avril 1979")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("6 avril 1979")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("06.04.1979")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("18:35")
}
}

View file

@ -0,0 +1,260 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import kotlinx.datetime.Instant
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
@Config(qualifiers = "en")
class DefaultDateFormatterTest {
@Test
fun `test null`() {
val now = "1980-04-06T18:35:24.00Z"
val ts: Long? = null
val formatter = createFormatter(now)
assertThat(formatter.format(ts)).isEmpty()
}
@Test
fun `test epoch`() {
val now = "1980-04-06T18:35:24.00Z"
val ts = 0L
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("January 1, 1970 at 12:00AM")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("January 1970")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("January 1, 1970")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("01.01.1970")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("12:00AM")
}
@Test
fun `test epoch relative`() {
val now = "1980-04-06T18:35:24.00Z"
val ts = 0L
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("January 1, 1970 at 12:00AM")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("January 1970")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("January 1, 1970")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("01.01.1970")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("12:00AM")
}
@Test
fun `test now`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("April 6, 1980 at 6:35PM")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("April 1980")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Sunday 6 April")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("6:35PM")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("6:35PM")
}
@Test
fun `test now relative`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("6:35PM")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("This month")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Today")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("6:35PM")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("6:35PM")
}
@Test
fun `test one second before`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:35:23.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("April 6, 1980 at 6:35PM")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("April 1980")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Sunday 6 April")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("6:35PM")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("6:35PM")
}
@Test
fun `test one second before relative`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:35:23.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("6:35PM")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("This month")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Today")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("6:35PM")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("6:35PM")
}
@Test
fun `test one minute before`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:34:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("April 6, 1980 at 6:34PM")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("April 1980")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Sunday 6 April")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("6:34PM")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("6:34PM")
}
@Test
fun `test one minute before relative`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:34:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("6:34PM")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("This month")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Today")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("6:34PM")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("6:34PM")
}
@Test
fun `test one hour before`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T17:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("April 6, 1980 at 5:35PM")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("April 1980")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Sunday 6 April")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("5:35PM")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("5:35PM")
}
@Test
fun `test one hour before relative`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T17:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("5:35PM")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("This month")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Today")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("5:35PM")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("5:35PM")
}
@Test
fun `test one day before same time`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-05T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("April 5, 1980 at 6:35PM")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("April 1980")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Saturday 5 April")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("5 Apr")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("6:35PM")
}
@Test
fun `test one day before same time relative`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-05T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("Yesterday at 6:35PM")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("This month")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Yesterday")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("Yesterday")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("6:35PM")
}
@Test
fun `test two days before same time`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-04T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("April 4, 1980 at 6:35PM")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("April 1980")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Friday 4 April")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("4 Apr")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("6:35PM")
}
@Test
fun `test two days before same time relative`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-04T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("Friday at 6:35PM")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("This month")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Friday")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("4 Apr")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("6:35PM")
}
@Test
fun `test one month before same time`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-03-06T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("March 6, 1980 at 6:35PM")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("March 1980")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("Thursday 6 March")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("6 Mar")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("6:35PM")
}
@Test
fun `test one month before same time relative`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-03-06T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("Thursday 6 March at 6:35PM")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("March 1980")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("Thursday 6 March")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("6 Mar")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("6:35PM")
}
@Test
fun `test one year before same time`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1979-04-06T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full)).isEqualTo("April 6, 1979 at 6:35PM")
assertThat(formatter.format(ts, DateFormatterMode.Month)).isEqualTo("April 1979")
assertThat(formatter.format(ts, DateFormatterMode.Day)).isEqualTo("April 6, 1979")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate)).isEqualTo("06.04.1979")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly)).isEqualTo("6:35PM")
}
@Test
fun `test one year before same time relative`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1979-04-06T18:35:24.00Z"
val ts = Instant.parse(dat).toEpochMilliseconds()
val formatter = createFormatter(now)
assertThat(formatter.format(ts, DateFormatterMode.Full, true)).isEqualTo("April 6, 1979 at 6:35PM")
assertThat(formatter.format(ts, DateFormatterMode.Month, true)).isEqualTo("April 1979")
assertThat(formatter.format(ts, DateFormatterMode.Day, true)).isEqualTo("April 6, 1979")
assertThat(formatter.format(ts, DateFormatterMode.TimeOrDate, true)).isEqualTo("06.04.1979")
assertThat(formatter.format(ts, DateFormatterMode.TimeOnly, true)).isEqualTo("6:35PM")
}
}

View file

@ -1,109 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.test.FakeClock
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.junit.Test
import java.util.Locale
class DefaultLastMessageTimestampFormatterTest {
@Test
fun `test null`() {
val now = "1980-04-06T18:35:24.00Z"
val formatter = createFormatter(now)
assertThat(formatter.format(null)).isEmpty()
}
@Test
fun `test epoch`() {
val now = "1980-04-06T18:35:24.00Z"
val formatter = createFormatter(now)
assertThat(formatter.format(0)).isEqualTo("01.01.1970")
}
@Test
fun `test now`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:35:24.00Z"
val formatter = createFormatter(now)
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("6:35PM")
}
@Test
fun `test one second before`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:35:23.00Z"
val formatter = createFormatter(now)
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("6:35PM")
}
@Test
fun `test one minute before`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T18:34:24.00Z"
val formatter = createFormatter(now)
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("6:34PM")
}
@Test
fun `test one hour before`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-06T17:35:24.00Z"
val formatter = createFormatter(now)
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("5:35PM")
}
@Test
fun `test one day before same time`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-04-05T18:35:24.00Z"
val formatter = createFormatter(now)
// TODO DateUtils.getRelativeTimeSpanString returns null.
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("")
}
@Test
fun `test one month before same time`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1980-03-06T18:35:24.00Z"
val formatter = createFormatter(now)
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("6 Mar")
}
@Test
fun `test one year before same time`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1979-04-06T18:35:24.00Z"
val formatter = createFormatter(now)
assertThat(formatter.format(Instant.parse(dat).toEpochMilliseconds())).isEqualTo("06.04.1979")
}
@Test
fun `test full format`() {
val now = "1980-04-06T18:35:24.00Z"
val dat = "1979-04-06T18:35:24.00Z"
val clock = FakeClock().apply { givenInstant(Instant.parse(now)) }
val dateFormatters = DateFormatters(Locale.US, clock) { TimeZone.UTC }
assertThat(dateFormatters.formatDateWithFullFormat(Instant.parse(dat).toLocalDateTime(TimeZone.UTC))).isEqualTo("Friday, April 6, 1979")
}
/**
* Create DefaultLastMessageFormatter and set current time to the provided date.
*/
private fun createFormatter(@Suppress("SameParameterValue") currentDate: String): LastMessageTimestampFormatter {
val clock = FakeClock().apply { givenInstant(Instant.parse(currentDate)) }
val localDateTimeProvider = LocalDateTimeProvider(clock) { TimeZone.UTC }
val dateFormatters = DateFormatters(Locale.US, clock) { TimeZone.UTC }
return DefaultLastMessageTimestampFormatter(localDateTimeProvider, dateFormatters)
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.impl
import io.element.android.tests.testutils.InstrumentationStringProvider
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import java.util.Locale
/**
* Create DefaultDateFormatter and set current time to the provided date.
*/
fun createFormatter(currentDate: String): DefaultDateFormatter {
val clock = FakeClock().apply { givenInstant(Instant.parse(currentDate)) }
val localDateTimeProvider = LocalDateTimeProvider(clock) { TimeZone.UTC }
val dateFormatters = DateFormatters(
localeChangeObserver = {},
clock = clock,
timeZoneProvider = { TimeZone.UTC },
locale = Locale.getDefault(),
)
val stringProvider = InstrumentationStringProvider()
val dateFormatterDay = DefaultDateFormatterDay(
localDateTimeProvider = localDateTimeProvider,
dateFormatters = dateFormatters,
)
return DefaultDateFormatter(
dateFormatterFull = DateFormatterFull(
stringProvider = stringProvider,
localDateTimeProvider = localDateTimeProvider,
dateFormatters = dateFormatters,
dateFormatterDay = dateFormatterDay,
),
dateFormatterMonth = DateFormatterMonth(
stringProvider = stringProvider,
localDateTimeProvider = localDateTimeProvider,
dateFormatters = dateFormatters,
),
dateFormatterDay = dateFormatterDay,
dateFormatterTime = DateFormatterTime(
localDateTimeProvider = localDateTimeProvider,
dateFormatters = dateFormatters,
),
dateFormatterTimeOnly = DateFormatterTimeOnly(
localDateTimeProvider = localDateTimeProvider,
dateFormatters = dateFormatters,
),
)
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.test
package io.element.android.libraries.dateformatter.impl
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant

View file

@ -0,0 +1,25 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.test
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
class FakeDateFormatter(
private val formatLambda: (Long?, DateFormatterMode, Boolean) -> String = { timestamp, mode, useRelative ->
"$timestamp $mode $useRelative"
},
) : DateFormatter {
override fun format(
timestamp: Long?,
mode: DateFormatterMode,
useRelative: Boolean,
): String {
return formatLambda(timestamp, mode, useRelative)
}
}

View file

@ -1,22 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.test
import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter
class FakeDaySeparatorFormatter : DaySeparatorFormatter {
private var format = ""
fun givenFormat(format: String) {
this.format = format
}
override fun format(timestamp: Long): String {
return format
}
}

View file

@ -1,24 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.dateformatter.test
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
const val A_FORMATTED_DATE = "formatted_date"
class FakeLastMessageTimestampFormatter(
var format: String = "",
) : LastMessageTimestampFormatter {
fun givenFormat(format: String) {
this.format = format
}
override fun format(timestamp: Long?): String {
return format
}
}

View file

@ -189,7 +189,7 @@ internal fun WaveformPlaybackViewPreview() = ElementPreview {
showCursor = false,
playbackProgress = 0.5f,
onSeek = {},
waveform = persistentListOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f),
waveform = aWaveForm().toPersistentList(),
)
WaveformPlaybackView(
modifier = Modifier.height(34.dp),
@ -219,3 +219,45 @@ private fun ImmutableList<Float>.normalisedData(maxSamplesCount: Int): Immutable
return result.toPersistentList()
}
fun aWaveForm(): List<Float> {
return listOf(
0.000f,
0.000f,
0.000f,
0.003f,
0.354f,
0.353f,
0.365f,
0.790f,
0.787f,
0.167f,
0.333f,
0.975f,
0.000f,
0.102f,
0.003f,
0.531f,
0.584f,
0.317f,
0.140f,
0.475f,
0.496f,
0.561f,
0.042f,
0.263f,
0.169f,
0.829f,
0.349f,
0.010f,
0.000f,
0.000f,
1.000f,
0.334f,
0.321f,
0.011f,
0.000f,
0.000f,
0.003f,
)
}

View file

@ -13,9 +13,7 @@ import io.element.android.libraries.designsystem.R
// All the icons should be defined in Compound.
internal val iconsOther = listOf(
R.drawable.ic_cancel,
R.drawable.ic_developer_options,
R.drawable.ic_encryption_enabled,
R.drawable.ic_groups,
R.drawable.ic_notification_small,
R.drawable.ic_plus_composer,
R.drawable.ic_stop,

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M8.825,12L10.3,10.525C10.5,10.325 10.6,10.092 10.6,9.825C10.6,9.558 10.5,9.325 10.3,9.125C10.1,8.925 9.863,8.825 9.587,8.825C9.313,8.825 9.075,8.925 8.875,9.125L6.7,11.3C6.6,11.4 6.529,11.508 6.488,11.625C6.446,11.742 6.425,11.867 6.425,12C6.425,12.133 6.446,12.258 6.488,12.375C6.529,12.492 6.6,12.6 6.7,12.7L8.875,14.875C9.075,15.075 9.313,15.175 9.587,15.175C9.863,15.175 10.1,15.075 10.3,14.875C10.5,14.675 10.6,14.442 10.6,14.175C10.6,13.908 10.5,13.675 10.3,13.475L8.825,12ZM15.175,12L13.7,13.475C13.5,13.675 13.4,13.908 13.4,14.175C13.4,14.442 13.5,14.675 13.7,14.875C13.9,15.075 14.137,15.175 14.413,15.175C14.688,15.175 14.925,15.075 15.125,14.875L17.3,12.7C17.4,12.6 17.471,12.492 17.513,12.375C17.554,12.258 17.575,12.133 17.575,12C17.575,11.867 17.554,11.742 17.513,11.625C17.471,11.508 17.4,11.4 17.3,11.3L15.125,9.125C15.025,9.025 14.913,8.95 14.788,8.9C14.663,8.85 14.538,8.825 14.413,8.825C14.288,8.825 14.163,8.85 14.038,8.9C13.913,8.95 13.8,9.025 13.7,9.125C13.5,9.325 13.4,9.558 13.4,9.825C13.4,10.092 13.5,10.325 13.7,10.525L15.175,12ZM5,21C4.45,21 3.979,20.804 3.588,20.413C3.196,20.021 3,19.55 3,19V5C3,4.45 3.196,3.979 3.588,3.588C3.979,3.196 4.45,3 5,3H19C19.55,3 20.021,3.196 20.413,3.588C20.804,3.979 21,4.45 21,5V19C21,19.55 20.804,20.021 20.413,20.413C20.021,20.804 19.55,21 19,21H5ZM5,19H19V5H5V19Z"
android:fillColor="#1B1D22"/>
</vector>

View file

@ -1,15 +0,0 @@
<!--
~ Copyright 2023 New Vector Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only
~ Please see LICENSE in the repository root for full details.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M1.365,17.788C1.12,17.788 0.915,17.705 0.749,17.54C0.583,17.374 0.5,17.168 0.5,16.923V16.569C0.5,15.894 0.844,15.346 1.531,14.927C2.219,14.508 3.126,14.298 4.251,14.298C4.454,14.298 4.647,14.305 4.829,14.318C5.011,14.332 5.187,14.355 5.358,14.389C5.168,14.681 5.028,14.995 4.94,15.332C4.852,15.668 4.808,16.024 4.808,16.4V17.788H1.365ZM7.408,17.788C7.147,17.788 6.931,17.702 6.759,17.529C6.586,17.355 6.5,17.141 6.5,16.885V16.438C6.5,15.483 7.006,14.713 8.018,14.128C9.03,13.543 10.356,13.25 11.996,13.25C13.651,13.25 14.982,13.543 15.989,14.128C16.996,14.713 17.5,15.483 17.5,16.438V16.885C17.5,17.141 17.413,17.355 17.24,17.529C17.067,17.702 16.852,17.788 16.596,17.788H7.408ZM19.192,17.788V16.4C19.192,16.024 19.145,15.668 19.05,15.332C18.955,14.995 18.819,14.681 18.642,14.389C18.813,14.355 18.989,14.332 19.17,14.318C19.352,14.305 19.545,14.298 19.75,14.298C20.875,14.298 21.781,14.508 22.469,14.927C23.156,15.346 23.5,15.894 23.5,16.569V16.923C23.5,17.168 23.417,17.374 23.251,17.54C23.085,17.705 22.88,17.788 22.635,17.788H19.192ZM12,14.75C10.954,14.75 10.059,14.892 9.315,15.176C8.572,15.46 8.159,15.799 8.077,16.192V16.288H15.923V16.192C15.831,15.788 15.415,15.447 14.677,15.168C13.938,14.889 13.046,14.75 12,14.75ZM4.25,13.327C3.777,13.327 3.375,13.159 3.044,12.824C2.713,12.489 2.548,12.086 2.548,11.615C2.548,11.145 2.713,10.742 3.044,10.407C3.375,10.071 3.777,9.904 4.25,9.904C4.723,9.904 5.127,10.071 5.461,10.407C5.794,10.742 5.961,11.145 5.961,11.615C5.961,12.086 5.794,12.489 5.459,12.824C5.124,13.159 4.721,13.327 4.25,13.327ZM19.75,13.327C19.277,13.327 18.873,13.159 18.539,12.824C18.205,12.489 18.038,12.086 18.038,11.615C18.038,11.145 18.206,10.742 18.541,10.407C18.876,10.071 19.279,9.904 19.75,9.904C20.223,9.904 20.625,10.071 20.956,10.407C21.286,10.742 21.452,11.145 21.452,11.615C21.452,12.086 21.286,12.489 20.956,12.824C20.625,13.159 20.223,13.327 19.75,13.327ZM12,12.5C11.279,12.5 10.666,12.248 10.161,11.743C9.656,11.238 9.404,10.625 9.404,9.904C9.404,9.183 9.656,8.57 10.161,8.065C10.666,7.56 11.279,7.308 12,7.308C12.721,7.308 13.334,7.56 13.839,8.065C14.344,8.57 14.596,9.183 14.596,9.904C14.596,10.625 14.344,11.238 13.839,11.743C13.334,12.248 12.721,12.5 12,12.5ZM12.002,8.808C11.691,8.808 11.431,8.913 11.22,9.122C11.009,9.332 10.904,9.592 10.904,9.902C10.904,10.212 11.009,10.473 11.218,10.684C11.428,10.895 11.688,11 11.998,11C12.308,11 12.569,10.895 12.78,10.685C12.991,10.476 13.096,10.216 13.096,9.906C13.096,9.595 12.991,9.335 12.781,9.124C12.572,8.913 12.312,8.808 12.002,8.808Z" />
</vector>

View file

@ -161,4 +161,11 @@ enum class FeatureFlags(
defaultValue = { buildMeta -> buildMeta.buildType != BuildType.RELEASE },
isFinished = false,
),
EventCache(
key = "feature.event_cache",
title = "Use SDK Event cache",
description = "Warning: you must kill and restart the app for the change to take effect.",
defaultValue = { false },
isFinished = false,
),
}

View file

@ -12,4 +12,5 @@ enum class CurrentUserMembership {
JOINED,
LEFT,
KNOCKED,
BANNED,
}

View file

@ -241,6 +241,11 @@ interface MatrixRoom : Closeable {
*/
suspend fun setUnreadFlag(isUnread: Boolean): Result<Unit>
/**
* Clear the event cache storage for the current room.
*/
suspend fun clearEventCacheStorage(): Result<Unit>
/**
* Share a location message in the room.
*

View file

@ -7,6 +7,8 @@
package io.element.android.libraries.matrix.api.roomlist
import io.element.android.libraries.core.extensions.withoutAccents
sealed interface RoomListFilter {
companion object {
/**
@ -73,5 +75,7 @@ sealed interface RoomListFilter {
*/
data class NormalizedMatchRoomName(
val pattern: String
) : RoomListFilter
) : RoomListFilter {
val normalizedPattern: String = pattern.withoutAccents()
}
}

View file

@ -15,10 +15,17 @@ enum class UtdCause {
UnknownDevice,
/**
* Expected utd because this is a device-historical message and
* key storage is not setup or not configured correctly.
* We are missing the keys for this event, but it is a "device-historical" message and
* there is no key storage backup on the server, presumably because the user has turned it off.
*/
HistoricalMessage,
HistoricalMessageAndBackupIsDisabled,
/**
* We are missing the keys for this event, but it is a "device-historical"
* message, and even though a key storage backup does exist, we can't use
* it because our device is unverified.
*/
HistoricalMessageAndDeviceIsUnverified,
/**
* The key was withheld on purpose because your device is insecure and/or the

View file

@ -109,9 +109,7 @@ class RustMatrixClientFactory @Inject constructor(
.addRootCertificates(userCertificatesProvider.provides())
.autoEnableBackups(true)
.autoEnableCrossSigning(true)
// TODO Add a feature flag to enable persistent storage
// See https://github.com/matrix-org/matrix-rust-sdk/pull/4396
.useEventCachePersistentStorage(false)
.useEventCachePersistentStorage(featureFlagService.isFeatureEnabled(FeatureFlags.EventCache))
.roomKeyRecipientStrategy(
strategy = if (featureFlagService.isFeatureEnabled(FeatureFlags.OnlySignedDeviceIsolationMode)) {
CollectStrategy.IdentityBasedStrategy

View file

@ -27,7 +27,9 @@ class UtdTracker(
UtdCause.UNKNOWN_DEVICE -> {
Error.Name.ExpectedSentByInsecureDevice
}
UtdCause.HISTORICAL_MESSAGE -> Error.Name.HistoricalMessage
UtdCause.HISTORICAL_MESSAGE_AND_BACKUP_IS_DISABLED,
UtdCause.HISTORICAL_MESSAGE_AND_DEVICE_IS_UNVERIFIED,
-> Error.Name.HistoricalMessage
UtdCause.WITHHELD_FOR_UNVERIFIED_OR_INSECURE_DEVICE -> Error.Name.RoomKeysWithheldForUnverifiedDevice
UtdCause.WITHHELD_BY_SENDER -> Error.Name.OlmKeysNotSentError
}
@ -39,6 +41,10 @@ class UtdTracker(
timeToDecryptMillis = info.timeToDecryptMs?.toInt() ?: -1,
domain = Error.Domain.E2EE,
name = name,
eventLocalAgeMillis = info.eventLocalAgeMillis.toInt(),
userTrustsOwnIdentity = info.userTrustsOwnIdentity,
isFederated = info.ownHomeserver != info.senderHomeserver,
isMatrixDotOrg = info.ownHomeserver == "matrix.org",
)
analyticsService.capture(event)
}

View file

@ -69,6 +69,7 @@ fun RustMembership.map(): CurrentUserMembership = when (this) {
RustMembership.JOINED -> CurrentUserMembership.JOINED
RustMembership.LEFT -> CurrentUserMembership.LEFT
Membership.KNOCKED -> CurrentUserMembership.KNOCKED
RustMembership.BANNED -> CurrentUserMembership.BANNED
}
fun RustRoomNotificationMode.map(): RoomNotificationMode = when (this) {

View file

@ -248,7 +248,7 @@ class RustMatrixRoom(
RoomMessageEventMessageType.VIDEO,
RoomMessageEventMessageType.AUDIO,
),
dateDividerMode = DateDividerMode.DAILY,
dateDividerMode = DateDividerMode.MONTHLY,
).let { inner ->
createTimeline(inner, mode = Timeline.Mode.MEDIA)
}
@ -582,6 +582,12 @@ class RustMatrixRoom(
}
}
override suspend fun clearEventCacheStorage(): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.clearEventCacheStorage()
}
}
override suspend fun kickUser(userId: UserId, reason: String?): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.kickUser(userId.value, reason)

View file

@ -7,6 +7,7 @@
package io.element.android.libraries.matrix.impl.roomlist
import io.element.android.libraries.core.extensions.withoutAccents
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
@ -30,7 +31,7 @@ val RoomListFilter.predicate
!roomSummary.isInvited() && (roomSummary.info.numUnreadNotifications > 0 || roomSummary.info.isMarkedUnread)
}
is RoomListFilter.NormalizedMatchRoomName -> { roomSummary: RoomSummary ->
roomSummary.info.name.orEmpty().contains(pattern, ignoreCase = true)
roomSummary.info.name?.withoutAccents().orEmpty().contains(normalizedPattern, ignoreCase = true)
}
RoomListFilter.Invite -> { roomSummary: RoomSummary ->
roomSummary.isInvited()

View file

@ -145,7 +145,8 @@ private fun RustUtdCause.map(): UtdCause {
RustUtdCause.VERIFICATION_VIOLATION -> UtdCause.VerificationViolation
RustUtdCause.UNSIGNED_DEVICE -> UtdCause.UnsignedDevice
RustUtdCause.UNKNOWN_DEVICE -> UtdCause.UnknownDevice
RustUtdCause.HISTORICAL_MESSAGE -> UtdCause.HistoricalMessage
RustUtdCause.HISTORICAL_MESSAGE_AND_BACKUP_IS_DISABLED -> UtdCause.HistoricalMessageAndBackupIsDisabled
RustUtdCause.HISTORICAL_MESSAGE_AND_DEVICE_IS_UNVERIFIED -> UtdCause.HistoricalMessageAndDeviceIsUnverified
RustUtdCause.WITHHELD_FOR_UNVERIFIED_OR_INSECURE_DEVICE -> UtdCause.WithheldUnverifiedOrInsecureDevice
RustUtdCause.WITHHELD_BY_SENDER -> UtdCause.WithheldBySender
}

View file

@ -9,10 +9,10 @@ package io.element.android.libraries.matrix.impl.analytics
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Error
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUnableToDecryptInfo
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.services.analytics.test.FakeAnalyticsService
import org.junit.Test
import org.matrix.rustcomponents.sdk.UnableToDecryptInfo
import uniffi.matrix_sdk_crypto.UtdCause
class UtdTrackerTest {
@ -21,10 +21,11 @@ class UtdTrackerTest {
val fakeAnalyticsService = FakeAnalyticsService()
val sut = UtdTracker(fakeAnalyticsService)
sut.onUtd(
UnableToDecryptInfo(
aRustUnableToDecryptInfo(
eventId = AN_EVENT_ID.value,
timeToDecryptMs = null,
cause = UtdCause.UNKNOWN,
eventLocalAgeMillis = 100L,
)
)
assertThat(fakeAnalyticsService.capturedEvents).containsExactly(
@ -34,7 +35,11 @@ class UtdTrackerTest {
cryptoSDK = Error.CryptoSDK.Rust,
timeToDecryptMillis = -1,
domain = Error.Domain.E2EE,
name = Error.Name.OlmKeysNotSentError
name = Error.Name.OlmKeysNotSentError,
isFederated = false,
isMatrixDotOrg = false,
userTrustsOwnIdentity = false,
eventLocalAgeMillis = 100,
)
)
assertThat(fakeAnalyticsService.screenEvents).isEmpty()
@ -46,7 +51,7 @@ class UtdTrackerTest {
val fakeAnalyticsService = FakeAnalyticsService()
val sut = UtdTracker(fakeAnalyticsService)
sut.onUtd(
UnableToDecryptInfo(
aRustUnableToDecryptInfo(
eventId = AN_EVENT_ID.value,
timeToDecryptMs = 123.toULong(),
cause = UtdCause.UNKNOWN,
@ -59,7 +64,11 @@ class UtdTrackerTest {
cryptoSDK = Error.CryptoSDK.Rust,
timeToDecryptMillis = 123,
domain = Error.Domain.E2EE,
name = Error.Name.OlmKeysNotSentError
name = Error.Name.OlmKeysNotSentError,
isFederated = false,
isMatrixDotOrg = false,
userTrustsOwnIdentity = false,
eventLocalAgeMillis = 0,
)
)
assertThat(fakeAnalyticsService.screenEvents).isEmpty()
@ -71,7 +80,7 @@ class UtdTrackerTest {
val fakeAnalyticsService = FakeAnalyticsService()
val sut = UtdTracker(fakeAnalyticsService)
sut.onUtd(
UnableToDecryptInfo(
aRustUnableToDecryptInfo(
eventId = AN_EVENT_ID.value,
timeToDecryptMs = 123.toULong(),
cause = UtdCause.SENT_BEFORE_WE_JOINED,
@ -84,7 +93,11 @@ class UtdTrackerTest {
cryptoSDK = Error.CryptoSDK.Rust,
timeToDecryptMillis = 123,
domain = Error.Domain.E2EE,
name = Error.Name.ExpectedDueToMembership
name = Error.Name.ExpectedDueToMembership,
isFederated = false,
isMatrixDotOrg = false,
userTrustsOwnIdentity = false,
eventLocalAgeMillis = 0,
)
)
assertThat(fakeAnalyticsService.screenEvents).isEmpty()
@ -96,7 +109,7 @@ class UtdTrackerTest {
val fakeAnalyticsService = FakeAnalyticsService()
val sut = UtdTracker(fakeAnalyticsService)
sut.onUtd(
UnableToDecryptInfo(
aRustUnableToDecryptInfo(
eventId = AN_EVENT_ID.value,
timeToDecryptMs = 123.toULong(),
cause = UtdCause.UNSIGNED_DEVICE,
@ -109,7 +122,11 @@ class UtdTrackerTest {
cryptoSDK = Error.CryptoSDK.Rust,
timeToDecryptMillis = 123,
domain = Error.Domain.E2EE,
name = Error.Name.ExpectedSentByInsecureDevice
name = Error.Name.ExpectedSentByInsecureDevice,
isFederated = false,
isMatrixDotOrg = false,
userTrustsOwnIdentity = false,
eventLocalAgeMillis = 0,
)
)
}
@ -119,7 +136,7 @@ class UtdTrackerTest {
val fakeAnalyticsService = FakeAnalyticsService()
val sut = UtdTracker(fakeAnalyticsService)
sut.onUtd(
UnableToDecryptInfo(
aRustUnableToDecryptInfo(
eventId = AN_EVENT_ID.value,
timeToDecryptMs = 123.toULong(),
cause = UtdCause.VERIFICATION_VIOLATION,
@ -132,7 +149,90 @@ class UtdTrackerTest {
cryptoSDK = Error.CryptoSDK.Rust,
timeToDecryptMillis = 123,
domain = Error.Domain.E2EE,
name = Error.Name.ExpectedVerificationViolation
name = Error.Name.ExpectedVerificationViolation,
isFederated = false,
isMatrixDotOrg = false,
userTrustsOwnIdentity = false,
eventLocalAgeMillis = 0,
)
)
}
@Test
fun `when onUtd is called with different sender and receiver servers, the expected analytics Event is sent`() {
val fakeAnalyticsService = FakeAnalyticsService()
val sut = UtdTracker(fakeAnalyticsService)
sut.onUtd(
aRustUnableToDecryptInfo(
eventId = AN_EVENT_ID.value,
ownHomeserver = "example.com",
senderHomeserver = "matrix.org",
)
)
assertThat(fakeAnalyticsService.capturedEvents).containsExactly(
Error(
context = null,
cryptoModule = Error.CryptoModule.Rust,
cryptoSDK = Error.CryptoSDK.Rust,
timeToDecryptMillis = -1,
domain = Error.Domain.E2EE,
name = Error.Name.OlmKeysNotSentError,
isFederated = true,
isMatrixDotOrg = false,
userTrustsOwnIdentity = false,
eventLocalAgeMillis = 0,
)
)
}
@Test
fun `when onUtd is called from a matrix-org user, the expected analytics Event is sent`() {
val fakeAnalyticsService = FakeAnalyticsService()
val sut = UtdTracker(fakeAnalyticsService)
sut.onUtd(
aRustUnableToDecryptInfo(
eventId = AN_EVENT_ID.value,
ownHomeserver = "matrix.org",
)
)
assertThat(fakeAnalyticsService.capturedEvents).containsExactly(
Error(
context = null,
cryptoModule = Error.CryptoModule.Rust,
cryptoSDK = Error.CryptoSDK.Rust,
timeToDecryptMillis = -1,
domain = Error.Domain.E2EE,
name = Error.Name.OlmKeysNotSentError,
isFederated = true,
isMatrixDotOrg = true,
userTrustsOwnIdentity = false,
eventLocalAgeMillis = 0,
)
)
}
@Test
fun `when onUtd is called from a verified device, the expected analytics Event is sent`() {
val fakeAnalyticsService = FakeAnalyticsService()
val sut = UtdTracker(fakeAnalyticsService)
sut.onUtd(
aRustUnableToDecryptInfo(
eventId = AN_EVENT_ID.value,
userTrustsOwnIdentity = true,
)
)
assertThat(fakeAnalyticsService.capturedEvents).containsExactly(
Error(
context = null,
cryptoModule = Error.CryptoModule.Rust,
cryptoSDK = Error.CryptoSDK.Rust,
timeToDecryptMillis = -1,
domain = Error.Domain.E2EE,
name = Error.Name.OlmKeysNotSentError,
isFederated = false,
isMatrixDotOrg = false,
userTrustsOwnIdentity = true,
eventLocalAgeMillis = 0,
)
)
}

View file

@ -0,0 +1,31 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.fixtures.factories
import org.matrix.rustcomponents.sdk.UnableToDecryptInfo
import uniffi.matrix_sdk_crypto.UtdCause
internal fun aRustUnableToDecryptInfo(
eventId: String,
timeToDecryptMs: ULong? = null,
cause: UtdCause = UtdCause.UNKNOWN,
eventLocalAgeMillis: Long = 0L,
userTrustsOwnIdentity: Boolean = false,
senderHomeserver: String = "",
ownHomeserver: String = "",
): UnableToDecryptInfo {
return UnableToDecryptInfo(
eventId = eventId,
timeToDecryptMs = timeToDecryptMs,
cause = cause,
eventLocalAgeMillis = eventLocalAgeMillis,
userTrustsOwnIdentity = userTrustsOwnIdentity,
senderHomeserver = senderHomeserver,
ownHomeserver = ownHomeserver,
)
}

View file

@ -34,6 +34,9 @@ class RoomListFilterTest {
private val roomToSearch = aRoomSummary(
name = "Room to search"
)
private val roomWithAccent = aRoomSummary(
name = "Frédéric"
)
private val invitedRoom = aRoomSummary(
currentUserMembership = CurrentUserMembership.INVITED
)
@ -45,6 +48,7 @@ class RoomListFilterTest {
markedAsUnreadRoom,
unreadNotificationRoom,
roomToSearch,
roomWithAccent,
invitedRoom
)
@ -69,7 +73,14 @@ class RoomListFilterTest {
@Test
fun `Room list filter group`() = runTest {
val filter = RoomListFilter.Category.Group
assertThat(roomSummaries.filter(filter)).containsExactly(regularRoom, favoriteRoom, markedAsUnreadRoom, unreadNotificationRoom, roomToSearch)
assertThat(roomSummaries.filter(filter)).containsExactly(
regularRoom,
favoriteRoom,
markedAsUnreadRoom,
unreadNotificationRoom,
roomToSearch,
roomWithAccent,
)
}
@Test
@ -96,6 +107,18 @@ class RoomListFilterTest {
assertThat(roomSummaries.filter(filter)).containsExactly(roomToSearch)
}
@Test
fun `Room list filter normalized match room name with accent`() = runTest {
val filter = RoomListFilter.NormalizedMatchRoomName("Fred")
assertThat(roomSummaries.filter(filter)).containsExactly(roomWithAccent)
}
@Test
fun `Room list filter normalized match room name with accent when searching with accent`() = runTest {
val filter = RoomListFilter.NormalizedMatchRoomName("Fréd")
assertThat(roomSummaries.filter(filter)).containsExactly(roomWithAccent)
}
@Test
fun `Room list filter all with one match`() = runTest {
val filter = RoomListFilter.all(

View file

@ -585,6 +585,10 @@ class FakeMatrixRoom(
fun givenRoomMembersState(state: MatrixRoomMembersState) {
membersStateFlow.value = state
}
override suspend fun clearEventCacheStorage(): Result<Unit> {
return Result.success(Unit)
}
}
fun defaultRoomPowerLevels() = MatrixRoomPowerLevels(

View file

@ -15,7 +15,6 @@ import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@ -36,6 +35,7 @@ import kotlin.time.Duration.Companion.seconds
@SingleIn(RoomScope::class)
class DefaultMediaPlayer @Inject constructor(
private val player: SimplePlayer,
private val coroutineScope: CoroutineScope,
) : MediaPlayer {
private val listener = object : SimplePlayer.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
@ -47,7 +47,7 @@ class DefaultMediaPlayer @Inject constructor(
)
}
if (isPlaying) {
job = scope.launch { updateCurrentPosition() }
job = coroutineScope.launch { updateCurrentPosition() }
} else {
job?.cancel()
}
@ -79,7 +79,6 @@ class DefaultMediaPlayer @Inject constructor(
player.addListener(listener)
}
private val scope = CoroutineScope(Job() + Dispatchers.Main)
private var job: Job? = null
private val _state = MutableStateFlow(
@ -102,7 +101,8 @@ class DefaultMediaPlayer @Inject constructor(
mimeType: String,
startPositionMs: Long,
): MediaPlayer.State {
player.pause() // Must pause here otherwise if the player was playing it would keep on playing the new media item.
// Must pause here otherwise if the player was playing it would keep on playing the new media item.
player.pause()
player.clearMediaItems()
player.setMediaItem(
MediaItem.Builder()
@ -129,11 +129,9 @@ class DefaultMediaPlayer @Inject constructor(
player.getCurrentMediaItem()?.let {
player.setMediaItem(it, 0)
player.prepare()
player.play()
}
} else {
player.play()
}
player.play()
}
override fun pause() {

View file

@ -7,12 +7,396 @@
package io.element.android.libraries.mediaplayer.impl
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertThrows
import org.junit.Test
class DefaultMediaPlayerTest {
private val aMediaId = "mediaId"
private val aMediaItem = MediaItem.Builder().setMediaId(aMediaId).build()
@Test
fun `default test`() = runTest {
// TODO
fun `initial state`() = runTest {
val sut = createDefaultMediaPlayer()
sut.state.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
MediaPlayer.State(
isReady = false,
isPlaying = false,
isEnded = false,
mediaId = null,
currentPosition = 0,
duration = null,
)
)
}
}
@Test
fun `start player will update the current position and pause it will stop`() = runTest {
val playLambda = lambdaRecorder<Unit> { }
val pauseLambda = lambdaRecorder<Unit> { }
val player = FakeSimplePlayer(
playLambda = playLambda,
pauseLambda = pauseLambda,
)
val sut = createDefaultMediaPlayer(
simplePlayer = player,
)
sut.state.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
MediaPlayer.State(
isReady = false,
isPlaying = false,
isEnded = false,
mediaId = null,
currentPosition = 0,
duration = null,
)
)
sut.play()
playLambda.assertions().isCalledOnce()
player.durationResult = 123L
player.simulateIsPlayingChanged(true)
val playingState = awaitItem()
assertThat(playingState).isEqualTo(
MediaPlayer.State(
isReady = false,
isPlaying = true,
isEnded = false,
mediaId = null,
currentPosition = 0,
duration = 123,
)
)
player.currentPositionResult = 1L
assertThat(awaitItem()).isEqualTo(
MediaPlayer.State(
isReady = false,
isPlaying = true,
isEnded = false,
mediaId = null,
currentPosition = 1,
duration = 123,
)
)
player.currentPositionResult = 2L
assertThat(awaitItem()).isEqualTo(
MediaPlayer.State(
isReady = false,
isPlaying = true,
isEnded = false,
mediaId = null,
currentPosition = 2,
duration = 123,
)
)
player.pause()
pauseLambda.assertions().isCalledOnce()
player.simulateIsPlayingChanged(false)
assertThat(awaitItem()).isEqualTo(
MediaPlayer.State(
isReady = false,
isPlaying = false,
isEnded = false,
mediaId = null,
currentPosition = 2,
duration = 123,
)
)
}
}
@Test
fun `start player on ended playback will not invoke more methods if current media item is null`() = runTest {
val playLambda = lambdaRecorder<Unit> { }
val getCurrentMediaItemLambda = lambdaRecorder<MediaItem?> { null }
val player = FakeSimplePlayer(
playLambda = playLambda,
getCurrentMediaItemLambda = getCurrentMediaItemLambda,
)
val sut = createDefaultMediaPlayer(
simplePlayer = player,
)
sut.state.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
MediaPlayer.State(
isReady = false,
isPlaying = false,
isEnded = false,
mediaId = null,
currentPosition = 0,
duration = null,
)
)
player.playbackStateResult = Player.STATE_ENDED
sut.play()
playLambda.assertions().isCalledOnce()
}
}
@Test
fun `start player on ended playback will invoke more methods if current media item is not null`() = runTest {
val playLambda = lambdaRecorder<Unit> { }
val prepareLambda = lambdaRecorder<Unit> { }
val getCurrentMediaItemLambda = lambdaRecorder<MediaItem?> { aMediaItem }
val setMediaItemLambda = lambdaRecorder<MediaItem, Long, Unit> { _, _ -> }
val player = FakeSimplePlayer(
playLambda = playLambda,
prepareLambda = prepareLambda,
setMediaItemLambda = setMediaItemLambda,
getCurrentMediaItemLambda = getCurrentMediaItemLambda,
)
val sut = createDefaultMediaPlayer(
simplePlayer = player,
)
sut.state.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
MediaPlayer.State(
isReady = false,
isPlaying = false,
isEnded = false,
mediaId = null,
currentPosition = 0,
duration = null,
)
)
player.playbackStateResult = Player.STATE_ENDED
sut.play()
setMediaItemLambda.assertions().isCalledOnce().with(
value(aMediaItem),
value(0L),
)
prepareLambda.assertions().isCalledOnce()
playLambda.assertions().isCalledOnce()
}
}
@Test
fun `pause player invokes pause on the embedded player`() = runTest {
val pauseLambda = lambdaRecorder<Unit> { }
val player = FakeSimplePlayer(
pauseLambda = pauseLambda,
)
val sut = createDefaultMediaPlayer(
simplePlayer = player,
)
sut.pause()
pauseLambda.assertions().isCalledOnce()
}
@Test
fun `close player invokes release on the embedded player`() = runTest {
val releaseLambda = lambdaRecorder<Unit> { }
val player = FakeSimplePlayer(
releaseLambda = releaseLambda,
)
val sut = createDefaultMediaPlayer(
simplePlayer = player,
)
sut.close()
releaseLambda.assertions().isCalledOnce()
}
@Test
fun `seekTo invokes release on the embedded player`() = runTest {
val seekToLambda = lambdaRecorder<Long, Unit> { }
val player = FakeSimplePlayer(
seekToLambda = seekToLambda,
)
val sut = createDefaultMediaPlayer(
simplePlayer = player,
)
sut.state.test {
awaitItem()
player.currentPositionResult = 33L
sut.seekTo(33L)
seekToLambda.assertions().isCalledOnce().with(value(33L))
val finalState = awaitItem()
assertThat(finalState).isEqualTo(
MediaPlayer.State(
isReady = false,
isPlaying = false,
isEnded = false,
mediaId = null,
currentPosition = 33L,
duration = null,
)
)
}
}
@Test
fun `onPlaybackStateChanged update the state`() = runTest {
val player = FakeSimplePlayer()
val sut = createDefaultMediaPlayer(
simplePlayer = player,
)
sut.state.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
MediaPlayer.State(
isReady = false,
isPlaying = false,
isEnded = false,
mediaId = null,
currentPosition = 0,
duration = null,
)
)
player.currentPositionResult = 44
player.durationResult = 123L
player.simulatePlaybackStateChanged(Player.STATE_READY)
val readyState = awaitItem()
assertThat(readyState).isEqualTo(
MediaPlayer.State(
isReady = true,
isPlaying = false,
isEnded = false,
mediaId = null,
currentPosition = 44,
duration = 123,
)
)
player.simulatePlaybackStateChanged(Player.STATE_ENDED)
val endedState = awaitItem()
assertThat(endedState).isEqualTo(
MediaPlayer.State(
isReady = false,
isPlaying = false,
isEnded = true,
mediaId = null,
currentPosition = 44,
duration = 123,
)
)
}
}
@Test
fun `setMedia with timeout error`() = runTest {
val pauseLambda = lambdaRecorder<Unit> { }
val clearMediaItemsLambda = lambdaRecorder<Unit> { }
val setMediaItemLambda = lambdaRecorder<MediaItem, Long, Unit> { _, _ -> }
val prepareLambda = lambdaRecorder<Unit> { }
val player = FakeSimplePlayer(
pauseLambda = pauseLambda,
clearMediaItemsLambda = clearMediaItemsLambda,
setMediaItemLambda = setMediaItemLambda,
prepareLambda = prepareLambda,
)
val sut = createDefaultMediaPlayer(
simplePlayer = player,
)
sut.state.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
MediaPlayer.State(
isReady = false,
isPlaying = false,
isEnded = false,
mediaId = null,
currentPosition = 0,
duration = null,
)
)
val result = runCatching {
sut.setMedia("uri", "mediaId", "mimeType", 12)
}
pauseLambda.assertions().isCalledOnce()
clearMediaItemsLambda.assertions().isCalledOnce()
setMediaItemLambda.assertions().isCalledOnce().with(
value(MediaItem.Builder().setUri("uri").setMediaId("mediaId").setMimeType("mimeType").build()),
value(12L),
)
prepareLambda.assertions().isCalledOnce()
assertThat(result.isFailure).isTrue()
assertThrows(TimeoutCancellationException::class.java) {
result.getOrThrow()
}
}
}
@Test
fun `setMedia success`() = runTest {
var player: FakeSimplePlayer? = null
val pauseLambda = lambdaRecorder<Unit> { }
val clearMediaItemsLambda = lambdaRecorder<Unit> { }
val setMediaItemLambda = lambdaRecorder<MediaItem, Long, Unit> { _, _ -> }
val prepareLambda = lambdaRecorder<Unit> {
player?.simulatePlaybackStateChanged(Player.STATE_READY)
player?.simulateMediaItemTransition(aMediaItem)
}
player = FakeSimplePlayer(
pauseLambda = pauseLambda,
clearMediaItemsLambda = clearMediaItemsLambda,
setMediaItemLambda = setMediaItemLambda,
prepareLambda = prepareLambda,
)
val sut = createDefaultMediaPlayer(
simplePlayer = player,
)
sut.state.test {
val initialState = awaitItem()
assertThat(initialState).isEqualTo(
MediaPlayer.State(
isReady = false,
isPlaying = false,
isEnded = false,
mediaId = null,
currentPosition = 0,
duration = null,
)
)
val state = sut.setMedia("uri", "mediaId", "mimeType", 12)
pauseLambda.assertions().isCalledOnce()
clearMediaItemsLambda.assertions().isCalledOnce()
setMediaItemLambda.assertions().isCalledOnce().with(
value(MediaItem.Builder().setUri("uri").setMediaId("mediaId").setMimeType("mimeType").build()),
value(12L),
)
prepareLambda.assertions().isCalledOnce()
val finalState = MediaPlayer.State(
isReady = true,
isPlaying = false,
isEnded = false,
mediaId = "mediaId",
currentPosition = 0,
duration = 0,
)
assertThat(awaitItem()).isEqualTo(
MediaPlayer.State(
isReady = true,
isPlaying = false,
isEnded = false,
mediaId = null,
currentPosition = 0,
duration = 0,
)
)
assertThat(awaitItem()).isEqualTo(finalState)
assertThat(state).isEqualTo(finalState)
}
}
private fun TestScope.createDefaultMediaPlayer(
simplePlayer: SimplePlayer = FakeSimplePlayer(),
): DefaultMediaPlayer = DefaultMediaPlayer(
simplePlayer,
backgroundScope,
)
}

View file

@ -0,0 +1,58 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaplayer.impl
import androidx.media3.common.MediaItem
import io.element.android.tests.testutils.lambda.lambdaError
class FakeSimplePlayer(
private val clearMediaItemsLambda: () -> Unit = { lambdaError() },
private val setMediaItemLambda: (MediaItem, Long) -> Unit = { _, _ -> lambdaError() },
private val getCurrentMediaItemLambda: () -> MediaItem? = { lambdaError() },
private val prepareLambda: () -> Unit = { lambdaError() },
private val playLambda: () -> Unit = { lambdaError() },
private val pauseLambda: () -> Unit = { lambdaError() },
private val seekToLambda: (Long) -> Unit = { lambdaError() },
private val releaseLambda: () -> Unit = { lambdaError() },
) : SimplePlayer {
private val listeners = mutableListOf<SimplePlayer.Listener>()
override fun addListener(listener: SimplePlayer.Listener) {
listeners.add(listener)
}
var currentPositionResult: Long = 0
override val currentPosition: Long get() = currentPositionResult
var playbackStateResult: Int = 0
override val playbackState: Int get() = playbackStateResult
var durationResult: Long = 0
override val duration: Long get() = durationResult
override fun clearMediaItems() = clearMediaItemsLambda()
override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) {
setMediaItemLambda(mediaItem, startPositionMs)
}
override fun getCurrentMediaItem(): MediaItem? = getCurrentMediaItemLambda()
override fun prepare() = prepareLambda()
override fun play() = playLambda()
override fun pause() = pauseLambda()
override fun seekTo(positionMs: Long) = seekToLambda(positionMs)
override fun release() = releaseLambda()
fun simulateIsPlayingChanged(isPlaying: Boolean) {
listeners.forEach { it.onIsPlayingChanged(isPlaying) }
}
fun simulateMediaItemTransition(mediaItem: MediaItem?) {
listeners.forEach { it.onMediaItemTransition(mediaItem) }
}
fun simulatePlaybackStateChanged(playbackState: Int) {
listeners.forEach { it.onPlaybackStateChanged(playbackState) }
}
}

View file

@ -23,6 +23,8 @@ data class MediaInfo(
val senderName: String?,
val senderAvatar: String?,
val dateSent: String?,
val dateSentFull: String?,
val waveform: List<Float>?,
) : Parcelable
fun anImageMediaInfo(
@ -30,6 +32,7 @@ fun anImageMediaInfo(
caption: String? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
): MediaInfo = MediaInfo(
filename = "an image file.jpg",
caption = caption,
@ -40,12 +43,15 @@ fun anImageMediaInfo(
senderName = senderName,
senderAvatar = null,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
)
fun aVideoMediaInfo(
caption: String? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
): MediaInfo = MediaInfo(
filename = "a video file.mp4",
caption = caption,
@ -56,6 +62,8 @@ fun aVideoMediaInfo(
senderName = senderName,
senderAvatar = null,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
)
fun aPdfMediaInfo(
@ -63,6 +71,7 @@ fun aPdfMediaInfo(
caption: String? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
): MediaInfo = MediaInfo(
filename = filename,
caption = caption,
@ -73,12 +82,15 @@ fun aPdfMediaInfo(
senderName = senderName,
senderAvatar = null,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
)
fun anApkMediaInfo(
senderId: UserId? = UserId("@alice:server.org"),
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
): MediaInfo = MediaInfo(
filename = "an apk file.apk",
caption = null,
@ -89,14 +101,20 @@ fun anApkMediaInfo(
senderName = senderName,
senderAvatar = null,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
)
fun anAudioMediaInfo(
filename: String = "an audio file.mp3",
caption: String? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
waveForm: List<Float>? = null,
): MediaInfo = MediaInfo(
filename = "an audio file.mp3",
caption = null,
filename = filename,
caption = caption,
mimeType = MimeTypes.Mp3,
formattedFileSize = "7MB",
fileExtension = "mp3",
@ -104,4 +122,27 @@ fun anAudioMediaInfo(
senderName = senderName,
senderAvatar = null,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = waveForm,
)
fun aVoiceMediaInfo(
filename: String = "a voice file.ogg",
caption: String? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
waveForm: List<Float>? = null,
): MediaInfo = MediaInfo(
filename = filename,
caption = caption,
mimeType = MimeTypes.Ogg,
formattedFileSize = "3MB",
fileExtension = "ogg",
senderId = UserId("@alice:server.org"),
senderName = senderName,
senderAvatar = null,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = waveForm,
)

View file

@ -43,6 +43,7 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.voiceplayer.api)
implementation(projects.services.toolbox.api)
api(projects.libraries.mediaviewer.api)

View file

@ -53,6 +53,8 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
senderName = null,
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
),
mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null,

View file

@ -71,7 +71,7 @@ fun MediaDetailsBottomSheet(
}
SectionText(
title = stringResource(R.string.screen_media_details_uploaded_on),
text = state.mediaInfo.dateSent.orEmpty(),
text = state.mediaInfo.dateSentFull.orEmpty(),
)
SectionText(
title = stringResource(R.string.screen_media_details_filename),

View file

@ -10,12 +10,15 @@ package io.element.android.libraries.mediaviewer.impl.details
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
fun aMediaDetailsBottomSheetState(): MediaBottomSheetState.MediaDetailsBottomSheetState {
fun aMediaDetailsBottomSheetState(
dateSentFull: String = "December 6, 2024 at 12:59",
): MediaBottomSheetState.MediaDetailsBottomSheetState {
return MediaBottomSheetState.MediaDetailsBottomSheetState(
eventId = EventId("\$eventId"),
canDelete = true,
mediaInfo = anImageMediaInfo(
senderName = "Alice",
dateSentFull = dateSentFull,
),
thumbnailSource = null,
)

View file

@ -8,7 +8,8 @@
package io.element.android.libraries.mediaviewer.impl.gallery
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
@ -39,19 +40,27 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import kotlinx.collections.immutable.persistentListOf
import timber.log.Timber
import javax.inject.Inject
class EventItemFactory @Inject constructor(
private val fileSizeFormatter: FileSizeFormatter,
private val fileExtensionExtractor: FileExtensionExtractor,
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
private val dateFormatter: DateFormatter,
) {
fun create(
currentTimelineItem: MatrixTimelineItem.Event,
): MediaItem.Event? {
val event = currentTimelineItem.event
val sentTime = lastMessageTimestampFormatter.format(currentTimelineItem.event.timestamp)
val dateSent = dateFormatter.format(
currentTimelineItem.event.timestamp,
mode = DateFormatterMode.Day,
)
val dateSentFull = dateFormatter.format(
timestamp = currentTimelineItem.event.timestamp,
mode = DateFormatterMode.Full,
)
return when (val content = event.content) {
CallNotifyContent,
is FailedToParseMessageLikeContent,
@ -78,7 +87,7 @@ class EventItemFactory @Inject constructor(
Timber.w("Should not happen: ${content.type}")
null
}
is AudioMessageType -> MediaItem.File(
is AudioMessageType -> MediaItem.Audio(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
@ -90,7 +99,9 @@ class EventItemFactory @Inject constructor(
senderId = event.sender,
senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = sentTime,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
),
mediaSource = type.source,
)
@ -106,7 +117,9 @@ class EventItemFactory @Inject constructor(
senderId = event.sender,
senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = sentTime,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
),
mediaSource = type.source,
)
@ -122,7 +135,9 @@ class EventItemFactory @Inject constructor(
senderId = event.sender,
senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = sentTime,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
),
mediaSource = type.source,
thumbnailSource = null,
@ -139,7 +154,9 @@ class EventItemFactory @Inject constructor(
senderId = event.sender,
senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = sentTime,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
),
mediaSource = type.source,
thumbnailSource = null,
@ -156,13 +173,15 @@ class EventItemFactory @Inject constructor(
senderId = event.sender,
senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = sentTime,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
),
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
)
is VoiceMessageType -> MediaItem.File(
is VoiceMessageType -> MediaItem.Voice(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
@ -174,9 +193,13 @@ class EventItemFactory @Inject constructor(
senderId = event.sender,
senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = sentTime,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = type.details?.waveform.orEmpty(),
),
mediaSource = type.source,
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
waveform = type.details?.waveform ?: persistentListOf(),
)
}
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.mediaviewer.impl.gallery
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@ -18,12 +19,15 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaviewer.impl.gallery.di.LocalMediaItemPresenterFactories
import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemPresenterFactories
@ContributesNode(RoomScope::class)
class MediaGalleryNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: MediaGalleryPresenter.Factory,
private val mediaItemPresenterFactories: MediaItemPresenterFactories,
) : Node(buildContext, plugins = plugins),
MediaGalleryNavigator {
private val presenter = presenterFactory.create(
@ -56,12 +60,16 @@ class MediaGalleryNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
MediaGalleryView(
state = state,
onBackClick = ::onBackClick,
onItemClick = ::onItemClick,
modifier = modifier,
)
CompositionLocalProvider(
LocalMediaItemPresenterFactories provides mediaItemPresenterFactories,
) {
val state = presenter.present()
MediaGalleryView(
state = state,
onBackClick = ::onBackClick,
onItemClick = ::onItemClick,
modifier = modifier,
)
}
}
}

View file

@ -135,7 +135,9 @@ class MediaGalleryPresenter @AssistedInject constructor(
thumbnailSource = when (event.mediaItem) {
is MediaItem.Image -> event.mediaItem.thumbnailSource ?: event.mediaItem.mediaSource
is MediaItem.Video -> event.mediaItem.thumbnailSource ?: event.mediaItem.mediaSource
is MediaItem.Audio -> null
is MediaItem.File -> null
is MediaItem.Voice -> null
},
)
}

View file

@ -9,14 +9,17 @@ package io.element.android.libraries.mediaviewer.impl.gallery
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.media.aWaveForm
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemAudio
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemLoadingIndicator
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemVideo
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemVoice
import kotlinx.collections.immutable.toImmutableList
open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryState> {
@ -61,8 +64,11 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
id = UniqueId("2"),
formattedDate = "September 2004",
),
aMediaItemFile(id = UniqueId("3")),
aMediaItemFile(id = UniqueId("4")),
aMediaItemAudio(id = UniqueId("4")),
aMediaItemVoice(
id = UniqueId("5"),
waveform = aWaveForm(),
),
aMediaItemLoadingIndicator(),
).toImmutableList()
)

View file

@ -27,18 +27,20 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.components.async.AsyncFailure
@ -59,12 +61,17 @@ import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet
import io.element.android.libraries.mediaviewer.impl.details.MediaDetailsBottomSheet
import io.element.android.libraries.mediaviewer.impl.gallery.di.LocalMediaItemPresenterFactories
import io.element.android.libraries.mediaviewer.impl.gallery.di.aFakeMediaItemPresenterFactories
import io.element.android.libraries.mediaviewer.impl.gallery.di.rememberPresenter
import io.element.android.libraries.mediaviewer.impl.gallery.ui.AudioItemView
import io.element.android.libraries.mediaviewer.impl.gallery.ui.DateItemView
import io.element.android.libraries.mediaviewer.impl.gallery.ui.FileItemView
import io.element.android.libraries.mediaviewer.impl.gallery.ui.ImageItemView
import io.element.android.libraries.mediaviewer.impl.gallery.ui.VideoItemView
import io.element.android.libraries.mediaviewer.impl.gallery.ui.VoiceItemView
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import kotlinx.collections.immutable.ImmutableList
import kotlin.math.max
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -212,7 +219,11 @@ private fun MediaGalleryImages(
onItemClick: (MediaItem.Event) -> Unit,
) {
if (imagesAndVideos.isEmpty()) {
EmptyContent()
EmptyContent(
titleRes = R.string.screen_media_browser_media_empty_state_title,
subtitleRes = R.string.screen_media_browser_media_empty_state_subtitle,
icon = CompoundIcons.Image(),
)
} else {
MediaGalleryImageGrid(
imagesAndVideos = imagesAndVideos,
@ -229,7 +240,11 @@ private fun MediaGalleryFiles(
onItemClick: (MediaItem.Event) -> Unit,
) {
if (files.isEmpty()) {
EmptyContent()
EmptyContent(
titleRes = R.string.screen_media_browser_files_empty_state_title,
subtitleRes = R.string.screen_media_browser_files_empty_state_subtitle,
icon = CompoundIcons.Files(),
)
} else {
MediaGalleryFilesList(
files = files,
@ -245,30 +260,50 @@ private fun MediaGalleryFilesList(
eventSink: (MediaGalleryEvents) -> Unit,
onItemClick: (MediaItem.Event) -> Unit,
) {
val presenterFactories = LocalMediaItemPresenterFactories.current
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(files) { item ->
items(
items = files,
key = { it.id() },
contentType = { it::class.java },
) { item ->
when (item) {
is MediaItem.File -> FileItemView(
item,
modifier = Modifier.animateItem(),
file = item,
onClick = { onItemClick(item) },
onShareClick = { eventSink(MediaGalleryEvents.Share(item)) },
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) },
)
is MediaItem.DateSeparator -> DateItemView(item)
is MediaItem.Audio -> AudioItemView(
modifier = Modifier.animateItem(),
audio = item,
onClick = { onItemClick(item) },
)
is MediaItem.Voice -> {
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(item)
VoiceItemView(
modifier = Modifier.animateItem(),
state = presenter.present(),
voice = item,
onShareClick = { eventSink(MediaGalleryEvents.Share(item)) },
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) },
)
}
is MediaItem.DateSeparator -> DateItemView(
modifier = Modifier.animateItem(),
item = item
)
is MediaItem.Image,
is MediaItem.Video -> {
// Should not happen
}
is MediaItem.LoadingIndicator -> {
LoadingMoreIndicator(item.direction)
val latestEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(item.timestamp) {
latestEventSink(MediaGalleryEvents.LoadMore(item.direction))
}
}
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
modifier = Modifier.animateItem(),
item = item,
eventSink = eventSink,
)
}
}
}
@ -280,28 +315,20 @@ private fun MediaGalleryImageGrid(
eventSink: (MediaGalleryEvents) -> Unit,
onItemClick: (MediaItem.Event) -> Unit,
) {
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp.dp
val horizontalPadding = 16.dp
val itemSpacing = 4.dp
val availableWidth = screenWidth - horizontalPadding * 2
val minCellWidth = 80.dp
// Calculate the number of columns
val columns = max(1, (availableWidth / (minCellWidth + itemSpacing)).toInt())
LazyVerticalGrid(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = horizontalPadding),
columns = GridCells.Fixed(columns),
.padding(horizontal = 16.dp),
columns = GridCells.Adaptive(80.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(
imagesAndVideos,
items = imagesAndVideos,
span = { item ->
when (item) {
is MediaItem.LoadingIndicator,
is MediaItem.DateSeparator -> GridItemSpan(columns)
is MediaItem.DateSeparator -> GridItemSpan(maxLineSpan)
is MediaItem.Event -> GridItemSpan(1)
}
},
@ -309,37 +336,40 @@ private fun MediaGalleryImageGrid(
contentType = { it::class.java },
) { item ->
when (item) {
is MediaItem.DateSeparator -> {
DateItemView(item)
is MediaItem.DateSeparator -> DateItemView(
modifier = Modifier.animateItem(),
item = item,
)
is MediaItem.Audio -> {
// Should not happen
}
is MediaItem.Voice -> {
// Should not happen
}
is MediaItem.File -> {
// Should not happen
}
is MediaItem.Image -> {
ImageItemView(
image = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
},
)
}
is MediaItem.Video -> {
VideoItemView(
video = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
},
)
}
is MediaItem.LoadingIndicator -> {
LoadingMoreIndicator(item.direction)
val latestEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(item.timestamp) {
latestEventSink(MediaGalleryEvents.LoadMore(item.direction))
}
}
is MediaItem.Image -> ImageItemView(
modifier = Modifier.animateItem(),
image = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
},
)
is MediaItem.Video -> VideoItemView(
modifier = Modifier.animateItem(),
video = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
},
)
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
modifier = Modifier.animateItem(),
item = item,
eventSink = eventSink,
)
}
}
}
@ -347,14 +377,15 @@ private fun MediaGalleryImageGrid(
@Composable
private fun LoadingMoreIndicator(
direction: Timeline.PaginationDirection,
item: MediaItem.LoadingIndicator,
eventSink: (MediaGalleryEvents) -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
when (direction) {
when (item.direction) {
Timeline.PaginationDirection.FORWARDS -> {
LinearProgressIndicator(
modifier = Modifier
@ -370,6 +401,10 @@ private fun LoadingMoreIndicator(
)
}
}
val latestEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(item.timestamp) {
latestEventSink(MediaGalleryEvents.LoadMore(item.direction))
}
}
}
@ -383,7 +418,11 @@ private fun ErrorContent(error: Throwable) {
}
@Composable
private fun EmptyContent() {
private fun EmptyContent(
titleRes: Int,
subtitleRes: Int,
icon: ImageVector,
) {
Box(
modifier = Modifier.fillMaxSize(),
) {
@ -392,9 +431,9 @@ private fun EmptyContent() {
.fillMaxWidth()
.padding(top = 44.dp)
.padding(24.dp),
title = stringResource(R.string.screen_media_browser_empty_state_title),
iconStyle = BigIcon.Style.Default(CompoundIcons.Image()),
subtitle = stringResource(R.string.screen_media_browser_empty_state_subtitle),
title = stringResource(titleRes),
iconStyle = BigIcon.Style.Default(icon),
subtitle = stringResource(subtitleRes),
)
}
}
@ -428,9 +467,13 @@ private fun LoadingContent(
internal fun MediaGalleryViewPreview(
@PreviewParameter(MediaGalleryStateProvider::class) state: MediaGalleryState
) = ElementPreview {
MediaGalleryView(
state = state,
onBackClick = {},
onItemClick = {},
)
CompositionLocalProvider(
LocalMediaItemPresenterFactories provides aFakeMediaItemPresenterFactories(),
) {
MediaGalleryView(
state = state,
onBackClick = {},
onItemClick = {},
)
}
}

View file

@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.mediaviewer.api.MediaInfo
import kotlinx.collections.immutable.ImmutableList
sealed interface MediaItem {
data class DateSeparator(
@ -51,6 +52,22 @@ sealed interface MediaItem {
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
}
data class Audio(
val id: UniqueId,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
) : Event
data class Voice(
val id: UniqueId,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val duration: String?,
val waveform: ImmutableList<Float>,
) : Event
data class File(
val id: UniqueId,
val eventId: EventId?,
@ -66,6 +83,8 @@ fun MediaItem.id(): UniqueId {
is MediaItem.Image -> id
is MediaItem.Video -> id
is MediaItem.File -> id
is MediaItem.Audio -> id
is MediaItem.Voice -> id
}
}
@ -74,6 +93,8 @@ fun MediaItem.Event.eventId(): EventId? {
is MediaItem.Image -> eventId
is MediaItem.Video -> eventId
is MediaItem.File -> eventId
is MediaItem.Audio -> eventId
is MediaItem.Voice -> eventId
}
}
@ -82,6 +103,8 @@ fun MediaItem.Event.mediaInfo(): MediaInfo {
is MediaItem.Image -> mediaInfo
is MediaItem.Video -> mediaInfo
is MediaItem.File -> mediaInfo
is MediaItem.Audio -> mediaInfo
is MediaItem.Voice -> mediaInfo
}
}
@ -90,6 +113,8 @@ fun MediaItem.Event.mediaSource(): MediaSource {
is MediaItem.Image -> mediaSource
is MediaItem.Video -> mediaSource
is MediaItem.File -> mediaSource
is MediaItem.Audio -> mediaSource
is MediaItem.Voice -> mediaSource
}
}
@ -98,5 +123,7 @@ fun MediaItem.Event.thumbnailSource(): MediaSource? {
is MediaItem.Image -> thumbnailSource
is MediaItem.Video -> thumbnailSource
is MediaItem.File -> null
is MediaItem.Audio -> null
is MediaItem.Voice -> null
}
}

View file

@ -54,10 +54,12 @@ class MediaItemsPostProcessor @Inject constructor() {
when (item) {
is MediaItem.Image,
is MediaItem.Video -> {
imageAndVideoItemsSubList.add(0, item)
imageAndVideoItemsSubList.add(item)
}
is MediaItem.Audio,
is MediaItem.Voice,
is MediaItem.File -> {
fileItemsSublist.add(0, item)
fileItemsSublist.add(item)
}
}
}

View file

@ -7,19 +7,24 @@
package io.element.android.libraries.mediaviewer.impl.gallery
import io.element.android.libraries.dateformatter.api.DaySeparatorFormatter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import javax.inject.Inject
class VirtualItemFactory @Inject constructor(
private val daySeparatorFormatter: DaySeparatorFormatter,
private val dateFormatter: DateFormatter,
) {
fun create(timelineItem: MatrixTimelineItem.Virtual): MediaItem? {
return when (val virtual = timelineItem.virtual) {
is VirtualTimelineItem.DayDivider -> MediaItem.DateSeparator(
id = timelineItem.uniqueId,
formattedDate = daySeparatorFormatter.format(virtual.timestamp)
formattedDate = dateFormatter.format(
timestamp = virtual.timestamp,
mode = DateFormatterMode.Month,
useRelative = true,
)
)
VirtualTimelineItem.LastForwardIndicator -> null
is VirtualTimelineItem.LoadingIndicator -> MediaItem.LoadingIndicator(

View file

@ -0,0 +1,25 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.di
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
/**
* A fake [MediaItemPresenterFactories] for screenshot tests.
*/
fun aFakeMediaItemPresenterFactories() = MediaItemPresenterFactories(
mapOf(
Pair(
MediaItem.Voice::class.java,
MediaItemPresenterFactory<MediaItem.Voice, VoiceMessageState> { Presenter { aVoiceMessageState() } },
),
)
)

View file

@ -0,0 +1,17 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.di
import androidx.compose.runtime.staticCompositionLocalOf
/**
* Provides a [MediaItemPresenterFactories] to the composition.
*/
val LocalMediaItemPresenterFactories = staticCompositionLocalOf {
MediaItemPresenterFactories(emptyMap())
}

View file

@ -0,0 +1,20 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.di
import dagger.MapKey
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import kotlin.reflect.KClass
/**
* Annotation to add a factory of type [MediaItemPresenterFactory] to a
* Dagger map multi binding keyed with a subclass of [MediaItem.Event].
*/
@Retention(AnnotationRetention.RUNTIME)
@MapKey
annotation class MediaItemEventContentKey(val value: KClass<out MediaItem.Event>)

View file

@ -0,0 +1,90 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.di
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.multibindings.Multibinds
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import javax.inject.Inject
/**
* Dagger module that declares the [MediaItemPresenterFactory] map multi binding.
*
* Its sole purpose is to support the case of an empty map multibinding.
*/
@Module
@ContributesTo(RoomScope::class)
interface MediaItemPresenterFactoriesModule {
@Multibinds
fun multiBindMediaItemPresenterFactories(): @JvmSuppressWildcards Map<Class<out MediaItem.Event>, MediaItemPresenterFactory<*, *>>
}
/**
* Room level caching layer for the [MediaItemPresenterFactory] instances.
*
* It will cache the presenter instances in the room scope, so that they can be
* reused across recompositions of the gallery items that happen whenever an item
* goes out of the [LazyColumn] viewport.
*/
@SingleIn(RoomScope::class)
class MediaItemPresenterFactories @Inject constructor(
private val factories: @JvmSuppressWildcards Map<Class<out MediaItem.Event>, MediaItemPresenterFactory<*, *>>,
) {
private val presenters: MutableMap<MediaItem.Event, Presenter<*>> = mutableMapOf()
/**
* Creates and caches a presenter for the given content.
*
* Will throw if the presenter is not found in the [MediaItemPresenterFactory] map multi binding.
*
* @param C The [MediaItem.Event] subtype handled by this TimelineItem presenter.
* @param S The state type produced by this timeline item presenter.
* @param content The [MediaItem.Event] instance to create a presenter for.
* @param contentClass The class of [content].
* @return An instance of a TimelineItem presenter that will be cached in the room scope.
*/
@Composable
fun <C : MediaItem.Event, S : Any> rememberPresenter(
content: C,
contentClass: Class<C>,
): Presenter<S> = remember(content) {
presenters[content]?.let {
@Suppress("UNCHECKED_CAST")
it as Presenter<S>
} ?: factories.getValue(contentClass).let {
@Suppress("UNCHECKED_CAST")
(it as MediaItemPresenterFactory<C, S>).create(content).apply {
presenters[content] = this
}
}
}
}
/**
* Creates and caches a presenter for the given content.
*
* Will throw if the presenter is not found in the [MediaItemPresenterFactory] map multi binding.
*
* @param C The [MediaItem.Event] subtype handled by this TimelineItem presenter.
* @param S The state type produced by this timeline item presenter.
* @param content The [MediaItem.Event] instance to create a presenter for.
* @return An instance of a TimelineItem presenter that will be cached in the room scope.
*/
@Composable
inline fun <reified C : MediaItem.Event, S : Any> MediaItemPresenterFactories.rememberPresenter(
content: C
): Presenter<S> = rememberPresenter(
content = content,
contentClass = C::class.java
)

View file

@ -0,0 +1,24 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.di
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
/**
* A factory for a [Presenter] associated with a timeline item.
*
* Implementations should be annotated with [AssistedFactory] to be created by Dagger.
*
* @param C The timeline item's [MediaItem.Event] subtype.
* @param S The [Presenter]'s state class.
* @return A [Presenter] that produces a state of type [S] for the given content of type [C].
*/
fun interface MediaItemPresenterFactory<C : MediaItem.Event, S : Any> {
fun create(content: C): Presenter<S>
}

View file

@ -0,0 +1,123 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.core.extensions.withBrackets
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
@Composable
fun AudioItemView(
audio: MediaItem.Audio,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Spacer(modifier = Modifier.height(20.dp))
FilenameRow(
audio = audio,
onClick = onClick,
)
val caption = audio.mediaInfo.caption
if (caption != null) {
CaptionView(caption)
} else {
Spacer(modifier = Modifier.height(20.dp))
}
HorizontalDivider()
}
}
@Composable
private fun FilenameRow(
audio: MediaItem.Audio,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
)
.clickable { onClick() }
.fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.background(
color = ElementTheme.colors.bgActionSecondaryRest,
shape = CircleShape,
)
.size(32.dp)
.padding(6.dp),
imageVector = Icons.Outlined.GraphicEq,
contentDescription = null,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = audio.mediaInfo.filename,
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val formattedSize = audio.mediaInfo.formattedFileSize
if (formattedSize.isNotEmpty()) {
Text(
text = formattedSize.withBrackets(),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun AudioItemViewPreview(
@PreviewParameter(MediaItemAudioProvider::class) audio: MediaItem.Audio,
) = ElementPreview {
AudioItemView(
audio = audio,
onClick = {},
)
}

View file

@ -0,0 +1,34 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun CaptionView(
caption: String,
modifier: Modifier = Modifier,
) {
Text(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
text = caption,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}

View file

@ -9,7 +9,6 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -34,7 +33,6 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
@ -42,31 +40,24 @@ import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
fun FileItemView(
file: MediaItem.File,
onClick: () -> Unit,
onShareClick: () -> Unit,
onDownloadClick: () -> Unit,
onInfoClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(top = 20.dp, start = 16.dp, end = 16.dp),
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Spacer(modifier = Modifier.height(20.dp))
FilenameRow(
file = file,
onClick = onClick,
)
val caption = file.mediaInfo.caption
if (caption != null) {
Spacer(modifier = Modifier.height(16.dp))
Caption(caption)
CaptionView(caption)
} else {
Spacer(modifier = Modifier.height(20.dp))
}
Spacer(modifier = Modifier.height(16.dp))
ActionIconsRow(
onShareClick = onShareClick,
onDownloadClick = onDownloadClick,
onInfoClick = onInfoClick,
)
HorizontalDivider()
}
}
@ -78,24 +69,24 @@ private fun FilenameRow(
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
)
.clickable { onClick() }
.fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
.clip(RoundedCornerShape(12.dp))
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
)
.clickable { onClick() }
.fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.background(
color = ElementTheme.colors.bgActionSecondaryRest,
shape = CircleShape,
)
.size(32.dp)
.padding(6.dp),
.background(
color = ElementTheme.colors.bgActionSecondaryRest,
shape = CircleShape,
)
.size(32.dp)
.padding(6.dp),
imageVector = CompoundIcons.Attachment(),
contentDescription = null,
)
@ -119,55 +110,6 @@ private fun FilenameRow(
}
}
@Composable
private fun Caption(caption: String) {
Text(
modifier = Modifier.fillMaxWidth(),
text = caption,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}
@Composable
private fun ActionIconsRow(
onShareClick: () -> Unit,
onDownloadClick: () -> Unit,
onInfoClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
IconButton(
onClick = onShareClick,
) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = null,
)
}
IconButton(
onClick = onDownloadClick,
) {
Icon(
imageVector = CompoundIcons.Download(),
contentDescription = null,
)
}
IconButton(
onClick = onInfoClick,
) {
Icon(
imageVector = CompoundIcons.Info(),
contentDescription = null,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun FileItemViewPreview(
@ -176,8 +118,5 @@ internal fun FileItemViewPreview(
FileItemView(
file = file,
onClick = {},
onShareClick = {},
onDownloadClick = {},
onInfoClick = {},
)
}

View file

@ -0,0 +1,45 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.preview.loremIpsum
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
class MediaItemAudioProvider : PreviewParameterProvider<MediaItem.Audio> {
override val values: Sequence<MediaItem.Audio>
get() = sequenceOf(
aMediaItemAudio(),
aMediaItemAudio(
filename = "A long filename that should be truncated.mp3",
caption = "A caption",
),
aMediaItemAudio(
caption = loremIpsum,
),
)
}
fun aMediaItemAudio(
id: UniqueId = UniqueId("fileId"),
filename: String = "filename",
caption: String? = null,
): MediaItem.Audio {
return MediaItem.Audio(
id = id,
eventId = null,
mediaInfo = anAudioMediaInfo(
filename = filename,
caption = caption,
),
mediaSource = MediaSource(""),
)
}

View file

@ -0,0 +1,54 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.preview.loremIpsum
import io.element.android.libraries.designsystem.components.media.aWaveForm
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import kotlinx.collections.immutable.toImmutableList
class MediaItemVoiceProvider : PreviewParameterProvider<MediaItem.Voice> {
override val values: Sequence<MediaItem.Voice>
get() = sequenceOf(
aMediaItemVoice(),
aMediaItemVoice(
filename = "A long filename that should be truncated.ogg",
caption = "A caption",
),
aMediaItemVoice(
caption = loremIpsum,
),
aMediaItemVoice(
waveform = emptyList(),
),
)
}
fun aMediaItemVoice(
id: UniqueId = UniqueId("fileId"),
filename: String = "filename.ogg",
caption: String? = null,
duration: String? = "1:23",
waveform: List<Float> = aWaveForm(),
): MediaItem.Voice {
return MediaItem.Voice(
id = id,
eventId = null,
mediaInfo = aVoiceMediaInfo(
filename = filename,
caption = caption,
),
mediaSource = MediaSource(""),
duration = duration,
waveform = waveform.toImmutableList(),
)
}

View file

@ -0,0 +1,323 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.delay
@Composable
fun VoiceItemView(
state: VoiceMessageState,
voice: MediaItem.Voice,
onShareClick: () -> Unit,
onDownloadClick: () -> Unit,
onInfoClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Spacer(modifier = Modifier.height(20.dp))
VoiceInfoRow(
state = state,
voice = voice,
)
val caption = voice.mediaInfo.caption
if (caption != null) {
CaptionView(caption)
} else {
Spacer(modifier = Modifier.height(16.dp))
}
ActionIconsRow(
onShareClick = onShareClick,
onDownloadClick = onDownloadClick,
onInfoClick = onInfoClick,
)
HorizontalDivider()
}
}
@Composable
private fun VoiceInfoRow(
state: VoiceMessageState,
voice: MediaItem.Voice,
) {
fun playPause() {
state.eventSink(VoiceMessageEvents.PlayPause)
}
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
)
.fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (state.button) {
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.Button.Downloading -> ProgressButton()
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
}
Spacer(Modifier.width(8.dp))
Text(
text = if (state.progress > 0f) state.time else voice.duration ?: state.time,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(8.dp))
WaveformPlaybackView(
modifier = Modifier
.weight(1f)
.height(34.dp),
showCursor = state.showCursor,
playbackProgress = state.progress,
waveform = voice.waveform.toPersistentList(),
onSeek = {
state.eventSink(VoiceMessageEvents.Seek(it))
},
seekEnabled = true,
)
}
}
/**
* Progress button is shown when the voice message is being downloaded.
*
* The progress indicator is optimistic and displays a pause button (which
* indicates the audio is playing) for 2 seconds before revealing the
* actual progress indicator.
*/
@Composable
private fun ProgressButton(
displayImmediately: Boolean = false,
) {
var canDisplay by remember { mutableStateOf(displayImmediately) }
LaunchedEffect(Unit) {
delay(2000L)
canDisplay = true
}
CustomIconButton(
onClick = {},
enabled = false,
) {
if (canDisplay) {
CircularProgressIndicator(
modifier = Modifier
.padding(2.dp)
.size(16.dp),
color = ElementTheme.colors.iconSecondary,
strokeWidth = 2.dp,
)
} else {
ControlIcon(
imageVector = CompoundIcons.PauseSolid(),
contentDescription = stringResource(id = CommonStrings.a11y_pause),
)
}
}
}
@Composable
private fun PlayButton(
onClick: () -> Unit,
enabled: Boolean = true,
) {
CustomIconButton(
onClick = onClick,
enabled = enabled,
) {
ControlIcon(
imageVector = CompoundIcons.PlaySolid(),
contentDescription = stringResource(id = CommonStrings.a11y_play),
)
}
}
@Composable
private fun PauseButton(
onClick: () -> Unit,
) {
CustomIconButton(
onClick = onClick,
) {
ControlIcon(
imageVector = CompoundIcons.PauseSolid(),
contentDescription = stringResource(id = CommonStrings.a11y_pause),
)
}
}
@Composable
private fun RetryButton(
onClick: () -> Unit,
) {
CustomIconButton(
onClick = onClick,
) {
ControlIcon(
imageVector = CompoundIcons.Restart(),
contentDescription = stringResource(id = CommonStrings.action_retry),
)
}
}
@Composable
private fun ControlIcon(
imageVector: ImageVector,
contentDescription: String?,
) {
Icon(
modifier = Modifier.padding(vertical = 10.dp),
imageVector = imageVector,
contentDescription = contentDescription,
)
}
@Composable
private fun CustomIconButton(
onClick: () -> Unit,
enabled: Boolean = true,
content: @Composable () -> Unit,
) {
IconButton(
onClick = onClick,
modifier = Modifier
.background(color = ElementTheme.colors.bgCanvasDefault, shape = CircleShape)
.border(
width = 1.dp,
color = ElementTheme.colors.borderInteractiveSecondary,
shape = CircleShape,
)
.size(36.dp),
enabled = enabled,
colors = IconButtonDefaults.iconButtonColors(
contentColor = ElementTheme.colors.iconSecondary,
disabledContentColor = ElementTheme.colors.iconDisabled,
),
content = content,
)
}
@Composable
private fun ActionIconsRow(
onShareClick: () -> Unit,
onDownloadClick: () -> Unit,
onInfoClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
IconButton(
onClick = onShareClick,
) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = null,
)
}
IconButton(
onClick = onDownloadClick,
) {
Icon(
imageVector = CompoundIcons.Download(),
contentDescription = null,
)
}
IconButton(
onClick = onInfoClick,
) {
Icon(
imageVector = CompoundIcons.Info(),
contentDescription = null,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun VoiceItemViewPreview(
@PreviewParameter(MediaItemVoiceProvider::class) voice: MediaItem.Voice,
) = ElementPreview {
VoiceItemView(
state = aVoiceMessageState(),
voice = voice,
onShareClick = {},
onDownloadClick = {},
onInfoClick = {},
)
}
@PreviewsDayNight
@Composable
internal fun VoiceItemViewPlayPreview(
@PreviewParameter(VoiceMessageStateProvider::class) state: VoiceMessageState,
) = ElementPreview {
VoiceItemView(
state = state,
voice = aMediaItemVoice(),
onShareClick = {},
onDownloadClick = {},
onInfoClick = {},
)
}

View file

@ -0,0 +1,58 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.gallery.voice
import androidx.compose.runtime.Composable
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.multibindings.IntoMap
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemEventContentKey
import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemPresenterFactory
import io.element.android.libraries.voiceplayer.api.VoiceMessagePresenterFactory
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import kotlin.time.Duration
@Module
@ContributesTo(RoomScope::class)
interface VoiceMessagePresenterModule {
@Binds
@IntoMap
@MediaItemEventContentKey(MediaItem.Voice::class)
fun bindVoiceMessagePresenterFactory(factory: VoiceMessagePresenter.Factory): MediaItemPresenterFactory<*, *>
}
class VoiceMessagePresenter @AssistedInject constructor(
voiceMessagePresenterFactory: VoiceMessagePresenterFactory,
@Assisted private val item: MediaItem.Voice,
) : Presenter<VoiceMessageState> {
@AssistedFactory
fun interface Factory : MediaItemPresenterFactory<MediaItem.Voice, VoiceMessageState> {
override fun create(content: MediaItem.Voice): VoiceMessagePresenter
}
private val presenter = voiceMessagePresenterFactory.createVoiceMessagePresenter(
eventId = item.eventId,
mediaSource = item.mediaSource,
mimeType = item.mediaInfo.mimeType,
filename = item.mediaInfo.filename,
// TODO Get the duration for the fallback?
duration = Duration.ZERO,
)
@Composable
override fun present(): VoiceMessageState {
return presenter.present()
}
}

View file

@ -46,6 +46,8 @@ class AndroidLocalMediaFactory @Inject constructor(
senderName = mediaInfo.senderName,
senderAvatar = mediaInfo.senderAvatar,
dateSent = mediaInfo.dateSent,
dateSentFull = mediaInfo.dateSentFull,
waveform = mediaInfo.waveform,
)
override fun createFromUri(
@ -63,6 +65,8 @@ class AndroidLocalMediaFactory @Inject constructor(
senderName = null,
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
)
private fun createFromUri(
@ -75,6 +79,8 @@ class AndroidLocalMediaFactory @Inject constructor(
senderName: String?,
senderAvatar: String?,
dateSent: String?,
dateSentFull: String?,
waveform: List<Float>?,
): LocalMedia {
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
val fileName = name ?: context.getFileName(uri) ?: ""
@ -92,6 +98,8 @@ class AndroidLocalMediaFactory @Inject constructor(
senderName = senderName,
senderAvatar = senderAvatar,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = waveform,
)
)
}

View file

@ -10,10 +10,12 @@ package io.element.android.libraries.mediaviewer.impl.local
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.local.audio.MediaAudioView
import io.element.android.libraries.mediaviewer.impl.local.file.MediaFileView
import io.element.android.libraries.mediaviewer.impl.local.image.MediaImageView
import io.element.android.libraries.mediaviewer.impl.local.pdf.MediaPdfView
@ -48,7 +50,13 @@ fun LocalMediaView(
modifier = modifier,
onClick = onClick,
)
// TODO handle audio with exoplayer
mimeType.isMimeTypeAudio() -> MediaAudioView(
localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels,
localMedia = localMedia,
info = mediaInfo,
modifier = modifier,
)
else -> MediaFileView(
localMediaViewState = localMediaViewState,
uri = localMedia?.uri,

View file

@ -0,0 +1,366 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.local.audio
import android.annotation.SuppressLint
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.material3.MaterialTheme
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
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.OnLifecycleEvent
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.local.PlayableState
import io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerState
import io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerView
import io.element.android.libraries.mediaviewer.impl.local.player.rememberExoPlayer
import io.element.android.libraries.mediaviewer.impl.local.player.seekToEnsurePlaying
import io.element.android.libraries.mediaviewer.impl.local.player.togglePlay
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.delay
@SuppressLint("UnsafeOptInUsageError")
@Composable
fun MediaAudioView(
localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int,
localMedia: LocalMedia?,
info: MediaInfo?,
modifier: Modifier = Modifier,
) {
val exoPlayer = rememberExoPlayer()
ExoPlayerMediaAudioView(
localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels,
exoPlayer = exoPlayer,
localMedia = localMedia,
info = info,
modifier = modifier,
)
}
@SuppressLint("UnsafeOptInUsageError")
@Composable
private fun ExoPlayerMediaAudioView(
localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int,
exoPlayer: ExoPlayer,
localMedia: LocalMedia?,
info: MediaInfo?,
modifier: Modifier = Modifier,
) {
var mediaPlayerControllerState: MediaPlayerControllerState by remember {
mutableStateOf(
MediaPlayerControllerState(
isVisible = true,
isPlaying = false,
progressInMillis = 0,
durationInMillis = 0,
canMute = false,
isMuted = false,
)
)
}
var metadata: MediaMetadata? by remember {
mutableStateOf(null)
}
val playableState: PlayableState.Playable by remember {
derivedStateOf {
PlayableState.Playable(
isShowingControls = mediaPlayerControllerState.isVisible,
)
}
}
localMediaViewState.playableState = playableState
val playerListener = remember {
object : Player.Listener {
override fun onRenderedFirstFrame() {
localMediaViewState.isReady = true
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isPlaying = isPlaying,
)
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
exoPlayer.duration.takeIf { it >= 0 }
?.let {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
durationInMillis = it,
)
}
}
}
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
metadata = mediaMetadata
}
}
}
LaunchedEffect(exoPlayer.isPlaying) {
if (exoPlayer.isPlaying) {
while (true) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
progressInMillis = exoPlayer.currentPosition,
)
delay(200)
}
} else {
// Ensure we render the final state
mediaPlayerControllerState = mediaPlayerControllerState.copy(
progressInMillis = exoPlayer.currentPosition,
)
}
}
if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri)
exoPlayer.setMediaItem(mediaItem)
}
} else {
exoPlayer.setMediaItems(emptyList())
}
val context = LocalContext.current
val waveform = info?.waveform
Box(
modifier = modifier
.fillMaxSize()
.background(ElementTheme.colors.bgSubtlePrimary),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
if (LocalInspectionMode.current) {
Text(
modifier = Modifier
.padding(16.dp)
.width(240.dp),
text = "An audio Player may render an image here if the audio file contains some artwork.",
textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary,
)
} else {
AndroidView(
modifier = Modifier
.clip(shape = RoundedCornerShape(12.dp))
.clipToBounds()
.width(240.dp),
factory = {
PlayerView(context).apply {
player = exoPlayer
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
useController = false
}
},
update = { playerView ->
playerView.isVisible = metadata.hasArtwork()
},
onRelease = { playerView ->
playerView.player = null
},
)
}
if (waveform != null) {
WaveformPlaybackView(
modifier = Modifier
.height(48.dp),
playbackProgress = mediaPlayerControllerState.progressAsFloat,
showCursor = true,
waveform = waveform.toPersistentList(),
onSeek = {
exoPlayer.seekToEnsurePlaying((it * exoPlayer.duration).toLong())
},
seekEnabled = true,
)
} else {
if (!metadata.hasArtwork()) {
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onBackground),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.GraphicEq,
contentDescription = null,
tint = MaterialTheme.colorScheme.background,
modifier = Modifier
.size(32.dp),
)
}
}
}
}
if (waveform == null) {
// Display the info below the player
AudioInfoView(
modifier = Modifier.padding(horizontal = 16.dp),
info = info,
metadata = metadata,
)
}
}
MediaPlayerControllerView(
state = mediaPlayerControllerState,
onTogglePlay = {
exoPlayer.togglePlay()
},
onSeekChange = {
exoPlayer.seekToEnsurePlaying(it.toLong())
},
onToggleMute = {
// Cannot happen for audio files
},
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = bottomPaddingInPixels.toDp()),
)
}
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> exoPlayer.addListener(playerListener)
Lifecycle.Event.ON_RESUME -> exoPlayer.prepare()
Lifecycle.Event.ON_PAUSE -> exoPlayer.pause()
Lifecycle.Event.ON_DESTROY -> {
exoPlayer.release()
exoPlayer.removeListener(playerListener)
}
else -> Unit
}
}
}
@Composable
private fun AudioInfoView(
info: MediaInfo?,
metadata: MediaMetadata?,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Render the info about the file and from the metadata
val metaDataInfo = metadata.buildInfo()
if (metaDataInfo.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = metaDataInfo,
style = ElementTheme.typography.fontBodyMdRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary
)
}
if (info != null) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = info.filename,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatFileExtensionAndSize(info.fileExtension, info.formattedFileSize),
style = ElementTheme.typography.fontBodyMdRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary
)
}
}
}
@PreviewsDayNight
@Composable
internal fun MediaAudioViewPreview(
@PreviewParameter(MediaInfoAudioProvider::class) info: MediaInfo
) = ElementPreview {
MediaAudioView(
modifier = Modifier.fillMaxSize(),
bottomPaddingInPixels = 0,
localMediaViewState = rememberLocalMediaViewState(),
info = info,
localMedia = null,
)
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.local.audio
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.media.aWaveForm
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
open class MediaInfoAudioProvider : PreviewParameterProvider<MediaInfo> {
override val values: Sequence<MediaInfo>
get() = sequenceOf(
anAudioMediaInfo(),
anAudioMediaInfo(
waveForm = aWaveForm(),
),
)
}

View file

@ -0,0 +1,35 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.local.audio
import androidx.media3.common.MediaMetadata
fun MediaMetadata?.hasArtwork(): Boolean {
return this?.artworkData != null || this?.artworkUri != null
}
fun MediaMetadata?.buildInfo(): String {
this ?: return ""
return buildString {
if (artist != null) {
append(artist)
}
if (title != null) {
if (isNotEmpty()) {
append(" - ")
}
append(title)
}
if (recordingYear != null) {
if (isNotEmpty()) {
append(" - ")
}
append(recordingYear)
}
}
}

View file

@ -10,12 +10,10 @@ package io.element.android.libraries.mediaviewer.impl.local.file
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
open class MediaInfoFileProvider : PreviewParameterProvider<MediaInfo> {
override val values: Sequence<MediaInfo>
get() = sequenceOf(
aPdfMediaInfo(),
anAudioMediaInfo(),
)
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.local.player
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
fun ExoPlayer.togglePlay() {
if (isPlaying) {
pause()
} else {
if (playbackState == Player.STATE_ENDED) {
seekTo(0)
} else {
play()
}
}
}
fun ExoPlayer.seekToEnsurePlaying(positionMs: Long) {
if (isPlaying.not()) {
play()
}
seekTo(positionMs)
}

View file

@ -0,0 +1,28 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.local.player
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.media3.exoplayer.ExoPlayer
@Composable
fun rememberExoPlayer(): ExoPlayer {
return if (LocalInspectionMode.current) {
remember {
ExoPlayerForPreview()
}
} else {
val context = LocalContext.current
remember {
ExoPlayer.Builder(context).build()
}
}
}

View file

@ -11,7 +11,7 @@
"DEPRECATION",
)
package io.element.android.libraries.mediaviewer.impl.local.video
package io.element.android.libraries.mediaviewer.impl.local.player
import android.annotation.SuppressLint
import android.media.AudioDeviceInfo

View file

@ -5,12 +5,18 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.local.video
package io.element.android.libraries.mediaviewer.impl.local.player
import androidx.annotation.FloatRange
data class MediaPlayerControllerState(
val isVisible: Boolean,
val isPlaying: Boolean,
val progressInMillis: Long,
val durationInMillis: Long,
val canMute: Boolean,
val isMuted: Boolean,
)
) {
@FloatRange(from = 0.0, to = 1.0)
val progressAsFloat = (progressInMillis.toFloat() / durationInMillis.toFloat()).coerceIn(0f, 1f)
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.local.video
package io.element.android.libraries.mediaviewer.impl.local.player
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -18,6 +18,9 @@ open class MediaPlayerControllerStateProvider : PreviewParameterProvider<MediaPl
durationInMillis = 83_000,
isMuted = true,
),
aMediaPlayerControllerState(
canMute = false,
),
)
}
@ -27,11 +30,13 @@ private fun aMediaPlayerControllerState(
progressInMillis: Long = 0,
// Default to 1 minute and 23 seconds
durationInMillis: Long = 83_000,
canMute: Boolean = true,
isMuted: Boolean = false,
) = MediaPlayerControllerState(
isVisible = isVisible,
isPlaying = isPlaying,
progressInMillis = progressInMillis,
durationInMillis = durationInMillis,
canMute = canMute,
isMuted = isMuted,
)

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.local.video
package io.element.android.libraries.mediaviewer.impl.local.player
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@ -133,21 +133,23 @@ fun MediaPlayerControllerView(
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyXsMedium,
)
IconButton(
onClick = onToggleMute,
) {
if (state.isMuted) {
Icon(
imageVector = CompoundIcons.VolumeOffSolid(),
tint = ElementTheme.colors.iconPrimary,
contentDescription = stringResource(CommonStrings.common_unmute)
)
} else {
Icon(
imageVector = CompoundIcons.VolumeOnSolid(),
tint = ElementTheme.colors.iconPrimary,
contentDescription = stringResource(CommonStrings.common_mute)
)
if (state.canMute) {
IconButton(
onClick = onToggleMute,
) {
if (state.isMuted) {
Icon(
imageVector = CompoundIcons.VolumeOffSolid(),
tint = ElementTheme.colors.iconPrimary,
contentDescription = stringResource(CommonStrings.common_unmute)
)
} else {
Icon(
imageVector = CompoundIcons.VolumeOnSolid(),
tint = ElementTheme.colors.iconPrimary,
contentDescription = stringResource(CommonStrings.common_mute)
)
}
}
}
}

View file

@ -1,39 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.local.video
import android.content.Context
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
/**
* Wrapper around ExoPlayer to disable some commands.
* Necessary to hide the settings wheels from the player.
*/
@UnstableApi
class ExoPlayerWrapper(private val exoPlayer: ExoPlayer) : ExoPlayer by exoPlayer {
override fun isCommandAvailable(command: Int): Boolean {
return availableCommands.contains(command)
}
override fun getAvailableCommands(): Player.Commands {
return exoPlayer.availableCommands
.buildUpon()
.remove(Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS)
.build()
}
companion object {
fun create(context: Context): ExoPlayer {
return ExoPlayerWrapper(
ExoPlayer.Builder(context).build()
)
}
}
}

View file

@ -45,6 +45,11 @@ import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.local.PlayableState
import io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerState
import io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerView
import io.element.android.libraries.mediaviewer.impl.local.player.rememberExoPlayer
import io.element.android.libraries.mediaviewer.impl.local.player.seekToEnsurePlaying
import io.element.android.libraries.mediaviewer.impl.local.player.togglePlay
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
@ -57,16 +62,7 @@ fun MediaVideoView(
localMedia: LocalMedia?,
modifier: Modifier = Modifier,
) {
val exoPlayer = if (LocalInspectionMode.current) {
remember {
ExoPlayerForPreview()
}
} else {
val context = LocalContext.current
remember {
ExoPlayerWrapper.create(context)
}
}
val exoPlayer = rememberExoPlayer()
ExoPlayerMediaVideoView(
localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels,
@ -92,6 +88,7 @@ private fun ExoPlayerMediaVideoView(
isPlaying = false,
progressInMillis = 0,
durationInMillis = 0,
canMute = true,
isMuted = false,
)
)
@ -107,31 +104,33 @@ private fun ExoPlayerMediaVideoView(
localMediaViewState.playableState = playableState
val playerListener = object : Player.Listener {
override fun onRenderedFirstFrame() {
localMediaViewState.isReady = true
}
val playerListener = remember {
object : Player.Listener {
override fun onRenderedFirstFrame() {
localMediaViewState.isReady = true
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isPlaying = isPlaying,
)
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isPlaying = isPlaying,
)
}
override fun onVolumeChanged(volume: Float) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isMuted = volume == 0f,
)
}
override fun onVolumeChanged(volume: Float) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isMuted = volume == 0f,
)
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
exoPlayer.duration.takeIf { it >= 0 }
?.let {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
durationInMillis = it,
)
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
exoPlayer.duration.takeIf { it >= 0 }
?.let {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
durationInMillis = it,
)
}
}
}
}
}
@ -211,22 +210,11 @@ private fun ExoPlayerMediaVideoView(
state = mediaPlayerControllerState,
onTogglePlay = {
autoHideController++
if (exoPlayer.isPlaying) {
exoPlayer.pause()
} else {
if (exoPlayer.playbackState == Player.STATE_ENDED) {
exoPlayer.seekTo(0)
} else {
exoPlayer.play()
}
}
exoPlayer.togglePlay()
},
onSeekChange = {
autoHideController++
if (exoPlayer.isPlaying.not()) {
exoPlayer.play()
}
exoPlayer.seekTo(it.toLong())
exoPlayer.seekToEnsurePlaying(it.toLong())
},
onToggleMute = {
autoHideController++

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.mediaviewer.impl.viewer
import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.media.aWaveForm
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
@ -100,6 +101,16 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
aMediaViewerState(
mediaBottomSheetState = aMediaDeleteConfirmationState(),
),
anAudioMediaInfo(
waveForm = aWaveForm(),
).let {
aMediaViewerState(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
},
)
}

View file

@ -1,10 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_empty_state_subtitle">"Obrázky a videa nahraná do této místnosti budou zobrazeny zde."</string>
<string name="screen_media_browser_empty_state_title">"Zatím nebyla nahrána žádná média"</string>
<string name="screen_media_browser_delete_confirmation_subtitle">"Tento soubor bude odstraněn z místnosti a členové k němu nebudou mít přístup."</string>
<string name="screen_media_browser_delete_confirmation_title">"Smazat soubor?"</string>
<string name="screen_media_browser_files_empty_state_subtitle">"Zde se zobrazí dokumenty, zvukové soubory a hlasové zprávy nahrané do této místnosti."</string>
<string name="screen_media_browser_files_empty_state_title">"Zatím nebyly nahrány žádné soubory"</string>
<string name="screen_media_browser_list_loading_files">"Načítání souborů…"</string>
<string name="screen_media_browser_list_loading_media">"Načítání médií…"</string>
<string name="screen_media_browser_list_mode_files">"Soubory"</string>
<string name="screen_media_browser_list_mode_media">"Média"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"Obrázky a videa nahraná do této místnosti budou zobrazeny zde."</string>
<string name="screen_media_browser_media_empty_state_title">"Zatím nebyla nahrána žádná média"</string>
<string name="screen_media_browser_title">"Média a soubory"</string>
<string name="screen_media_details_file_format">"Formát souboru"</string>
<string name="screen_media_details_filename">"Název souboru"</string>
<string name="screen_media_details_redact_confirmation_message">"Tento soubor bude odstraněn z místnosti a členové k němu nebudou mít přístup."</string>
<string name="screen_media_details_redact_confirmation_title">"Smazat soubor?"</string>
<string name="screen_media_details_uploaded_by">"Nahrál(a)"</string>
<string name="screen_media_details_uploaded_on">"Nahráno"</string>
</resources>

View file

@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_empty_state_subtitle">"In diesen Chatroom hochgeladene Bilder und Videos werden hier angezeigt."</string>
<string name="screen_media_browser_empty_state_title">"Noch keine Medien hochgeladen"</string>
<string name="screen_media_browser_list_loading_files">"Dateien werden geladen…"</string>
<string name="screen_media_browser_list_loading_media">"Medien werden geladen…"</string>
<string name="screen_media_browser_list_mode_files">"Dateien"</string>
<string name="screen_media_browser_list_mode_media">"Medien"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"In diesen Chatroom hochgeladene Bilder und Videos werden hier angezeigt."</string>
<string name="screen_media_browser_media_empty_state_title">"Noch keine Medien hochgeladen"</string>
<string name="screen_media_browser_title">"Medien und Dateien"</string>
<string name="screen_media_details_file_format">"Dateiformat"</string>
<string name="screen_media_details_filename">"Dateiname"</string>

Some files were not shown because too many files have changed in this diff Show more