Merge branch 'main' into wallet
# Conflicts: # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt # libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt
This commit is contained in:
commit
0ef6b69a79
912 changed files with 17051 additions and 4425 deletions
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.androidutils.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
|
||||
interface ServiceBinder {
|
||||
fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean
|
||||
fun unbindService(conn: ServiceConnection)
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultServiceBinder(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ServiceBinder {
|
||||
override fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean {
|
||||
return context.bindService(service, conn, flags)
|
||||
}
|
||||
|
||||
override fun unbindService(conn: ServiceConnection) {
|
||||
context.unbindService(conn)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,9 +16,11 @@ import android.view.ViewTreeObserver
|
|||
import android.view.WindowInsets
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.core.content.getSystemService
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
fun View.hideKeyboard() {
|
||||
val imm = context?.getSystemService<InputMethodManager>()
|
||||
|
|
@ -26,29 +28,39 @@ fun View.hideKeyboard() {
|
|||
}
|
||||
|
||||
suspend fun View.hideKeyboardAndAwaitAnimation() {
|
||||
val imm = context?.getSystemService<InputMethodManager>()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !rootWindowInsets.isVisible(WindowInsets.Type.ime())) {
|
||||
// Keyboard is already hidden, no need to do anything
|
||||
return
|
||||
}
|
||||
|
||||
val mutex = Mutex()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val imm = context?.getSystemService<InputMethodManager>() ?: return
|
||||
val future = CompletableDeferred<Unit>()
|
||||
|
||||
val requested = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
setOnApplyWindowInsetsListener { view, insets ->
|
||||
if (!insets.isVisible(WindowInsets.Type.ime())) {
|
||||
mutex.unlock()
|
||||
future.complete(Unit)
|
||||
// Remove the listener now, it's a single use operation
|
||||
setOnApplyWindowInsetsListener(null)
|
||||
}
|
||||
insets
|
||||
}
|
||||
imm?.hideSoftInputFromWindow(windowToken, 0)
|
||||
imm.hideSoftInputFromWindow(windowToken, 0)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
imm?.hideSoftInputFromWindow(windowToken, 0, object : ResultReceiver(null) {
|
||||
imm.hideSoftInputFromWindow(windowToken, 0, object : ResultReceiver(null) {
|
||||
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
|
||||
if (resultCode == InputMethodManager.RESULT_UNCHANGED_HIDDEN ||
|
||||
resultCode == InputMethodManager.RESULT_HIDDEN) {
|
||||
mutex.unlock()
|
||||
if (resultCode == InputMethodManager.RESULT_UNCHANGED_HIDDEN || resultCode == InputMethodManager.RESULT_HIDDEN) {
|
||||
future.complete(Unit)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
mutex.lock()
|
||||
|
||||
if (requested) {
|
||||
// Await the future to ensure the keyboard hide animation has completed before proceeding
|
||||
withTimeoutOrNull(1.seconds) { future.await() }
|
||||
}
|
||||
}
|
||||
|
||||
fun View.showKeyboard(andRequestFocus: Boolean = false) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="error_no_compatible_app_found">"このアクションを処理できるアプリが見つかりません。"</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="error_no_compatible_app_found">"Không tìm thấy ứng dụng tương thích nào để xử lý hành động này."</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.architecture.appyx
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.Transition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler
|
||||
import com.bumble.appyx.core.navigation.transition.TransitionDescriptor
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.NewRoot
|
||||
import com.bumble.appyx.navmodel.backstack.operation.Replace
|
||||
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
|
||||
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider
|
||||
|
||||
/**
|
||||
* A TransitionHandler that uses fade transition when the operation is Replace or NewRoot,
|
||||
* and slide transition for all other cases.
|
||||
*/
|
||||
private class FaderOrSliderTransitionHandler<NavTarget>(
|
||||
private val slider: ModifierTransitionHandler<NavTarget, BackStack.State>,
|
||||
private val fader: ModifierTransitionHandler<NavTarget, BackStack.State>,
|
||||
) : ModifierTransitionHandler<NavTarget, BackStack.State>() {
|
||||
override fun createModifier(
|
||||
modifier: Modifier,
|
||||
transition: Transition<BackStack.State>,
|
||||
descriptor: TransitionDescriptor<NavTarget, BackStack.State>
|
||||
): Modifier {
|
||||
val operation = descriptor.operation
|
||||
val useFader = operation is Replace || operation is NewRoot
|
||||
val handler = if (useFader) fader else slider
|
||||
return handler.createModifier(modifier, transition, descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <NavTarget> rememberFaderOrSliderTransitionHandler(): ModifierTransitionHandler<NavTarget, BackStack.State> {
|
||||
val slider = rememberBackstackSlider<NavTarget>(
|
||||
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
|
||||
)
|
||||
val fader = rememberBackstackFader<NavTarget>(
|
||||
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
|
||||
)
|
||||
return rememberDelegateTransitionHandler {
|
||||
FaderOrSliderTransitionHandler(slider, fader)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:37f6acca46890e98087ece62e2716fa60791479fab02999406050517e3b79307
|
||||
size 240187
|
||||
oid sha256:7901fea2f578c8ed796160c9c08f417c61f4fb21580f958844fdf0cb794adf8a
|
||||
size 239731
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a2de5e6d24dcbe0baa75a69485f5a308466fa599625bcbdb0cb96e9bc5a1b708
|
||||
size 253233
|
||||
oid sha256:245f012d419817f6557d92a71729b3b70092f24f0eba37f2f1fc431ad27592be
|
||||
size 252969
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ae1cb46d82acbb23cc172f41e20a41bbe88c350ab53c20e5b2a91f2c16590fbf
|
||||
size 254525
|
||||
oid sha256:2d15c52b21cc279d306fa187cd0c318820109b5ec66270e6447e1b02e800eeba
|
||||
size 254206
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8a8a9b6e61758a40d01028a4edb4a4d21b845b83b3e0793ed0934e48f3d9eea0
|
||||
size 94637
|
||||
oid sha256:85ef188fa3a27e42f4beafc899c1f3e7e8bcfad980ed76af6a03f76d70d6a511
|
||||
size 93807
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7f29d225df71587fefe07ec8739b84f1a0469786c6b1d6778da0bad33d19574e
|
||||
size 101183
|
||||
oid sha256:f6e38386e95dc0c50384f06fca122ce14851ceff8ffc7865e394c1b4fccc5db6
|
||||
size 100555
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -229,9 +229,6 @@ object CompoundIcons {
|
|||
@Composable fun Filter(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_filter)
|
||||
}
|
||||
@Composable fun Folder(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_folder)
|
||||
}
|
||||
@Composable fun Forward(): ImageVector {
|
||||
return ImageVector.vectorResource(R.drawable.ic_compound_forward)
|
||||
}
|
||||
|
|
@ -771,7 +768,6 @@ object CompoundIcons {
|
|||
FileError(),
|
||||
Files(),
|
||||
Filter(),
|
||||
Folder(),
|
||||
Forward(),
|
||||
FullScreen(),
|
||||
Grid(),
|
||||
|
|
@ -1000,7 +996,6 @@ object CompoundIcons {
|
|||
R.drawable.ic_compound_file_error,
|
||||
R.drawable.ic_compound_files,
|
||||
R.drawable.ic_compound_filter,
|
||||
R.drawable.ic_compound_folder,
|
||||
R.drawable.ic_compound_forward,
|
||||
R.drawable.ic_compound_full_screen,
|
||||
R.drawable.ic_compound_grid,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ data class SemanticColors(
|
|||
val bgSubtleSecondaryLevel0: Color,
|
||||
/** Subtle background colour for success state elements. State: Rest. */
|
||||
val bgSuccessSubtle: Color,
|
||||
/** Accent borders for containers */
|
||||
val borderAccentPrimary: Color,
|
||||
/** accent border intended for keylines on message highlights */
|
||||
val borderAccentSubtle: Color,
|
||||
/** High-contrast border for critical state. State: Hover. */
|
||||
|
|
@ -171,6 +173,8 @@ data class SemanticColors(
|
|||
val iconTertiary: Color,
|
||||
/** Translucent version of tertiary icon. Refer to it for intended use. */
|
||||
val iconTertiaryAlpha: Color,
|
||||
/** Used to separate core sections of the UI as well as containers */
|
||||
val separatorPrimary: Color,
|
||||
/** Accent text colour for plain actions. */
|
||||
val textActionAccent: Color,
|
||||
/** Default text colour for plain actions. */
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ val compoundColorsDark = SemanticColors(
|
|||
bgSubtleSecondary = DarkColorTokens.colorGray300,
|
||||
bgSubtleSecondaryLevel0 = DarkColorTokens.colorThemeBg,
|
||||
bgSuccessSubtle = DarkColorTokens.colorGreen200,
|
||||
borderAccentPrimary = DarkColorTokens.colorGreen900,
|
||||
borderAccentSubtle = DarkColorTokens.colorGreen700,
|
||||
borderCriticalHovered = DarkColorTokens.colorRed1000,
|
||||
borderCriticalPrimary = DarkColorTokens.colorRed900,
|
||||
|
|
@ -101,6 +102,7 @@ val compoundColorsDark = SemanticColors(
|
|||
iconSuccessPrimary = DarkColorTokens.colorGreen900,
|
||||
iconTertiary = DarkColorTokens.colorGray800,
|
||||
iconTertiaryAlpha = DarkColorTokens.colorAlphaGray800,
|
||||
separatorPrimary = DarkColorTokens.colorGray400,
|
||||
textActionAccent = DarkColorTokens.colorGreen900,
|
||||
textActionPrimary = DarkColorTokens.colorGray1400,
|
||||
textBadgeAccent = DarkColorTokens.colorGreen1100,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ val compoundColorsHcDark = SemanticColors(
|
|||
bgSubtleSecondary = DarkHcColorTokens.colorGray300,
|
||||
bgSubtleSecondaryLevel0 = DarkHcColorTokens.colorThemeBg,
|
||||
bgSuccessSubtle = DarkHcColorTokens.colorGreen200,
|
||||
borderAccentPrimary = DarkHcColorTokens.colorGreen900,
|
||||
borderAccentSubtle = DarkHcColorTokens.colorGreen700,
|
||||
borderCriticalHovered = DarkHcColorTokens.colorRed1000,
|
||||
borderCriticalPrimary = DarkHcColorTokens.colorRed900,
|
||||
|
|
@ -101,6 +102,7 @@ val compoundColorsHcDark = SemanticColors(
|
|||
iconSuccessPrimary = DarkHcColorTokens.colorGreen900,
|
||||
iconTertiary = DarkHcColorTokens.colorGray800,
|
||||
iconTertiaryAlpha = DarkHcColorTokens.colorAlphaGray800,
|
||||
separatorPrimary = DarkHcColorTokens.colorGray400,
|
||||
textActionAccent = DarkHcColorTokens.colorGreen900,
|
||||
textActionPrimary = DarkHcColorTokens.colorGray1400,
|
||||
textBadgeAccent = DarkHcColorTokens.colorGreen1100,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ val compoundColorsLight = SemanticColors(
|
|||
bgSubtleSecondary = LightColorTokens.colorGray300,
|
||||
bgSubtleSecondaryLevel0 = LightColorTokens.colorGray300,
|
||||
bgSuccessSubtle = LightColorTokens.colorGreen200,
|
||||
borderAccentPrimary = LightColorTokens.colorGreen900,
|
||||
borderAccentSubtle = LightColorTokens.colorGreen700,
|
||||
borderCriticalHovered = LightColorTokens.colorRed1000,
|
||||
borderCriticalPrimary = LightColorTokens.colorRed900,
|
||||
|
|
@ -101,6 +102,7 @@ val compoundColorsLight = SemanticColors(
|
|||
iconSuccessPrimary = LightColorTokens.colorGreen900,
|
||||
iconTertiary = LightColorTokens.colorGray800,
|
||||
iconTertiaryAlpha = LightColorTokens.colorAlphaGray800,
|
||||
separatorPrimary = LightColorTokens.colorGray400,
|
||||
textActionAccent = LightColorTokens.colorGreen900,
|
||||
textActionPrimary = LightColorTokens.colorGray1400,
|
||||
textBadgeAccent = LightColorTokens.colorGreen1100,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ val compoundColorsHcLight = SemanticColors(
|
|||
bgSubtleSecondary = LightHcColorTokens.colorGray300,
|
||||
bgSubtleSecondaryLevel0 = LightHcColorTokens.colorGray300,
|
||||
bgSuccessSubtle = LightHcColorTokens.colorGreen200,
|
||||
borderAccentPrimary = LightHcColorTokens.colorGreen900,
|
||||
borderAccentSubtle = LightHcColorTokens.colorGreen700,
|
||||
borderCriticalHovered = LightHcColorTokens.colorRed1000,
|
||||
borderCriticalPrimary = LightHcColorTokens.colorRed900,
|
||||
|
|
@ -101,6 +102,7 @@ val compoundColorsHcLight = SemanticColors(
|
|||
iconSuccessPrimary = LightHcColorTokens.colorGreen900,
|
||||
iconTertiary = LightHcColorTokens.colorGray800,
|
||||
iconTertiaryAlpha = LightHcColorTokens.colorAlphaGray800,
|
||||
separatorPrimary = LightHcColorTokens.colorGray400,
|
||||
textActionAccent = LightHcColorTokens.colorGreen900,
|
||||
textActionPrimary = LightHcColorTokens.colorGray1400,
|
||||
textBadgeAccent = LightHcColorTokens.colorGreen1100,
|
||||
|
|
|
|||
|
|
@ -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">"今月"</string>
|
||||
</resources>
|
||||
|
|
@ -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 lúc %2$s"</string>
|
||||
<string name="common_date_this_month">"Tháng này"</string>
|
||||
</resources>
|
||||
|
|
@ -16,7 +16,10 @@ import android.widget.EditText
|
|||
import androidx.appcompat.app.ActionBar.LayoutParams
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.awaitVerticalPointerSlopOrCancellation
|
||||
import androidx.compose.foundation.gestures.verticalDrag
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -41,10 +44,14 @@ 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.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
|
@ -94,7 +101,7 @@ fun ExpandableBottomSheetLayout(
|
|||
.run {
|
||||
if (isSwipeGestureEnabled) {
|
||||
pointerInput(maxBottomSheetContentHeight) {
|
||||
detectVerticalDragGestures(
|
||||
customDetectVerticalDragGestures(
|
||||
onVerticalDrag = { _, dragAmount ->
|
||||
val calculatedHeight = max(minBottomContentHeightPx, currentBottomContentHeightPx - dragAmount.roundToInt())
|
||||
val newHeight = min(calculatedMaxBottomContentHeightPx, calculatedHeight)
|
||||
|
|
@ -120,7 +127,11 @@ fun ExpandableBottomSheetLayout(
|
|||
|
||||
animatable.animateTo(destination)
|
||||
}
|
||||
}
|
||||
},
|
||||
canScroll = {
|
||||
// We only consider we can scroll in the contents if the min size matches the max size so it's maximized
|
||||
minBottomContentHeightPx == calculatedMaxBottomContentHeightPx
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -189,6 +200,45 @@ fun ExpandableBottomSheetLayout(
|
|||
)
|
||||
}
|
||||
|
||||
// The original detectVerticalDragGestures doesn't allow us to conditionally consume the initial slop event that triggers the drag,
|
||||
// which is necessary in our case to allow inner scrollables to work when the sheet is not fully expanded, so we need to re-implement it here
|
||||
private suspend fun PointerInputScope.customDetectVerticalDragGestures(
|
||||
onDragStart: (Offset) -> Unit = {},
|
||||
onDragEnd: () -> Unit = {},
|
||||
onDragCancel: () -> Unit = {},
|
||||
canScroll: () -> Boolean = { false },
|
||||
onVerticalDrag: (change: PointerInputChange, dragAmount: Float) -> Unit,
|
||||
) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var overSlop = 0f
|
||||
val drag =
|
||||
awaitVerticalPointerSlopOrCancellation(down.id, down.type) { change, over ->
|
||||
// Consuming this event is what triggers the dragging instead of the inner content scrolling
|
||||
// We should only consume it if we can't scroll in the inner content so we drag the bottom sheet instead, otherwise we let it pass through
|
||||
// This is the only change compared to the original detectVerticalDragGestures implementation
|
||||
if (!canScroll()) {
|
||||
change.consume()
|
||||
}
|
||||
overSlop = over
|
||||
}
|
||||
if (drag != null) {
|
||||
onDragStart.invoke(drag.position)
|
||||
onVerticalDrag.invoke(drag, overSlop)
|
||||
if (
|
||||
verticalDrag(drag.id) {
|
||||
onVerticalDrag(it, it.positionChange().y)
|
||||
it.consume()
|
||||
}
|
||||
) {
|
||||
onDragEnd()
|
||||
} else {
|
||||
onDragCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
@Suppress("UnusedPrivateMember")
|
||||
|
|
|
|||
|
|
@ -32,9 +32,13 @@ import androidx.compose.ui.graphics.asImageBitmap
|
|||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.graphics.withSave
|
||||
import coil3.Image
|
||||
import coil3.ImageLoader
|
||||
|
|
@ -50,6 +54,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
|||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
|
||||
private val PIN_WIDTH = 42.dp
|
||||
private val PIN_HEIGHT = PIN_WIDTH * 1.2f
|
||||
|
|
@ -99,21 +104,33 @@ fun LocationPin(
|
|||
fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? {
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
val imageLoader = SingletonImageLoader.get(context)
|
||||
val colors = pinColors(variant)
|
||||
val cacheKey = rememberCacheKey(variant)
|
||||
return produceState<ImageBitmap?>(initialValue = null, cacheKey) {
|
||||
val memoryCacheKey = MemoryCache.Key(cacheKey)
|
||||
val cached = imageLoader.memoryCache?.get(memoryCacheKey)
|
||||
if (cached != null) {
|
||||
value = cached.image.toBitmap().asImageBitmap()
|
||||
} else {
|
||||
val dimensions = PinDimensions(density)
|
||||
val bitmap = LocationPinRenderer.renderPin(variant, colors, dimensions, context, imageLoader)
|
||||
imageLoader.memoryCache?.set(memoryCacheKey, MemoryCache.Value(bitmap.asImage()))
|
||||
value = bitmap.asImageBitmap()
|
||||
}
|
||||
}.value
|
||||
val resources = LocalResources.current
|
||||
|
||||
return if (LocalInspectionMode.current) {
|
||||
// In preview mode, skip async loading and return a simple placeholder image instead to avoid using ImageLoader
|
||||
val dimensions = PinDimensions(density)
|
||||
val avatarImage = ResourcesCompat.getDrawable(resources, CommonDrawables.sample_avatar, context.theme)?.toBitmap()?.asImage()
|
||||
LocationPinRenderer.renderPin(variant, colors, dimensions, avatarImage).asImageBitmap()
|
||||
} else {
|
||||
produceState<ImageBitmap?>(initialValue = null, cacheKey) {
|
||||
val imageLoader = SingletonImageLoader.get(context)
|
||||
val memoryCacheKey = MemoryCache.Key(cacheKey)
|
||||
val cached = imageLoader.memoryCache?.get(memoryCacheKey)
|
||||
if (cached != null) {
|
||||
value = cached.image.toBitmap().asImageBitmap()
|
||||
} else {
|
||||
val dimensions = PinDimensions(density)
|
||||
val bitmap = with(LocationPinRenderer) {
|
||||
val avatarImage = loadAvatarImage(variant, context, imageLoader)
|
||||
renderPin(variant, colors, dimensions, avatarImage)
|
||||
}
|
||||
imageLoader.memoryCache?.set(memoryCacheKey, MemoryCache.Value(bitmap.asImage()))
|
||||
value = bitmap.asImageBitmap()
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -208,19 +225,17 @@ private object LocationPinRenderer {
|
|||
/**
|
||||
* Renders a pin variant to bitmap. Suspending for async avatar loading.
|
||||
*/
|
||||
suspend fun renderPin(
|
||||
fun renderPin(
|
||||
variant: PinVariant,
|
||||
colors: PinColors,
|
||||
dimensions: PinDimensions,
|
||||
context: Context,
|
||||
imageLoader: ImageLoader,
|
||||
avatarImage: Image?,
|
||||
): Bitmap {
|
||||
val bitmap = createBitmap(dimensions.pinWidth.toInt(), dimensions.pinHeight.toInt())
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawPinShape(colors.fill, colors.stroke, dimensions)
|
||||
when (variant) {
|
||||
is PinVariant.UserLocation -> {
|
||||
val avatarImage = loadAvatarImage(variant.avatarData, context, imageLoader)
|
||||
canvas.drawAvatar(
|
||||
avatarImage = avatarImage,
|
||||
avatarData = variant.avatarData,
|
||||
|
|
@ -284,11 +299,15 @@ private object LocationPinRenderer {
|
|||
return path
|
||||
}
|
||||
|
||||
private suspend fun loadAvatarImage(
|
||||
avatarData: AvatarData,
|
||||
suspend fun loadAvatarImage(
|
||||
variant: PinVariant,
|
||||
context: Context,
|
||||
imageLoader: ImageLoader,
|
||||
): Image? {
|
||||
val avatarData = when (variant) {
|
||||
is PinVariant.UserLocation -> variant.avatarData
|
||||
else -> return null
|
||||
}
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(avatarData)
|
||||
// Disable hardware rendering for Canvas
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@ import androidx.compose.ui.unit.dp
|
|||
|
||||
enum class AvatarSize(val dp: Dp) {
|
||||
CurrentUserTopBar(32.dp),
|
||||
CurrentRoomTopBar(32.dp),
|
||||
|
||||
IncomingCall(140.dp),
|
||||
RoomDetailsHeader(96.dp),
|
||||
RoomListItem(52.dp),
|
||||
ThreadsListItem(52.dp),
|
||||
|
||||
SpaceListItem(52.dp),
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.components.avatar
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import coil3.compose.SubcomposeAsyncImageContent
|
||||
import io.element.android.libraries.designsystem.components.avatar.internal.InitialLetterAvatar
|
||||
import timber.log.Timber
|
||||
|
||||
// For user avatar only.
|
||||
@Composable
|
||||
fun BitmapAvatar(
|
||||
avatarData: AvatarData,
|
||||
bitmap: Bitmap?,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
) {
|
||||
val avatarShape = AvatarType.User.avatarShape()
|
||||
when {
|
||||
bitmap == null -> InitialLetterAvatar(
|
||||
avatarData = avatarData,
|
||||
avatarShape = avatarShape,
|
||||
forcedAvatarSize = null,
|
||||
modifier = modifier,
|
||||
contentDescription = contentDescription,
|
||||
)
|
||||
else -> {
|
||||
val size = avatarData.size.dp
|
||||
SubcomposeAsyncImage(
|
||||
model = bitmap,
|
||||
contentDescription = contentDescription,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(avatarShape)
|
||||
) {
|
||||
val collectedState by painter.state.collectAsState()
|
||||
when (val state = collectedState) {
|
||||
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
|
||||
is AsyncImagePainter.State.Error -> {
|
||||
SideEffect {
|
||||
Timber.e(
|
||||
state.result.throwable,
|
||||
"Error loading avatar $state\n${state.result}"
|
||||
)
|
||||
}
|
||||
InitialLetterAvatar(
|
||||
avatarData = avatarData,
|
||||
avatarShape = avatarShape,
|
||||
forcedAvatarSize = null,
|
||||
contentDescription = contentDescription,
|
||||
)
|
||||
}
|
||||
else -> InitialLetterAvatar(
|
||||
avatarData = avatarData,
|
||||
avatarShape = avatarShape,
|
||||
forcedAvatarSize = null,
|
||||
contentDescription = contentDescription,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -75,6 +75,9 @@ val SemanticColors.pinnedMessageBannerIndicator
|
|||
val SemanticColors.pinnedMessageBannerBorder
|
||||
get() = if (isLight) LightColorTokens.colorAlphaGray400 else DarkColorTokens.colorAlphaGray400
|
||||
|
||||
val SemanticColors.floatingDateBadgeBackground
|
||||
get() = if (isLight) bgCanvasDefault else bgSubtlePrimary
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ColorAliasesPreview() = ElementPreview {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.utils
|
||||
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun hasCompactHeightWindowSize(): Boolean {
|
||||
return currentWindowAdaptiveInfo().windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact
|
||||
}
|
||||
|
|
@ -118,15 +118,6 @@ class StateContentFormatter(
|
|||
"PolicyRuleUser"
|
||||
}
|
||||
}
|
||||
OtherState.RoomAliases -> when (renderingMode) {
|
||||
RenderingMode.RoomList -> {
|
||||
Timber.v("Filtering timeline item for room state change: $content")
|
||||
null
|
||||
}
|
||||
RenderingMode.Timeline -> {
|
||||
"RoomAliases"
|
||||
}
|
||||
}
|
||||
OtherState.RoomCanonicalAlias -> when (renderingMode) {
|
||||
RenderingMode.RoomList -> {
|
||||
Timber.v("Filtering timeline item for room state change: $content")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="state_event_avatar_changed_too">"(アバターも変更)"</string>
|
||||
<string name="state_event_avatar_url_changed">"%1$s がアバターを変更"</string>
|
||||
<string name="state_event_avatar_url_changed_by_you">"あなたがアバターを変更"</string>
|
||||
<string name="state_event_demoted_to_member">"%1$s がメンバーに降格"</string>
|
||||
<string name="state_event_demoted_to_moderator">"%1$s がモデレーターに降格"</string>
|
||||
<string name="state_event_display_name_changed_from">"%1$sが表示名を変更: %2$s > %3$s"</string>
|
||||
<string name="state_event_display_name_changed_from_by_you">"あなたが表示名を変更: %1$s > %2$s"</string>
|
||||
<string name="state_event_display_name_removed">"%1$sが表示名を削除 (%2$s)"</string>
|
||||
<string name="state_event_display_name_removed_by_you">"表示名を削除 (%1$s)"</string>
|
||||
<string name="state_event_display_name_set">"%1$sが表示名を設定: %2$s"</string>
|
||||
<string name="state_event_display_name_set_by_you">"あなたが表示名を設定: %1$s"</string>
|
||||
<string name="state_event_promoted_to_administrator">"%1$s が管理者に昇格"</string>
|
||||
<string name="state_event_promoted_to_moderator">"%1$s がモデレーターに昇格"</string>
|
||||
<string name="state_event_room_avatar_changed">"%1$sがルームアバターを変更"</string>
|
||||
<string name="state_event_room_avatar_changed_by_you">"あなたがルームアバターを変更"</string>
|
||||
<string name="state_event_room_avatar_removed">"%1$sがルームアバターを削除"</string>
|
||||
<string name="state_event_room_avatar_removed_by_you">"あなたがルームアバターを削除"</string>
|
||||
<string name="state_event_room_ban">"%1$s が %2$s を追放"</string>
|
||||
<string name="state_event_room_ban_by_you">"あなたが %1$s を追放"</string>
|
||||
<string name="state_event_room_ban_by_you_with_reason">"あなたが %1$s を追放: %2$s"</string>
|
||||
<string name="state_event_room_ban_with_reason">"%1$s が %2$s を追放: %3$s"</string>
|
||||
<string name="state_event_room_created">"%1$s がルームを作成"</string>
|
||||
<string name="state_event_room_created_by_you">"あなたがルームを作成"</string>
|
||||
<string name="state_event_room_invite">"%1$s が %2$s を招待"</string>
|
||||
<string name="state_event_room_invite_accepted">"%1$s が招待を受諾"</string>
|
||||
<string name="state_event_room_invite_accepted_by_you">"あなたが招待を受諾"</string>
|
||||
<string name="state_event_room_invite_by_you">"あなたが %1$s を招待"</string>
|
||||
<string name="state_event_room_invite_you">"%1$s があなたを招待"</string>
|
||||
<string name="state_event_room_join">"%1$s がルームに参加"</string>
|
||||
<string name="state_event_room_join_by_you">"あなたがルームに参加"</string>
|
||||
<string name="state_event_room_knock">"%1$s が参加をリクエスト"</string>
|
||||
<string name="state_event_room_knock_accepted">"%1$s が %2$s の参加を許可"</string>
|
||||
<string name="state_event_room_knock_accepted_by_you">"あなたが %1$s の参加を許可"</string>
|
||||
<string name="state_event_room_knock_by_you">"あなたが参加をリクエスト"</string>
|
||||
<string name="state_event_room_knock_denied">"%1$s が %2$s の参加リクエストを拒否"</string>
|
||||
<string name="state_event_room_knock_denied_by_you">"あなたが %1$s の参加リクエストを拒否"</string>
|
||||
<string name="state_event_room_knock_denied_you">"%1$s があなたの参加リクエストを拒否"</string>
|
||||
<string name="state_event_room_knock_retracted">"%1$s が参加リクエストを取り消し"</string>
|
||||
<string name="state_event_room_knock_retracted_by_you">"あなたが参加リクエストを取り消し"</string>
|
||||
<string name="state_event_room_leave">"%1$s がルームを退出"</string>
|
||||
<string name="state_event_room_leave_by_you">"あなたがルームを退出"</string>
|
||||
<string name="state_event_room_name_changed">"%1$s がルーム名を変更: %2$s"</string>
|
||||
<string name="state_event_room_name_changed_by_you">"あなたがルーム名を変更: %1$s"</string>
|
||||
<string name="state_event_room_name_removed">"%1$s がルーム名を削除"</string>
|
||||
<string name="state_event_room_name_removed_by_you">"あなたがルーム名を削除"</string>
|
||||
<string name="state_event_room_none">"%1$s による変更はありません"</string>
|
||||
<string name="state_event_room_none_by_you">"あなたによる変更はありません"</string>
|
||||
<string name="state_event_room_pinned_events_changed">"%1$s はピン留めメッセージを変更しました"</string>
|
||||
<string name="state_event_room_pinned_events_changed_by_you">"あなたがピン留めメッセージを変更しました"</string>
|
||||
<string name="state_event_room_pinned_events_pinned">"%1$s がメッセージをピン留め"</string>
|
||||
<string name="state_event_room_pinned_events_pinned_by_you">"あなたがメッセージをピン留め"</string>
|
||||
<string name="state_event_room_pinned_events_unpinned">"%1$s がメッセージのピン留めを解除"</string>
|
||||
<string name="state_event_room_pinned_events_unpinned_by_you">"あなたがメッセージのピン留めを解除"</string>
|
||||
<string name="state_event_room_reject">"%1$s が招待を拒否"</string>
|
||||
<string name="state_event_room_reject_by_you">"あなたが招待を拒否"</string>
|
||||
<string name="state_event_room_remove">"%1$s が %2$s を削除"</string>
|
||||
<string name="state_event_room_remove_by_you">"あなたが %1$s を削除"</string>
|
||||
<string name="state_event_room_remove_by_you_with_reason">"あなたが%1$s を削除: %2$s"</string>
|
||||
<string name="state_event_room_remove_with_reason">"%1$s が %2$s を削除: %3$s"</string>
|
||||
<string name="state_event_room_third_party_invite">"%1$s が %2$s をルームに招待"</string>
|
||||
<string name="state_event_room_third_party_invite_by_you">"あなたが %1$s をルームに招待"</string>
|
||||
<string name="state_event_room_third_party_revoked_invite">"%1$s が %2$s へのルームの招待を取り消し"</string>
|
||||
<string name="state_event_room_third_party_revoked_invite_by_you">"あなたが %1$s へのルームの招待を取り消し"</string>
|
||||
<string name="state_event_room_topic_changed">"%1$s がトピックを変更: %2$s"</string>
|
||||
<string name="state_event_room_topic_changed_by_you">"あなたがトピックを変更: %1$s"</string>
|
||||
<string name="state_event_room_topic_removed">"%1$s がルームのトピックを削除"</string>
|
||||
<string name="state_event_room_topic_removed_by_you">"あなたがルームのトピックを削除"</string>
|
||||
<string name="state_event_room_unban">"%1$s が %2$s の追放を解除"</string>
|
||||
<string name="state_event_room_unban_by_you">"あなたが %1$s の追放を解除"</string>
|
||||
<string name="state_event_room_unknown_membership_change">"%1$s がメンバーシップに未知の変更を追加"</string>
|
||||
</resources>
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="state_event_avatar_changed_too">"(taip pat buvo pakeistas ir avataras)"</string>
|
||||
<string name="state_event_avatar_url_changed">"%1$s pakeitė savo avatarą"</string>
|
||||
<string name="state_event_avatar_url_changed_by_you">"Jūs pakeitėte savo avatarą"</string>
|
||||
<string name="state_event_avatar_url_changed">"%1$s pakeitė savo pseudoportretą"</string>
|
||||
<string name="state_event_avatar_url_changed_by_you">"Jūs pakeitėte savo pseudoportretą"</string>
|
||||
<string name="state_event_display_name_changed_from">"%1$s pakeitė savo slapyvardį iš %2$s į %3$s"</string>
|
||||
<string name="state_event_display_name_changed_from_by_you">"Jūs pakeitėte savo slapyvardį iš %1$s į %2$s"</string>
|
||||
<string name="state_event_display_name_removed">"%1$s pašalino savo slapyvardį (jis buvo %2$s)"</string>
|
||||
<string name="state_event_display_name_removed_by_you">"Jūs pašalinote savo slapyvardį (jis buvo %1$s)"</string>
|
||||
<string name="state_event_display_name_set">"%1$s pakeitė savo slapyvardį į %2$s"</string>
|
||||
<string name="state_event_display_name_set">"%1$s nustatė savo rodomą vardą į %2$s"</string>
|
||||
<string name="state_event_display_name_set_by_you">"Jūs nustatėte savo slapyvardį į %1$s"</string>
|
||||
<string name="state_event_room_avatar_changed">"%1$s pakeitė kambario avatarą"</string>
|
||||
<string name="state_event_room_avatar_changed_by_you">"Jūs pakeitėte kambario avatarą"</string>
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
<string name="state_event_room_invite_accepted">"%1$s priėmė kvietimą"</string>
|
||||
<string name="state_event_room_invite_accepted_by_you">"Priėmėte kvietimą"</string>
|
||||
<string name="state_event_room_invite_by_you">"Jūs pakvietėte %1$s"</string>
|
||||
<string name="state_event_room_invite_you">"%1$s pakvietė Jus"</string>
|
||||
<string name="state_event_room_invite_you">"%1$s pakvietė jus"</string>
|
||||
<string name="state_event_room_join">"%1$s prisijungė prie kambario"</string>
|
||||
<string name="state_event_room_join_by_you">"Jūs prisijungėte prie kambario"</string>
|
||||
<string name="state_event_room_knock">"%1$s prašo prisijungti"</string>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="state_event_avatar_changed_too">"(ảnh hồ sơ cũng được thay)"</string>
|
||||
<string name="state_event_avatar_url_changed">"%1$s đổi ảnh hồ sơ"</string>
|
||||
<string name="state_event_avatar_url_changed_by_you">"Bạn đổi ảnh hồ sơ"</string>
|
||||
<string name="state_event_demoted_to_member">"%1$s bị giáng cấp xuống thành thành viên"</string>
|
||||
<string name="state_event_demoted_to_moderator">"%1$s bị giáng chức xuống làm người điều hành"</string>
|
||||
<string name="state_event_display_name_changed_from">"%1$s đổi tên hiển thị từ %2$s sang %3$s"</string>
|
||||
<string name="state_event_display_name_changed_from_by_you">"Bạn đổi tên hiển thị từ %1$s sang %2$s"</string>
|
||||
<string name="state_event_display_name_removed">"%1$s xoá tên hiển thị (trước kia là %2$s)"</string>
|
||||
<string name="state_event_display_name_removed_by_you">"Bạn xoá tên hiển thị (trước kia là %1$s)"</string>
|
||||
<string name="state_event_display_name_set">"%1$s đặt tên hiển thị thành %2$s"</string>
|
||||
<string name="state_event_display_name_set_by_you">"Bạn đặt tên hiển thị thành %1$s"</string>
|
||||
<string name="state_event_promoted_to_administrator">"%1$s đã được thăng chức lên quản trị viên"</string>
|
||||
<string name="state_event_promoted_to_moderator">"%1$s đã được thăng chức lên làm người điều hành"</string>
|
||||
<string name="state_event_room_avatar_changed">"%1$s đổi ảnh phòng"</string>
|
||||
<string name="state_event_room_avatar_changed_by_you">"Bạn đổi ảnh phòng"</string>
|
||||
<string name="state_event_room_avatar_removed">"%1$s đã xóa ảnh đại diện của phòng."</string>
|
||||
<string name="state_event_room_avatar_removed_by_you">"Bạn đã xóa hình đại diện của phòng trò chuyện"</string>
|
||||
<string name="state_event_room_ban">"%1$s cấm %2$s vào phòng"</string>
|
||||
<string name="state_event_room_ban_by_you">"Bạn cấm %1$s vào phòng"</string>
|
||||
<string name="state_event_room_created">"%1$s tạo phòng này"</string>
|
||||
<string name="state_event_room_created_by_you">"Bạn tạo phòng này"</string>
|
||||
<string name="state_event_room_invite">"%1$s mời %2$s"</string>
|
||||
<string name="state_event_room_invite_accepted">"%1$s đã chấp nhận lời mời"</string>
|
||||
<string name="state_event_room_invite_accepted_by_you">"Bạn đã chấp nhận lời mời"</string>
|
||||
<string name="state_event_room_invite_by_you">"Bạn mời %1$s"</string>
|
||||
<string name="state_event_room_invite_you">"%1$s mời bạn"</string>
|
||||
<string name="state_event_room_join">"%1$s vào phòng"</string>
|
||||
<string name="state_event_room_join_by_you">"Bạn vào phòng"</string>
|
||||
<string name="state_event_room_knock">"%1$s đang yêu cầu tham gia"</string>
|
||||
<string name="state_event_room_knock_accepted">"%1$s được cấp quyền truy cập vào %2$s"</string>
|
||||
<string name="state_event_room_knock_accepted_by_you">"Bạn đã cho phép %1$s tham gia"</string>
|
||||
<string name="state_event_room_knock_by_you">"Bạn đã yêu cầu tham gia"</string>
|
||||
<string name="state_event_room_knock_denied">"%1$s đã từ chối yêu cầu tham gia của %2$s"</string>
|
||||
<string name="state_event_room_knock_denied_by_you">"Bạn đã từ chối yêu cầu tham gia của %1$s"</string>
|
||||
<string name="state_event_room_knock_denied_you">"%1$s đã từ chối yêu cầu tham gia của bạn"</string>
|
||||
<string name="state_event_room_knock_retracted">"%1$s không còn mong muốn tham gia"</string>
|
||||
<string name="state_event_room_knock_retracted_by_you">"Bạn đã hủy yêu cầu tham gia"</string>
|
||||
<string name="state_event_room_leave">"%1$s rời phòng"</string>
|
||||
<string name="state_event_room_leave_by_you">"Bạn rời phòng"</string>
|
||||
<string name="state_event_room_name_changed">"%1$s đổi tên phòng thành %2$s"</string>
|
||||
<string name="state_event_room_name_changed_by_you">"Bạn đổi tên phòng thành %1$s"</string>
|
||||
<string name="state_event_room_name_removed">"%1$s xóa tên phòng"</string>
|
||||
<string name="state_event_room_name_removed_by_you">"Bạn xóa tên phòng"</string>
|
||||
<string name="state_event_room_none">"%1$s không có thay đổi nào"</string>
|
||||
<string name="state_event_room_none_by_you">"Bạn chưa thực hiện thay đổi nào"</string>
|
||||
<string name="state_event_room_pinned_events_changed_by_you">"Bạn đã thay đổi tin nhắn được ghim"</string>
|
||||
<string name="state_event_room_reject">"%1$s từ chối lời mời"</string>
|
||||
<string name="state_event_room_reject_by_you">"Bạn từ chối lời mời"</string>
|
||||
<string name="state_event_room_remove">"%1$s cho %2$s cút khỏi phòng"</string>
|
||||
<string name="state_event_room_remove_by_you">"Bạn cho %1$s cút khỏi phòng"</string>
|
||||
<string name="state_event_room_third_party_invite">"%1$s đã gửi lời mời đến %2$s để tham gia phòng trò chuyện"</string>
|
||||
<string name="state_event_room_third_party_invite_by_you">"Bạn đã gửi lời mời đến %1$s để tham gia phòng trò chuyện"</string>
|
||||
<string name="state_event_room_third_party_revoked_invite">"%1$s đã thu hồi lời mời tham gia phòng trò chuyện của %2$s "</string>
|
||||
<string name="state_event_room_third_party_revoked_invite_by_you">"Bạn đã thu hồi lời mời tham gia phòng trò chuyện của %1$s "</string>
|
||||
<string name="state_event_room_topic_changed">"%1$s đổi chủ đề sang: %2$s"</string>
|
||||
<string name="state_event_room_topic_changed_by_you">"Bạn đổi chủ đề sang: %1$s"</string>
|
||||
<string name="state_event_room_topic_removed">"%1$s đã xóa chủ đề phòng"</string>
|
||||
<string name="state_event_room_topic_removed_by_you">"Bạn đã xóa chủ đề của phòng."</string>
|
||||
<string name="state_event_room_unban">"%1$s hủy lệnh cấm với %2$s"</string>
|
||||
<string name="state_event_room_unban_by_you">"Bạn hủy lệnh cấm với %1$s"</string>
|
||||
<string name="state_event_room_unknown_membership_change">"%1$s đã thực hiện một thay đổi không xác định đối với tư cách thành viên của họ"</string>
|
||||
</resources>
|
||||
|
|
@ -54,11 +54,11 @@
|
|||
<string name="state_event_room_pinned_events_unpinned">"%1$s 取消置顶了一条消息"</string>
|
||||
<string name="state_event_room_pinned_events_unpinned_by_you">"您取消置顶了一条消息"</string>
|
||||
<string name="state_event_room_reject">"%1$s 拒绝了邀请"</string>
|
||||
<string name="state_event_room_reject_by_you">"你拒绝了邀请"</string>
|
||||
<string name="state_event_room_remove">"%1$s 移除了 %2$s"</string>
|
||||
<string name="state_event_room_remove_by_you">"你移除了 %1$s"</string>
|
||||
<string name="state_event_room_remove_by_you_with_reason">"您已删除%1$s :%2$s"</string>
|
||||
<string name="state_event_room_remove_with_reason">"%1$s已移除%2$s:%3$s"</string>
|
||||
<string name="state_event_room_reject_by_you">"您拒绝了邀请"</string>
|
||||
<string name="state_event_room_remove">"%1$s 已移除 %2$s"</string>
|
||||
<string name="state_event_room_remove_by_you">"您移除了 %1$s"</string>
|
||||
<string name="state_event_room_remove_by_you_with_reason">"您移除了 %1$s:%2$s"</string>
|
||||
<string name="state_event_room_remove_with_reason">"%1$s 移除了 %2$s:%3$s"</string>
|
||||
<string name="state_event_room_third_party_invite">"%1$s 向 %2$s 发送了加入聊天室的邀请"</string>
|
||||
<string name="state_event_room_third_party_invite_by_you">"你邀请 %1$s 加入聊天室"</string>
|
||||
<string name="state_event_room_third_party_revoked_invite">"%1$s 撤销了 %2$s 加入聊天室的邀请"</string>
|
||||
|
|
|
|||
|
|
@ -601,7 +601,6 @@ class DefaultPinnedMessagesBannerFormatterTest {
|
|||
OtherState.PolicyRuleRoom,
|
||||
OtherState.PolicyRuleServer,
|
||||
OtherState.PolicyRuleUser,
|
||||
OtherState.RoomAliases,
|
||||
OtherState.RoomCanonicalAlias,
|
||||
OtherState.RoomGuestAccess,
|
||||
OtherState.RoomHistoryVisibility,
|
||||
|
|
|
|||
|
|
@ -746,7 +746,6 @@ class DefaultRoomLatestEventFormatterTest {
|
|||
OtherState.PolicyRuleRoom,
|
||||
OtherState.PolicyRuleServer,
|
||||
OtherState.PolicyRuleUser,
|
||||
OtherState.RoomAliases,
|
||||
OtherState.RoomCanonicalAlias,
|
||||
OtherState.RoomGuestAccess,
|
||||
OtherState.RoomHistoryVisibility,
|
||||
|
|
|
|||
|
|
@ -70,27 +70,6 @@ enum class FeatureFlags(
|
|||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
),
|
||||
CreateSpaces(
|
||||
key = "feature.createSpaces",
|
||||
title = "Create spaces",
|
||||
description = "Allow creating spaces.",
|
||||
defaultValue = { true },
|
||||
isFinished = false,
|
||||
),
|
||||
SpaceSettings(
|
||||
key = "feature.spaceSettings",
|
||||
title = "Space settings",
|
||||
description = "Allow managing space settings such as details, permissions and privacy.",
|
||||
defaultValue = { true },
|
||||
isFinished = false,
|
||||
),
|
||||
RoomListSpaceFilters(
|
||||
key = "feature.roomListSpaceFilters",
|
||||
title = "Room list space filters",
|
||||
description = "Allow filtering the room list by space.",
|
||||
defaultValue = { true },
|
||||
isFinished = false,
|
||||
),
|
||||
PrintLogsToLogcat(
|
||||
key = "feature.print_logs_to_logcat",
|
||||
title = "Print logs to logcat",
|
||||
|
|
@ -156,10 +135,31 @@ enum class FeatureFlags(
|
|||
),
|
||||
ValidateNetworkWhenSchedulingNotificationFetching(
|
||||
key = "feature.validate_network_when_scheduling_notification_fetching",
|
||||
title = "validate internet connectivity when scheduling notification fetching",
|
||||
title = "Validate internet connectivity when scheduling notification fetching",
|
||||
description = "Only fetch events for push notifications when the device has internet connectivity. " +
|
||||
"Enabling this can be problematic in air-gapped environments.",
|
||||
defaultValue = { true },
|
||||
isFinished = false,
|
||||
),
|
||||
FloatingDateBadge(
|
||||
key = "feature.floating_date_badge",
|
||||
title = "Display sticky date headers in the timeline",
|
||||
description = "When scrolling, a sticky date badge will be displayed so you can easily know on which date the messages you're seeing were sent.",
|
||||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
),
|
||||
SlashCommand(
|
||||
key = "feature.slash_command",
|
||||
title = "Parse slash commands in the message composer",
|
||||
description = "Allow parsing slash commands in the message composer and perform action.",
|
||||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
),
|
||||
RoomThreadList(
|
||||
key = "feature.room_thread_list",
|
||||
title = "Add a list of threads in a room",
|
||||
description = "Add a new screen with a list of threads in a room.",
|
||||
defaultValue = { false },
|
||||
isFinished = false,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api
|
||||
|
||||
/**
|
||||
* Provides information about the capabilities of the homeserver.
|
||||
*
|
||||
* Spec: https://spec.matrix.org/latest/client-server-api/#capabilities-negotiation
|
||||
*/
|
||||
interface HomeserverCapabilitiesProvider {
|
||||
/**
|
||||
* Manually refresh the capabilities of the homeserver performing a network request.
|
||||
*/
|
||||
suspend fun refresh(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Indicates whether the homeserver allows the user to change their display name.
|
||||
*/
|
||||
suspend fun canChangeDisplayName(): Result<Boolean>
|
||||
|
||||
/**
|
||||
* Indicates whether the homeserver allows the user to change their avatar URL.
|
||||
*/
|
||||
suspend fun canChangeAvatarUrl(): Result<Boolean>
|
||||
}
|
||||
|
|
@ -225,6 +225,8 @@ interface MatrixClient {
|
|||
* Resets the cached client `well-known` config by the SDK.
|
||||
*/
|
||||
suspend fun resetWellKnownConfig(): Result<Unit>
|
||||
|
||||
fun homeserverCapabilities(): HomeserverCapabilitiesProvider
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.auth
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
data class ElementClassicSession(
|
||||
val userId: UserId,
|
||||
val homeserverUrl: String?,
|
||||
val secrets: String?,
|
||||
val roomKeysVersion: String?,
|
||||
val doesContainBackupKey: Boolean,
|
||||
)
|
||||
|
|
@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
|||
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
||||
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
interface MatrixAuthenticationService {
|
||||
/**
|
||||
|
|
@ -52,6 +53,20 @@ interface MatrixAuthenticationService {
|
|||
*/
|
||||
suspend fun cancelOidcLogin(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Set the existing data about Element Classic session, if any.
|
||||
*/
|
||||
fun setElementClassicSession(session: ElementClassicSession?)
|
||||
|
||||
/**
|
||||
* Check if the provided secrets from Element Classic session contain a key backup.
|
||||
*/
|
||||
fun doSecretsContainBackupKey(
|
||||
userId: UserId,
|
||||
secrets: String,
|
||||
backupInfo: String,
|
||||
): Boolean
|
||||
|
||||
/**
|
||||
* Attempt to login using the [callbackUrl] provided by the Oidc page.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -27,6 +27,16 @@ sealed interface RoomIdOrAlias : Parcelable {
|
|||
is Id -> roomId.value
|
||||
is Alias -> roomAlias.value
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(id: String): RoomIdOrAlias? {
|
||||
return when {
|
||||
MatrixPatterns.isRoomId(id) -> Id(RoomId(id))
|
||||
MatrixPatterns.isRoomAlias(id) -> Alias(RoomAlias(id))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun RoomId.toRoomIdOrAlias() = RoomIdOrAlias.Id(this)
|
||||
|
|
|
|||
|
|
@ -17,3 +17,18 @@ interface MxcTools {
|
|||
*/
|
||||
fun mxcUri2FilePath(mxcUri: String): String?
|
||||
}
|
||||
|
||||
/**
|
||||
* "mxc" scheme, including "://". So "mxc://".
|
||||
*/
|
||||
const val MATRIX_CONTENT_URI_SCHEME = "mxc://"
|
||||
|
||||
/**
|
||||
* Return true if the String starts with "mxc://".
|
||||
*/
|
||||
fun String.isMxcUrl() = startsWith(MATRIX_CONTENT_URI_SCHEME)
|
||||
|
||||
/**
|
||||
* Remove the "mxc://" prefix. No op if the String is not a Mxc URL.
|
||||
*/
|
||||
fun String.removeMxcPrefix() = removePrefix(MATRIX_CONTENT_URI_SCHEME)
|
||||
|
|
|
|||
|
|
@ -95,7 +95,6 @@ sealed interface NotificationContent {
|
|||
data object PolicyRuleRoom : StateEvent
|
||||
data object PolicyRuleServer : StateEvent
|
||||
data object PolicyRuleUser : StateEvent
|
||||
data object RoomAliases : StateEvent
|
||||
data object RoomAvatar : StateEvent
|
||||
data object RoomCanonicalAlias : StateEvent
|
||||
data object RoomCreate : StateEvent
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.room.knock.KnockRequest
|
|||
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
|
||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
|
|
@ -44,6 +45,8 @@ interface JoinedRoom : BaseRoom {
|
|||
*/
|
||||
val liveTimeline: Timeline
|
||||
|
||||
val threadsListService: ThreadsListService
|
||||
|
||||
/**
|
||||
* Create a new timeline.
|
||||
* @param createTimelineParams contains parameters about how to filter the timeline. Will also configure the date separators.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ sealed interface StateEventType {
|
|||
data object PolicyRuleServer : StateEventType
|
||||
data object PolicyRuleUser : StateEventType
|
||||
data object CallMember : StateEventType
|
||||
data object RoomAliases : StateEventType
|
||||
data object RoomAvatar : StateEventType
|
||||
data object RoomCanonicalAlias : StateEventType
|
||||
data object RoomCreate : StateEventType
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.room.threads
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.toThreadId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||
|
||||
@Immutable
|
||||
data class ThreadListItem(
|
||||
val rootEvent: ThreadListItemEvent,
|
||||
val latestEvent: ThreadListItemEvent?,
|
||||
val numberOfReplies: Long,
|
||||
) {
|
||||
val threadId = rootEvent.eventId.toThreadId()
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class ThreadListItemEvent(
|
||||
val eventId: EventId,
|
||||
val senderId: UserId,
|
||||
val senderProfile: ProfileDetails,
|
||||
val isOwn: Boolean,
|
||||
val content: EventContent?,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.room.threads
|
||||
|
||||
sealed interface ThreadListPaginationStatus {
|
||||
data class Idle(
|
||||
val hasMoreToLoad: Boolean,
|
||||
) : ThreadListPaginationStatus
|
||||
|
||||
data object Loading : ThreadListPaginationStatus
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.room.threads
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ThreadsListService {
|
||||
fun subscribeToItemUpdates(): Flow<List<ThreadListItem>>
|
||||
fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus>
|
||||
suspend fun paginate(): Result<Unit>
|
||||
suspend fun reset(): Result<Unit>
|
||||
fun destroy()
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.timeline
|
||||
|
||||
enum class MsgType {
|
||||
MSG_TYPE_TEXT,
|
||||
MSG_TYPE_EMOTE,
|
||||
|
||||
// For future support
|
||||
MSG_TYPE_SNOW,
|
||||
|
||||
// For future support
|
||||
MSG_TYPE_CONFETTI,
|
||||
}
|
||||
|
|
@ -69,6 +69,8 @@ interface Timeline : AutoCloseable {
|
|||
body: String,
|
||||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
|
||||
asPlainText: Boolean = false,
|
||||
): Result<Unit>
|
||||
|
||||
/**
|
||||
|
|
@ -102,6 +104,7 @@ interface Timeline : AutoCloseable {
|
|||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
fromNotification: Boolean = false,
|
||||
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
|
||||
): Result<Unit>
|
||||
|
||||
suspend fun sendImage(
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ sealed interface OtherState {
|
|||
data object PolicyRuleRoom : OtherState
|
||||
data object PolicyRuleServer : OtherState
|
||||
data object PolicyRuleUser : OtherState
|
||||
data object RoomAliases : OtherState
|
||||
data class RoomAvatar(val url: String?) : OtherState
|
||||
data object RoomCanonicalAlias : OtherState
|
||||
data object RoomCreate : OtherState
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.core
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class UserIdTest {
|
||||
@Test
|
||||
fun `valid user id`() {
|
||||
val userId = UserId("@alice:example.org")
|
||||
assertThat(userId.extractedDisplayName).isEqualTo("alice")
|
||||
assertThat(userId.domainName).isEqualTo("example.org")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl
|
||||
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
|
||||
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
|
||||
|
||||
class RustHomeserverCapabilitiesProvider(
|
||||
private val homeserverCapabilities: HomeserverCapabilities,
|
||||
) : HomeserverCapabilitiesProvider {
|
||||
override suspend fun refresh(): Result<Unit> = runCatchingExceptions {
|
||||
homeserverCapabilities.refresh()
|
||||
}
|
||||
|
||||
override suspend fun canChangeDisplayName(): Result<Boolean> = runCatchingExceptions {
|
||||
homeserverCapabilities.canChangeDisplayname()
|
||||
}
|
||||
|
||||
override suspend fun canChangeAvatarUrl(): Result<Boolean> = runCatchingExceptions {
|
||||
homeserverCapabilities.canChangeAvatar()
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import io.element.android.libraries.core.data.tryOrNull
|
|||
import io.element.android.libraries.core.extensions.mapFailure
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
|
|
@ -838,6 +839,10 @@ class RustMatrixClient(
|
|||
val request = PerformDatabaseVacuumRequestBuilder(sessionId)
|
||||
sessionCoroutineScope.launch { workManagerScheduler.submit(request) }
|
||||
}
|
||||
|
||||
override fun homeserverCapabilities(): HomeserverCapabilitiesProvider {
|
||||
return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities())
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultRoomCreationPowerLevels(isPublic: Boolean, isSpace: Boolean) = PowerLevels(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import io.element.android.libraries.core.extensions.mapFailure
|
|||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.auth.AuthenticationException
|
||||
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
|
|
@ -25,6 +26,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
|||
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
||||
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync
|
||||
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
|
||||
|
|
@ -50,6 +52,7 @@ import org.matrix.rustcomponents.sdk.QrCodeData
|
|||
import org.matrix.rustcomponents.sdk.QrCodeDecodeException
|
||||
import org.matrix.rustcomponents.sdk.QrLoginProgress
|
||||
import org.matrix.rustcomponents.sdk.QrLoginProgressListener
|
||||
import org.matrix.rustcomponents.sdk.SecretsBundleWithUserId
|
||||
import timber.log.Timber
|
||||
import uniffi.matrix_sdk.OAuthAuthorizationData
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
|
@ -64,6 +67,9 @@ class RustMatrixAuthenticationService(
|
|||
private val passphraseGenerator: PassphraseGenerator,
|
||||
private val oidcConfigurationProvider: OidcConfigurationProvider,
|
||||
) : MatrixAuthenticationService {
|
||||
// Any existing Element Classic session that we want to try to import secrets from during login.
|
||||
private var elementClassicSession: ElementClassicSession? = null
|
||||
|
||||
// Passphrase which will be used for new sessions. Existing sessions will use the passphrase
|
||||
// stored in the SessionData.
|
||||
private val pendingPassphrase = getDatabasePassphrase()
|
||||
|
|
@ -138,9 +144,15 @@ class RustMatrixAuthenticationService(
|
|||
runCatchingExceptions {
|
||||
val client = currentClient ?: error("You need to call `setHomeserver()` first")
|
||||
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
|
||||
client.login(username, password, "Element X Android", null)
|
||||
client.login(
|
||||
username = username,
|
||||
password = password,
|
||||
initialDeviceName = "Element X Android",
|
||||
deviceId = null,
|
||||
)
|
||||
// Ensure that the user is not already logged in with the same account
|
||||
ensureNotAlreadyLoggedIn(client)
|
||||
tryToImportSecretForElementClassicSession(client)
|
||||
val sessionData = client.session()
|
||||
.toSessionData(
|
||||
isTokenValid = true,
|
||||
|
|
@ -162,6 +174,53 @@ class RustMatrixAuthenticationService(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun tryToImportSecretForElementClassicSession(client: Client) {
|
||||
elementClassicSession
|
||||
?.takeIf {
|
||||
// Note: the SDK will also do this check
|
||||
it.userId.value == client.userId()
|
||||
}
|
||||
?.let {
|
||||
val secrets = it.secrets
|
||||
val roomKeysVersion = it.roomKeysVersion
|
||||
if (secrets == null || roomKeysVersion == null) {
|
||||
Timber.d("No secrets or roomKeysVersion found for Element Classic session ${it.userId}, skipping import")
|
||||
} else {
|
||||
Timber.d("Trying to import secrets for Element Classic session ${it.userId}")
|
||||
runCatchingExceptions {
|
||||
SecretsBundleWithUserId.fromStr(
|
||||
userId = it.userId.value,
|
||||
bundle = secrets,
|
||||
backupInfo = roomKeysVersion,
|
||||
).use { secretsBundle ->
|
||||
client.encryption().importSecretsBundle(secretsBundle)
|
||||
}
|
||||
}.onFailure { failure ->
|
||||
Timber.e(failure, "Failed to import secrets for Element Classic session ${it.userId}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun doSecretsContainBackupKey(
|
||||
userId: UserId,
|
||||
secrets: String,
|
||||
backupInfo: String,
|
||||
): Boolean {
|
||||
return try {
|
||||
SecretsBundleWithUserId.fromStr(
|
||||
userId = userId.value,
|
||||
bundle = secrets,
|
||||
backupInfo = backupInfo,
|
||||
).use { secretsBundle ->
|
||||
secretsBundle.containsBackupKey()
|
||||
}
|
||||
} catch (failure: Exception) {
|
||||
Timber.e(failure, "Failed to parse secrets for Element Classic session $userId")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId> =
|
||||
withContext(coroutineDispatchers.io) {
|
||||
runCatchingExceptions {
|
||||
|
|
@ -233,6 +292,10 @@ class RustMatrixAuthenticationService(
|
|||
}
|
||||
}
|
||||
|
||||
override fun setElementClassicSession(session: ElementClassicSession?) {
|
||||
elementClassicSession = session
|
||||
}
|
||||
|
||||
/**
|
||||
* callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters).
|
||||
*/
|
||||
|
|
@ -241,14 +304,15 @@ class RustMatrixAuthenticationService(
|
|||
runCatchingExceptions {
|
||||
val client = currentClient ?: error("You need to call `setHomeserver()` first")
|
||||
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
|
||||
client.loginWithOidcCallback(callbackUrl)
|
||||
|
||||
client.loginWithOidcCallback(
|
||||
callbackUrl = callbackUrl,
|
||||
)
|
||||
// Free the pending data since we won't use it to abort the flow anymore
|
||||
pendingOAuthAuthorizationData?.close()
|
||||
pendingOAuthAuthorizationData = null
|
||||
|
||||
// Ensure that the user is not already logged in with the same account
|
||||
ensureNotAlreadyLoggedIn(client)
|
||||
tryToImportSecretForElementClassicSession(client)
|
||||
val sessionData = client.session().toSessionData(
|
||||
isTokenValid = true,
|
||||
loginType = LoginType.OIDC,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import dev.zacsweers.metro.ContributesTo
|
|||
import dev.zacsweers.metro.Provides
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
|
|
@ -90,4 +91,9 @@ object SessionMatrixModule {
|
|||
fun providesSpaceService(matrixClient: MatrixClient): SpaceService {
|
||||
return matrixClient.spaceService
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun providesHomeserverCapabilitiesProvider(matrixClient: MatrixClient): HomeserverCapabilitiesProvider {
|
||||
return matrixClient.homeserverCapabilities()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ private fun StateEventContent.toContent(): NotificationContent.StateEvent {
|
|||
StateEventContent.PolicyRuleRoom -> NotificationContent.StateEvent.PolicyRuleRoom
|
||||
StateEventContent.PolicyRuleServer -> NotificationContent.StateEvent.PolicyRuleServer
|
||||
StateEventContent.PolicyRuleUser -> NotificationContent.StateEvent.PolicyRuleUser
|
||||
StateEventContent.RoomAliases -> NotificationContent.StateEvent.RoomAliases
|
||||
StateEventContent.RoomAvatar -> NotificationContent.StateEvent.RoomAvatar
|
||||
StateEventContent.RoomCanonicalAlias -> NotificationContent.StateEvent.RoomCanonicalAlias
|
||||
StateEventContent.RoomCreate -> NotificationContent.StateEvent.RoomCreate
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
|||
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
|
||||
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
|
||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
|
|
@ -43,10 +44,11 @@ import io.element.android.libraries.matrix.impl.mapper.map
|
|||
import io.element.android.libraries.matrix.impl.room.history.map
|
||||
import io.element.android.libraries.matrix.impl.room.join.map
|
||||
import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest
|
||||
import io.element.android.libraries.matrix.impl.room.location.map
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
|
||||
import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListService
|
||||
import io.element.android.libraries.matrix.impl.roomdirectory.map
|
||||
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
||||
import io.element.android.libraries.matrix.impl.util.MessageEventContent
|
||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
|
||||
|
|
@ -68,7 +70,6 @@ import kotlinx.coroutines.withContext
|
|||
import org.matrix.rustcomponents.sdk.DateDividerMode
|
||||
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
|
||||
import org.matrix.rustcomponents.sdk.KnockRequestsListener
|
||||
import org.matrix.rustcomponents.sdk.LiveLocationShareListener
|
||||
import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
|
||||
import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate
|
||||
import org.matrix.rustcomponents.sdk.SendQueueListener
|
||||
|
|
@ -147,6 +148,12 @@ class JoinedRustRoom(
|
|||
|
||||
override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live)
|
||||
|
||||
override val threadsListService: ThreadsListService = RustThreadsListService(
|
||||
inner = innerRoom.threadListService(),
|
||||
contentMapper = TimelineEventContentMapper(),
|
||||
roomCoroutineScope = roomCoroutineScope,
|
||||
)
|
||||
|
||||
override val syncUpdateFlow = flow {
|
||||
var counter = 0L
|
||||
liveTimeline.onSyncedEventReceived.collect {
|
||||
|
|
@ -504,13 +511,7 @@ class JoinedRustRoom(
|
|||
}
|
||||
|
||||
override fun subscribeToLiveLocationShares(): Flow<List<LiveLocationShare>> {
|
||||
return mxCallbackFlow {
|
||||
innerRoom.subscribeToLiveLocationShares(object : LiveLocationShareListener {
|
||||
override fun call(liveLocationShares: List<org.matrix.rustcomponents.sdk.LiveLocationShare>) {
|
||||
trySend(liveLocationShares.map { it.map() })
|
||||
}
|
||||
})
|
||||
}
|
||||
TODO("Not implemented yet")
|
||||
}
|
||||
|
||||
override suspend fun startLiveLocationShare(durationMillis: Long): Result<Unit> = withContext(roomDispatcher) {
|
||||
|
|
@ -536,6 +537,7 @@ class JoinedRustRoom(
|
|||
override fun destroy() {
|
||||
baseRoom.destroy()
|
||||
liveInnerTimeline.destroy()
|
||||
threadsListService.destroy()
|
||||
Timber.d("Room $roomId destroyed")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ fun StateEventType.map(): RustStateEventType = when (this) {
|
|||
StateEventType.PolicyRuleServer -> RustStateEventType.PolicyRuleServer
|
||||
StateEventType.PolicyRuleUser -> RustStateEventType.PolicyRuleUser
|
||||
StateEventType.CallMember -> RustStateEventType.CallMember
|
||||
StateEventType.RoomAliases -> RustStateEventType.RoomAliases
|
||||
StateEventType.RoomAvatar -> RustStateEventType.RoomAvatar
|
||||
StateEventType.RoomCanonicalAlias -> RustStateEventType.RoomCanonicalAlias
|
||||
StateEventType.RoomCreate -> RustStateEventType.RoomCreate
|
||||
|
|
@ -46,7 +45,6 @@ fun RustStateEventType.map(): StateEventType = when (this) {
|
|||
RustStateEventType.PolicyRuleServer -> StateEventType.PolicyRuleServer
|
||||
RustStateEventType.PolicyRuleUser -> StateEventType.PolicyRuleUser
|
||||
RustStateEventType.CallMember -> StateEventType.CallMember
|
||||
RustStateEventType.RoomAliases -> StateEventType.RoomAliases
|
||||
RustStateEventType.RoomAvatar -> StateEventType.RoomAvatar
|
||||
RustStateEventType.RoomCanonicalAlias -> StateEventType.RoomCanonicalAlias
|
||||
RustStateEventType.RoomCreate -> StateEventType.RoomCreate
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.room.location
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
||||
import org.matrix.rustcomponents.sdk.LiveLocationShare as RustLiveLocationShare
|
||||
|
||||
fun RustLiveLocationShare.map(): LiveLocationShare {
|
||||
return LiveLocationShare(
|
||||
userId = UserId(userId),
|
||||
lastGeoUri = lastLocation.location.geoUri,
|
||||
lastTimestamp = lastLocation.ts.toLong(),
|
||||
isLive = isLive,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.room.threads
|
||||
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadListItemEvent
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.map
|
||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
|
||||
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
|
||||
import org.matrix.rustcomponents.sdk.ThreadListUpdate
|
||||
import uniffi.matrix_sdk_ui.ThreadListPaginationState
|
||||
import org.matrix.rustcomponents.sdk.ThreadListService as InnerThreadListService
|
||||
|
||||
class RustThreadsListService(
|
||||
private val inner: InnerThreadListService,
|
||||
private val roomCoroutineScope: CoroutineScope,
|
||||
private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(),
|
||||
) : ThreadsListService {
|
||||
private var itemSubscriptionJob: Job? = null
|
||||
|
||||
private val items = MutableStateFlow<List<ThreadListItem>>(emptyList())
|
||||
|
||||
override fun subscribeToItemUpdates(): Flow<List<ThreadListItem>> {
|
||||
if (itemSubscriptionJob?.isActive != true) {
|
||||
itemSubscriptionJob = doSubscribeToItemUpdates()
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private fun doSubscribeToItemUpdates(): Job {
|
||||
val updatesFlow = mxCallbackFlow {
|
||||
inner.subscribeToItemsUpdates(object : ThreadListEntriesListener {
|
||||
override fun onUpdate(diff: List<ThreadListUpdate>) {
|
||||
trySend(diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return updatesFlow
|
||||
.onStart { items.value = inner.items().map { it.map(contentMapper) } }
|
||||
.onEach { diff ->
|
||||
val updated = items.value.toMutableList()
|
||||
updated.apply(diff, contentMapper)
|
||||
items.value = updated
|
||||
}
|
||||
.launchIn(roomCoroutineScope)
|
||||
}
|
||||
|
||||
override fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus> {
|
||||
return mxCallbackFlow {
|
||||
inner.subscribeToPaginationStateUpdates(object : ThreadListPaginationStateListener {
|
||||
override fun onUpdate(state: ThreadListPaginationState) {
|
||||
trySend(state.map())
|
||||
}
|
||||
}).also {
|
||||
// Send the initial state
|
||||
trySend(inner.paginationState().map())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun paginate(): Result<Unit> = runCatchingExceptions {
|
||||
inner.paginate()
|
||||
}
|
||||
|
||||
override suspend fun reset(): Result<Unit> = runCatchingExceptions {
|
||||
inner.reset()
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
itemSubscriptionJob?.cancel()
|
||||
inner.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<ThreadListItem>.apply(
|
||||
diff: List<ThreadListUpdate>,
|
||||
contentMapper: TimelineEventContentMapper
|
||||
) {
|
||||
for (diffItem in diff) {
|
||||
when (diffItem) {
|
||||
is ThreadListUpdate.Append -> {
|
||||
val newItems = diffItem.values.map { it.map(contentMapper) }
|
||||
addAll(newItems)
|
||||
}
|
||||
ThreadListUpdate.Clear -> clear()
|
||||
is ThreadListUpdate.Insert -> {
|
||||
add(diffItem.index.toInt(), diffItem.value.map(contentMapper))
|
||||
}
|
||||
ThreadListUpdate.PopBack -> {
|
||||
removeAt(lastIndex)
|
||||
}
|
||||
ThreadListUpdate.PopFront -> {
|
||||
removeAt(0)
|
||||
}
|
||||
is ThreadListUpdate.PushBack -> {
|
||||
add(diffItem.value.map(contentMapper))
|
||||
}
|
||||
is ThreadListUpdate.PushFront -> {
|
||||
add(0, diffItem.value.map(contentMapper))
|
||||
}
|
||||
is ThreadListUpdate.Remove -> {
|
||||
removeAt(diffItem.index.toInt())
|
||||
}
|
||||
is ThreadListUpdate.Reset -> {
|
||||
clear()
|
||||
addAll(diffItem.values.map { it.map(contentMapper) })
|
||||
}
|
||||
is ThreadListUpdate.Set -> {
|
||||
set(diffItem.index.toInt(), diffItem.value.map(contentMapper))
|
||||
}
|
||||
is ThreadListUpdate.Truncate -> {
|
||||
subList(diffItem.length.toInt(), size).clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun org.matrix.rustcomponents.sdk.ThreadListItem.map(contentMapper: TimelineEventContentMapper): ThreadListItem = ThreadListItem(
|
||||
rootEvent = rootEvent.map(contentMapper),
|
||||
latestEvent = latestEvent?.map(contentMapper),
|
||||
numberOfReplies = numReplies.toLong(),
|
||||
)
|
||||
|
||||
fun org.matrix.rustcomponents.sdk.ThreadListItemEvent.map(contentMapper: TimelineEventContentMapper): ThreadListItemEvent = ThreadListItemEvent(
|
||||
eventId = EventId(eventId),
|
||||
senderId = UserId(sender),
|
||||
isOwn = isOwn,
|
||||
senderProfile = senderProfile.map(),
|
||||
content = content?.let(contentMapper::map),
|
||||
timestamp = timestamp.toLong(),
|
||||
)
|
||||
|
||||
fun ThreadListPaginationState.map(): ThreadListPaginationStatus = when (this) {
|
||||
is ThreadListPaginationState.Idle -> ThreadListPaginationStatus.Idle(hasMoreToLoad = !endReached)
|
||||
ThreadListPaginationState.Loading -> ThreadListPaginationStatus.Loading
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
|
|||
import io.element.android.libraries.matrix.api.room.isDm
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
|
|
@ -129,6 +130,8 @@ class RustTimeline(
|
|||
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode is Timeline.Mode.FocusedOnEvent)
|
||||
)
|
||||
|
||||
private val loggerTag = "Timeline($mode)"
|
||||
|
||||
init {
|
||||
when (mode) {
|
||||
is Timeline.Mode.Live, is Timeline.Mode.FocusedOnEvent -> coroutineScope.fetchMembers()
|
||||
|
|
@ -176,10 +179,11 @@ class RustTimeline(
|
|||
}
|
||||
|
||||
private fun updatePaginationStatus(direction: Timeline.PaginationDirection, update: (Timeline.PaginationStatus) -> Timeline.PaginationStatus) {
|
||||
when (direction) {
|
||||
val result = when (direction) {
|
||||
Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus.getAndUpdate(update)
|
||||
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.getAndUpdate(update)
|
||||
}
|
||||
Timber.tag(loggerTag).d("updatePaginationStatus $direction: $result")
|
||||
}
|
||||
|
||||
// Use NonCancellable to avoid breaking the timeline when the coroutine is cancelled.
|
||||
|
|
@ -194,12 +198,13 @@ class RustTimeline(
|
|||
}
|
||||
}.onFailure { error ->
|
||||
if (error is TimelineException.CannotPaginate) {
|
||||
Timber.d("Can't paginate $direction on room ${joinedRoom.roomId} with paginationStatus: ${backwardPaginationStatus.value}")
|
||||
Timber.tag(loggerTag).d("Can't paginate $direction on room ${joinedRoom.roomId} with paginationStatus: ${backwardPaginationStatus.value}")
|
||||
} else {
|
||||
updatePaginationStatus(direction) { it.copy(isPaginating = false) }
|
||||
Timber.e(error, "Error paginating $direction on room ${joinedRoom.roomId}")
|
||||
Timber.tag(loggerTag).e(error, "Error paginating $direction on room ${joinedRoom.roomId}")
|
||||
}
|
||||
}.onSuccess { hasReachedEnd ->
|
||||
Timber.tag(loggerTag).d("Finished paginating $direction on room ${joinedRoom.roomId}, hasReachedEnd: $hasReachedEnd")
|
||||
updatePaginationStatus(direction) { it.copy(isPaginating = false, hasMoreToLoad = !hasReachedEnd) }
|
||||
}
|
||||
}
|
||||
|
|
@ -263,7 +268,7 @@ class RustTimeline(
|
|||
try {
|
||||
inner.fetchMembers()
|
||||
} catch (exception: Exception) {
|
||||
Timber.e(exception, "Error fetching members for room ${joinedRoom.roomId}")
|
||||
Timber.tag(loggerTag).e(exception, "Error fetching members for room ${joinedRoom.roomId}")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -271,8 +276,16 @@ class RustTimeline(
|
|||
body: String,
|
||||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
msgType: MsgType,
|
||||
asPlainText: Boolean,
|
||||
): Result<Unit> = withContext(dispatcher) {
|
||||
MessageEventContent.from(body, htmlBody, intentionalMentions).use { content ->
|
||||
MessageEventContent.from(
|
||||
body = body,
|
||||
htmlBody = htmlBody,
|
||||
intentionalMentions = intentionalMentions,
|
||||
msgType = msgType,
|
||||
asPlainText = asPlainText,
|
||||
).use { content ->
|
||||
runCatchingExceptions<Unit> {
|
||||
inner.send(content)
|
||||
}
|
||||
|
|
@ -354,9 +367,15 @@ class RustTimeline(
|
|||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
fromNotification: Boolean,
|
||||
msgType: MsgType,
|
||||
): Result<Unit> = withContext(dispatcher) {
|
||||
runCatchingExceptions {
|
||||
val msg = MessageEventContent.from(body, htmlBody, intentionalMentions)
|
||||
val msg = MessageEventContent.from(
|
||||
body = body,
|
||||
htmlBody = htmlBody,
|
||||
intentionalMentions = intentionalMentions,
|
||||
msgType = msgType,
|
||||
)
|
||||
inner.sendReply(
|
||||
msg = msg,
|
||||
eventId = repliedToEventId.value,
|
||||
|
|
@ -372,7 +391,7 @@ class RustTimeline(
|
|||
formattedCaption: String?,
|
||||
inReplyToEventId: EventId?,
|
||||
): Result<MediaUploadHandler> {
|
||||
Timber.d("Sending image ${file.path.hash()}")
|
||||
Timber.tag(loggerTag).d("Sending image ${file.path.hash()}")
|
||||
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
|
||||
inner.sendImage(
|
||||
params = UploadParameters(
|
||||
|
|
@ -398,7 +417,7 @@ class RustTimeline(
|
|||
formattedCaption: String?,
|
||||
inReplyToEventId: EventId?,
|
||||
): Result<MediaUploadHandler> {
|
||||
Timber.d("Sending video ${file.path.hash()}")
|
||||
Timber.tag(loggerTag).d("Sending video ${file.path.hash()}")
|
||||
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
|
||||
inner.sendVideo(
|
||||
params = UploadParameters(
|
||||
|
|
@ -423,7 +442,7 @@ class RustTimeline(
|
|||
formattedCaption: String?,
|
||||
inReplyToEventId: EventId?,
|
||||
): Result<MediaUploadHandler> {
|
||||
Timber.d("Sending audio ${file.path.hash()}")
|
||||
Timber.tag(loggerTag).d("Sending audio ${file.path.hash()}")
|
||||
return sendAttachment(listOf(file)) {
|
||||
inner.sendAudio(
|
||||
params = UploadParameters(
|
||||
|
|
@ -447,7 +466,7 @@ class RustTimeline(
|
|||
formattedCaption: String?,
|
||||
inReplyToEventId: EventId?,
|
||||
): Result<MediaUploadHandler> {
|
||||
Timber.d("Sending file ${file.path.hash()}")
|
||||
Timber.tag(loggerTag).d("Sending file ${file.path.hash()}")
|
||||
return sendAttachment(listOf(file)) {
|
||||
inner.sendFile(
|
||||
params = UploadParameters(
|
||||
|
|
@ -477,7 +496,7 @@ class RustTimeline(
|
|||
runCatchingExceptions {
|
||||
roomContentForwarder.forward(fromTimeline = inner, eventId = eventId, toRoomIds = roomIds)
|
||||
}.onFailure {
|
||||
Timber.e(it)
|
||||
Timber.tag(loggerTag).e(it)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -232,7 +232,6 @@ private fun RustOtherState.map(): OtherState {
|
|||
RustOtherState.PolicyRuleRoom -> OtherState.PolicyRuleRoom
|
||||
RustOtherState.PolicyRuleServer -> OtherState.PolicyRuleServer
|
||||
RustOtherState.PolicyRuleUser -> OtherState.PolicyRuleUser
|
||||
RustOtherState.RoomAliases -> OtherState.RoomAliases
|
||||
is RustOtherState.RoomAvatar -> OtherState.RoomAvatar(url)
|
||||
RustOtherState.RoomCanonicalAlias -> OtherState.RoomCanonicalAlias
|
||||
RustOtherState.RoomCreate -> OtherState.RoomCreate
|
||||
|
|
|
|||
|
|
@ -9,20 +9,54 @@
|
|||
package io.element.android.libraries.matrix.impl.util
|
||||
|
||||
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
||||
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||
import io.element.android.libraries.matrix.impl.room.map
|
||||
import org.matrix.rustcomponents.sdk.MessageContent
|
||||
import org.matrix.rustcomponents.sdk.MessageType
|
||||
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
|
||||
import org.matrix.rustcomponents.sdk.TextMessageContent
|
||||
import org.matrix.rustcomponents.sdk.contentWithoutRelationFromMessage
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromHtmlAsEmote
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdownAsEmote
|
||||
|
||||
/**
|
||||
* Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions.
|
||||
*/
|
||||
object MessageEventContent {
|
||||
fun from(body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>): RoomMessageEventContentWithoutRelation {
|
||||
return if (htmlBody != null) {
|
||||
messageEventContentFromHtml(body, htmlBody)
|
||||
} else {
|
||||
messageEventContentFromMarkdown(body)
|
||||
}.withMentions(intentionalMentions.map())
|
||||
fun from(
|
||||
body: String,
|
||||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
|
||||
asPlainText: Boolean = false,
|
||||
): RoomMessageEventContentWithoutRelation {
|
||||
return when {
|
||||
asPlainText -> contentWithoutRelationFromMessage(
|
||||
MessageContent(
|
||||
msgType = MessageType.Text(
|
||||
TextMessageContent(
|
||||
body = body,
|
||||
formatted = null,
|
||||
)
|
||||
),
|
||||
body = body,
|
||||
isEdited = false,
|
||||
mentions = null,
|
||||
)
|
||||
)
|
||||
htmlBody != null -> if (msgType == MsgType.MSG_TYPE_EMOTE) {
|
||||
messageEventContentFromHtmlAsEmote(body, htmlBody)
|
||||
} else {
|
||||
messageEventContentFromHtml(body, htmlBody)
|
||||
}
|
||||
else -> if (msgType == MsgType.MSG_TYPE_EMOTE) {
|
||||
messageEventContentFromMarkdownAsEmote(body)
|
||||
} else {
|
||||
messageEventContentFromMarkdown(body)
|
||||
}
|
||||
}
|
||||
.withMentions(intentionalMentions.map())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverCapabilities
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class RustHomeserverCapabilitiesProviderTest {
|
||||
@Test
|
||||
fun `refresh calls client refresh`() = runTest {
|
||||
val refreshLambda = lambdaRecorder<Unit> {}
|
||||
val provider = createCapabilitiesProvider(
|
||||
capabilities = FakeFfiHomeserverCapabilities(refresh = refreshLambda),
|
||||
)
|
||||
assertThat(provider.refresh().isSuccess).isTrue()
|
||||
refreshLambda.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh fails when client refresh does`() = runTest {
|
||||
val refreshLambda = lambdaRecorder<Unit> { throw IllegalStateException("Failed to refresh capabilities") }
|
||||
val provider = createCapabilitiesProvider(
|
||||
capabilities = FakeFfiHomeserverCapabilities(refresh = refreshLambda),
|
||||
)
|
||||
assertThat(provider.refresh().isFailure).isTrue()
|
||||
refreshLambda.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canChangeDisplayName returns expected value`() = runTest {
|
||||
val provider = createCapabilitiesProvider(
|
||||
capabilities = FakeFfiHomeserverCapabilities(canChangeDisplayName = { true }),
|
||||
)
|
||||
assertThat(provider.canChangeDisplayName().getOrNull()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canChangeAvatarUrl returns expected value`() = runTest {
|
||||
val provider = createCapabilitiesProvider(
|
||||
capabilities = FakeFfiHomeserverCapabilities(canChangeAvatar = { true }),
|
||||
)
|
||||
assertThat(provider.canChangeAvatarUrl().getOrNull()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canChangeDisplayName returns failure when client throws`() = runTest {
|
||||
val provider = createCapabilitiesProvider(
|
||||
capabilities = FakeFfiHomeserverCapabilities(canChangeDisplayName = { throw IllegalStateException("Failed to get display name capability") }),
|
||||
)
|
||||
assert(provider.canChangeDisplayName().isFailure)
|
||||
}
|
||||
|
||||
private fun createCapabilitiesProvider(
|
||||
capabilities: FakeFfiHomeserverCapabilities = FakeFfiHomeserverCapabilities(),
|
||||
) = RustHomeserverCapabilitiesProvider(capabilities)
|
||||
}
|
||||
|
|
@ -14,10 +14,9 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
|||
import org.matrix.rustcomponents.sdk.EventOrTransactionId
|
||||
import org.matrix.rustcomponents.sdk.EventSendState
|
||||
import org.matrix.rustcomponents.sdk.EventTimelineItem
|
||||
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo
|
||||
import org.matrix.rustcomponents.sdk.LazyTimelineItemProvider
|
||||
import org.matrix.rustcomponents.sdk.ProfileDetails
|
||||
import org.matrix.rustcomponents.sdk.Receipt
|
||||
import org.matrix.rustcomponents.sdk.ShieldState
|
||||
import org.matrix.rustcomponents.sdk.TimelineItemContent
|
||||
import uniffi.matrix_sdk_ui.EventItemOrigin
|
||||
|
||||
|
|
@ -26,37 +25,35 @@ internal fun aRustEventTimelineItem(
|
|||
eventOrTransactionId: EventOrTransactionId = EventOrTransactionId.EventId(AN_EVENT_ID.value),
|
||||
sender: String = A_USER_ID.value,
|
||||
senderProfile: ProfileDetails = ProfileDetails.Unavailable,
|
||||
forwarder: String? = null,
|
||||
forwarderProfile: ProfileDetails? = null,
|
||||
isOwn: Boolean = true,
|
||||
isEditable: Boolean = true,
|
||||
content: TimelineItemContent = aRustTimelineItemContentMsgLike(),
|
||||
eventTypeRaw: String? = null,
|
||||
timestamp: ULong = 0uL,
|
||||
debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(),
|
||||
localSendState: EventSendState? = null,
|
||||
localCreatedAt: ULong? = null,
|
||||
readReceipts: Map<String, Receipt> = emptyMap(),
|
||||
origin: EventItemOrigin? = EventItemOrigin.SYNC,
|
||||
canBeRepliedTo: Boolean = true,
|
||||
shieldsState: ShieldState = ShieldState.None,
|
||||
localCreatedAt: ULong? = null,
|
||||
forwarder: String? = null,
|
||||
forwarderProfile: ProfileDetails? = null,
|
||||
lazyProvider: LazyTimelineItemProvider = FakeFfiLazyTimelineItemProvider(),
|
||||
) = EventTimelineItem(
|
||||
isRemote = isRemote,
|
||||
eventOrTransactionId = eventOrTransactionId,
|
||||
sender = sender,
|
||||
senderProfile = senderProfile,
|
||||
timestamp = timestamp,
|
||||
isOwn = isOwn,
|
||||
isEditable = isEditable,
|
||||
canBeRepliedTo = canBeRepliedTo,
|
||||
content = content,
|
||||
localSendState = localSendState,
|
||||
readReceipts = readReceipts,
|
||||
origin = origin,
|
||||
localCreatedAt = localCreatedAt,
|
||||
lazyProvider = FakeFfiLazyTimelineItemProvider(
|
||||
debugInfo = debugInfo,
|
||||
shieldsState = shieldsState,
|
||||
),
|
||||
forwarder = forwarder,
|
||||
forwarderProfile = forwarderProfile,
|
||||
isOwn = isOwn,
|
||||
isEditable = isEditable,
|
||||
content = content,
|
||||
eventTypeRaw = eventTypeRaw,
|
||||
timestamp = timestamp,
|
||||
localSendState = localSendState,
|
||||
localCreatedAt = localCreatedAt,
|
||||
readReceipts = readReceipts,
|
||||
origin = origin,
|
||||
canBeRepliedTo = canBeRepliedTo,
|
||||
lazyProvider = lazyProvider,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import org.matrix.rustcomponents.sdk.Client
|
|||
import org.matrix.rustcomponents.sdk.ClientDelegate
|
||||
import org.matrix.rustcomponents.sdk.CreateRoomParameters
|
||||
import org.matrix.rustcomponents.sdk.Encryption
|
||||
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
|
||||
import org.matrix.rustcomponents.sdk.HomeserverLoginDetails
|
||||
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
|
||||
import org.matrix.rustcomponents.sdk.NoHandle
|
||||
|
|
@ -50,6 +51,7 @@ class FakeFfiClient(
|
|||
private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() },
|
||||
private val getStoreSizesResult: () -> StoreSizes = { lambdaError() },
|
||||
private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() },
|
||||
private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(),
|
||||
private val closeResult: () -> Unit = {},
|
||||
) : Client(NoHandle) {
|
||||
override fun userId(): String = userId
|
||||
|
|
@ -103,5 +105,9 @@ class FakeFfiClient(
|
|||
return createRoomResult(request)
|
||||
}
|
||||
|
||||
override fun homeserverCapabilities(): HomeserverCapabilities {
|
||||
return homeserverCapabilities
|
||||
}
|
||||
|
||||
override fun close() = closeResult()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.fixtures.fakes
|
||||
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import org.matrix.rustcomponents.sdk.ExtendedProfileFields
|
||||
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
|
||||
import org.matrix.rustcomponents.sdk.NoHandle
|
||||
|
||||
class FakeFfiHomeserverCapabilities(
|
||||
private val refresh: () -> Unit = { lambdaError() },
|
||||
private val canChangeDisplayName: () -> Boolean = { lambdaError() },
|
||||
private val canChangeAvatar: () -> Boolean = { lambdaError() },
|
||||
private val canChangePassword: () -> Boolean = { lambdaError() },
|
||||
private val canChangeThirdPartyIds: () -> Boolean = { lambdaError() },
|
||||
private val canGetLoginToken: () -> Boolean = { lambdaError() },
|
||||
private val forgetsRoomWhenLeaving: () -> Boolean = { lambdaError() },
|
||||
private val extendedProfileFields: () -> ExtendedProfileFields = { lambdaError() },
|
||||
) : HomeserverCapabilities(NoHandle) {
|
||||
override suspend fun refresh() = refresh.invoke()
|
||||
override suspend fun canChangeDisplayname(): Boolean = canChangeDisplayName.invoke()
|
||||
override suspend fun canChangeAvatar(): Boolean = canChangeAvatar.invoke()
|
||||
override suspend fun canChangePassword(): Boolean = canChangePassword.invoke()
|
||||
override suspend fun canChangeThirdpartyIds(): Boolean = canChangeThirdPartyIds.invoke()
|
||||
override suspend fun canGetLoginToken(): Boolean = canGetLoginToken.invoke()
|
||||
override suspend fun forgetsRoomWhenLeaving(): Boolean = forgetsRoomWhenLeaving.invoke()
|
||||
override suspend fun extendedProfileFields(): ExtendedProfileFields = extendedProfileFields.invoke()
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.fixtures.fakes
|
||||
|
||||
import org.matrix.rustcomponents.sdk.NoHandle
|
||||
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
|
||||
import org.matrix.rustcomponents.sdk.ThreadListItem
|
||||
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
|
||||
import org.matrix.rustcomponents.sdk.ThreadListService
|
||||
import org.matrix.rustcomponents.sdk.ThreadListUpdate
|
||||
import uniffi.matrix_sdk_ui.ThreadListPaginationState
|
||||
|
||||
class FakeFfiThreadListService(
|
||||
private val subscribeToItemsUpdates: (ThreadListEntriesListener) -> TaskHandle = { FakeFfiTaskHandle() },
|
||||
private val subscribeToPaginationStateUpdates: (ThreadListPaginationStateListener) -> TaskHandle = { FakeFfiTaskHandle() },
|
||||
private val items: () -> List<ThreadListItem> = { emptyList() },
|
||||
private val paginationState: () -> ThreadListPaginationState = { ThreadListPaginationState.Idle(endReached = false) },
|
||||
private val paginate: suspend () -> Unit = {},
|
||||
private val reset: suspend () -> Unit = {},
|
||||
private val destroy: () -> Unit = {},
|
||||
) : ThreadListService(NoHandle) {
|
||||
private var itemsListener: ThreadListEntriesListener? = null
|
||||
private var paginationStateListener: ThreadListPaginationStateListener? = null
|
||||
|
||||
override fun subscribeToItemsUpdates(listener: ThreadListEntriesListener): TaskHandle {
|
||||
itemsListener = listener
|
||||
return subscribeToItemsUpdates.invoke(listener)
|
||||
}
|
||||
|
||||
override fun subscribeToPaginationStateUpdates(listener: ThreadListPaginationStateListener): TaskHandle {
|
||||
paginationStateListener = listener
|
||||
return subscribeToPaginationStateUpdates.invoke(listener)
|
||||
}
|
||||
|
||||
override fun items(): List<ThreadListItem> = items.invoke()
|
||||
|
||||
override fun paginationState(): ThreadListPaginationState = paginationState.invoke()
|
||||
|
||||
override suspend fun paginate() = paginate.invoke()
|
||||
|
||||
override suspend fun reset() = reset.invoke()
|
||||
|
||||
override fun destroy() = destroy.invoke()
|
||||
|
||||
fun emitUpdates(updates: List<ThreadListUpdate>) {
|
||||
itemsListener?.onUpdate(updates)
|
||||
}
|
||||
|
||||
fun emitPaginationState(state: ThreadListPaginationState) {
|
||||
paginationStateListener?.onUpdate(state)
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,6 @@ class StateEventTypeTest {
|
|||
assertThat(RustStateEventType.PolicyRuleRoom.map()).isEqualTo(StateEventType.PolicyRuleRoom)
|
||||
assertThat(RustStateEventType.PolicyRuleServer.map()).isEqualTo(StateEventType.PolicyRuleServer)
|
||||
assertThat(RustStateEventType.PolicyRuleUser.map()).isEqualTo(StateEventType.PolicyRuleUser)
|
||||
assertThat(RustStateEventType.RoomAliases.map()).isEqualTo(StateEventType.RoomAliases)
|
||||
assertThat(RustStateEventType.RoomAvatar.map()).isEqualTo(StateEventType.RoomAvatar)
|
||||
assertThat(RustStateEventType.RoomCanonicalAlias.map()).isEqualTo(StateEventType.RoomCanonicalAlias)
|
||||
assertThat(RustStateEventType.RoomCreate.map()).isEqualTo(StateEventType.RoomCreate)
|
||||
|
|
@ -47,7 +46,6 @@ class StateEventTypeTest {
|
|||
assertThat(StateEventType.PolicyRuleRoom.map()).isEqualTo(RustStateEventType.PolicyRuleRoom)
|
||||
assertThat(StateEventType.PolicyRuleServer.map()).isEqualTo(RustStateEventType.PolicyRuleServer)
|
||||
assertThat(StateEventType.PolicyRuleUser.map()).isEqualTo(RustStateEventType.PolicyRuleUser)
|
||||
assertThat(StateEventType.RoomAliases.map()).isEqualTo(RustStateEventType.RoomAliases)
|
||||
assertThat(StateEventType.RoomAvatar.map()).isEqualTo(RustStateEventType.RoomAvatar)
|
||||
assertThat(StateEventType.RoomCanonicalAlias.map()).isEqualTo(RustStateEventType.RoomCanonicalAlias)
|
||||
assertThat(StateEventType.RoomCreate.map()).isEqualTo(RustStateEventType.RoomCreate)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.room.threads
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustTimelineItemContentMsgLike
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTaskHandle
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiThreadListService
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_TIMESTAMP
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.matrix.rustcomponents.sdk.ProfileDetails
|
||||
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
|
||||
import org.matrix.rustcomponents.sdk.ThreadListItem
|
||||
import org.matrix.rustcomponents.sdk.ThreadListItemEvent
|
||||
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
|
||||
import org.matrix.rustcomponents.sdk.ThreadListUpdate
|
||||
import uniffi.matrix_sdk_ui.ThreadListPaginationState
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RustThreadsListServiceTest {
|
||||
@Test
|
||||
fun `subscribing to item updates calls the FFI method and allows retrieving new items`() = runTest {
|
||||
val subscribeToItemsUpdatesRecorder = lambdaRecorder<ThreadListEntriesListener, TaskHandle> { FakeFfiTaskHandle() }
|
||||
val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder)
|
||||
val service = createThreadsListService(inner = inner)
|
||||
|
||||
service.subscribeToItemUpdates().test {
|
||||
assertThat(awaitItem()).isEmpty()
|
||||
|
||||
runCurrent()
|
||||
subscribeToItemsUpdatesRecorder.assertions().isCalledOnce()
|
||||
|
||||
inner.emitUpdates(listOf(aRustThreadListUpdate()))
|
||||
|
||||
assertThat(awaitItem()).isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UnusedFlow")
|
||||
@Test
|
||||
fun `subscribing to item updates twice only calls the FFI method once`() = runTest {
|
||||
val subscribeToItemsUpdatesRecorder = lambdaRecorder<ThreadListEntriesListener, TaskHandle> { FakeFfiTaskHandle() }
|
||||
val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder)
|
||||
val service = createThreadsListService(inner = inner)
|
||||
|
||||
service.subscribeToItemUpdates()
|
||||
service.subscribeToItemUpdates()
|
||||
|
||||
runCurrent()
|
||||
|
||||
subscribeToItemsUpdatesRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subscribing to pagination updates calls the FFI method and allows retrieving new items`() = runTest {
|
||||
val subscribeToPaginationUpdatesRecorder = lambdaRecorder<ThreadListPaginationStateListener, TaskHandle> { FakeFfiTaskHandle() }
|
||||
val inner = FakeFfiThreadListService(subscribeToPaginationStateUpdates = subscribeToPaginationUpdatesRecorder)
|
||||
val service = createThreadsListService(inner = inner)
|
||||
|
||||
service.subscribeToPaginationUpdates().test {
|
||||
assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Idle(hasMoreToLoad = true))
|
||||
|
||||
runCurrent()
|
||||
subscribeToPaginationUpdatesRecorder.assertions().isCalledOnce()
|
||||
|
||||
inner.emitPaginationState(ThreadListPaginationState.Loading)
|
||||
|
||||
assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Loading)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paginate calls the FFI method`() = runTest {
|
||||
val paginateRecorder = lambdaRecorder<Unit> {}
|
||||
val inner = FakeFfiThreadListService(paginate = paginateRecorder)
|
||||
val service = createThreadsListService(inner = inner)
|
||||
|
||||
service.paginate()
|
||||
|
||||
paginateRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset calls the FFI method`() = runTest {
|
||||
val resetRecorder = lambdaRecorder<Unit> {}
|
||||
val inner = FakeFfiThreadListService(reset = resetRecorder)
|
||||
val service = createThreadsListService(inner = inner)
|
||||
|
||||
service.reset()
|
||||
|
||||
resetRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `destroy calls the FFI method`() = runTest {
|
||||
val destroyRecorder = lambdaRecorder<Unit> {}
|
||||
val inner = FakeFfiThreadListService(destroy = destroyRecorder)
|
||||
val service = createThreadsListService(inner = inner)
|
||||
|
||||
service.destroy()
|
||||
|
||||
destroyRecorder.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
private fun TestScope.createThreadsListService(
|
||||
inner: FakeFfiThreadListService = FakeFfiThreadListService(),
|
||||
) = RustThreadsListService(
|
||||
inner = inner,
|
||||
roomCoroutineScope = backgroundScope,
|
||||
)
|
||||
|
||||
private fun aRustThreadListUpdate() = ThreadListUpdate.Append(
|
||||
values = listOf(
|
||||
ThreadListItem(
|
||||
rootEvent = ThreadListItemEvent(
|
||||
eventId = AN_EVENT_ID.value,
|
||||
timestamp = A_TIMESTAMP.toULong(),
|
||||
sender = A_USER_ID.value,
|
||||
senderProfile = ProfileDetails.Pending,
|
||||
isOwn = true,
|
||||
content = aRustTimelineItemContentMsgLike(),
|
||||
),
|
||||
numReplies = 0u,
|
||||
latestEvent = null,
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -19,7 +19,6 @@ dependencies {
|
|||
api(projects.libraries.matrix.api)
|
||||
api(libs.coroutines.core)
|
||||
implementation(libs.coroutines.test)
|
||||
implementation(projects.libraries.matrix.impl)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.tests.testutils)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.test
|
||||
|
||||
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
|
||||
|
||||
class FakeHomeserverCapabilitiesProvider(
|
||||
private val refresh: () -> Result<Unit> = { Result.success(Unit) },
|
||||
private val canChangeDisplayName: () -> Result<Boolean> = { Result.success(true) },
|
||||
private val canChangeAvatarUrl: () -> Result<Boolean> = { Result.success(true) },
|
||||
) : HomeserverCapabilitiesProvider {
|
||||
override suspend fun refresh(): Result<Unit> = refresh.invoke()
|
||||
override suspend fun canChangeDisplayName(): Result<Boolean> = canChangeDisplayName.invoke()
|
||||
override suspend fun canChangeAvatarUrl(): Result<Boolean> = canChangeAvatarUrl.invoke()
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
package io.element.android.libraries.matrix.test
|
||||
|
||||
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
|
|
@ -84,6 +85,7 @@ class FakeMatrixClient(
|
|||
override val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(),
|
||||
override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
|
||||
override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
|
||||
private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(),
|
||||
private val accountManagementUrlResult: (AccountManagementAction?) -> Result<String?> = { lambdaError() },
|
||||
private val resolveRoomAliasResult: (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = {
|
||||
Result.success(
|
||||
|
|
@ -384,4 +386,8 @@ class FakeMatrixClient(
|
|||
override suspend fun resetWellKnownConfig(): Result<Unit> {
|
||||
return resetWellKnownConfigLambda()
|
||||
}
|
||||
|
||||
override fun homeserverCapabilities(): HomeserverCapabilitiesProvider {
|
||||
return homeserverCapabilitiesProvider
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
package io.element.android.libraries.matrix.test.auth
|
||||
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
|
|
@ -17,6 +18,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
|
|||
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
||||
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
|
|
@ -32,6 +34,8 @@ class FakeMatrixAuthenticationService(
|
|||
lambdaRecorder<MatrixQrCodeLoginData, (QrCodeLoginStep) -> Unit, Result<SessionId>> { _, _ -> Result.success(A_SESSION_ID) },
|
||||
private val importCreatedSessionLambda: (ExternalSession) -> Result<SessionId> = { lambdaError() },
|
||||
private val setHomeserverResult: (String) -> Result<MatrixHomeServerDetails> = { lambdaError() },
|
||||
private val setElementClassicSessionResult: (ElementClassicSession?) -> Unit = { lambdaError() },
|
||||
private val doSecretsContainBackupKeyResult: (UserId, String, String) -> Boolean = { _, _, _ -> lambdaError() },
|
||||
) : MatrixAuthenticationService {
|
||||
private var oidcError: Throwable? = null
|
||||
private var oidcCancelError: Throwable? = null
|
||||
|
|
@ -108,4 +112,12 @@ class FakeMatrixAuthenticationService(
|
|||
fun givenMatrixClient(matrixClient: MatrixClient) {
|
||||
this.matrixClient = matrixClient
|
||||
}
|
||||
|
||||
override fun setElementClassicSession(session: ElementClassicSession?) {
|
||||
setElementClassicSessionResult(session)
|
||||
}
|
||||
|
||||
override fun doSecretsContainBackupKey(userId: UserId, secrets: String, backupInfo: String): Boolean {
|
||||
return doSecretsContainBackupKeyResult(userId, secrets, backupInfo)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@
|
|||
package io.element.android.libraries.matrix.test.mxc
|
||||
|
||||
import io.element.android.libraries.matrix.api.mxc.MxcTools
|
||||
import io.element.android.libraries.matrix.impl.mxc.DefaultMxcTools
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeMxcTools(
|
||||
private val delegate: MxcTools = DefaultMxcTools()
|
||||
) : MxcTools by delegate
|
||||
private val mxcUri2FilePathResult: (String) -> String? = { lambdaError() }
|
||||
) : MxcTools {
|
||||
override fun mxcUri2FilePath(mxcUri: String): String? {
|
||||
return mxcUri2FilePathResult(mxcUri)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
|
|||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
|
||||
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
|
||||
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
|
||||
import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
|
|
@ -56,6 +57,7 @@ class FakeJoinedRoom(
|
|||
override val roomNotificationSettingsStateFlow: StateFlow<RoomNotificationSettingsState> =
|
||||
MutableStateFlow(RoomNotificationSettingsState.Unknown),
|
||||
override val knockRequestsFlow: Flow<List<KnockRequest>> = MutableStateFlow(emptyList()),
|
||||
override val threadsListService: FakeThreadsListService = FakeThreadsListService(),
|
||||
private val roomNotificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
|
||||
private var createTimelineResult: (CreateTimelineParams) -> Result<Timeline> = { lambdaError() },
|
||||
private val editMessageLambda: (EventId, String, String?, List<IntentionalMention>) -> Result<Unit> = { _, _, _, _ -> lambdaError() },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.test.room.threads
|
||||
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
|
||||
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class FakeThreadsListService(
|
||||
private val items: MutableStateFlow<List<ThreadListItem>> = MutableStateFlow(emptyList()),
|
||||
private val paginationStatus: MutableStateFlow<ThreadListPaginationStatus> = MutableStateFlow(ThreadListPaginationStatus.Idle(hasMoreToLoad = true)),
|
||||
private val subscribeToItemUpdates: () -> Flow<List<ThreadListItem>> = { items },
|
||||
private val subscribeToPaginationUpdates: () -> Flow<ThreadListPaginationStatus> = { paginationStatus },
|
||||
private val paginate: suspend () -> Result<Unit> = { Result.success(Unit) },
|
||||
private val reset: suspend () -> Result<Unit> = { Result.success(Unit) },
|
||||
private val destroy: () -> Unit = {},
|
||||
) : ThreadsListService {
|
||||
override fun subscribeToItemUpdates(): Flow<List<ThreadListItem>> {
|
||||
return subscribeToItemUpdates.invoke()
|
||||
}
|
||||
|
||||
override fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus> {
|
||||
return subscribeToPaginationUpdates.invoke()
|
||||
}
|
||||
|
||||
override suspend fun paginate(): Result<Unit> {
|
||||
return paginate.invoke()
|
||||
}
|
||||
|
||||
override suspend fun reset(): Result<Unit> {
|
||||
return reset.invoke()
|
||||
}
|
||||
|
||||
override fun destroy() {
|
||||
return destroy.invoke()
|
||||
}
|
||||
|
||||
suspend fun emit(items: List<ThreadListItem>) {
|
||||
this.items.emit(items)
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.poll.PollKind
|
|||
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
|
|
@ -78,7 +79,9 @@ class FakeTimeline(
|
|||
body: String,
|
||||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
) -> Result<Unit> = { _, _, _ ->
|
||||
msgType: MsgType,
|
||||
asPlainText: Boolean,
|
||||
) -> Result<Unit> = { _, _, _, _, _ ->
|
||||
lambdaError()
|
||||
}
|
||||
|
||||
|
|
@ -90,8 +93,10 @@ class FakeTimeline(
|
|||
body: String,
|
||||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
msgType: MsgType,
|
||||
asPlainText: Boolean,
|
||||
): Result<Unit> = simulateLongTask {
|
||||
sendMessageLambda(body, htmlBody, intentionalMentions)
|
||||
sendMessageLambda(body, htmlBody, intentionalMentions, msgType, asPlainText)
|
||||
}
|
||||
|
||||
var redactEventLambda: (eventOrTransactionId: EventOrTransactionId, reason: String?) -> Result<Unit> = { _, _ ->
|
||||
|
|
@ -148,7 +153,8 @@ class FakeTimeline(
|
|||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
fromNotification: Boolean,
|
||||
) -> Result<Unit> = { _, _, _, _, _ ->
|
||||
msgType: MsgType,
|
||||
) -> Result<Unit> = { _, _, _, _, _, _ ->
|
||||
lambdaError()
|
||||
}
|
||||
|
||||
|
|
@ -158,12 +164,14 @@ class FakeTimeline(
|
|||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
fromNotification: Boolean,
|
||||
msgType: MsgType,
|
||||
): Result<Unit> = replyMessageLambda(
|
||||
repliedToEventId,
|
||||
body,
|
||||
htmlBody,
|
||||
intentionalMentions,
|
||||
fromNotification,
|
||||
msgType,
|
||||
)
|
||||
|
||||
var sendImageLambda: (
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
package io.element.android.libraries.matrix.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
|
@ -22,9 +23,13 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
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.atomic.molecules.ButtonRowMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||
|
|
@ -33,6 +38,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.R
|
||||
|
|
@ -48,10 +54,23 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
@Composable
|
||||
fun CreateDmConfirmationBottomSheet(
|
||||
matrixUser: MatrixUser,
|
||||
enableKeyShareOnInvite: Boolean,
|
||||
isUserIdentityUnknown: Boolean,
|
||||
onSendInvite: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val titleContent = if (enableKeyShareOnInvite && isUserIdentityUnknown) {
|
||||
stringResource(R.string.screen_bottom_sheet_create_dm_unknown_user_title)
|
||||
} else {
|
||||
stringResource(R.string.screen_bottom_sheet_create_dm_title)
|
||||
}
|
||||
val descriptionContent = if (enableKeyShareOnInvite && isUserIdentityUnknown) {
|
||||
stringResource(R.string.screen_bottom_sheet_create_dm_unknown_user_content)
|
||||
} else {
|
||||
stringResource(R.string.screen_bottom_sheet_create_dm_message, matrixUser.getFullName())
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismiss,
|
||||
|
|
@ -63,47 +82,95 @@ fun CreateDmConfirmationBottomSheet(
|
|||
.padding(top = 24.dp, bottom = 16.dp, start = 16.dp, end = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Avatar(
|
||||
avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation),
|
||||
avatarType = AvatarType.User,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.screen_bottom_sheet_create_dm_title),
|
||||
style = ElementTheme.typography.fontHeadingMdBold,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.screen_bottom_sheet_create_dm_message, matrixUser.getFullName()),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onSendInvite,
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.UserAdd()),
|
||||
text = stringResource(R.string.screen_bottom_sheet_create_dm_confirmation_button_title),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onDismiss,
|
||||
text = stringResource(CommonStrings.action_cancel),
|
||||
)
|
||||
if (isUserIdentityUnknown) {
|
||||
IconTitleSubtitleMolecule(
|
||||
modifier = Modifier.padding(
|
||||
bottom = 16.dp,
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
),
|
||||
title = titleContent,
|
||||
subTitle = descriptionContent,
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.UserAddSolid()),
|
||||
)
|
||||
MatrixUserRow(matrixUser)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
ButtonRowMolecule(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = stringResource(CommonStrings.action_cancel),
|
||||
onClick = onDismiss
|
||||
)
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = stringResource(CommonStrings.action_continue),
|
||||
onClick = onSendInvite
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
} else {
|
||||
Avatar(
|
||||
avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation),
|
||||
avatarType = AvatarType.User,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = titleContent,
|
||||
style = ElementTheme.typography.fontHeadingMdBold,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = descriptionContent,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onSendInvite,
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.UserAdd()),
|
||||
text = stringResource(R.string.screen_bottom_sheet_create_dm_confirmation_button_title),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onDismiss,
|
||||
text = stringResource(CommonStrings.action_cancel),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview {
|
||||
internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter(
|
||||
CreateDmConfirmationBottomSheetStateProvider::class
|
||||
) state: CreateDmConfirmationBottomSheetState) = ElementPreview {
|
||||
CreateDmConfirmationBottomSheet(
|
||||
matrixUser = matrixUser,
|
||||
matrixUser = state.matrixUser,
|
||||
enableKeyShareOnInvite = state.enableKeyShareOnInvite,
|
||||
isUserIdentityUnknown = state.isUserIdentityUnknown,
|
||||
onSendInvite = {},
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
|
||||
data class CreateDmConfirmationBottomSheetState(
|
||||
val matrixUser: MatrixUser,
|
||||
val enableKeyShareOnInvite: Boolean,
|
||||
val isUserIdentityUnknown: Boolean,
|
||||
)
|
||||
|
||||
class CreateDmConfirmationBottomSheetStateProvider : PreviewParameterProvider<CreateDmConfirmationBottomSheetState> {
|
||||
override val values = sequenceOf(
|
||||
CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = false, isUserIdentityUnknown = false),
|
||||
CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = true, isUserIdentityUnknown = false),
|
||||
CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = true, isUserIdentityUnknown = true),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"招待を送信"</string>
|
||||
<string name="screen_bottom_sheet_create_dm_message">"%1$s とチャットを始めますか?"</string>
|
||||
<string name="screen_bottom_sheet_create_dm_title">"招待を送信しますか?"</string>
|
||||
<string name="screen_bottom_sheet_create_dm_unknown_user_content">"この人物とのチャットがありません。続行する前に、まず招待してください。"</string>
|
||||
<string name="screen_bottom_sheet_create_dm_unknown_user_title">"この新しい連絡先と新規にチャットを開始しますか?"</string>
|
||||
<string name="screen_invites_invited_you">"%1$s (%2$s) があなたを招待しました"</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_invites_invited_you">"%1$s(%2$s ) đã mời bạn"</string>
|
||||
</resources>
|
||||
|
|
@ -3,5 +3,7 @@
|
|||
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Send invite"</string>
|
||||
<string name="screen_bottom_sheet_create_dm_message">"Would you like to start a chat with %1$s?"</string>
|
||||
<string name="screen_bottom_sheet_create_dm_title">"Send invite?"</string>
|
||||
<string name="screen_bottom_sheet_create_dm_unknown_user_content">"You currently don’t have any chats with this person. Confirm inviting them before continuing."</string>
|
||||
<string name="screen_bottom_sheet_create_dm_unknown_user_title">"Start a chat with this new contact?"</string>
|
||||
<string name="screen_invites_invited_you">"%1$s (%2$s) invited you"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -195,18 +195,16 @@ class AndroidMediaPreProcessor(
|
|||
file = file,
|
||||
mimeType = mimeType,
|
||||
)
|
||||
val imageInfo = contentResolver.openInputStream(uri).use { input ->
|
||||
val bitmap = BitmapFactory.decodeStream(input, null, null)!!
|
||||
ImageInfo(
|
||||
width = bitmap.width.toLong(),
|
||||
height = bitmap.height.toLong(),
|
||||
mimetype = mimeType,
|
||||
size = file.length(),
|
||||
thumbnailInfo = thumbnailResult?.info,
|
||||
thumbnailSource = null,
|
||||
blurhash = thumbnailResult?.blurhash,
|
||||
)
|
||||
}
|
||||
val (width, height) = extractOrientedImageDimensions(file)
|
||||
val imageInfo = ImageInfo(
|
||||
width = width,
|
||||
height = height,
|
||||
mimetype = mimeType,
|
||||
size = file.length(),
|
||||
thumbnailInfo = thumbnailResult?.info,
|
||||
thumbnailSource = null,
|
||||
blurhash = thumbnailResult?.blurhash,
|
||||
)
|
||||
removeSensitiveImageMetadata(file)
|
||||
return MediaUploadInfo.Image(
|
||||
file = file,
|
||||
|
|
@ -354,6 +352,23 @@ class AndroidMediaPreProcessor(
|
|||
return contentResolver.openInputStream(uri)?.use { createTmpFileWithInput(it) }
|
||||
?: error("Could not copy the contents of $uri to a temporary file")
|
||||
}
|
||||
|
||||
private fun extractOrientedImageDimensions(file: File): Pair<Long, Long> {
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.path, options)
|
||||
|
||||
val rawWidth = options.outWidth.toLong()
|
||||
val rawHeight = options.outHeight.toLong()
|
||||
val orientation = tryOrNull {
|
||||
ExifInterface(file).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
|
||||
} ?: ExifInterface.ORIENTATION_UNDEFINED
|
||||
|
||||
return orientedImageDimensions(
|
||||
rawWidth = rawWidth,
|
||||
rawHeight = rawHeight,
|
||||
orientation = orientation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailResult: ThumbnailResult?) = ImageInfo(
|
||||
|
|
@ -371,3 +386,18 @@ private fun MediaMetadataRetriever.extractDuration(): Duration {
|
|||
val durationInMs = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L
|
||||
return durationInMs.milliseconds
|
||||
}
|
||||
|
||||
internal fun orientedImageDimensions(rawWidth: Long, rawHeight: Long, orientation: Int): Pair<Long, Long> {
|
||||
return if (orientation.rotatesRightAngle()) {
|
||||
rawHeight to rawWidth
|
||||
} else {
|
||||
rawWidth to rawHeight
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int.rotatesRightAngle(): Boolean {
|
||||
return this == ExifInterface.ORIENTATION_ROTATE_90 ||
|
||||
this == ExifInterface.ORIENTATION_ROTATE_270 ||
|
||||
this == ExifInterface.ORIENTATION_TRANSPOSE ||
|
||||
this == ExifInterface.ORIENTATION_TRANSVERSE
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import android.content.Context
|
|||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.net.toUri
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||
|
|
@ -42,6 +43,30 @@ import kotlin.time.Duration
|
|||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class AndroidMediaPreProcessorTest {
|
||||
@Test
|
||||
fun `orientedImageDimensions swaps width and height for 90 degree exif orientation`() {
|
||||
val (width, height) = orientedImageDimensions(
|
||||
rawWidth = 4032,
|
||||
rawHeight = 2268,
|
||||
orientation = ExifInterface.ORIENTATION_ROTATE_90,
|
||||
)
|
||||
|
||||
assertThat(width).isEqualTo(2268)
|
||||
assertThat(height).isEqualTo(4032)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `orientedImageDimensions keeps width and height for upright exif orientation`() {
|
||||
val (width, height) = orientedImageDimensions(
|
||||
rawWidth = 4032,
|
||||
rawHeight = 2268,
|
||||
orientation = ExifInterface.ORIENTATION_NORMAL,
|
||||
)
|
||||
|
||||
assertThat(width).isEqualTo(4032)
|
||||
assertThat(height).isEqualTo(2268)
|
||||
}
|
||||
|
||||
private suspend fun TestScope.process(
|
||||
asset: Asset,
|
||||
mediaOptimizationConfig: MediaOptimizationConfig,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -51,6 +52,7 @@ import androidx.media3.common.Timeline
|
|||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.bumble.appyx.core.node.LocalNodeTargetVisibility
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.libraries.audio.api.AudioFocus
|
||||
|
|
@ -130,6 +132,8 @@ private fun ExoPlayerMediaAudioView(
|
|||
mutableStateOf(null)
|
||||
}
|
||||
|
||||
val isTargetVisible = LocalNodeTargetVisibility.current
|
||||
|
||||
val playableState: PlayableState.Playable by remember {
|
||||
derivedStateOf {
|
||||
PlayableState.Playable(
|
||||
|
|
@ -196,13 +200,21 @@ private fun ExoPlayerMediaAudioView(
|
|||
exoPlayer.pause()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(isTargetVisible) {
|
||||
if (!isTargetVisible) {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
}
|
||||
if (localMedia?.uri != null) {
|
||||
LaunchedEffect(localMedia.uri) {
|
||||
val mediaItem = MediaItem.fromUri(localMedia.uri)
|
||||
exoPlayer.setMediaItem(mediaItem)
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
} else {
|
||||
exoPlayer.setMediaItems(emptyList())
|
||||
LaunchedEffect(Unit) {
|
||||
exoPlayer.setMediaItems(emptyList())
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val waveform = info?.waveform
|
||||
|
|
@ -247,7 +259,7 @@ private fun ExoPlayerMediaAudioView(
|
|||
}
|
||||
},
|
||||
update = { playerView ->
|
||||
playerView.isVisible = metadata.hasArtwork()
|
||||
playerView.isVisible = metadata.hasArtwork() && isTargetVisible
|
||||
},
|
||||
onRelease = { playerView ->
|
||||
playerView.player = null
|
||||
|
|
@ -317,16 +329,19 @@ private fun ExoPlayerMediaAudioView(
|
|||
)
|
||||
}
|
||||
|
||||
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()
|
||||
DisposableEffect(exoPlayer) {
|
||||
exoPlayer.addListener(playerListener)
|
||||
onDispose {
|
||||
if (!exoPlayer.isReleased) {
|
||||
exoPlayer.removeListener(playerListener)
|
||||
exoPlayer.release()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
OnLifecycleEvent { _, event ->
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ 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.rememberLocalMediaViewState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
|
||||
import me.saket.telephoto.zoomable.coil3.ZoomableAsyncImage
|
||||
import me.saket.telephoto.zoomable.rememberZoomableImageState
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -29,6 +29,14 @@ import io.element.android.libraries.mediaviewer.impl.details.aMediaDeleteConfirm
|
|||
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
private const val LONG_CAPTION = "This is a very long caption that should be scrollable in the media viewer. " +
|
||||
"It contains multiple lines of text to demonstrate the scrolling behavior. " +
|
||||
"Line 1: Lorem ipsum dolor sit amet, consectetur adipiscing elit. " +
|
||||
"Line 2: Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. " +
|
||||
"Line 3: Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. " +
|
||||
"Line 4: Duis aute irure dolor in reprehenderit in voluptate velit esse cillum. " +
|
||||
"Line 5: Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia."
|
||||
|
||||
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
|
||||
override val values: Sequence<MediaViewerState>
|
||||
get() = sequenceOf(
|
||||
|
|
@ -170,6 +178,22 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
|
|||
)
|
||||
)
|
||||
),
|
||||
anImageMediaInfo(
|
||||
senderName = "Alice",
|
||||
dateSent = "21 NOV, 2024",
|
||||
caption = LONG_CAPTION,
|
||||
).let {
|
||||
aMediaViewerState(
|
||||
listOf(
|
||||
aMediaViewerPageData(
|
||||
downloadedMedia = AsyncData.Success(
|
||||
LocalMedia(Uri.EMPTY, it)
|
||||
),
|
||||
mediaInfo = it,
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,18 +17,24 @@ import androidx.compose.animation.fadeOut
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -39,14 +45,17 @@ import androidx.compose.runtime.snapshotFlow
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Devices
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -69,6 +78,7 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
|
|||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.designsystem.utils.hasCompactHeightWindowSize
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
|
|
@ -102,8 +112,9 @@ fun MediaViewerView(
|
|||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||
var showOverlay by remember { mutableStateOf(true) }
|
||||
|
||||
val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0
|
||||
val currentData = state.listData.getOrNull(state.currentIndex)
|
||||
val defaultBottomPaddingInPixels = if (LocalInspectionMode.current && !hasCompactHeightWindowSize()) 303 else 0
|
||||
|
||||
BackHandler { onBackClick() }
|
||||
Scaffold(
|
||||
modifier,
|
||||
|
|
@ -153,10 +164,11 @@ fun MediaViewerView(
|
|||
// So we need to update this value only when the `settledPage` value changes. It seems like a bug that needs to be fixed in Compose.
|
||||
page == pagerState.settledPage
|
||||
}
|
||||
val navigationBarPadding = WindowInsets.navigationBars.getBottom(LocalDensity.current)
|
||||
MediaViewerPage(
|
||||
isDisplayed = isDisplayed,
|
||||
showOverlay = showOverlay,
|
||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||
bottomPaddingInPixels = (bottomPaddingInPixels - navigationBarPadding).coerceAtLeast(0),
|
||||
data = dataForPage,
|
||||
textFileViewer = textFileViewer,
|
||||
onDismiss = onBackClick,
|
||||
|
|
@ -175,9 +187,7 @@ fun MediaViewerView(
|
|||
// Bottom bar
|
||||
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
MediaViewerBottomBar(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
|
|
@ -538,19 +548,46 @@ private fun MediaViewerBottomBar(
|
|||
if (showDivider) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
Text(
|
||||
val scrollState = rememberScrollState()
|
||||
val showBottomShadow by remember { derivedStateOf { scrollState.value < scrollState.maxValue } }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
text = caption,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
)
|
||||
.heightIn(max = if (hasCompactHeightWindowSize()) maxCaptionHeightLandscape else maxCaptionHeightPortrait),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.verticalScroll(scrollState)
|
||||
.navigationBarsPadding(),
|
||||
text = caption,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
)
|
||||
if (showBottomShadow) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
bgCanvasWithTransparency,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val maxCaptionHeightPortrait = 200.dp
|
||||
private val maxCaptionHeightLandscape = 128.dp
|
||||
|
||||
@Composable
|
||||
private fun ThumbnailView(
|
||||
thumbnailSource: MediaSource?,
|
||||
|
|
@ -604,3 +641,14 @@ internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::
|
|||
onBackClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(device = "${Devices.PHONE}, orientation=landscape")
|
||||
@Composable
|
||||
internal fun MediaViewerViewLandscapePreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark {
|
||||
MediaViewerView(
|
||||
state = state,
|
||||
audioFocus = null,
|
||||
textFileViewer = { _, _ -> },
|
||||
onBackClick = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_media_browser_delete_confirmation_subtitle">"このファイルはルームから削除され、他のユーザーは確認することができなくなります。"</string>
|
||||
<string name="screen_media_browser_delete_confirmation_title">"ファイルを削除しますか?"</string>
|
||||
<string name="screen_media_browser_download_error_message">"インターネット接続を確認した上、再度お試しください。"</string>
|
||||
<string name="screen_media_browser_files_empty_state_subtitle">"このルームに投稿された文書ファイルや音声ファイル・メッセージはここに表示されます。"</string>
|
||||
<string name="screen_media_browser_files_empty_state_title">"アップロードされたファイルはありません"</string>
|
||||
<string name="screen_media_browser_list_loading_files">"ファイルを読み込み中…"</string>
|
||||
<string name="screen_media_browser_list_loading_media">"メディアを読み込み中…"</string>
|
||||
<string name="screen_media_browser_list_mode_files">"ファイル"</string>
|
||||
<string name="screen_media_browser_list_mode_media">"メディア"</string>
|
||||
<string name="screen_media_browser_media_empty_state_subtitle">"このルームに投稿された画像と動画はここに表示されます。"</string>
|
||||
<string name="screen_media_browser_media_empty_state_title">"アップロードされたメディアはありません"</string>
|
||||
<string name="screen_media_browser_title">"ファイルとメディア"</string>
|
||||
<string name="screen_media_details_file_format">"ファイル形式"</string>
|
||||
<string name="screen_media_details_filename">"ファイル名"</string>
|
||||
<string name="screen_media_details_no_more_files_to_show">"これ以上ファイルはありません"</string>
|
||||
<string name="screen_media_details_no_more_media_to_show">"これ以上メディアはありません"</string>
|
||||
<string name="screen_media_details_uploaded_by">"アップロード元"</string>
|
||||
<string name="screen_media_details_uploaded_on">"アップロード先"</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="dialog_permission_camera">"カメラを使用するには、本体の設定から権限を付与する必要があります。"</string>
|
||||
<string name="dialog_permission_generic">"本体の設定から権限を付与してください。"</string>
|
||||
<string name="dialog_permission_microphone">"マイクを使用するには、本体の設定から権限を付与してください。"</string>
|
||||
<string name="dialog_permission_notification">"通知を表示するには、本体の設定から権限を付与してください。"</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="dialog_permission_camera">"Để ứng dụng sử dụng camera, vui lòng cấp quyền trong cài đặt hệ thống."</string>
|
||||
<string name="dialog_permission_generic">"Vui lòng cấp quyền trong cài đặt hệ thống."</string>
|
||||
<string name="dialog_permission_microphone">"Để ứng dụng có thể sử dụng micro, vui lòng cấp quyền trong cài đặt hệ thống."</string>
|
||||
<string name="dialog_permission_notification">"Để ứng dụng hiển thị thông báo, vui lòng cấp quyền trong cài đặt hệ thống."</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="troubleshoot_notifications_test_check_permission_description">"アプリケーションが通知を表示できることを確認してください。"</string>
|
||||
<string name="troubleshoot_notifications_test_check_permission_title">"権限の確認"</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="troubleshoot_notifications_test_check_permission_description">"Kiểm tra xem ứng dụng có hiển thị thông báo hay không."</string>
|
||||
<string name="troubleshoot_notifications_test_check_permission_title">"Kiểm tra quyền truy cập"</string>
|
||||
</resources>
|
||||
|
|
@ -302,7 +302,6 @@ class DefaultNotifiableEventResolver(
|
|||
NotificationContent.StateEvent.PolicyRuleRoom,
|
||||
NotificationContent.StateEvent.PolicyRuleServer,
|
||||
NotificationContent.StateEvent.PolicyRuleUser,
|
||||
NotificationContent.StateEvent.RoomAliases,
|
||||
NotificationContent.StateEvent.RoomAvatar,
|
||||
NotificationContent.StateEvent.RoomCanonicalAlias,
|
||||
NotificationContent.StateEvent.RoomCreate,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import dev.zacsweers.metro.SingleIn
|
|||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.push.api.push.PushHandlingWakeLock
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.time.Duration
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
|
|
@ -22,24 +21,13 @@ import kotlin.time.Duration
|
|||
class DefaultPushHandlingWakeLock(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : PushHandlingWakeLock {
|
||||
private val count = AtomicInteger(0)
|
||||
|
||||
override fun lock(time: Duration) {
|
||||
Timber.d("Acquiring wakelock for push handling, starting service.")
|
||||
FetchPushForegroundService.startIfNeeded(context)
|
||||
|
||||
count.incrementAndGet()
|
||||
}
|
||||
|
||||
override suspend fun unlock() {
|
||||
Timber.d("Releasing wakelock used for push handling.")
|
||||
FetchPushForegroundService.stop(context)
|
||||
if (count.decrementAndGet() <= 0) {
|
||||
Timber.d("No more wakelock needed for push handling, stopping service.")
|
||||
count.set(0)
|
||||
} else {
|
||||
Timber.d("Wakelock still needed for push handling, restarting service | count: ${count.get()}.")
|
||||
FetchPushForegroundService.startIfNeeded(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import android.os.PowerManager
|
|||
import androidx.core.app.NotificationCompat
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.architecture.bindings
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.push.api.push.PushHandlingWakeLock
|
||||
|
|
@ -57,6 +58,8 @@ class FetchPushForegroundService : Service() {
|
|||
}
|
||||
}
|
||||
|
||||
private var isOnForeground = false
|
||||
|
||||
override fun onCreate() {
|
||||
Timber.d("Creating FetchPushForegroundService")
|
||||
|
||||
|
|
@ -71,19 +74,39 @@ class FetchPushForegroundService : Service() {
|
|||
.setVibrate(longArrayOf(0))
|
||||
.setSound(null)
|
||||
.build()
|
||||
startForeground(NOTIFICATION_ID, notificationCompat)
|
||||
|
||||
// Try to start the service in foreground. This can fail, even in cases where it's supposed to work according to the docs.
|
||||
// In those cases we catch the exception and handle the failure so we don't try to start the wakelock or stop the service
|
||||
// from running in foreground later.
|
||||
runCatchingExceptions {
|
||||
startForeground(NOTIFICATION_ID, notificationCompat)
|
||||
}
|
||||
.onSuccess {
|
||||
isOnForeground = true
|
||||
Timber.d("FetchPushForegroundService started in foreground successfully")
|
||||
}
|
||||
.onFailure {
|
||||
isOnForeground = false
|
||||
Timber.e(it, "Failed to start FetchPushForegroundService in foreground")
|
||||
}
|
||||
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (!isOnForeground) {
|
||||
Timber.w("FetchPushForegroundService is not running in foreground, stopping it to avoid crash")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
wakelock.acquire(wakelockTimeout)
|
||||
|
||||
// The timeout is not automatic before Android 15, so we need to schedule it ourselves
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) {
|
||||
coroutineScope.launch {
|
||||
delay(wakelockTimeout)
|
||||
onTimeout(startId)
|
||||
onTimeoutAction(calledByTheSystem = false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,18 +114,25 @@ class FetchPushForegroundService : Service() {
|
|||
}
|
||||
|
||||
override fun stopService(intent: Intent?): Boolean {
|
||||
wakelock.release()
|
||||
if (isOnForeground) {
|
||||
wakelock.release()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
return super.stopService(intent)
|
||||
}
|
||||
|
||||
override fun onTimeout(startId: Int) {
|
||||
super.onTimeout(startId)
|
||||
onTimeoutAction(calledByTheSystem = true)
|
||||
}
|
||||
|
||||
Timber.d("Wakelock timeout reached, stopping FetchPushForegroundService")
|
||||
|
||||
coroutineScope.launch { pushHandlingWakeLock.unlock() }
|
||||
private fun onTimeoutAction(calledByTheSystem: Boolean) {
|
||||
Timber.d("onTimeoutAction, calledByTheSystem: $calledByTheSystem, isOnForeground: $isOnForeground")
|
||||
if (isOnForeground) {
|
||||
Timber.d("Wakelock timeout reached, stopping FetchPushForegroundService")
|
||||
coroutineScope.launch { pushHandlingWakeLock.unlock() }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
@ -119,7 +149,13 @@ class FetchPushForegroundService : Service() {
|
|||
fun start(context: Context) {
|
||||
val intent = Intent(context, FetchPushForegroundService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
runCatchingExceptions { context.startForegroundService(intent) }
|
||||
.onFailure { throwable ->
|
||||
Timber.e(
|
||||
throwable,
|
||||
"Failed to start FetchPushForegroundService, notifications may take longer than usual to sync"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@ class DefaultSyncPendingNotificationsRequestBuilder(
|
|||
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
|
||||
// If we're in an air-gapped environment, we shouldn't validate internet connectivity, as the checker will fail and the worker won't run at all.
|
||||
// Note this will always be false for FOSS, since the feature is only enabled in Element Pro.
|
||||
if (networkMonitor.isInAirGappedEnvironment.first()) {
|
||||
Timber.d("In an air-gapped environment, not adding NET_CAPABILITY_VALIDATED to the network request")
|
||||
networkRequestBuilder.removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@
|
|||
</plurals>
|
||||
<string name="notification_error_unified_push_unregistered_android">"Non è stato possibile registrare il distributore di notifiche UnifiedPush, quindi non riceverai più notifiche. Controlla le impostazioni delle notifiche dell\'app e lo stato del distributore push."</string>
|
||||
<string name="notification_fallback_content">"Hai nuovi messaggi."</string>
|
||||
<plurals name="notification_fallback_n_content">
|
||||
<item quantity="one">"Hai %d nuovo messaggio."</item>
|
||||
<item quantity="other">"Hai %d nuovi messaggi."</item>
|
||||
</plurals>
|
||||
<string name="notification_incoming_audio_call">"📞 Chiamata in arrivo"</string>
|
||||
<string name="notification_incoming_call">"📹 Chiamata in arrivo"</string>
|
||||
<string name="notification_inline_reply_failed">"** Invio fallito - si prega di aprire la stanza"</string>
|
||||
<string name="notification_invitation_action_join">"Entra"</string>
|
||||
|
|
@ -38,6 +43,8 @@
|
|||
<string name="notification_room_invite_body_with_sender">"%1$s ti ha invitato a unirti alla stanza"</string>
|
||||
<string name="notification_sender_me">"Io"</string>
|
||||
<string name="notification_sender_mention_reply">"%1$s ti ha menzionato o risposto"</string>
|
||||
<string name="notification_space_invite_body">"Ti abbiamo invitato a unirti allo spazio"</string>
|
||||
<string name="notification_space_invite_body_with_sender">"%1$s ti ha invitato a unirti allo spazio"</string>
|
||||
<string name="notification_test_push_notification_content">"Stai visualizzando la notifica! Cliccami!"</string>
|
||||
<string name="notification_thread_in_room">"Discussione in %1$s"</string>
|
||||
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
|
||||
|
|
|
|||
93
libraries/push/impl/src/main/res/values-ja/translations.xml
Normal file
93
libraries/push/impl/src/main/res/values-ja/translations.xml
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="notification_channel_call">"通話"</string>
|
||||
<string name="notification_channel_listening_for_events">"イベントを監視中"</string>
|
||||
<string name="notification_channel_noisy">"通常の通知"</string>
|
||||
<string name="notification_channel_ringing_calls">"着信"</string>
|
||||
<string name="notification_channel_silent">"サイレント通知"</string>
|
||||
<plurals name="notification_compat_summary_line_for_room">
|
||||
<item quantity="other">"%1$s: %2$d件のメッセージ"</item>
|
||||
</plurals>
|
||||
<plurals name="notification_compat_summary_title">
|
||||
<item quantity="other">"%d 件の通知"</item>
|
||||
</plurals>
|
||||
<string name="notification_error_unified_push_unregistered_android">"Unified Push の通知配信サービス (notification distributor) を登録できないため、通知を受け取ることができません。通知の設定と通知ディストリビューター (push distributor) の状況を確認してください。"</string>
|
||||
<string name="notification_fallback_content">"新着メッセージがあります。"</string>
|
||||
<plurals name="notification_fallback_n_content">
|
||||
<item quantity="other">"新着のメッセージが%d 件あります。"</item>
|
||||
</plurals>
|
||||
<string name="notification_incoming_audio_call">"📞 着信"</string>
|
||||
<string name="notification_incoming_call">"📹 通話着信"</string>
|
||||
<string name="notification_inline_reply_failed">"** 送信失敗 - ルームを開いてください"</string>
|
||||
<string name="notification_invitation_action_join">"参加"</string>
|
||||
<string name="notification_invitation_action_reject">"拒否"</string>
|
||||
<plurals name="notification_invitations">
|
||||
<item quantity="other">"%d 件の招待"</item>
|
||||
</plurals>
|
||||
<string name="notification_invite_body">"チャットにあなたを招待しました"</string>
|
||||
<string name="notification_invite_body_with_sender">"%1$sがあなたをチャットに招待しました"</string>
|
||||
<string name="notification_mentioned_you_body">"%1$s があなたをメンションしました"</string>
|
||||
<string name="notification_new_messages">"新着メッセージ"</string>
|
||||
<plurals name="notification_new_messages_for_room">
|
||||
<item quantity="other">"%d 件の新着メッセージ"</item>
|
||||
</plurals>
|
||||
<string name="notification_reaction_body">"%1$sへのリアクション"</string>
|
||||
<string name="notification_room_action_mark_as_read">"既読にする"</string>
|
||||
<string name="notification_room_action_quick_reply">"クイック返信"</string>
|
||||
<string name="notification_room_invite_body">"ルームに招待されました"</string>
|
||||
<string name="notification_room_invite_body_with_sender">"%1$s があなたをルームに招待しました"</string>
|
||||
<string name="notification_sender_me">"自分"</string>
|
||||
<string name="notification_sender_mention_reply">"%1$s がメンションまたは返信しました"</string>
|
||||
<string name="notification_space_invite_body">"スペースに招待されました"</string>
|
||||
<string name="notification_space_invite_body_with_sender">"%1$s があなたをスペースに招待しました"</string>
|
||||
<string name="notification_test_push_notification_content">"通知を表示しています。タップしてください。"</string>
|
||||
<string name="notification_thread_in_room">"%1$sのスレッド"</string>
|
||||
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
|
||||
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
|
||||
<plurals name="notification_unread_notified_messages">
|
||||
<item quantity="other">"%d件の未読メッセージ"</item>
|
||||
</plurals>
|
||||
<string name="notification_unread_notified_messages_and_invitation">"%1$sと%2$s"</string>
|
||||
<string name="notification_unread_notified_messages_in_room">"%2$sに%1$s"</string>
|
||||
<string name="notification_unread_notified_messages_in_room_and_invitation">"%2$sに%1$sと%3$s"</string>
|
||||
<plurals name="notification_unread_notified_messages_in_room_rooms">
|
||||
<item quantity="other">"%d 個のルーム"</item>
|
||||
</plurals>
|
||||
<string name="push_distributor_background_sync_android">"バックグラウンド同期"</string>
|
||||
<string name="push_distributor_firebase_android">"Google サービス"</string>
|
||||
<string name="push_no_valid_google_play_services_apk_android">"有効なGoogle Play 開発者サービスがありません。通知が正しく機能しない可能性があります。"</string>
|
||||
<string name="troubleshoot_notifications_test_blocked_users_description">"ブロックしたユーザーを確認中"</string>
|
||||
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"ブロックしたユーザーを表示"</string>
|
||||
<string name="troubleshoot_notifications_test_blocked_users_result_none">"ブロックしたユーザーはいません。"</string>
|
||||
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
|
||||
<item quantity="other">"%1$d 人のユーザーをブロックしました。以降の通知を受信しません。"</item>
|
||||
</plurals>
|
||||
<string name="troubleshoot_notifications_test_blocked_users_title">"ブロックしたユーザー"</string>
|
||||
<string name="troubleshoot_notifications_test_current_push_provider_description">"現在のプロバイダーの名前を取得してください。"</string>
|
||||
<string name="troubleshoot_notifications_test_current_push_provider_failure">"プッシュ通知プロバイダーが選択されていません。"</string>
|
||||
<string name="troubleshoot_notifications_test_current_push_provider_failure_distributor_not_found">"現在のプッシュ通知プロバイダーは %1$s で、現在のプッシュ通知ディストリビューター は %2$s です。しかし、ディストリビューター %3$s は見つかりませんでした。アンインストールされている可能性があります。"</string>
|
||||
<string name="troubleshoot_notifications_test_current_push_provider_failure_no_distributor">"現在のプッシュ通知プロバイダーは %1$s ですが、ディストリビューターが設定されていません。"</string>
|
||||
<string name="troubleshoot_notifications_test_current_push_provider_success">"現在のプッシュ通知プロバイダー: %1$s"</string>
|
||||
<string name="troubleshoot_notifications_test_current_push_provider_success_with_distributor">"現在のプッシュ通知プロバイダー: %1$s (%2$s)"</string>
|
||||
<string name="troubleshoot_notifications_test_current_push_provider_title">"現在のプッシュ通知プロバイダー"</string>
|
||||
<string name="troubleshoot_notifications_test_detect_push_provider_description">"少なくとも一つ以上のプッシュ通知プロバイダーに、アプリケーションが対応していることを確認してください。"</string>
|
||||
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"対応しているプッシュ通知プロバイダーが見つかりませんでした。"</string>
|
||||
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
|
||||
<item quantity="other">"%1$d 個の通知プロバイダーを発見: %2$s"</item>
|
||||
</plurals>
|
||||
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"このアプリケーションは %1$s に対応しています。"</string>
|
||||
<string name="troubleshoot_notifications_test_detect_push_provider_title">"プッシュ通知プロバイダーへの対応状況"</string>
|
||||
<string name="troubleshoot_notifications_test_display_notification_description">"アプリケーションが通知を表示できることを確認してください。"</string>
|
||||
<string name="troubleshoot_notifications_test_display_notification_failure">"通知がタップされていません。"</string>
|
||||
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"通知を表示できません。"</string>
|
||||
<string name="troubleshoot_notifications_test_display_notification_success">"通知がタップされました。"</string>
|
||||
<string name="troubleshoot_notifications_test_display_notification_title">"通知の表示"</string>
|
||||
<string name="troubleshoot_notifications_test_display_notification_waiting">"テストを続行するには、通知にタップしてください。"</string>
|
||||
<string name="troubleshoot_notifications_test_push_loop_back_description">"プッシュ通知をアプリケーションが受信していることを確認してください。"</string>
|
||||
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"エラー: プッシュ通知プロバイダーがリクエストを拒否しました。"</string>
|
||||
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"エラー: %1$s"</string>
|
||||
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"エラー: プッシュ通知をテストできません。"</string>
|
||||
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"エラー: 通知の待機がタイムアウトしました。"</string>
|
||||
<string name="troubleshoot_notifications_test_push_loop_back_success">"プッシュ通知のループバックに %1$d ms 要しました。"</string>
|
||||
<string name="troubleshoot_notifications_test_push_loop_back_title">"プッシュ通知のループバックをテスト"</string>
|
||||
</resources>
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
<plurals name="notification_fallback_n_content">
|
||||
<item quantity="other">"%d개의 새 메시지가 있습니다."</item>
|
||||
</plurals>
|
||||
<string name="notification_incoming_audio_call">"📞 수신 전화"</string>
|
||||
<string name="notification_incoming_call">"📹 수신 전화"</string>
|
||||
<string name="notification_inline_reply_failed">"** 전송 실패 - 방을 열여주세요"</string>
|
||||
<string name="notification_invitation_action_join">"참가하기"</string>
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
<string name="notification_space_invite_body">"Пригласил(а) вас в пространство"</string>
|
||||
<string name="notification_space_invite_body_with_sender">"%1$s пригласил(а) вас в пространство"</string>
|
||||
<string name="notification_test_push_notification_content">"Вы просматриваете уведомление! Нажмите на меня!"</string>
|
||||
<string name="notification_thread_in_room">"Ветка в %1$s"</string>
|
||||
<string name="notification_thread_in_room">"Обсуждение в %1$s"</string>
|
||||
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
|
||||
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
|
||||
<plurals name="notification_unread_notified_messages">
|
||||
|
|
|
|||
61
libraries/push/impl/src/main/res/values-vi/translations.xml
Normal file
61
libraries/push/impl/src/main/res/values-vi/translations.xml
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="notification_channel_call">"Gọi"</string>
|
||||
<string name="notification_channel_listening_for_events">"Đang lắng nghe sự kiện"</string>
|
||||
<string name="notification_channel_noisy">"Thông báo ồn ào"</string>
|
||||
<string name="notification_channel_ringing_calls">"Cuộc gọi đang đổ chuông"</string>
|
||||
<string name="notification_channel_silent">"Thông báo im lặng"</string>
|
||||
<plurals name="notification_compat_summary_line_for_room">
|
||||
<item quantity="other">"%1$s:%2$d tin nhắn"</item>
|
||||
</plurals>
|
||||
<plurals name="notification_compat_summary_title">
|
||||
<item quantity="other">"%d thông báo"</item>
|
||||
</plurals>
|
||||
<string name="notification_fallback_content">"Bạn có tin nhắn mới."</string>
|
||||
<string name="notification_incoming_call">"📹 Cuộc gọi đến"</string>
|
||||
<string name="notification_inline_reply_failed">"** Không gửi được - vui lòng mở phòng"</string>
|
||||
<string name="notification_invitation_action_join">"Tham gia"</string>
|
||||
<string name="notification_invitation_action_reject">"Từ chối"</string>
|
||||
<plurals name="notification_invitations">
|
||||
<item quantity="other">"%d lời mời"</item>
|
||||
</plurals>
|
||||
<string name="notification_invite_body">"Đã mời bạn trò chuyện"</string>
|
||||
<string name="notification_mentioned_you_body">"Đã nhắc đến bạn: %1$s"</string>
|
||||
<string name="notification_new_messages">"Tin nhắn mới"</string>
|
||||
<plurals name="notification_new_messages_for_room">
|
||||
<item quantity="other">"%dtin nhắn mới"</item>
|
||||
</plurals>
|
||||
<string name="notification_reaction_body">"Đã thả %1$s vào tin nhắn"</string>
|
||||
<string name="notification_room_action_mark_as_read">"Đánh dấu đã đọc"</string>
|
||||
<string name="notification_room_action_quick_reply">"Trả lời nhanh"</string>
|
||||
<string name="notification_room_invite_body">"Đã mời bạn tham gia phòng"</string>
|
||||
<string name="notification_sender_me">"Tôi"</string>
|
||||
<string name="notification_test_push_notification_content">"Bạn đang xem thông báo! Bấm vào đây!"</string>
|
||||
<string name="notification_ticker_text_dm">"%1$s:%2$s"</string>
|
||||
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
|
||||
<plurals name="notification_unread_notified_messages">
|
||||
<item quantity="other">"%dtin nhắn chưa đọc đã thông báo"</item>
|
||||
</plurals>
|
||||
<string name="notification_unread_notified_messages_and_invitation">"%1$s và %2$s"</string>
|
||||
<string name="notification_unread_notified_messages_in_room">"%1$s in %2$s"</string>
|
||||
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s trong %2$s và %3$s"</string>
|
||||
<plurals name="notification_unread_notified_messages_in_room_rooms">
|
||||
<item quantity="other">"%d phòng"</item>
|
||||
</plurals>
|
||||
<string name="push_distributor_background_sync_android">"Đồng bộ hóa trong nền"</string>
|
||||
<string name="push_distributor_firebase_android">"Dịch vụ của Google"</string>
|
||||
<string name="push_no_valid_google_play_services_apk_android">"Không tìm thấy Dịch vụ Google Play hợp lệ. Thông báo có thể không hoạt động đúng cách."</string>
|
||||
<string name="troubleshoot_notifications_test_blocked_users_title">"Người dùng bị chặn"</string>
|
||||
<string name="troubleshoot_notifications_test_detect_push_provider_description">"Hãy đảm bảo rằng ứng dụng hỗ trợ ít nhất một nhà cung cấp thông báo đẩy."</string>
|
||||
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Không tìm thấy hỗ trợ từ nhà cung cấp thông báo đẩy."</string>
|
||||
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
|
||||
<item quantity="other">"Đã tìm thấy %1$d nhà cung cấp thông báo đẩy: %2$s"</item>
|
||||
</plurals>
|
||||
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Hỗ trợ nhà cung cấp thông báo đẩy"</string>
|
||||
<string name="troubleshoot_notifications_test_display_notification_description">"Kiểm tra xem ứng dụng có thể hiển thị thông báo hay không."</string>
|
||||
<string name="troubleshoot_notifications_test_display_notification_failure">"Thông báo chưa được nhấp vào."</string>
|
||||
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Không thể hiển thị thông báo."</string>
|
||||
<string name="troubleshoot_notifications_test_display_notification_success">"Thông báo đã được nhấp!"</string>
|
||||
<string name="troubleshoot_notifications_test_display_notification_title">"Hiển thị thông báo"</string>
|
||||
<string name="troubleshoot_notifications_test_display_notification_waiting">"Hãy nhấp vào thông báo để tiếp tục thử nghiệm."</string>
|
||||
</resources>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue