From 3a430efb0289f7382b009473d06acc5d02cbc913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaqu=C3=ADn=20Aguirrezabalaga?= Date: Sun, 30 Oct 2022 11:32:52 +0100 Subject: [PATCH 001/173] Add setting to allow disabling direct share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct share continues to be enabled by default. As requested in #2725 Signed-off-by: Joaquín Aguirrezabalaga --- changelog.d/2725.feature | 1 + library/ui-strings/src/main/res/values/strings.xml | 2 ++ .../im/vector/app/features/home/ShortcutCreator.kt | 12 ++++++++---- .../app/features/settings/VectorPreferences.kt | 5 +++++ .../src/main/res/xml/vector_settings_preferences.xml | 6 ++++++ 5 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 changelog.d/2725.feature diff --git a/changelog.d/2725.feature b/changelog.d/2725.feature new file mode 100644 index 0000000000..eb3fcaed57 --- /dev/null +++ b/changelog.d/2725.feature @@ -0,0 +1 @@ +Add setting to allow disabling direct share diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 897c2853d8..18d80f7aa4 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -1032,6 +1032,8 @@ Use /confetti command or send a message containing ❄️ or 🎉 Autoplay animated images Play animated images in the timeline as soon as they are visible + Enable direct share + Show recent chats in the system share menu Show join and leave events Invites, removes, and bans are unaffected. Show account events diff --git a/vector/src/main/java/im/vector/app/features/home/ShortcutCreator.kt b/vector/src/main/java/im/vector/app/features/home/ShortcutCreator.kt index e0565debf2..a0bcea217f 100644 --- a/vector/src/main/java/im/vector/app/features/home/ShortcutCreator.kt +++ b/vector/src/main/java/im/vector/app/features/home/ShortcutCreator.kt @@ -29,6 +29,7 @@ import im.vector.app.core.glide.GlideApp import im.vector.app.core.resources.BuildMeta import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.MainActivity +import im.vector.app.features.settings.VectorPreferences import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject @@ -55,6 +56,7 @@ class ShortcutCreator @Inject constructor( dimensionConverter.dpToPx(72) } } + @Inject lateinit var vectorPreferences: VectorPreferences fun canCreateShortcut(): Boolean { return ShortcutManagerCompat.isRequestPinShortcutSupported(context) @@ -73,10 +75,12 @@ class ShortcutCreator @Inject constructor( } catch (failure: Throwable) { null } - val categories = if (Build.VERSION.SDK_INT >= 25) { - setOf(directShareCategory, ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION) - } else { - setOf(directShareCategory) + val categories = mutableSetOf() + if (vectorPreferences.directShareEnabled()) { + categories.add(directShareCategory) + } + if (Build.VERSION.SDK_INT >= 25) { + categories.add(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION) } return ShortcutInfoCompat.Builder(context, roomSummary.roomId) diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index 2dc8b12160..8cfc6a5baa 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -123,6 +123,7 @@ class VectorPreferences @Inject constructor( private const val SETTINGS_LABS_ENABLE_LATEX_MATHS = "SETTINGS_LABS_ENABLE_LATEX_MATHS" const val SETTINGS_PRESENCE_USER_ALWAYS_APPEARS_OFFLINE = "SETTINGS_PRESENCE_USER_ALWAYS_APPEARS_OFFLINE" const val SETTINGS_AUTOPLAY_ANIMATED_IMAGES = "SETTINGS_AUTOPLAY_ANIMATED_IMAGES" + private const val SETTINGS_ENABLE_DIRECT_SHARE = "SETTINGS_ENABLE_DIRECT_SHARE" // Room directory private const val SETTINGS_ROOM_DIRECTORY_SHOW_ALL_PUBLIC_ROOMS = "SETTINGS_ROOM_DIRECTORY_SHOW_ALL_PUBLIC_ROOMS" @@ -1004,6 +1005,10 @@ class VectorPreferences @Inject constructor( return defaultPrefs.getBoolean(SETTINGS_ENABLE_CHAT_EFFECTS, true) } + fun directShareEnabled(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_ENABLE_DIRECT_SHARE, true) + } + /** * Return true if Pin code is disabled, or if user set the settings to see full notification content. */ diff --git a/vector/src/main/res/xml/vector_settings_preferences.xml b/vector/src/main/res/xml/vector_settings_preferences.xml index 172fa5606c..52afe16bbb 100644 --- a/vector/src/main/res/xml/vector_settings_preferences.xml +++ b/vector/src/main/res/xml/vector_settings_preferences.xml @@ -147,6 +147,12 @@ android:title="@string/settings_vibrate_on_mention" app:isPreferenceVisible="@bool/false_not_implemented" /> + + Date: Mon, 7 Nov 2022 18:26:54 +0300 Subject: [PATCH 002/173] Add toggle ip address menu option. --- .../src/main/res/values/strings.xml | 2 ++ .../settings/devices/v2/DevicesAction.kt | 1 + .../settings/devices/v2/DevicesViewModel.kt | 6 ++++++ .../settings/devices/v2/DevicesViewState.kt | 1 + .../v2/VectorSettingsDevicesFragment.kt | 19 ++++++++++++++++--- .../res/menu/menu_other_sessions_header.xml | 5 +++++ 6 files changed, 31 insertions(+), 3 deletions(-) diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index cd7cb3f477..0292847f0b 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3350,6 +3350,8 @@ Sign out of %1$d session Sign out of %1$d sessions + Show IP address + Hide IP address Sign out of this session Session details Application, device, and activity information. diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesAction.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesAction.kt index 21cbb86e94..6f002359c8 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesAction.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesAction.kt @@ -29,4 +29,5 @@ sealed class DevicesAction : VectorViewModelAction { object VerifyCurrentSession : DevicesAction() data class MarkAsManuallyVerified(val cryptoDeviceInfo: CryptoDeviceInfo) : DevicesAction() object MultiSignoutOtherSessions : DevicesAction() + object ToggleIpAddressVisibility : DevicesAction() } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt index c714645b9a..3cacf82f14 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt @@ -116,9 +116,15 @@ class DevicesViewModel @AssistedInject constructor( is DevicesAction.VerifyCurrentSession -> handleVerifyCurrentSessionAction() is DevicesAction.MarkAsManuallyVerified -> handleMarkAsManuallyVerifiedAction() DevicesAction.MultiSignoutOtherSessions -> handleMultiSignoutOtherSessions() + DevicesAction.ToggleIpAddressVisibility -> handleToggleIpAddressVisibility() } } + private fun handleToggleIpAddressVisibility() = withState { state -> + val isShowingIpAddress = state.isShowingIpAddress + setState { copy(isShowingIpAddress = !isShowingIpAddress) } + } + private fun handleVerifyCurrentSessionAction() { viewModelScope.launch { val currentSessionCanBeVerified = checkIfCurrentSessionCanBeVerifiedUseCase.execute() diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewState.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewState.kt index e8bed35e24..e0531c34dc 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewState.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewState.kt @@ -27,4 +27,5 @@ data class DevicesViewState( val unverifiedSessionsCount: Int = 0, val inactiveSessionsCount: Int = 0, val isLoading: Boolean = false, + val isShowingIpAddress: Boolean = false, ) : MavericksState diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt index 3a3c3463fb..c9957efc58 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt @@ -146,11 +146,19 @@ class VectorSettingsDevicesFragment : confirmMultiSignoutOtherSessions() true } + R.id.otherSessionsHeaderToggleIpAddress -> { + handleToggleIpAddressVisibility() + true + } else -> false } } } + private fun handleToggleIpAddressVisibility() { + viewModel.handle(DevicesAction.ToggleIpAddressVisibility) + } + private fun confirmMultiSignoutOtherSessions() { activity?.let { buildConfirmSignoutDialogUseCase.execute(it, this::multiSignoutOtherSessions) @@ -240,7 +248,7 @@ class VectorSettingsDevicesFragment : renderSecurityRecommendations(state.inactiveSessionsCount, state.unverifiedSessionsCount, isCurrentSessionVerified) renderCurrentDevice(currentDeviceInfo) - renderOtherSessionsView(otherDevices) + renderOtherSessionsView(otherDevices, state.isShowingIpAddress) } else { hideSecurityRecommendations() hideCurrentSessionView() @@ -297,7 +305,7 @@ class VectorSettingsDevicesFragment : hideInactiveSessionsRecommendation() } - private fun renderOtherSessionsView(otherDevices: List?) { + private fun renderOtherSessionsView(otherDevices: List?, isShowingIpAddress: Boolean) { if (otherDevices.isNullOrEmpty()) { hideOtherSessionsView() } else { @@ -313,7 +321,12 @@ class VectorSettingsDevicesFragment : totalNumberOfDevices = otherDevices.size, showViewAll = otherDevices.size > NUMBER_OF_OTHER_DEVICES_TO_RENDER ) - } + views.deviceListHeaderOtherSessions.menu.findItem(R.id.otherSessionsHeaderToggleIpAddress).title = if (isShowingIpAddress) { + stringProvider.getString(R.string.device_manager_other_sessions_hide_ip_address) + } else { + stringProvider.getString(R.string.device_manager_other_sessions_show_ip_address) + } + } } private fun hideOtherSessionsView() { diff --git a/vector/src/main/res/menu/menu_other_sessions_header.xml b/vector/src/main/res/menu/menu_other_sessions_header.xml index 00778ed36e..d1386ba461 100644 --- a/vector/src/main/res/menu/menu_other_sessions_header.xml +++ b/vector/src/main/res/menu/menu_other_sessions_header.xml @@ -4,6 +4,11 @@ xmlns:tools="http://schemas.android.com/tools" tools:ignore="AlwaysShowAction"> + + Date: Tue, 8 Nov 2022 14:36:27 +0300 Subject: [PATCH 003/173] Toggle ip address on others section of the main screen. --- .../devices/v2/VectorSettingsDevicesFragment.kt | 7 ++++--- .../settings/devices/v2/list/OtherSessionItem.kt | 6 ++++++ .../devices/v2/list/OtherSessionsController.kt | 1 + vector/src/main/res/layout/item_other_session.xml | 15 +++++++++++++-- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt index c9957efc58..b27d8a7270 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt @@ -316,10 +316,11 @@ class VectorSettingsDevicesFragment : multiSignoutItem.title = stringProvider.getQuantityString(R.plurals.device_manager_other_sessions_multi_signout_all, nbDevices, nbDevices) multiSignoutItem.setTextColor(color) views.deviceListOtherSessions.isVisible = true + val devices = if (isShowingIpAddress) otherDevices else otherDevices.map { it.copy(deviceInfo = it.deviceInfo.copy(lastSeenIp = null)) } views.deviceListOtherSessions.render( - devices = otherDevices.take(NUMBER_OF_OTHER_DEVICES_TO_RENDER), - totalNumberOfDevices = otherDevices.size, - showViewAll = otherDevices.size > NUMBER_OF_OTHER_DEVICES_TO_RENDER + devices = devices.take(NUMBER_OF_OTHER_DEVICES_TO_RENDER), + totalNumberOfDevices = devices.size, + showViewAll = devices.size > NUMBER_OF_OTHER_DEVICES_TO_RENDER ) views.deviceListHeaderOtherSessions.menu.findItem(R.id.otherSessionsHeaderToggleIpAddress).title = if (isShowingIpAddress) { stringProvider.getString(R.string.device_manager_other_sessions_hide_ip_address) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt index de1cd33d35..9d9cb15c28 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt @@ -29,6 +29,7 @@ import im.vector.app.core.epoxy.ClickListener import im.vector.app.core.epoxy.VectorEpoxyHolder import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.epoxy.onClick +import im.vector.app.core.extensions.setTextOrHide import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.StringProvider @@ -69,6 +70,9 @@ abstract class OtherSessionItem : VectorEpoxyModel(R.la @EpoxyAttribute var selected: Boolean = false + @EpoxyAttribute + var ipAddress: String? = null + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var clickListener: ClickListener? = null @@ -100,6 +104,7 @@ abstract class OtherSessionItem : VectorEpoxyModel(R.la holder.otherSessionDescriptionTextView.setTextColor(it) } holder.otherSessionDescriptionTextView.setCompoundDrawablesWithIntrinsicBounds(sessionDescriptionDrawable, null, null, null) + holder.otherSessionIpAddressTextView.setTextOrHide(ipAddress) holder.otherSessionItemBackgroundView.isSelected = selected } @@ -108,6 +113,7 @@ abstract class OtherSessionItem : VectorEpoxyModel(R.la val otherSessionVerificationStatusImageView by bind(R.id.otherSessionVerificationStatusImageView) val otherSessionNameTextView by bind(R.id.otherSessionNameTextView) val otherSessionDescriptionTextView by bind(R.id.otherSessionDescriptionTextView) + val otherSessionIpAddressTextView by bind(R.id.otherSessionIpAddressTextView) val otherSessionItemBackgroundView by bind(R.id.otherSessionItemBackground) } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt index 8d70552101..5e2549f42a 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt @@ -72,6 +72,7 @@ class OtherSessionsController @Inject constructor( sessionDescription(description) sessionDescriptionDrawable(descriptionDrawable) sessionDescriptionColor(descriptionColor) + ipAddress(device.deviceInfo.lastSeenIp) stringProvider(host.stringProvider) colorProvider(host.colorProvider) drawableProvider(host.drawableProvider) diff --git a/vector/src/main/res/layout/item_other_session.xml b/vector/src/main/res/layout/item_other_session.xml index f514cea56b..dee96d2b2f 100644 --- a/vector/src/main/res/layout/item_other_session.xml +++ b/vector/src/main/res/layout/item_other_session.xml @@ -67,12 +67,23 @@ android:layout_height="wrap_content" android:layout_marginTop="2dp" android:drawablePadding="8dp" - app:layout_constraintBottom_toBottomOf="@id/otherSessionDeviceTypeImageView" app:layout_constraintEnd_toEndOf="@id/otherSessionNameTextView" app:layout_constraintStart_toStartOf="@id/otherSessionNameTextView" app:layout_constraintTop_toBottomOf="@id/otherSessionNameTextView" tools:text="@string/device_manager_verification_status_verified" /> + + + app:layout_constraintTop_toBottomOf="@id/otherSessionIpAddressTextView" /> From b5e8375592d43aaebfbcc20a6efe8694170f3668 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Tue, 8 Nov 2022 15:16:09 +0300 Subject: [PATCH 004/173] Toggle ip address on other sessions screen. --- .../v2/othersessions/OtherSessionsAction.kt | 1 + .../v2/othersessions/OtherSessionsFragment.kt | 21 ++++++++++++++++--- .../othersessions/OtherSessionsViewModel.kt | 8 +++++++ .../othersessions/OtherSessionsViewState.kt | 1 + .../src/main/res/menu/menu_other_sessions.xml | 5 +++++ 5 files changed, 33 insertions(+), 3 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt index 24d2a08bdc..f249a9f0b6 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt @@ -33,4 +33,5 @@ sealed class OtherSessionsAction : VectorViewModelAction { object SelectAll : OtherSessionsAction() object DeselectAll : OtherSessionsAction() object MultiSignout : OtherSessionsAction() + object ToggleIpAddressVisibility: OtherSessionsAction() } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt index 487531646a..c2beeb4db5 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt @@ -85,6 +85,12 @@ class OtherSessionsFragment : menu.findItem(R.id.otherSessionsSelectAll).isVisible = isSelectModeEnabled menu.findItem(R.id.otherSessionsDeselectAll).isVisible = isSelectModeEnabled menu.findItem(R.id.otherSessionsSelect).isVisible = !isSelectModeEnabled && state.devices()?.isNotEmpty().orFalse() + menu.findItem(R.id.otherSessionsToggleIpAddress).isVisible = !isSelectModeEnabled + menu.findItem(R.id.otherSessionsToggleIpAddress).title = if (state.isShowingIpAddress) { + getString(R.string.device_manager_other_sessions_hide_ip_address) + } else { + getString(R.string.device_manager_other_sessions_show_ip_address) + } updateMultiSignoutMenuItem(menu, state) } } @@ -130,10 +136,18 @@ class OtherSessionsFragment : confirmMultiSignout() true } + R.id.otherSessionsToggleIpAddress -> { + toggleIpAddressVisibility() + true + } else -> false } } + private fun toggleIpAddressVisibility() { + viewModel.handle(OtherSessionsAction.ToggleIpAddressVisibility) + } + private fun confirmMultiSignout() { activity?.let { buildConfirmSignoutDialogUseCase.execute(it, this::multiSignout) @@ -213,7 +227,7 @@ class OtherSessionsFragment : updateLoading(state.isLoading) if (state.devices is Success) { val devices = state.devices.invoke() - renderDevices(devices, state.currentFilter) + renderDevices(devices, state.currentFilter, state.isShowingIpAddress) updateToolbar(devices, state.isSelectModeEnabled) } } @@ -237,7 +251,7 @@ class OtherSessionsFragment : toolbar?.title = title } - private fun renderDevices(devices: List, currentFilter: DeviceManagerFilterType) { + private fun renderDevices(devices: List, currentFilter: DeviceManagerFilterType, isShowingIpAddress: Boolean) { views.otherSessionsFilterBadgeImageView.isVisible = currentFilter != DeviceManagerFilterType.ALL_SESSIONS views.otherSessionsSecurityRecommendationView.isVisible = currentFilter != DeviceManagerFilterType.ALL_SESSIONS views.deviceListHeaderOtherSessions.isVisible = currentFilter == DeviceManagerFilterType.ALL_SESSIONS @@ -296,7 +310,8 @@ class OtherSessionsFragment : } else { views.deviceListOtherSessions.isVisible = true views.otherSessionsNotFoundLayout.isVisible = false - views.deviceListOtherSessions.render(devices = devices, totalNumberOfDevices = devices.size, showViewAll = false) + val mappedDevices = if (isShowingIpAddress) devices else devices.map { it.copy(deviceInfo = it.deviceInfo.copy(lastSeenIp = null)) } + views.deviceListOtherSessions.render(devices = mappedDevices, totalNumberOfDevices = mappedDevices.size, showViewAll = false) } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt index c33490400b..d1302cf443 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt @@ -91,6 +91,14 @@ class OtherSessionsViewModel @AssistedInject constructor( OtherSessionsAction.DeselectAll -> handleDeselectAll() OtherSessionsAction.SelectAll -> handleSelectAll() OtherSessionsAction.MultiSignout -> handleMultiSignout() + OtherSessionsAction.ToggleIpAddressVisibility -> handleToggleIpAddressVisibility() + } + } + + private fun handleToggleIpAddressVisibility() = withState { state -> + val isShowingIpAddress = state.isShowingIpAddress + setState { + copy(isShowingIpAddress = !isShowingIpAddress) } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt index c0b50fded8..f4dd3640ee 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt @@ -28,6 +28,7 @@ data class OtherSessionsViewState( val excludeCurrentDevice: Boolean = false, val isSelectModeEnabled: Boolean = false, val isLoading: Boolean = false, + val isShowingIpAddress: Boolean = false, ) : MavericksState { constructor(args: OtherSessionsArgs) : this(excludeCurrentDevice = args.excludeCurrentDevice) diff --git a/vector/src/main/res/menu/menu_other_sessions.xml b/vector/src/main/res/menu/menu_other_sessions.xml index 7893575dde..98f9dd8256 100644 --- a/vector/src/main/res/menu/menu_other_sessions.xml +++ b/vector/src/main/res/menu/menu_other_sessions.xml @@ -9,6 +9,11 @@ android:title="@string/device_manager_other_sessions_select" app:showAsAction="withText|never" /> + + Date: Tue, 8 Nov 2022 16:33:20 +0300 Subject: [PATCH 005/173] Toggle ip address on sessions overview screen. --- .../devices/v2/list/SessionInfoView.kt | 3 +++ .../devices/v2/list/SessionInfoViewState.kt | 1 + .../v2/overview/SessionOverviewAction.kt | 1 + .../v2/overview/SessionOverviewFragment.kt | 20 +++++++++++++++++++ .../v2/overview/SessionOverviewViewModel.kt | 8 ++++++++ .../v2/overview/SessionOverviewViewState.kt | 1 + .../main/res/menu/menu_session_overview.xml | 5 +++++ 7 files changed, 39 insertions(+) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt index 3d9c3a8f37..6e7e57fc49 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt @@ -76,6 +76,7 @@ class SessionInfoView @JvmOverloads constructor( sessionInfoViewState.deviceFullInfo.isInactive, sessionInfoViewState.deviceFullInfo.deviceInfo, sessionInfoViewState.isLastSeenDetailsVisible, + sessionInfoViewState.isShowingIpAddress, dateFormatter, drawableProvider, colorProvider, @@ -157,6 +158,7 @@ class SessionInfoView @JvmOverloads constructor( isInactive: Boolean, deviceInfo: DeviceInfo, isLastSeenDetailsVisible: Boolean, + isShowingIpAddress: Boolean, dateFormatter: VectorDateFormatter, drawableProvider: DrawableProvider, colorProvider: ColorProvider, @@ -187,6 +189,7 @@ class SessionInfoView @JvmOverloads constructor( views.sessionInfoLastActivityTextView.isGone = true } views.sessionInfoLastIPAddressTextView.setTextOrHide(deviceInfo.lastSeenIp?.takeIf { isLastSeenDetailsVisible }) + views.sessionInfoLastIPAddressTextView.isVisible = isShowingIpAddress } private fun renderDetailsButton(isDetailsButtonVisible: Boolean) { diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoViewState.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoViewState.kt index 287bb956f5..5d3c4b4f4b 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoViewState.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoViewState.kt @@ -25,4 +25,5 @@ data class SessionInfoViewState( val isDetailsButtonVisible: Boolean = true, val isLearnMoreLinkVisible: Boolean = false, val isLastSeenDetailsVisible: Boolean = false, + val isShowingIpAddress: Boolean = false, ) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewAction.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewAction.kt index 9a92d5b629..2b6c40eead 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewAction.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewAction.kt @@ -29,4 +29,5 @@ sealed class SessionOverviewAction : VectorViewModelAction { val deviceId: String, val enabled: Boolean, ) : SessionOverviewAction() + object ToggleIpAddressVisibility : SessionOverviewAction() } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt index e149023f22..61f7ab39dd 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt @@ -19,6 +19,7 @@ package im.vector.app.features.settings.devices.v2.overview import android.app.Activity import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup @@ -156,16 +157,34 @@ class SessionOverviewFragment : override fun getMenuRes() = R.menu.menu_session_overview + override fun handlePrepareMenu(menu: Menu) { + withState(viewModel) { state -> + menu.findItem(R.id.sessionOverviewToggleIpAddress).title = if (state.isShowingIpAddress) { + getString(R.string.device_manager_other_sessions_hide_ip_address) + } else { + getString(R.string.device_manager_other_sessions_show_ip_address) + } + } + } + override fun handleMenuItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.sessionOverviewRename -> { goToRenameSession() true } + R.id.sessionOverviewToggleIpAddress -> { + toggleIpAddressVisibility() + true + } else -> false } } + private fun toggleIpAddressVisibility() { + viewModel.handle(SessionOverviewAction.ToggleIpAddressVisibility) + } + private fun goToRenameSession() = withState(viewModel) { state -> viewNavigator.goToRenameSession(requireContext(), state.deviceId) } @@ -206,6 +225,7 @@ class SessionOverviewFragment : isDetailsButtonVisible = false, isLearnMoreLinkVisible = deviceInfo.roomEncryptionTrustLevel != RoomEncryptionTrustLevel.Default, isLastSeenDetailsVisible = !isCurrentSession, + isShowingIpAddress = viewState.isShowingIpAddress, ) views.sessionOverviewInfo.render(infoViewState, dateFormatter, drawableProvider, colorProvider, stringProvider) views.sessionOverviewInfo.onLearnMoreClickListener = { diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt index 59eeaaadb4..74ab5ce617 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt @@ -113,6 +113,14 @@ class SessionOverviewViewModel @AssistedInject constructor( is SessionOverviewAction.PasswordAuthDone -> handlePasswordAuthDone(action) SessionOverviewAction.ReAuthCancelled -> handleReAuthCancelled() is SessionOverviewAction.TogglePushNotifications -> handleTogglePusherAction(action) + SessionOverviewAction.ToggleIpAddressVisibility -> handleToggleIpAddressVisibility() + } + } + + private fun handleToggleIpAddressVisibility() = withState { state -> + val isShowingIpAddress = state.isShowingIpAddress + setState { + copy(isShowingIpAddress = !isShowingIpAddress) } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewState.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewState.kt index 019dd2d724..0f66605f98 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewState.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewState.kt @@ -28,6 +28,7 @@ data class SessionOverviewViewState( val deviceInfo: Async = Uninitialized, val isLoading: Boolean = false, val notificationsStatus: NotificationsStatus = NotificationsStatus.NOT_SUPPORTED, + val isShowingIpAddress: Boolean = false, ) : MavericksState { constructor(args: SessionOverviewArgs) : this( deviceId = args.deviceId diff --git a/vector/src/main/res/menu/menu_session_overview.xml b/vector/src/main/res/menu/menu_session_overview.xml index 7de3953dcc..4179a0d975 100644 --- a/vector/src/main/res/menu/menu_session_overview.xml +++ b/vector/src/main/res/menu/menu_session_overview.xml @@ -4,6 +4,11 @@ xmlns:tools="http://schemas.android.com/tools" tools:ignore="AlwaysShowAction"> + + Date: Tue, 8 Nov 2022 17:43:48 +0300 Subject: [PATCH 006/173] Persist user preference of ip address visibility. --- .../features/settings/VectorPreferences.kt | 3 +++ .../settings/devices/v2/DevicesViewModel.kt | 21 ++++++++++++++++++- .../othersessions/OtherSessionsViewModel.kt | 19 ++++++++++++++++- .../v2/overview/SessionOverviewViewModel.kt | 17 +++++++++++++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index 9f40a7cede..3424c2b54c 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -209,6 +209,9 @@ class VectorPreferences @Inject constructor( private const val SETTINGS_SECURITY_USE_GRACE_PERIOD_FLAG = "SETTINGS_SECURITY_USE_GRACE_PERIOD_FLAG" const val SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG = "SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG" + // New Session Manager + const val SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS = "SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS" + // other const val SETTINGS_MEDIA_SAVING_PERIOD_KEY = "SETTINGS_MEDIA_SAVING_PERIOD_KEY" private const val SETTINGS_MEDIA_SAVING_PERIOD_SELECTED_KEY = "SETTINGS_MEDIA_SAVING_PERIOD_SELECTED_KEY" diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt index 3cacf82f14..bf42867b38 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt @@ -16,15 +16,19 @@ package im.vector.app.features.settings.devices.v2 +import android.content.SharedPreferences +import androidx.core.content.edit import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Success import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.di.DefaultPreferences import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.features.auth.PendingAuthHandler +import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase import im.vector.app.features.settings.devices.v2.signout.SignoutSessionResult @@ -53,6 +57,8 @@ class DevicesViewModel @AssistedInject constructor( private val interceptSignoutFlowResponseUseCase: InterceptSignoutFlowResponseUseCase, private val pendingAuthHandler: PendingAuthHandler, refreshDevicesUseCase: RefreshDevicesUseCase, + @DefaultPreferences + private val sharedPreferences: SharedPreferences, ) : VectorSessionsListViewModel(initialState, activeSessionHolder, refreshDevicesUseCase) { @AssistedFactory @@ -67,6 +73,14 @@ class DevicesViewModel @AssistedInject constructor( observeDevices() refreshDevicesOnCryptoDevicesChange() refreshDeviceList() + refreshIpAddressVisibility() + } + + private fun refreshIpAddressVisibility() { + val shouldShowIpAddress = sharedPreferences.getBoolean(VectorPreferences.SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, false) + setState { + copy(isShowingIpAddress = shouldShowIpAddress) + } } private fun observeCurrentSessionCrossSigningInfo() { @@ -122,7 +136,12 @@ class DevicesViewModel @AssistedInject constructor( private fun handleToggleIpAddressVisibility() = withState { state -> val isShowingIpAddress = state.isShowingIpAddress - setState { copy(isShowingIpAddress = !isShowingIpAddress) } + setState { + copy(isShowingIpAddress = !isShowingIpAddress) + } + sharedPreferences.edit { + putBoolean(VectorPreferences.SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, !isShowingIpAddress) + } } private fun handleVerifyCurrentSessionAction() { diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt index d1302cf443..7cca2e54e1 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt @@ -16,15 +16,19 @@ package im.vector.app.features.settings.devices.v2.othersessions +import android.content.SharedPreferences +import androidx.core.content.edit import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Success import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.di.DefaultPreferences import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.features.auth.PendingAuthHandler +import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.devices.v2.GetDeviceFullInfoListUseCase import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase import im.vector.app.features.settings.devices.v2.VectorSessionsListViewModel @@ -48,7 +52,9 @@ class OtherSessionsViewModel @AssistedInject constructor( private val signoutSessionsUseCase: SignoutSessionsUseCase, private val interceptSignoutFlowResponseUseCase: InterceptSignoutFlowResponseUseCase, private val pendingAuthHandler: PendingAuthHandler, - refreshDevicesUseCase: RefreshDevicesUseCase + refreshDevicesUseCase: RefreshDevicesUseCase, + @DefaultPreferences + private val sharedPreferences: SharedPreferences, ) : VectorSessionsListViewModel( initialState, activeSessionHolder, refreshDevicesUseCase ) { @@ -64,6 +70,14 @@ class OtherSessionsViewModel @AssistedInject constructor( init { observeDevices(initialState.currentFilter) + refreshIpAddressVisibility() + } + + private fun refreshIpAddressVisibility() { + val shouldShowIpAddress = sharedPreferences.getBoolean(VectorPreferences.SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, false) + setState { + copy(isShowingIpAddress = shouldShowIpAddress) + } } private fun observeDevices(currentFilter: DeviceManagerFilterType) { @@ -100,6 +114,9 @@ class OtherSessionsViewModel @AssistedInject constructor( setState { copy(isShowingIpAddress = !isShowingIpAddress) } + sharedPreferences.edit { + putBoolean(VectorPreferences.SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, !isShowingIpAddress) + } } private fun handleFilterDevices(action: OtherSessionsAction.FilterDevices) { diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt index 74ab5ce617..a344474eba 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt @@ -16,15 +16,19 @@ package im.vector.app.features.settings.devices.v2.overview +import android.content.SharedPreferences +import androidx.core.content.edit import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Success import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.di.DefaultPreferences import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.features.auth.PendingAuthHandler +import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase import im.vector.app.features.settings.devices.v2.VectorSessionsListViewModel import im.vector.app.features.settings.devices.v2.notification.GetNotificationsStatusUseCase @@ -58,6 +62,8 @@ class SessionOverviewViewModel @AssistedInject constructor( private val togglePushNotificationUseCase: TogglePushNotificationUseCase, private val getNotificationsStatusUseCase: GetNotificationsStatusUseCase, refreshDevicesUseCase: RefreshDevicesUseCase, + @DefaultPreferences + private val sharedPreferences: SharedPreferences, ) : VectorSessionsListViewModel( initialState, activeSessionHolder, refreshDevicesUseCase ) { @@ -74,6 +80,14 @@ class SessionOverviewViewModel @AssistedInject constructor( observeSessionInfo(initialState.deviceId) observeCurrentSessionInfo() observeNotificationsStatus(initialState.deviceId) + refreshIpAddressVisibility() + } + + private fun refreshIpAddressVisibility() { + val shouldShowIpAddress = sharedPreferences.getBoolean(VectorPreferences.SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, false) + setState { + copy(isShowingIpAddress = shouldShowIpAddress) + } } private fun refreshPushers() { @@ -122,6 +136,9 @@ class SessionOverviewViewModel @AssistedInject constructor( setState { copy(isShowingIpAddress = !isShowingIpAddress) } + sharedPreferences.edit { + putBoolean(VectorPreferences.SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, !isShowingIpAddress) + } } private fun handleVerifySessionAction() = withState { viewState -> From 6073cf12da5a5b75989af416def2096453e455fc Mon Sep 17 00:00:00 2001 From: Kat Gerasimova Date: Thu, 3 Nov 2022 10:07:33 +0000 Subject: [PATCH 007/173] Update issue automation Stop using deprecated ProjectNext API in favour of the new ProjectV2 one --- .github/workflows/triage-labelled.yml | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml index 8e9cc6d76c..f1458a1d11 100644 --- a/.github/workflows/triage-labelled.yml +++ b/.github/workflows/triage-labelled.yml @@ -79,8 +79,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -103,8 +103,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -129,8 +129,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -154,8 +154,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -178,8 +178,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -203,8 +203,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -228,8 +228,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -258,8 +258,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } From 8bc70002d9eb047cdf39917a59b76b7173252c1b Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Tue, 8 Nov 2022 19:31:59 +0300 Subject: [PATCH 008/173] Add changelog. --- changelog.d/7546.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7546.feature diff --git a/changelog.d/7546.feature b/changelog.d/7546.feature new file mode 100644 index 0000000000..94450082c9 --- /dev/null +++ b/changelog.d/7546.feature @@ -0,0 +1 @@ +[Device Manager] Toggle IP address visibility From e888c1174726dedd0029efd004f57c1bda2ee71e Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Tue, 8 Nov 2022 20:05:16 +0300 Subject: [PATCH 009/173] Lint fix. --- .../settings/devices/v2/othersessions/OtherSessionsAction.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt index f249a9f0b6..bdad65ca43 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt @@ -33,5 +33,5 @@ sealed class OtherSessionsAction : VectorViewModelAction { object SelectAll : OtherSessionsAction() object DeselectAll : OtherSessionsAction() object MultiSignout : OtherSessionsAction() - object ToggleIpAddressVisibility: OtherSessionsAction() + object ToggleIpAddressVisibility : OtherSessionsAction() } From 46c60f5897670e98c8e477477599afce631304b8 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Wed, 9 Nov 2022 16:57:16 +0300 Subject: [PATCH 010/173] Fix unit tests. --- .../app/features/settings/devices/v2/DevicesViewModelTest.kt | 4 ++++ .../devices/v2/othersessions/OtherSessionsViewModelTest.kt | 4 ++++ .../devices/v2/overview/SessionOverviewViewModelTest.kt | 4 ++++ .../java/im/vector/app/test/fakes/FakeSharedPreferences.kt | 5 +++++ 4 files changed, 17 insertions(+) diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt index 852fc64fd5..87bae3abb7 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt @@ -28,6 +28,7 @@ import im.vector.app.features.settings.devices.v2.verification.CurrentSessionCro import im.vector.app.features.settings.devices.v2.verification.GetCurrentSessionCrossSigningInfoUseCase import im.vector.app.test.fakes.FakeActiveSessionHolder import im.vector.app.test.fakes.FakePendingAuthHandler +import im.vector.app.test.fakes.FakeSharedPreferences import im.vector.app.test.fakes.FakeSignoutSessionsUseCase import im.vector.app.test.fakes.FakeVerificationService import im.vector.app.test.test @@ -72,6 +73,7 @@ class DevicesViewModelTest { private val fakeInterceptSignoutFlowResponseUseCase = mockk() private val fakePendingAuthHandler = FakePendingAuthHandler() private val fakeRefreshDevicesUseCase = mockk(relaxUnitFun = true) + private val fakeSharedPreferences = FakeSharedPreferences() private fun createViewModel(): DevicesViewModel { return DevicesViewModel( @@ -85,6 +87,7 @@ class DevicesViewModelTest { interceptSignoutFlowResponseUseCase = fakeInterceptSignoutFlowResponseUseCase, pendingAuthHandler = fakePendingAuthHandler.instance, refreshDevicesUseCase = fakeRefreshDevicesUseCase, + sharedPreferences = fakeSharedPreferences, ) } @@ -97,6 +100,7 @@ class DevicesViewModelTest { givenVerificationService() givenCurrentSessionCrossSigningInfo() givenDeviceFullInfoList(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_DEVICE_ID_2) + fakeSharedPreferences.givenSessionManagerShowIpAddress(false) } private fun givenVerificationService(): FakeVerificationService { diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt index e01d6e058c..e7bb14695c 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt @@ -26,6 +26,7 @@ import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase import im.vector.app.test.fakes.FakeActiveSessionHolder import im.vector.app.test.fakes.FakePendingAuthHandler +import im.vector.app.test.fakes.FakeSharedPreferences import im.vector.app.test.fakes.FakeSignoutSessionsUseCase import im.vector.app.test.fakes.FakeVerificationService import im.vector.app.test.fixtures.aDeviceFullInfo @@ -68,6 +69,7 @@ class OtherSessionsViewModelTest { private val fakeSignoutSessionsUseCase = FakeSignoutSessionsUseCase() private val fakeInterceptSignoutFlowResponseUseCase = mockk() private val fakePendingAuthHandler = FakePendingAuthHandler() + private val fakeSharedPreferences = FakeSharedPreferences() private fun createViewModel(viewState: OtherSessionsViewState = OtherSessionsViewState(defaultArgs)) = OtherSessionsViewModel( @@ -78,6 +80,7 @@ class OtherSessionsViewModelTest { interceptSignoutFlowResponseUseCase = fakeInterceptSignoutFlowResponseUseCase, pendingAuthHandler = fakePendingAuthHandler.instance, refreshDevicesUseCase = fakeRefreshDevicesUseCase, + sharedPreferences = fakeSharedPreferences, ) @Before @@ -87,6 +90,7 @@ class OtherSessionsViewModelTest { every { SystemClock.elapsedRealtime() } returns 1234 givenVerificationService() + fakeSharedPreferences.givenSessionManagerShowIpAddress(false) } private fun givenVerificationService(): FakeVerificationService { diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt index b2ab939bd1..aec555d8eb 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt @@ -28,6 +28,7 @@ import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowRe import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase import im.vector.app.test.fakes.FakeActiveSessionHolder import im.vector.app.test.fakes.FakePendingAuthHandler +import im.vector.app.test.fakes.FakeSharedPreferences import im.vector.app.test.fakes.FakeSignoutSessionUseCase import im.vector.app.test.fakes.FakeTogglePushNotificationUseCase import im.vector.app.test.fakes.FakeVerificationService @@ -77,6 +78,7 @@ class SessionOverviewViewModelTest { private val togglePushNotificationUseCase = FakeTogglePushNotificationUseCase() private val fakeGetNotificationsStatusUseCase = mockk() private val notificationsStatus = NotificationsStatus.ENABLED + private val fakeSharedPreferences = FakeSharedPreferences() private fun createViewModel() = SessionOverviewViewModel( initialState = SessionOverviewViewState(args), @@ -89,6 +91,7 @@ class SessionOverviewViewModelTest { refreshDevicesUseCase = refreshDevicesUseCase, togglePushNotificationUseCase = togglePushNotificationUseCase.instance, getNotificationsStatusUseCase = fakeGetNotificationsStatusUseCase, + sharedPreferences = fakeSharedPreferences, ) @Before @@ -99,6 +102,7 @@ class SessionOverviewViewModelTest { givenVerificationService() every { fakeGetNotificationsStatusUseCase.execute(A_SESSION_ID_1) } returns flowOf(notificationsStatus) + fakeSharedPreferences.givenSessionManagerShowIpAddress(false) } private fun givenVerificationService(): FakeVerificationService { diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSharedPreferences.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSharedPreferences.kt index f9d525fd13..0242bfe148 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeSharedPreferences.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSharedPreferences.kt @@ -18,6 +18,7 @@ package im.vector.app.test.fakes import android.content.SharedPreferences import im.vector.app.features.settings.FontScaleValue +import im.vector.app.features.settings.VectorPreferences.Companion.SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS import io.mockk.every import io.mockk.mockk @@ -32,4 +33,8 @@ class FakeSharedPreferences : SharedPreferences by mockk() { every { contains("APPLICATION_USE_SYSTEM_FONT_SCALE_KEY") } returns true every { getBoolean("APPLICATION_USE_SYSTEM_FONT_SCALE_KEY", any()) } returns useSystemScale } + + fun givenSessionManagerShowIpAddress(showIpAddress: Boolean) { + every { getBoolean(SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, any()) } returns showIpAddress + } } From def74926d771256b05149ffcdc1b9074c25f9996 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Wed, 9 Nov 2022 11:40:44 +0100 Subject: [PATCH 011/173] Adding changelog entry --- changelog.d/7555.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7555.bugfix diff --git a/changelog.d/7555.bugfix b/changelog.d/7555.bugfix new file mode 100644 index 0000000000..064b21a9e5 --- /dev/null +++ b/changelog.d/7555.bugfix @@ -0,0 +1 @@ +Missing translations on "replyTo" messages From ab90da0e51e35b2d6d3d0eb93ce8000e0854fa2e Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Wed, 9 Nov 2022 17:30:46 +0100 Subject: [PATCH 012/173] Adding isReply extension method for RelationDefaultContent --- .../org/matrix/android/sdk/api/session/events/model/Event.kt | 3 ++- .../api/session/room/model/relation/RelationDefaultContent.kt | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt index 1f16041b54..57bd3dbc41 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt @@ -33,6 +33,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageStickerConte import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent +import org.matrix.android.sdk.api.session.room.model.relation.isReply import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.threads.ThreadDetails @@ -420,7 +421,7 @@ fun Event.getRelationContentForType(type: String): RelationDefaultContent? = getRelationContent()?.takeIf { it.type == type } fun Event.isReply(): Boolean { - return getRelationContent()?.inReplyTo?.eventId != null + return getRelationContent().isReply() } fun Event.isReplyRenderedInThread(): Boolean { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt index 5dcb1b4323..b9f9335dbd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt @@ -28,3 +28,5 @@ data class RelationDefaultContent( ) : RelationContent fun RelationDefaultContent.shouldRenderInThread(): Boolean = isFallingBack == false + +fun RelationDefaultContent?.isReply(): Boolean = this?.inReplyTo?.eventId != null From 235b629130ee5ec93e163fd42ac61918a6547c35 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Wed, 9 Nov 2022 17:31:00 +0100 Subject: [PATCH 013/173] Use case to process formatted body of reply to events --- .../src/main/res/values/strings.xml | 9 ++ .../sdk/api/session/events/model/Event.kt | 7 +- .../timeline/factory/MessageItemFactory.kt | 32 ++++- .../timeline/render/EventTextRenderer.kt | 18 +-- .../ProcessBodyOfReplyToEventUseCase.kt | 128 ++++++++++++++++++ 5 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCase.kt diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index f05a2a11e6..eccd40b2a9 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3458,4 +3458,13 @@ Apply underline format Toggle full screen mode + + In reply to + sent a file. + sent an audio file. + sent a voice message. + sent an image. + sent a video. + sent a sticker. + created a poll. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt index 57bd3dbc41..1c09b49298 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt @@ -229,11 +229,14 @@ data class Event( return when { isReplyRenderedInThread() || isQuote() -> ContentUtils.extractUsefulTextFromReply(text) isFileMessage() -> "sent a file." + isVoiceMessage() -> "sent a voice message." isAudioMessage() -> "sent an audio file." isImageMessage() -> "sent an image." isVideoMessage() -> "sent a video." - isSticker() -> "sent a sticker" + isSticker() -> "sent a sticker." isPoll() -> getPollQuestion() ?: "created a poll." + isLiveLocation() -> "Live location." + isLocationMessage() -> "has shared their location." else -> text } } @@ -444,7 +447,7 @@ fun Event.isInvitation(): Boolean = type == EventType.STATE_ROOM_MEMBER && content?.toModel()?.membership == Membership.INVITE fun Event.getPollContent(): MessagePollContent? { - return content.toModel() + return getDecryptedContent().toModel() } fun Event.supportsNotification() = diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index f4d506fa4b..373410775b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -65,6 +65,7 @@ import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem_ import im.vector.app.features.home.room.detail.timeline.render.EventTextRenderer +import im.vector.app.features.home.room.detail.timeline.render.ProcessBodyOfReplyToEventUseCase import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod import im.vector.app.features.home.room.detail.timeline.tools.linkify import im.vector.app.features.html.EventHtmlRenderer @@ -106,6 +107,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent import org.matrix.android.sdk.api.session.room.model.message.getFileUrl import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl +import org.matrix.android.sdk.api.session.room.model.relation.ReplyToContent import org.matrix.android.sdk.api.settings.LightweightSettingsStorage import org.matrix.android.sdk.api.util.MimeTypes import javax.inject.Inject @@ -139,6 +141,7 @@ class MessageItemFactory @Inject constructor( private val liveLocationShareMessageItemFactory: LiveLocationShareMessageItemFactory, private val pollItemViewStateFactory: PollItemViewStateFactory, private val voiceBroadcastItemFactory: VoiceBroadcastItemFactory, + private val processBodyOfReplyToEventUseCase: ProcessBodyOfReplyToEventUseCase, ) { // TODO inject this properly? @@ -200,7 +203,7 @@ class MessageItemFactory @Inject constructor( is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes) is MessagePollContent -> buildPollItem(messageContent, informationData, highlight, callback, attributes) is MessageLocationContent -> buildLocationItem(messageContent, informationData, highlight, attributes) - is MessageBeaconInfoContent -> liveLocationShareMessageItemFactory.create(params.event, highlight, attributes) + is MessageBeaconInfoContent -> liveLocationShareMessageItemFactory.create(event, highlight, attributes) is MessageVoiceBroadcastInfoContent -> voiceBroadcastItemFactory.create(params, messageContent, highlight, attributes) else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) } @@ -437,7 +440,14 @@ class MessageItemFactory @Inject constructor( attributes: AbsMessageItem.Attributes ): MessageTextItem? { // For compatibility reason we should display the body - return buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes) + return buildMessageTextItem( + messageContent.body, + false, + informationData, + highlight, + callback, + attributes, + ) } private fun buildImageMessageItem( @@ -540,7 +550,8 @@ class MessageItemFactory @Inject constructor( ): VectorEpoxyModel<*>? { val matrixFormattedBody = messageContent.matrixFormattedBody return if (matrixFormattedBody != null) { - buildFormattedTextItem(matrixFormattedBody, informationData, highlight, callback, attributes) + val replyToContent = messageContent.relatesTo?.inReplyTo + buildFormattedTextItem(matrixFormattedBody, informationData, highlight, callback, attributes, replyToContent) } else { buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes) } @@ -552,10 +563,21 @@ class MessageItemFactory @Inject constructor( highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, + replyToContent: ReplyToContent?, ): MessageTextItem? { - val compressed = htmlCompressor.compress(matrixFormattedBody) + val processedBody = replyToContent + ?.let { processBodyOfReplyToEventUseCase.execute(roomId, matrixFormattedBody, it) } + ?: matrixFormattedBody + val compressed = htmlCompressor.compress(processedBody) val renderedFormattedBody = htmlRenderer.get().render(compressed, pillsPostProcessor) as Spanned - return buildMessageTextItem(renderedFormattedBody, true, informationData, highlight, callback, attributes) + return buildMessageTextItem( + renderedFormattedBody, + true, + informationData, + highlight, + callback, + attributes, + ) } private fun buildMessageTextItem( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/EventTextRenderer.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/EventTextRenderer.kt index 920f3e3b80..c46112f995 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/EventTextRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/EventTextRenderer.kt @@ -34,22 +34,22 @@ class EventTextRenderer @AssistedInject constructor( @Assisted private val roomId: String?, private val context: Context, private val avatarRenderer: AvatarRenderer, - private val sessionHolder: ActiveSessionHolder + private val activeSessionHolder: ActiveSessionHolder, ) { - /* ========================================================================================== - * Public api - * ========================================================================================== */ - @AssistedFactory interface Factory { fun create(roomId: String?): EventTextRenderer } /** - * @param text the text you want to render + * @param text the text to be rendered */ fun render(text: CharSequence): CharSequence { + return renderNotifyEveryone(text) + } + + private fun renderNotifyEveryone(text: CharSequence): CharSequence { return if (roomId != null && text.contains(MatrixItem.NOTIFY_EVERYONE)) { SpannableStringBuilder(text).apply { addNotifyEveryoneSpans(this, roomId) @@ -59,12 +59,8 @@ class EventTextRenderer @AssistedInject constructor( } } - /* ========================================================================================== - * Helper methods - * ========================================================================================== */ - private fun addNotifyEveryoneSpans(text: Spannable, roomId: String) { - val room: RoomSummary? = sessionHolder.getSafeActiveSession()?.roomService()?.getRoomSummary(roomId) + val room: RoomSummary? = activeSessionHolder.getSafeActiveSession()?.roomService()?.getRoomSummary(roomId) val matrixItem = MatrixItem.EveryoneInRoomItem( id = roomId, avatarUrl = room?.avatarUrl, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCase.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCase.kt new file mode 100644 index 0000000000..44fd5a397c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCase.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.render + +import im.vector.app.R +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.resources.StringProvider +import org.matrix.android.sdk.api.session.events.model.getPollQuestion +import org.matrix.android.sdk.api.session.events.model.isAudioMessage +import org.matrix.android.sdk.api.session.events.model.isFileMessage +import org.matrix.android.sdk.api.session.events.model.isImageMessage +import org.matrix.android.sdk.api.session.events.model.isLiveLocation +import org.matrix.android.sdk.api.session.events.model.isPoll +import org.matrix.android.sdk.api.session.events.model.isSticker +import org.matrix.android.sdk.api.session.events.model.isVideoMessage +import org.matrix.android.sdk.api.session.events.model.isVoiceMessage +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.getTimelineEvent +import org.matrix.android.sdk.api.session.room.model.relation.ReplyToContent +import javax.inject.Inject + +private const val IN_REPLY_TO = "In reply to" +private const val BREAKING_LINE = "
" +private const val ENDING_BLOCK_QUOTE = "" + +// TODO add unit tests +class ProcessBodyOfReplyToEventUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, + private val stringProvider: StringProvider, +) { + + fun execute(roomId: String, matrixFormattedBody: String, replyToContent: ReplyToContent): String { + val repliedToEvent = replyToContent.eventId?.let { getEvent(it, roomId) } + val breakingLineIndex = matrixFormattedBody.lastIndexOf(BREAKING_LINE) + val endOfBlockQuoteIndex = matrixFormattedBody.lastIndexOf(ENDING_BLOCK_QUOTE) + + // TODO check in other platform how is handled the case of no repliedToEvent fetched + val withTranslatedContent = if (repliedToEvent != null && breakingLineIndex != -1 && endOfBlockQuoteIndex != -1) { + val afterBreakingLineIndex = breakingLineIndex + BREAKING_LINE.length + when { + repliedToEvent.isFileMessage() -> { + matrixFormattedBody.replaceRange( + afterBreakingLineIndex, + endOfBlockQuoteIndex, + stringProvider.getString(R.string.message_reply_to_sender_sent_file) + ) + } + repliedToEvent.isVoiceMessage() -> { + matrixFormattedBody.replaceRange( + afterBreakingLineIndex, + endOfBlockQuoteIndex, + stringProvider.getString(R.string.message_reply_to_sender_sent_voice_message) + ) + } + repliedToEvent.isAudioMessage() -> { + matrixFormattedBody.replaceRange( + afterBreakingLineIndex, + endOfBlockQuoteIndex, + stringProvider.getString(R.string.message_reply_to_sender_sent_audio_file) + ) + } + repliedToEvent.isImageMessage() -> { + matrixFormattedBody.replaceRange( + afterBreakingLineIndex, + endOfBlockQuoteIndex, + stringProvider.getString(R.string.message_reply_to_sender_sent_image) + ) + } + repliedToEvent.isVideoMessage() -> { + matrixFormattedBody.replaceRange( + afterBreakingLineIndex, + endOfBlockQuoteIndex, + stringProvider.getString(R.string.message_reply_to_sender_sent_video) + ) + } + repliedToEvent.isSticker() -> { + matrixFormattedBody.replaceRange( + afterBreakingLineIndex, + endOfBlockQuoteIndex, + stringProvider.getString(R.string.message_reply_to_sender_sent_sticker) + ) + } + repliedToEvent.isPoll() -> { + matrixFormattedBody.replaceRange( + afterBreakingLineIndex, + endOfBlockQuoteIndex, + repliedToEvent.getPollQuestion() ?: stringProvider.getString(R.string.message_reply_to_sender_created_poll) + ) + } + repliedToEvent.isLiveLocation() -> { + matrixFormattedBody.replaceRange( + afterBreakingLineIndex, + endOfBlockQuoteIndex, + stringProvider.getString(R.string.live_location_description) + ) + } + else -> matrixFormattedBody + } + } else { + matrixFormattedBody + } + + return withTranslatedContent.replace( + IN_REPLY_TO, + stringProvider.getString(R.string.message_reply_to_prefix) + ) + } + + private fun getEvent(eventId: String, roomId: String) = + activeSessionHolder.getSafeActiveSession() + ?.getRoom(roomId) + ?.getTimelineEvent(eventId) + ?.root +} From 57e90aee83ec735f6b189895f6262f5683d28831 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Thu, 10 Nov 2022 15:40:50 +0100 Subject: [PATCH 014/173] Removing description parameter in startLiveLocation method of SDK to avoid translated strings in beacon events --- .../api/session/room/location/LocationSharingService.kt | 3 +-- .../room/location/DefaultLocationSharingService.kt | 3 +-- .../session/room/location/StartLiveLocationShareTask.kt | 3 +-- .../room/location/DefaultLocationSharingServiceTest.kt | 9 +++------ .../location/DefaultStartLiveLocationShareTaskTest.kt | 6 +----- .../live/tracking/LocationSharingAndroidService.kt | 6 +----- 6 files changed, 8 insertions(+), 22 deletions(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt index cd8acbcccc..93208be27b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt @@ -47,10 +47,9 @@ interface LocationSharingService { /** * Starts sharing live location in the room. * @param timeoutMillis timeout of the live in milliseconds - * @param description description of the live for text fallback * @return the result of the update of the live */ - suspend fun startLiveLocationShare(timeoutMillis: Long, description: String): UpdateLiveLocationShareResult + suspend fun startLiveLocationShare(timeoutMillis: Long): UpdateLiveLocationShareResult /** * Stops sharing live location in the room. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt index 60312071d7..c36efa064f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt @@ -73,7 +73,7 @@ internal class DefaultLocationSharingService @AssistedInject constructor( return sendLiveLocationTask.execute(params) } - override suspend fun startLiveLocationShare(timeoutMillis: Long, description: String): UpdateLiveLocationShareResult { + override suspend fun startLiveLocationShare(timeoutMillis: Long): UpdateLiveLocationShareResult { // Ensure to stop any active live before starting a new one if (checkIfExistingActiveLive()) { val result = stopLiveLocationShare() @@ -84,7 +84,6 @@ internal class DefaultLocationSharingService @AssistedInject constructor( val params = StartLiveLocationShareTask.Params( roomId = roomId, timeoutMillis = timeoutMillis, - description = description ) return startLiveLocationShareTask.execute(params) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt index 79019e4765..781def1abe 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt @@ -30,7 +30,6 @@ internal interface StartLiveLocationShareTask : Task From 58d182aecbb8a18d57208954e06b7715f9523864 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Thu, 10 Nov 2022 16:46:16 +0100 Subject: [PATCH 015/173] Adding unit tests on ProcessBodyOfReplyToEventUseCase --- .../ProcessBodyOfReplyToEventUseCase.kt | 2 - .../ProcessBodyOfReplyToEventUseCaseTest.kt | 268 ++++++++++++++++++ .../app/test/fakes/FakeTimelineService.kt | 2 +- 3 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 vector/src/test/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCaseTest.kt diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCase.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCase.kt index 44fd5a397c..b22114a502 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCase.kt @@ -37,7 +37,6 @@ private const val IN_REPLY_TO = "In reply to" private const val BREAKING_LINE = "
" private const val ENDING_BLOCK_QUOTE = "" -// TODO add unit tests class ProcessBodyOfReplyToEventUseCase @Inject constructor( private val activeSessionHolder: ActiveSessionHolder, private val stringProvider: StringProvider, @@ -48,7 +47,6 @@ class ProcessBodyOfReplyToEventUseCase @Inject constructor( val breakingLineIndex = matrixFormattedBody.lastIndexOf(BREAKING_LINE) val endOfBlockQuoteIndex = matrixFormattedBody.lastIndexOf(ENDING_BLOCK_QUOTE) - // TODO check in other platform how is handled the case of no repliedToEvent fetched val withTranslatedContent = if (repliedToEvent != null && breakingLineIndex != -1 && endOfBlockQuoteIndex != -1) { val afterBreakingLineIndex = breakingLineIndex + BREAKING_LINE.length when { diff --git a/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCaseTest.kt new file mode 100644 index 0000000000..f612861511 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCaseTest.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.render + +import android.annotation.StringRes +import im.vector.app.R +import im.vector.app.test.fakes.FakeActiveSessionHolder +import im.vector.app.test.fakes.FakeStringProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.getPollQuestion +import org.matrix.android.sdk.api.session.events.model.isAudioMessage +import org.matrix.android.sdk.api.session.events.model.isFileMessage +import org.matrix.android.sdk.api.session.events.model.isImageMessage +import org.matrix.android.sdk.api.session.events.model.isLiveLocation +import org.matrix.android.sdk.api.session.events.model.isPoll +import org.matrix.android.sdk.api.session.events.model.isSticker +import org.matrix.android.sdk.api.session.events.model.isVideoMessage +import org.matrix.android.sdk.api.session.events.model.isVoiceMessage +import org.matrix.android.sdk.api.session.room.model.relation.ReplyToContent +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent + +private const val A_ROOM_ID = "room-id" +private const val AN_EVENT_ID = "event-id" +private const val A_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY = + "" + + "
" + + "In reply to " + + "@user:matrix.org" + + "
" + + "Message content" + + "
" + + "
" + + "Reply text" +private const val A_NEW_PREFIX = "new-prefix" +private const val A_NEW_CONTENT = "new-content" +private const val PREFIX_PROCESSED_ONLY_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY = + "" + + "
" + + "$A_NEW_PREFIX " + + "@user:matrix.org" + + "
" + + "Message content" + + "
" + + "
" + + "Reply text" +private const val FULLY_PROCESSED_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY = + "" + + "
" + + "$A_NEW_PREFIX " + + "@user:matrix.org" + + "
" + + A_NEW_CONTENT + + "
" + + "
" + + "Reply text" + +class ProcessBodyOfReplyToEventUseCaseTest { + + private val fakeActiveSessionHolder = FakeActiveSessionHolder() + private val fakeStringProvider = FakeStringProvider() + private val fakeReplyToContent = ReplyToContent(eventId = AN_EVENT_ID) + private val fakeRepliedEvent = givenARepliedEvent() + + private val processBodyOfReplyToEventUseCase = ProcessBodyOfReplyToEventUseCase( + activeSessionHolder = fakeActiveSessionHolder.instance, + stringProvider = fakeStringProvider.instance, + ) + + @Before + fun setup() { + givenNewPrefix() + mockkStatic("org.matrix.android.sdk.api.session.events.model.EventKt") + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given a replied event of type file message when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isFileMessage = true) + givenNewContentForId(R.string.message_reply_to_sender_sent_file) + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type voice message when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isVoiceMessage = true) + givenNewContentForId(R.string.message_reply_to_sender_sent_voice_message) + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type audio message when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isAudioMessage = true) + givenNewContentForId(R.string.message_reply_to_sender_sent_audio_file) + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type image message when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isImageMessage = true) + givenNewContentForId(R.string.message_reply_to_sender_sent_image) + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type video message when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isVideoMessage = true) + givenNewContentForId(R.string.message_reply_to_sender_sent_video) + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type sticker message when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isStickerMessage = true) + givenNewContentForId(R.string.message_reply_to_sender_sent_sticker) + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type poll message with null question when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isPollMessage = true) + givenNewContentForId(R.string.message_reply_to_sender_created_poll) + every { fakeRepliedEvent.getPollQuestion() } returns null + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type poll message with existing question when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isPollMessage = true) + givenNewContentForId(R.string.message_reply_to_sender_created_poll) + every { fakeRepliedEvent.getPollQuestion() } returns A_NEW_CONTENT + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type live location message when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isLiveLocationMessage = true) + givenNewContentForId(R.string.live_location_description) + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type not handled when process the formatted body only prefix is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent() + + // When + val result = processBodyOfReplyToEventUseCase.execute( + roomId = A_ROOM_ID, + matrixFormattedBody = A_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY, + replyToContent = fakeReplyToContent, + ) + + // Then + result shouldBeEqualTo PREFIX_PROCESSED_ONLY_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY + } + + @Test + fun `given no replied event found when process the formatted body then only prefix is replaced by correct string`() { + // Given + givenARepliedEvent(timelineEvent = null) + + // When + val result = processBodyOfReplyToEventUseCase.execute( + roomId = A_ROOM_ID, + matrixFormattedBody = A_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY, + replyToContent = fakeReplyToContent, + ) + + // Then + result shouldBeEqualTo PREFIX_PROCESSED_ONLY_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY + } + + private fun executeAndAssertResult() { + // When + val result = processBodyOfReplyToEventUseCase.execute( + roomId = A_ROOM_ID, + matrixFormattedBody = A_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY, + replyToContent = fakeReplyToContent, + ) + + // Then + result shouldBeEqualTo FULLY_PROCESSED_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY + } + + private fun givenARepliedEvent(timelineEvent: TimelineEvent? = mockk()): Event { + val event = mockk() + timelineEvent?.let { every { it.root } returns event } + fakeActiveSessionHolder + .fakeSession + .roomService() + .getRoom(A_ROOM_ID) + .timelineService() + .givenTimelineEvent(timelineEvent) + return event + } + + private fun givenTypeOfRepliedEvent( + isFileMessage: Boolean = false, + isVoiceMessage: Boolean = false, + isAudioMessage: Boolean = false, + isImageMessage: Boolean = false, + isVideoMessage: Boolean = false, + isStickerMessage: Boolean = false, + isPollMessage: Boolean = false, + isLiveLocationMessage: Boolean = false, + ) { + every { fakeRepliedEvent.isFileMessage() } returns isFileMessage + every { fakeRepliedEvent.isVoiceMessage() } returns isVoiceMessage + every { fakeRepliedEvent.isAudioMessage() } returns isAudioMessage + every { fakeRepliedEvent.isImageMessage() } returns isImageMessage + every { fakeRepliedEvent.isVideoMessage() } returns isVideoMessage + every { fakeRepliedEvent.isSticker() } returns isStickerMessage + every { fakeRepliedEvent.isPoll() } returns isPollMessage + every { fakeRepliedEvent.isLiveLocation() } returns isLiveLocationMessage + } + + private fun givenNewPrefix() { + fakeStringProvider.given(R.string.message_reply_to_prefix, A_NEW_PREFIX) + } + + private fun givenNewContentForId(@StringRes resId: Int) { + fakeStringProvider.given(resId, A_NEW_CONTENT) + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeTimelineService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeTimelineService.kt index 56f38724b1..a5fac5f1a1 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeTimelineService.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeTimelineService.kt @@ -23,7 +23,7 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineService class FakeTimelineService : TimelineService by mockk() { - fun givenTimelineEvent(event: TimelineEvent) { + fun givenTimelineEvent(event: TimelineEvent?) { every { getTimelineEvent(any()) } returns event } } From 9fd50b4e351e7bdeef59c8288f677a091579849b Mon Sep 17 00:00:00 2001 From: waclaw66 Date: Fri, 11 Nov 2022 08:04:06 +0000 Subject: [PATCH 016/173] Translated using Weblate (Czech) Currently translated at 100.0% (2542 of 2542 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/cs/ --- library/ui-strings/src/main/res/values-cs/strings.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/library/ui-strings/src/main/res/values-cs/strings.xml b/library/ui-strings/src/main/res/values-cs/strings.xml index 47caa52149..b4f9698197 100644 --- a/library/ui-strings/src/main/res/values-cs/strings.xml +++ b/library/ui-strings/src/main/res/values-cs/strings.xml @@ -2899,4 +2899,13 @@ Nelze zahájit nové hlasové vysílání Přetočení o 30 sekund zpět Přetočení o 30 sekund dopředu + Ověřené relace jsou všude tam, kde tento účet používáte po zadání přístupové fráze nebo po potvrzení své totožnosti jinou ověřenou relací. +\n +\nTo znamená, že máte všechny klíče potřebné k odemknutí zašifrovaných zpráv a potvrzení ostatním uživatelům, že této relaci důvěřujete. + + Odhlásit se z %1$d relace + Odhlásit se ze %1$d relací + Odhlásit se z %1$d relací + + Odhlásit se \ No newline at end of file From 47a909eaf4a34d4038e71113f4f931dd6824bb12 Mon Sep 17 00:00:00 2001 From: Vri Date: Thu, 10 Nov 2022 11:02:51 +0000 Subject: [PATCH 017/173] Translated using Weblate (German) Currently translated at 100.0% (2542 of 2542 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/de/ --- library/ui-strings/src/main/res/values-de/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/library/ui-strings/src/main/res/values-de/strings.xml b/library/ui-strings/src/main/res/values-de/strings.xml index cd215e175d..186533c8f9 100644 --- a/library/ui-strings/src/main/res/values-de/strings.xml +++ b/library/ui-strings/src/main/res/values-de/strings.xml @@ -2843,4 +2843,12 @@ Jemand anderes nimmt bereits eine Sprachübertragung auf. Warte auf das Ende der Übertragung, bevor du eine neue startest. 30 Sekunden vorspulen 30 Sekunden zurückspulen + Auf verifizierte Sitzungen kannst du überall mit deinem Konto zugreifen, wenn du deine Passphrase eingegeben oder Element mit einer anderen Sitzung verifiziert hast. +\n +\nDies bedeutet, dass du alle Schlüssel zum Entsperren deiner verschlüsselten Nachrichten hast und anderen bestätigst, dieser Sitzung zu vertrauen. + + Von %1$d Sitzung abmelden + Von %1$d Sitzungen abmelden + + Abmelden \ No newline at end of file From 2d80a25fd6b36616d79ddc01d1a19b7410854a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Thu, 10 Nov 2022 21:01:22 +0000 Subject: [PATCH 018/173] Translated using Weblate (Estonian) Currently translated at 99.6% (2534 of 2542 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/et/ --- library/ui-strings/src/main/res/values-et/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/library/ui-strings/src/main/res/values-et/strings.xml b/library/ui-strings/src/main/res/values-et/strings.xml index 22572a0f36..4747f03fee 100644 --- a/library/ui-strings/src/main/res/values-et/strings.xml +++ b/library/ui-strings/src/main/res/values-et/strings.xml @@ -2835,4 +2835,12 @@ Uue ringhäälingukõne alustamine pole võimalik Keri tagasi 30 sekundi kaupa Keri edasi 30 sekundi kaupa + Verifitseeritud sessioonideks loetakse Element\'is või mõnes muus Matrix\'i rakenduses selliseid sessioone, kus sa kas oled sisestanud oma salafraasi või tuvastanud end mõne teise oma verifitseeritud sessiooni abil. +\n +\nSee tähendab, et selles sessioonis on ka kõik vajalikud võtmed krüptitud sõnumite lugemiseks ja teistele kasutajatele kinnitamiseks, et sa usaldad seda sessiooni. + + Logi välja %1$d\'st sessioonist + Logi välja %1$d\'st sessioonist + + Logi välja \ No newline at end of file From 8b5f86ec51b2e76eb9c713059b83e60e2f7d0972 Mon Sep 17 00:00:00 2001 From: Glandos Date: Sat, 12 Nov 2022 10:37:18 +0000 Subject: [PATCH 019/173] Translated using Weblate (French) Currently translated at 100.0% (2542 of 2542 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/fr/ --- library/ui-strings/src/main/res/values-fr/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/library/ui-strings/src/main/res/values-fr/strings.xml b/library/ui-strings/src/main/res/values-fr/strings.xml index a02b062596..a8b5eb28ae 100644 --- a/library/ui-strings/src/main/res/values-fr/strings.xml +++ b/library/ui-strings/src/main/res/values-fr/strings.xml @@ -2844,4 +2844,12 @@ Impossible de commencer une nouvelle diffusion audio Avance rapide de 30 secondes Retour rapide de 30 secondes + Les sessions vérifiées sont toutes celles qui utilisent ce compte après avoir saisie la phrase de sécurité ou confirmé votre identité à l’aide d’une autre session vérifiée. +\n +\nCela veut dire qu’elles disposent de toutes les clés nécessaires pour lire les messages chiffrés, et confirment aux autres utilisateur que vous faites confiance à cette session. + + Déconnecter %1$d session + Déconnecter %1$d sessions + + Déconnecter \ No newline at end of file From e5783afb115a5543424dd31184075abd7d14fcb9 Mon Sep 17 00:00:00 2001 From: Szimszon Date: Sat, 12 Nov 2022 09:20:12 +0000 Subject: [PATCH 020/173] Translated using Weblate (Hungarian) Currently translated at 100.0% (2542 of 2542 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/hu/ --- .../src/main/res/values-hu/strings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/library/ui-strings/src/main/res/values-hu/strings.xml b/library/ui-strings/src/main/res/values-hu/strings.xml index 21ea6aab14..8f449dadaf 100644 --- a/library/ui-strings/src/main/res/values-hu/strings.xml +++ b/library/ui-strings/src/main/res/values-hu/strings.xml @@ -2836,4 +2836,20 @@ A Visszaállítási Kulcsot tartsd biztonságos helyen, mint pl. egy jelszókeze %1$d kiválasztva %1$d kiválasztva + Teljes képernyő váltás + Mindenhol ellenőrzött munkamenetek vannak ahol ezt a fiókot használva megadtad a jelmondatodat vagy egy másik már hitelesített munkamenetből megerősítetted az identitásodat. +\n +\nEz azt jelenti, hogy a titkosított üzenetek visszafejtéséhez rendelkezel a kulcsokkal és megerősíted a többiek felé, hogy megbízol a munkamenetben. + + Kijelentkezés %1$d munkamenetből + Kijelentkezés %1$d munkamenetből + + Kijelentkezés + Szöveg formázás + Egy hang közvetítés már folyamatban van. Először fejezze be a jelenlegi közvetítést egy új indításához. + Valaki már elindított egy hang közvetítést. Várd meg a közvetítés végét az új indításához. + Nincs jogosultságod hang közvetítést indítani ebben a szobában. Vedd fel a kapcsolatot a szoba adminisztrátorával a szükséges jogosultság megszerzéséhez. + Az új hang közvetítés nem indítható el + 30 másodperccel előre + 30 másodperccel vissza \ No newline at end of file From ac06ff52569b13185de0f8f50bf4a395e38b6b44 Mon Sep 17 00:00:00 2001 From: Linerly Date: Sat, 12 Nov 2022 06:50:29 +0000 Subject: [PATCH 021/173] Translated using Weblate (Indonesian) Currently translated at 100.0% (2542 of 2542 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/id/ --- library/ui-strings/src/main/res/values-in/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/library/ui-strings/src/main/res/values-in/strings.xml b/library/ui-strings/src/main/res/values-in/strings.xml index cde367faf9..8f7670d08f 100644 --- a/library/ui-strings/src/main/res/values-in/strings.xml +++ b/library/ui-strings/src/main/res/values-in/strings.xml @@ -2791,4 +2791,11 @@ Di masa mendatang proses verifikasi ini akan dimutakhirkan.
Tidak dapat memulai siaran suara baru Maju cepat 30 detik Mundur cepat 30 detik + Sesi terverifikasi ada di mana pun Anda menggunakan Element setelah memasukkan frasa sandi atau mengonfirmasi identitas Anda dengan sesi terverifikasi lainnya. +\n +\nIni berarti Anda memiliki semua kunci yang diperlukan untuk membuka kunci pesan terenkripsi dan mengonfirmasi kepada pengguna lain bahwa Anda memercayai sesi ini. + + Keluarkan %1$d sesi + + Keluarkan \ No newline at end of file From 6c52e47e473eb0a8287ddd78596a7110f45c2658 Mon Sep 17 00:00:00 2001 From: random Date: Fri, 11 Nov 2022 14:08:42 +0000 Subject: [PATCH 022/173] Translated using Weblate (Italian) Currently translated at 100.0% (2542 of 2542 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/it/ --- .../src/main/res/values-it/strings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/library/ui-strings/src/main/res/values-it/strings.xml b/library/ui-strings/src/main/res/values-it/strings.xml index 5cc509e8b9..4257d52c3e 100644 --- a/library/ui-strings/src/main/res/values-it/strings.xml +++ b/library/ui-strings/src/main/res/values-it/strings.xml @@ -2827,4 +2827,20 @@ %1$d selezionato %1$d selezionati + Attiva/disattiva schermo intero + Le sessioni verificate sono ovunque usi questo account dopo l\'inserimento della password o la conferma della tua identità con un\'altra sessione verificata. +\n +\nCiò significa che hai tutte le chiavi necessarie per sbloccare i tuoi messaggi cifrati e per confermare agli altri utenti che ti fidi di questa sessione. + + Disconnetti da %1$d sessione + Disconnetti da %1$d sessioni + + Disconnetti + Formattazione testo + Stai già registrando una trasmissione vocale. Termina quella in corso per iniziarne una nuova. + Qualcun altro sta già registrando una trasmissione vocale. Aspetta che finisca prima di iniziarne una nuova. + Non hai l\'autorizzazione necessaria per iniziare una trasmissione vocale in questa stanza. Contatta un amministratore della stanza per aggiornare le tue autorizzazioni. + Impossibile iniziare una nuova trasmissione vocale + Manda avanti di 30 secondi + Manda indietro di 30 secondi \ No newline at end of file From 24868a6c6404d7b408d8184611e89ac09191cc7e Mon Sep 17 00:00:00 2001 From: Platon Terekhov Date: Fri, 11 Nov 2022 19:26:31 +0000 Subject: [PATCH 023/173] Translated using Weblate (Russian) Currently translated at 95.3% (2423 of 2542 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/ru/ --- .../src/main/res/values-ru/strings.xml | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/library/ui-strings/src/main/res/values-ru/strings.xml b/library/ui-strings/src/main/res/values-ru/strings.xml index fb819efb69..cdac891840 100644 --- a/library/ui-strings/src/main/res/values-ru/strings.xml +++ b/library/ui-strings/src/main/res/values-ru/strings.xml @@ -2805,4 +2805,30 @@ Местоположение Камера Контакт + ${app_name} нуждается в разрешении для отображения оповещений. +\nПожалуйста, дайте разрешение. + + %1$s и %2$d другой + %1$s и %2$d другие + %1$s и %2$d других + %1$s и %2$d других + + ${app_name} нуждается в резрешении для отображения оповещений. Оповещения могут показывать ваши сообщения, приглашения и тому подобное. +\n +\nПожалуйста разрешите доступ при следующем всплывающем сообщении, чтобы иметь возможность видеть оповещения. + Здесь будут появляться новые запросы и приглашения. + Приглашения + Попробуйте расширенный текстовый редактор (режим набора обычного текста скоро появится) + Создавать личные сообщения только при отправке первого сообщения + Включить отложенные личные сообщения + Отменить выбор всего + Выбрать всё + Свернуть дочерние элементы %s + Развернуть дочерние элементы %s + + Выбрано %1$d + Выбрано %1$d + Выбрано %1$d + Выбрано %1$d + \ No newline at end of file From 6fa15e6ee90dd6be7a1f7aa341dd1171947ce3ab Mon Sep 17 00:00:00 2001 From: Jozef Gaal Date: Thu, 10 Nov 2022 16:33:24 +0000 Subject: [PATCH 024/173] Translated using Weblate (Slovak) Currently translated at 100.0% (2542 of 2542 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/sk/ --- library/ui-strings/src/main/res/values-sk/strings.xml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/library/ui-strings/src/main/res/values-sk/strings.xml b/library/ui-strings/src/main/res/values-sk/strings.xml index 9eac092a62..9c15c5ec0d 100644 --- a/library/ui-strings/src/main/res/values-sk/strings.xml +++ b/library/ui-strings/src/main/res/values-sk/strings.xml @@ -2765,7 +2765,7 @@ Zapnúť nové usporiadanie Ostatní používatelia v priamych správach a miestnostiach, do ktorých sa pripojíte, si môžu pozrieť úplný zoznam vašich relácií. \n -\nTo im poskytuje istotu, že sa s vami naozaj rozprávajú, ale zároveň to znamená, že vidia názov relácie, ktorý sem zadáte. +\nTo im poskytuje istotu, že sa komunikujú naozaj s vami, ale zároveň to znamená, že vidia názov relácie, ktorý sem zadáte. Premenovanie relácií Overené relácie, do ktorých ste sa prihlásili pomocou svojich prihlasovacích údajov a ktoré boli následne overené buď pomocou vašej bezpečnostnej prístupovej frázy, alebo krížovým overením. \n @@ -2899,4 +2899,13 @@ Nie je možné spustiť nové hlasové vysielanie Rýchle posunutie dozadu o 30 sekúnd Rýchle posunutie dopredu o 30 sekúnd + Overené relácie sú všade tam, kde používate toto konto po zadaní svojho prístupového hesla alebo po potvrdení svojej totožnosti inou overenou reláciou. +\n +\nTo znamená, že máte všetky kľúče potrebné na odomknutie zašifrovaných správ a potvrdenie pre ostatných používateľov, že tejto relácii dôverujete. + + Odhlásiť sa z %1$d relácie + Odhlásiť sa z %1$d relácií + Odhlásiť sa z %1$d relácií + + Odhlásiť sa \ No newline at end of file From baaace855c8c9db97e8d05611e432d714e63ae25 Mon Sep 17 00:00:00 2001 From: Ihor Hordiichuk Date: Thu, 10 Nov 2022 14:55:18 +0000 Subject: [PATCH 025/173] Translated using Weblate (Ukrainian) Currently translated at 100.0% (2542 of 2542 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/uk/ --- .../src/main/res/values-uk/strings.xml | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/library/ui-strings/src/main/res/values-uk/strings.xml b/library/ui-strings/src/main/res/values-uk/strings.xml index 8cbfeca6ba..8affc63685 100644 --- a/library/ui-strings/src/main/res/values-uk/strings.xml +++ b/library/ui-strings/src/main/res/values-uk/strings.xml @@ -2839,12 +2839,12 @@ Перейменувати сеанс Вийти з цього сеансу Не звірений - Ваш поточний сеанс - Розпочати трансляцію голосового повідомлення + Розпочати голосову трансляцію Справжність цього зашифрованого повідомлення не може бути гарантована на цьому пристрої. Заборонити клавіатурі оновлювати будь-які персоналізовані дані, як-от історію набору тексту та словник, на основі того, що ви набрали в розмовах. Зверніть увагу, що деякі клавіатури можуть не дотримуватися цього налаштування. Клавіатура інкогніто Надсилає (╯°□°)╯︵ ┻━┻ на початку текстового повідомлення - Голосові повідомлення + Голосові трансляції Відкрийте інструменти розробника 🔒 Ви увімкнули шифрування лише для перевірених сеансів для всіх кімнат у налаштуваннях безпеки. ⚠ У цій кімнаті є неперевірені пристрої, вони не зможуть розшифрувати повідомлення, які ви надсилаєте. @@ -2920,21 +2920,21 @@ Вхід з іншого пристрою вже виконано. Під час налаштування захищеного обміну повідомленнями виникла проблема з безпекою. Можливо, порушено одне з таких налаштувань: Ваш домашній сервер; Ваше інтернет-з\'єднання; Ваш пристрій; Запит не виконаний. - Можливість записувати та надсилати голосові повідомлення до стрічки кімнати. - Увімкнути голосові повідомлення (в активній розробці) + Можливість записувати та надсилати голосові трансляції до стрічки кімнати. + Увімкнути голосові трансляції (в активній розробці) Буферизація - Призупинити голосове повідомлення - Відтворити або поновити відтворення голосового повідомлення - Припинити запис голосового повідомлення - Призупинити запис голосового повідомлення - Відновити запис голосового повідомлення + Призупинити голосову трансляцію + Відтворити або поновити відтворення голосової трансляції + Припинити запис голосової трансляції + Призупинити запис голосової трансляції + Відновити запис голосової трансляції Наживо Вибрати сеанси Контакт Камера Місце перебування Опитування - Голосові повідомлення + Голосові трансляції Вкладення Наліпки Фотобібліотека @@ -2948,10 +2948,20 @@ Вибрати все Перемкнути повноекранний режим Форматування тексту - Ви вже записуєте голосове повідомлення. Завершіть поточну трансляцію, щоб розпочати нову. - Хтось інший вже записує голосове повідомлення. Зачекайте, поки закінчиться трансляція, щоб розпочати нову. - Ви не маєте необхідних дозволів для початку передавання голосового повідомлення в цю кімнату. Зверніться до адміністратора кімнати, щоб оновити ваші дозволи. - Не вдалося розпочати передавання нового голосового повідомлення + Ви вже записуєте голосову трансляцію. Завершіть поточну трансляцію, щоб розпочати нову. + Хтось інший вже записує голосову трансляцію. Зачекайте, поки вона завершиться, щоб розпочати нову. + Ви не маєте необхідних дозволів для початку голосової трансляції в цю кімнату. Зверніться до адміністратора кімнати, щоб оновити ваші дозволи. + Не вдалося розпочати нову голосову трансляцію Перемотати вперед на 30 секунд Перемотати назад на 30 секунд + Звірені сеанси — це будь-який пристрій, на якому ви використовуєте цей обліковий запис після введення парольної фрази або підтвердження вашої особи за допомогою іншого звіреного сеансу. +\n +\nЦе означає, що ви маєте всі ключі, необхідні для розблокування ваших зашифрованих повідомлень і підтвердження іншим користувачам, що ви довіряєте цьому сеансу. + + Вийти з %1$d сеансу + Вийти з %1$d сеансів + Вийти з %1$d сеансів + Вийти з %1$d сеансів + + Вийти \ No newline at end of file From a616ab9472b9575bd50c97a9e74b3e5c90b5366a Mon Sep 17 00:00:00 2001 From: Jeff Huang Date: Fri, 11 Nov 2022 02:09:46 +0000 Subject: [PATCH 026/173] Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (2542 of 2542 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/zh_Hant/ --- library/ui-strings/src/main/res/values-zh-rTW/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/library/ui-strings/src/main/res/values-zh-rTW/strings.xml b/library/ui-strings/src/main/res/values-zh-rTW/strings.xml index 91e08c803a..6976854649 100644 --- a/library/ui-strings/src/main/res/values-zh-rTW/strings.xml +++ b/library/ui-strings/src/main/res/values-zh-rTW/strings.xml @@ -2789,4 +2789,11 @@ 無法開始新的語音廣播 快轉30秒 快退30秒 + 已驗證的工作階段是您輸入通關密語或透過另一個已驗證工作階段確認您的身份後使用此帳號的任何地方。 +\n +\n這代表了您擁有解鎖加密訊息並向其他使用者確認您信任此工作階段所需的所有金鑰。 + + 登出 %1$d 個工作階段 + + 登出 \ No newline at end of file From 4578336245ca0816aa3855470f7a412b77255810 Mon Sep 17 00:00:00 2001 From: Ihor Hordiichuk Date: Thu, 10 Nov 2022 14:50:21 +0000 Subject: [PATCH 027/173] Translated using Weblate (Ukrainian) Currently translated at 100.0% (81 of 81 strings) Translation: Element Android/Element Android Store Translate-URL: https://translate.element.io/projects/element-android/element-store/uk/ --- fastlane/metadata/android/uk/changelogs/40105040.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastlane/metadata/android/uk/changelogs/40105040.txt b/fastlane/metadata/android/uk/changelogs/40105040.txt index bbc005f84c..b3327f68ab 100644 --- a/fastlane/metadata/android/uk/changelogs/40105040.txt +++ b/fastlane/metadata/android/uk/changelogs/40105040.txt @@ -1,2 +1,2 @@ -Основні зміни в цій версії: Нові можливості в налаштуваннях лабораторії: Текстовий редактор, нове керування пристроями, голосові повідомлення. Досі в активній розробці! +Основні зміни в цій версії: Нові можливості в налаштуваннях лабораторії: Текстовий редактор, нове керування пристроями, голосові трансляції. Досі в активній розробці! Список усіх змін: https://github.com/vector-im/element-android/releases From 227fb27a2ed9ede2fd9fa85c72668cc05f3bcd75 Mon Sep 17 00:00:00 2001 From: random Date: Fri, 11 Nov 2022 14:09:50 +0000 Subject: [PATCH 028/173] Translated using Weblate (Italian) Currently translated at 100.0% (81 of 81 strings) Translation: Element Android/Element Android Store Translate-URL: https://translate.element.io/projects/element-android/element-store/it/ --- fastlane/metadata/android/it-IT/changelogs/40105060.txt | 2 ++ fastlane/metadata/android/it-IT/changelogs/40105070.txt | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 fastlane/metadata/android/it-IT/changelogs/40105060.txt create mode 100644 fastlane/metadata/android/it-IT/changelogs/40105070.txt diff --git a/fastlane/metadata/android/it-IT/changelogs/40105060.txt b/fastlane/metadata/android/it-IT/changelogs/40105060.txt new file mode 100644 index 0000000000..34d299b774 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/40105060.txt @@ -0,0 +1,2 @@ +Modifiche principali in questa versione: nuova interfaccia utente per selezionare un allegato! +Cronologia completa: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/it-IT/changelogs/40105070.txt b/fastlane/metadata/android/it-IT/changelogs/40105070.txt new file mode 100644 index 0000000000..ec4d944d72 --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/40105070.txt @@ -0,0 +1,2 @@ +Modifiche principali in questa versione: nuova interfaccia utente per selezionare un allegato. +Cronologia completa: https://github.com/vector-im/element-android/releases From fcfef53043856b2c4f2ddbcc6265032aad530605 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Mon, 14 Nov 2022 10:12:25 +0100 Subject: [PATCH 029/173] Search for the first occurrence (and not last) of breaking line just in case --- .../detail/timeline/render/ProcessBodyOfReplyToEventUseCase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCase.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCase.kt index b22114a502..2197d89a2c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCase.kt @@ -44,7 +44,7 @@ class ProcessBodyOfReplyToEventUseCase @Inject constructor( fun execute(roomId: String, matrixFormattedBody: String, replyToContent: ReplyToContent): String { val repliedToEvent = replyToContent.eventId?.let { getEvent(it, roomId) } - val breakingLineIndex = matrixFormattedBody.lastIndexOf(BREAKING_LINE) + val breakingLineIndex = matrixFormattedBody.indexOf(BREAKING_LINE) val endOfBlockQuoteIndex = matrixFormattedBody.lastIndexOf(ENDING_BLOCK_QUOTE) val withTranslatedContent = if (repliedToEvent != null && breakingLineIndex != -1 && endOfBlockQuoteIndex != -1) { From 4a65e1153abcd25a8927340cc3f20c396c9d2ef1 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Mon, 14 Nov 2022 10:18:42 +0100 Subject: [PATCH 030/173] Fix retrieve of the question for poll events --- .../org/matrix/android/sdk/api/session/events/model/Event.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt index 1c09b49298..720e6f3deb 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt @@ -447,7 +447,7 @@ fun Event.isInvitation(): Boolean = type == EventType.STATE_ROOM_MEMBER && content?.toModel()?.membership == Membership.INVITE fun Event.getPollContent(): MessagePollContent? { - return getDecryptedContent().toModel() + return getClearContent().toModel() } fun Event.supportsNotification() = From f49a8af9dae72b1f79e817aa1953722db2527993 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Nov 2022 23:05:09 +0000 Subject: [PATCH 031/173] Bump dagger from 2.44 to 2.44.2 Bumps `dagger` from 2.44 to 2.44.2. Updates `hilt-android-gradle-plugin` from 2.44 to 2.44.2 - [Release notes](https://github.com/google/dagger/releases) - [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md) - [Commits](https://github.com/google/dagger/compare/dagger-2.44...dagger-2.44.2) Updates `dagger` from 2.44 to 2.44.2 - [Release notes](https://github.com/google/dagger/releases) - [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md) - [Commits](https://github.com/google/dagger/compare/dagger-2.44...dagger-2.44.2) Updates `dagger-compiler` from 2.44 to 2.44.2 - [Release notes](https://github.com/google/dagger/releases) - [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md) - [Commits](https://github.com/google/dagger/compare/dagger-2.44...dagger-2.44.2) Updates `hilt-android` from 2.44 to 2.44.2 - [Release notes](https://github.com/google/dagger/releases) - [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md) - [Commits](https://github.com/google/dagger/compare/dagger-2.44...dagger-2.44.2) Updates `hilt-android-testing` from 2.44 to 2.44.2 - [Release notes](https://github.com/google/dagger/releases) - [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md) - [Commits](https://github.com/google/dagger/compare/dagger-2.44...dagger-2.44.2) Updates `hilt-compiler` from 2.44 to 2.44.2 - [Release notes](https://github.com/google/dagger/releases) - [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md) - [Commits](https://github.com/google/dagger/compare/dagger-2.44...dagger-2.44.2) --- updated-dependencies: - dependency-name: com.google.dagger:hilt-android-gradle-plugin dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.dagger:dagger dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.dagger:dagger-compiler dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.dagger:hilt-android dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.dagger:hilt-android-testing dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.google.dagger:hilt-compiler dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index dc66de43ea..8fc38cbbab 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -10,7 +10,7 @@ def gradle = "7.3.1" // Ref: https://kotlinlang.org/releases.html def kotlin = "1.7.21" def kotlinCoroutines = "1.6.4" -def dagger = "2.44" +def dagger = "2.44.2" def appDistribution = "16.0.0-beta05" def retrofit = "2.9.0" def markwon = "4.6.2" From 7aa0e3350629f5277141c2caeae4a26c35e62c23 Mon Sep 17 00:00:00 2001 From: "Auri B. P" Date: Sun, 13 Nov 2022 19:59:47 +0000 Subject: [PATCH 032/173] Translated using Weblate (Catalan) Currently translated at 99.6% (2534 of 2542 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/ca/ --- library/ui-strings/src/main/res/values-ca/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/library/ui-strings/src/main/res/values-ca/strings.xml b/library/ui-strings/src/main/res/values-ca/strings.xml index f9d7145b66..7e3e019ee5 100644 --- a/library/ui-strings/src/main/res/values-ca/strings.xml +++ b/library/ui-strings/src/main/res/values-ca/strings.xml @@ -2837,4 +2837,6 @@ Adhesius Galeria Format de text + Enrere 30 segons + Avança 30 segons \ No newline at end of file From 06d621cd3cfb7957c92f40e476cb9936f4ec5036 Mon Sep 17 00:00:00 2001 From: Platon Terekhov Date: Mon, 14 Nov 2022 10:41:31 +0000 Subject: [PATCH 033/173] Translated using Weblate (Russian) Currently translated at 100.0% (2542 of 2542 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/ru/ --- .../src/main/res/values-ru/strings.xml | 137 ++++++++++++++++-- 1 file changed, 127 insertions(+), 10 deletions(-) diff --git a/library/ui-strings/src/main/res/values-ru/strings.xml b/library/ui-strings/src/main/res/values-ru/strings.xml index cdac891840..53609a4944 100644 --- a/library/ui-strings/src/main/res/values-ru/strings.xml +++ b/library/ui-strings/src/main/res/values-ru/strings.xml @@ -930,7 +930,7 @@ Безопасность Правила push-уведомлений ID приложения: - push_key: + Ключ Push: Отображаемое название приложения: Отображаемое название сессии: Url: @@ -1000,10 +1000,10 @@ Не удалось подключиться к серверу обнаружения Пожалуйста, введите URL сервера обнаружения Сервер обнаружения не имеет условий использования - Параметры обнаружения появятся после добавления электронной почты. + Параметры обнаружения появятся после добавления адреса электронной почты. Параметры поиска появятся после добавления номера телефона. Отключение от сервера обнаружения будет означать, что другие пользователи не смогут обнаружить вас, и вы не сможете приглашать других по электронной почте или по телефону. - Мы отправили вам электронное письмо с подтверждением на %s, проверьте вашу электронную почту и нажмите на ссылку для подтверждения + Мы отправили вам электронное письмо на %s, проверьте вашу электронную почту и нажмите на ссылку для подтверждения Выбранный сервер обнаружения не имеет условий использования. Продолжайте, только если вы доверяете его владельцу Текстовое сообщение отправлено %s. Введите код проверки, который он содержит. В настоящее время вы делитесь адресами электронной почты или телефонными номерами на сервере обнаружения %1$s. Вам нужно повторно подключиться к %2$s, чтобы прекратить делиться ими. @@ -1102,7 +1102,7 @@ Предупреждение! Смена пароля приведёт к сбросу всех сквозных ключей шифрования во всех ваших сессиях, что сделает зашифрованную историю разговоров нечитаемой. Настройте резервное копирование ключей или экспортируйте ключи от комнаты из другой сессии, прежде чем сбрасывать пароль. Продолжить - Данный электронный ящик не связан ни с одним аккаунтом + Данный адрес электронной почты не связан ни с одним аккаунтом Проверьте свою почту Письмо с подтверждением было отправлено на %1$s. Нажмите на ссылку, чтобы подтвердить свой новый пароль. Как только вы перейдете по ссылке нажмите ниже. @@ -1241,7 +1241,7 @@ %1$s: %2$s %3$s Удалённые сообщения Показывать заглушку на месте удалённых сообщений - Мы отправили письмо для подтверждения на %s, проверьте почту и нажмите на ссылку для подтверждения + Мы отправили письмо на %s, пожалуйста проверьте почту и нажмите на ссылку для подтверждения Код подтверждения неверный. Попробуйте снова после принятия условий обслуживания на вашем домашнем сервере. Похоже, сервер долгое время не отвечает, что может быть вызвано плохим соединением или ошибкой на сервере. Попробуйте снова через некоторое время. @@ -1351,7 +1351,7 @@ Введите адрес сервера, который вы хотите использовать На ваш почтовый ящик будет отправлено письмо для подтверждения установки нового пароля. Я подтвердил свою электронную почту - Установите адрес электронной почты для восстановления вашей учетной записи. Позже вы можете дополнительно разрешить людям, которых вы знаете, обнаружить вас по электронной почте. + Укажите адрес электронной почты для восстановления вашей учетной записи. Потом вы сможете, при желании, разрешить людям, которых вы знаете, обнаружить вас по адресу электронной почты. Введенный код неверен. Пожалуйста, проверьте. Войти с Matrix ID Войти с Matrix ID @@ -1482,7 +1482,7 @@ Незаверенная Эта сессия является доверенной для безопасного обмена сообщениями, так как %1$s (%2$s) проверил(а) его: %1$s (%2$s) вошел(ла), используя новую сессию: - Пока этот пользователь не доверяет этой сессии, сообщения, отправленные в обе стороны, помечаются предупреждениями. Кроме того, вы можете подтвердить сессию вручную. + Пока этот пользователь не доверяет этой сессии, сообщения, отправленные в обе стороны, помечаются предупреждениями. Вы также можете подтвердить эту сессию вручную. Начать перекрестную подпись Сбросить ключи Почти готово! Показывает ли %s галочку\? @@ -1606,7 +1606,7 @@ Эта операция невозможна. Домашний сервер устарел. Пожалуйста, настройте сначала сервер идентификации. Пожалуйста, примите сначала условия сервера идентификации в настройках. - Для вашей приватности, ${app_name} поддерживает отправку адреса электронной почты и номера телефона только в хэшированном виде. + Для вашей приватности, ${app_name} поддерживает отправку адреса электронной почты и номеров телефонов только в хэшированном виде. Привязка не удалась. Текущая взаимосвязь с этим идентификатором отсутствует. Ваш домашний сервер (%1$s) предлагает использовать %2$s для вашего сервера обнаружения @@ -1793,7 +1793,7 @@ Добавить изображение из Тема Название комнаты - Вы дали свое согласие на отправку электронных писем и телефонных номеров на этот сервер обнаружения для обнаружения других пользователей из ваших контактов. + Вы дали свое согласие на отправку адресов электронных почт и телефонных номеров на этот сервер идентификации для обнаружения других пользователей из ваших контактов. Добавить по QR-коду Разрешить доступ к вашим контактам. Чтобы отсканировать QR-код, вам нужно разрешить доступ к камере. @@ -2396,7 +2396,7 @@ Местоположение Вы согласны отправить эту информацию\? Чтобы обнаружить существующие контакты, необходимо отправить контактную информацию (электронную почту и номера телефонов) на сервер обнаружения. Мы хешируем ваши данные перед отправкой для обеспечения конфиденциальности. - Отправить электронные адреса и номера телефонов %s + Отправить адреса электронных почт и номера телефонов %s Ваши контакты приватны. Чтобы обнаружить пользователей из ваших контактов, нам необходимо ваше разрешение на отправку контактной информации на ваш сервер обнаружения. Системные настройки Версии @@ -2831,4 +2831,121 @@ Выбрано %1$d Выбрано %1$d + Войти в полноэкранный режим + Применить форматирование подчёркиванием + Применить форматирование перечёркиванием + Применить форматирование курсивом + Применить форматирование жирным + Пожалуйста удостоверьтесь в том, что вы знаете откуда этот код. При соединении устройств, вы даёте кому-то полный доступ к вашей учётной записи. + Подтвердить + Попробовать снова + Не сходится\? + Вход + Соединение с устройством + Сканировать QR-код + Входите с мобильного устройства\? + Показать QR-код на этом устройстве + Выберите «Сканировать QR-код» + Начните с экрана входа + Выберите «Войти при помощи QR-кода» + Начните с экрана входа + Выберите «Показать QR-код» + Зайдите в Настройки -> Безопасность и Приватность + Откройте приложение с другого устройства + Домашний сервер не поддерживает вход при помощи QR-кода. + Вход был отменён с другого устройства. + Этот QR-код не работает. + Другое устройство должно войти в учётную запись. + Другое устройство уже выполнило вход. + Во время установки безопасной переписки возникла проблема с безопасностью. Одно из следующего является скомпроментированным: Ваш домашний сервер; Ваше интернет-соединение; Ваше устройство; + Запрос не выполнен. + Запрос был отклонён на другом устройстве. + Соединение не было выполнено за нужное время. + Соединение с этим устройством не поддерживается. + Неудачное соединение + Проверьте устройство, с которого вы вошли в учётную запись. На его экране должен появиться код снизу. Подтвердите, что код снизу такой же, как и на том устройстве: + Безопасное соединение установлено + Сканируйте QR-код снизу при помощи устройства, с которого вы вышли с учётной записи. + Используйте устройство, с которого вы вошли в учётную запись, чтобы сканировать QR-код снизу: + Войти при помощи QR-кода + Используйте камеру на этом устройстве, чтобы сканировать QR-код, отображённый на вашем другом устройстве: + Сканировать QR-код + 3 + 2 + 1 + Нажмите слева сверху, чтобы увидеть опцию отзыва. + Чтобы упростить ${app_name}, вкладки теперь опциональные. Управляйте ими при помощи меню справа сверху. + Универсальное безопасное приложение для переписок с командами, друзьями и организациями. Создайте переписку или присоеденитесь к уже существующей, чтобы начать. + Пространства — новый способ групировать комнаты и людей. Добавьте существующую комнату или создайте новую, используя кнопку слева снизу. + Возможность записывать и отправлять голосовые трансляции в ленту комнаты. + Получите лучший надзор и контроль над всеми вашими сессиями. + Подтверждённые сессии есть везде, где вы используете эту учётную запись, после введения вашего пароля или подтверждения вашей личности при помощи другой подтверждённой сессии. +\n +\nЭто значит, что у вас есть все нужные ключи, чтобы разблокировать зашифрованные сообщения и даёте другим пользователям знать, что вы доверяете этой сессии. + Подтверждённые сессии вошли при помощи ваших учётных данных и были подтверждены, либо при помощи вашего безопасного пароля, либо при помощи подтверждения с другого устройства. +\n +\nЭто значит, что на них находятся ключи шифрования для ваших предыдущих сообщений и дают другим пользователям знать, что эти сессии действительно принадлежат вам. + Неподтверждённые сессии — это сессии, которые вошли при помощи ваших учётных данных, но не были подтверждены. +\n +\nВы должны удостовериться, что узнаёте эти сессии, так как они могут быть несанкционированным входом в вашу учётную запись. + Вы можете использовать это устройство для входа с телефона или веб-устройства при помощи QR-кода. Для этого есть два способа: + Войти при помощи QR-кода + Собственные названия сессий помогут вам легче распознать свои девайсы. + + Выйти из %1$d сессии + Выйти из %1$d сессий + Выйти из %1$d сессий + Выйти из %1$d сессий + + Выйти + Выбрать сессии + Фильтр + + Неактивен %1$d+ день (%2$s) + Неактивен %1$d+ дней (%2$s) + Неактивен %1$d+ дня (%2$s) + Неактивен %1$d+ дня (%2$s) + + Подтвердите текущую сессию, чтобы посмотреть её состояние подтверждения. + Неизвестное состояние проверки + Автоматически принимать виджеты Element Call и давать доступ к микрофону/камере + Включить ярлыки разрешений Element Call + Форматирование текста + Начать новую голосовую трансляцию + Вам необходимо иметь нужные разрешения, чтобы делиться местоположением в реальном времени в этой комнате. + У вас нет разрешения делиться местоположением в реальном времени + При приглашении кого-то в зашифрованную комнату, которая делится историей, зашифрованная история будет видимой. + Вы уже записываете голосовую трансляцию. Пожалуйста закончите текущую голосовую трансляцию, чтобы начать новую. + Кто-то другой уже записывает голосовую трансляцию. Подождите пока их голосовая трансляция закончится, чтобы начать новую. + У вас нет необходимых разрешений для начала голосовой трансляции в этой комнате. Свяжитесь с администратором комнаты, чтобы получить разрешения. + Не получилось начать новую голосовую трансляцию + Перемотать вперёд на 30 секунд + Перемотать назад на 30 секунд + Буферизация + Приостановить голосовую трансляцию + Проиграть или продолжить голосовую трансляцию + Остановить запись голосовой трансляции + Приостановить запись голосовой трансляции + Продолжить запись голосовой трансляции + Прямая трансляция + Подлинность этого зашифрованного сообщения не может быть гарантирована на этом устройстве. + Сканировать QR-код + Отправьте ваше первое сообщение, чтобы пригласить %s в переписку + Этот QR-код выглядит неправильно. Пожалуйста, попробуйте подтвердить другим способом. + Вы не сможете получить доступ к истории зашифрованных сообщений. Сбросьте вашу защищённую резевную копию и ключи подтверждения, чтобы начать заново. + Сброс пароля + Выберите новый пароль + %s пришлёт вам ссылку для подтверждения + %s нуждается в подтверждении вашей учётной записи + %s нуждается в подтверждении вашей учётной записи + Связаться + Element Matrix Services (EMS) — надёжная хостинговая служба для быстрой и безопасной связи в режиме реального времени. Узнайте больше на <a href=\"${ftue_ems_url}\">element.io/ems</a> + Открыть список пространств + Включено: + Что-то пошло не так. Пожалуйста, проверьте соединение и попробуйте ещё раз. + Открыть экран инструментов для разработчика + Простите, эта комната не была найдена. +\nПожалуйста, попробуйте снова позже.%s + ⚠ В этой комнате есть неподтверждённые устройства, они не смогут расшифровывать сообщения, отправленные вами. + Дать разрешение \ No newline at end of file From a23299de6445ae32536ac45cd664df10f67367d7 Mon Sep 17 00:00:00 2001 From: Nui Harime Date: Sun, 13 Nov 2022 19:54:46 +0000 Subject: [PATCH 034/173] Translated using Weblate (Russian) Currently translated at 100.0% (2542 of 2542 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/ru/ --- library/ui-strings/src/main/res/values-ru/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/library/ui-strings/src/main/res/values-ru/strings.xml b/library/ui-strings/src/main/res/values-ru/strings.xml index 53609a4944..fe71c0b596 100644 --- a/library/ui-strings/src/main/res/values-ru/strings.xml +++ b/library/ui-strings/src/main/res/values-ru/strings.xml @@ -2948,4 +2948,5 @@ \nПожалуйста, попробуйте снова позже.%s ⚠ В этой комнате есть неподтверждённые устройства, они не смогут расшифровывать сообщения, отправленные вами. Дать разрешение + Другие пользователи могут найти вас по %s \ No newline at end of file From 37dcb86e907d8cbe1ab982a18b23230aec66b58d Mon Sep 17 00:00:00 2001 From: LinAGKar Date: Mon, 14 Nov 2022 18:53:28 +0000 Subject: [PATCH 035/173] Translated using Weblate (Swedish) Currently translated at 100.0% (2542 of 2542 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/sv/ --- .../src/main/res/values-sv/strings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/library/ui-strings/src/main/res/values-sv/strings.xml b/library/ui-strings/src/main/res/values-sv/strings.xml index eaa327e977..65318096c7 100644 --- a/library/ui-strings/src/main/res/values-sv/strings.xml +++ b/library/ui-strings/src/main/res/values-sv/strings.xml @@ -2836,4 +2836,20 @@ %1$d vald %1$d valda + Växla fullskärmsläge + Verifierade sessioner är alla ställen där du använder det här kontot efter att ha angett din lösenfras eller bekräftat din identitet med en annan verifierad session. +\n +\nDetta betyder att du har alla nycklar som krävs för att låsa upp dina krypterade meddelanden att bekräfta för andra användare att du litar på den här sessionen. + + Logga ut ur %1$d session + Logga ut ur %1$d sessioner + + Logga ut + Textformatering + Du spelar redan in en röstsändning. Avsluta din nuvarande röstsändning för att starta en ny. + Någon annan spelar redan in en röstsändning. Vänta på att deras röstsändning avslutas för att starta en ny. + Du är inte behörig att starta en ny röstsändning i det här rummet. Kontakta en rumsadministratör för att uppgradera dina behörigheter. + Kan inte starta en ny röstsändning + Spola framåt 30 sekunder + Spola tillbaka 30 sekunder \ No newline at end of file From ade6028a696ad1ee0b8eeacf4aae562b94b0dc86 Mon Sep 17 00:00:00 2001 From: Platon Terekhov Date: Sun, 13 Nov 2022 19:42:55 +0000 Subject: [PATCH 036/173] Translated using Weblate (Russian) Currently translated at 100.0% (81 of 81 strings) Translation: Element Android/Element Android Store Translate-URL: https://translate.element.io/projects/element-android/element-store/ru/ --- fastlane/metadata/android/ru-RU/changelogs/40104260.txt | 2 ++ fastlane/metadata/android/ru-RU/changelogs/40104270.txt | 2 ++ fastlane/metadata/android/ru-RU/changelogs/40104280.txt | 2 ++ fastlane/metadata/android/ru-RU/changelogs/40104300.txt | 2 ++ fastlane/metadata/android/ru-RU/changelogs/40104310.txt | 2 ++ fastlane/metadata/android/ru-RU/changelogs/40104320.txt | 2 ++ fastlane/metadata/android/ru-RU/changelogs/40105000.txt | 2 ++ fastlane/metadata/android/ru-RU/changelogs/40105060.txt | 2 ++ fastlane/metadata/android/ru-RU/changelogs/40105070.txt | 2 ++ 9 files changed, 18 insertions(+) create mode 100644 fastlane/metadata/android/ru-RU/changelogs/40104260.txt create mode 100644 fastlane/metadata/android/ru-RU/changelogs/40104270.txt create mode 100644 fastlane/metadata/android/ru-RU/changelogs/40104280.txt create mode 100644 fastlane/metadata/android/ru-RU/changelogs/40104300.txt create mode 100644 fastlane/metadata/android/ru-RU/changelogs/40104310.txt create mode 100644 fastlane/metadata/android/ru-RU/changelogs/40104320.txt create mode 100644 fastlane/metadata/android/ru-RU/changelogs/40105000.txt create mode 100644 fastlane/metadata/android/ru-RU/changelogs/40105060.txt create mode 100644 fastlane/metadata/android/ru-RU/changelogs/40105070.txt diff --git a/fastlane/metadata/android/ru-RU/changelogs/40104260.txt b/fastlane/metadata/android/ru-RU/changelogs/40104260.txt new file mode 100644 index 0000000000..b023e07b3d --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40104260.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Использование UnifiedPush и разрешение пользователям получать push-оповещения без FCM. +Полный список изменений: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/ru-RU/changelogs/40104270.txt b/fastlane/metadata/android/ru-RU/changelogs/40104270.txt new file mode 100644 index 0000000000..ff4e5cdf15 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40104270.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Исправления различных багов и улучшения стабильности работы. +Полный список изменений: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/ru-RU/changelogs/40104280.txt b/fastlane/metadata/android/ru-RU/changelogs/40104280.txt new file mode 100644 index 0000000000..ff4e5cdf15 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40104280.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Исправления различных багов и улучшения стабильности работы. +Полный список изменений: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/ru-RU/changelogs/40104300.txt b/fastlane/metadata/android/ru-RU/changelogs/40104300.txt new file mode 100644 index 0000000000..aec45e0348 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40104300.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Улучшены вход и регистрация +Полный список изменений: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/ru-RU/changelogs/40104310.txt b/fastlane/metadata/android/ru-RU/changelogs/40104310.txt new file mode 100644 index 0000000000..aec45e0348 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40104310.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Улучшены вход и регистрация +Полный список изменений: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/ru-RU/changelogs/40104320.txt b/fastlane/metadata/android/ru-RU/changelogs/40104320.txt new file mode 100644 index 0000000000..d6c614f22b --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40104320.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: Исправления различных багов и улучшения стабильности работы +Полный список изменений: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/ru-RU/changelogs/40105000.txt b/fastlane/metadata/android/ru-RU/changelogs/40105000.txt new file mode 100644 index 0000000000..93ea0aff68 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40105000.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: отложённые личные сообщения включены по умолчанию +Полный список изменений: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/ru-RU/changelogs/40105060.txt b/fastlane/metadata/android/ru-RU/changelogs/40105060.txt new file mode 100644 index 0000000000..234d265dd8 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40105060.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: новый интерфейс для выбора прикреплённых файлов +Полный список изменений: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/ru-RU/changelogs/40105070.txt b/fastlane/metadata/android/ru-RU/changelogs/40105070.txt new file mode 100644 index 0000000000..234d265dd8 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/40105070.txt @@ -0,0 +1,2 @@ +Основные изменения в этой версии: новый интерфейс для выбора прикреплённых файлов +Полный список изменений: https://github.com/vector-im/element-android/releases From 21b92f7d154382c21880952c14a4dfcbf4440438 Mon Sep 17 00:00:00 2001 From: LinAGKar Date: Mon, 14 Nov 2022 18:39:55 +0000 Subject: [PATCH 037/173] Translated using Weblate (Swedish) Currently translated at 100.0% (81 of 81 strings) Translation: Element Android/Element Android Store Translate-URL: https://translate.element.io/projects/element-android/element-store/sv/ --- fastlane/metadata/android/sv-SE/changelogs/40105060.txt | 2 ++ fastlane/metadata/android/sv-SE/changelogs/40105070.txt | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 fastlane/metadata/android/sv-SE/changelogs/40105060.txt create mode 100644 fastlane/metadata/android/sv-SE/changelogs/40105070.txt diff --git a/fastlane/metadata/android/sv-SE/changelogs/40105060.txt b/fastlane/metadata/android/sv-SE/changelogs/40105060.txt new file mode 100644 index 0000000000..d64984fcfb --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/40105060.txt @@ -0,0 +1,2 @@ +Huvudsakliga ändringar i den här versionen: nytt gränssnitt för val av bilaga. +Full ändringslogg: https://github.com/vector-im/element-android/releases diff --git a/fastlane/metadata/android/sv-SE/changelogs/40105070.txt b/fastlane/metadata/android/sv-SE/changelogs/40105070.txt new file mode 100644 index 0000000000..d64984fcfb --- /dev/null +++ b/fastlane/metadata/android/sv-SE/changelogs/40105070.txt @@ -0,0 +1,2 @@ +Huvudsakliga ändringar i den här versionen: nytt gränssnitt för val av bilaga. +Full ändringslogg: https://github.com/vector-im/element-android/releases From f63c6c328fc1a13adbb15da68367373a0cc82752 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Thu, 17 Nov 2022 13:19:40 +0300 Subject: [PATCH 038/173] Fix italic text is truncated when bubble mode and markdown is enabled. --- .../im/vector/app/features/html/EventHtmlRenderer.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt index 9e869ecde1..a5bf6cc184 100644 --- a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt @@ -27,6 +27,7 @@ package im.vector.app.features.html import android.content.Context import android.content.res.Resources +import android.graphics.Typeface import android.graphics.drawable.Drawable import android.text.Spannable import androidx.core.text.toSpannable @@ -40,6 +41,7 @@ import im.vector.app.features.settings.VectorPreferences import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.Markwon import io.noties.markwon.MarkwonPlugin +import io.noties.markwon.MarkwonSpansFactory import io.noties.markwon.PrecomputedFutureTextSetterCompat import io.noties.markwon.ext.latex.JLatexMathPlugin import io.noties.markwon.ext.latex.JLatexMathTheme @@ -50,6 +52,8 @@ import io.noties.markwon.inlineparser.EntityInlineProcessor import io.noties.markwon.inlineparser.HtmlInlineProcessor import io.noties.markwon.inlineparser.MarkwonInlineParser import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin +import me.gujun.android.span.style.CustomTypefaceSpan +import org.commonmark.node.Emphasis import org.commonmark.node.Node import org.commonmark.parser.Parser import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl @@ -123,6 +127,11 @@ class EventHtmlRenderer @Inject constructor( ) ) .usePlugin(object : AbstractMarkwonPlugin() { + override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { + builder.setFactory( + Emphasis::class.java + ) { _, _ -> CustomTypefaceSpan(Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)) } + } override fun configureParser(builder: Parser.Builder) { /* Configuring the Markwon block formatting processor. * Default settings are all Markdown blocks. Turn those off. From c788deacf5a3de628f974cf1556479938cc023ea Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Thu, 17 Nov 2022 13:26:09 +0300 Subject: [PATCH 039/173] Revert "Fix italic text is truncated when bubble mode and markdown is enabled." This reverts commit f63c6c328fc1a13adbb15da68367373a0cc82752. --- .../im/vector/app/features/html/EventHtmlRenderer.kt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt index a5bf6cc184..9e869ecde1 100644 --- a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt @@ -27,7 +27,6 @@ package im.vector.app.features.html import android.content.Context import android.content.res.Resources -import android.graphics.Typeface import android.graphics.drawable.Drawable import android.text.Spannable import androidx.core.text.toSpannable @@ -41,7 +40,6 @@ import im.vector.app.features.settings.VectorPreferences import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.Markwon import io.noties.markwon.MarkwonPlugin -import io.noties.markwon.MarkwonSpansFactory import io.noties.markwon.PrecomputedFutureTextSetterCompat import io.noties.markwon.ext.latex.JLatexMathPlugin import io.noties.markwon.ext.latex.JLatexMathTheme @@ -52,8 +50,6 @@ import io.noties.markwon.inlineparser.EntityInlineProcessor import io.noties.markwon.inlineparser.HtmlInlineProcessor import io.noties.markwon.inlineparser.MarkwonInlineParser import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin -import me.gujun.android.span.style.CustomTypefaceSpan -import org.commonmark.node.Emphasis import org.commonmark.node.Node import org.commonmark.parser.Parser import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl @@ -127,11 +123,6 @@ class EventHtmlRenderer @Inject constructor( ) ) .usePlugin(object : AbstractMarkwonPlugin() { - override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { - builder.setFactory( - Emphasis::class.java - ) { _, _ -> CustomTypefaceSpan(Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)) } - } override fun configureParser(builder: Parser.Builder) { /* Configuring the Markwon block formatting processor. * Default settings are all Markdown blocks. Turn those off. From a31a9ab5219cfab3df6ce9b735253e980cb81631 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Thu, 17 Nov 2022 13:32:53 +0300 Subject: [PATCH 040/173] Fix italic text is truncated when bubble mode and markdown is enabled. --- .../im/vector/app/features/html/EventHtmlRenderer.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt index 9e869ecde1..9a2bdc791e 100644 --- a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt @@ -27,6 +27,7 @@ package im.vector.app.features.html import android.content.Context import android.content.res.Resources +import android.graphics.Typeface import android.graphics.drawable.Drawable import android.text.Spannable import androidx.core.text.toSpannable @@ -40,6 +41,7 @@ import im.vector.app.features.settings.VectorPreferences import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.Markwon import io.noties.markwon.MarkwonPlugin +import io.noties.markwon.MarkwonSpansFactory import io.noties.markwon.PrecomputedFutureTextSetterCompat import io.noties.markwon.ext.latex.JLatexMathPlugin import io.noties.markwon.ext.latex.JLatexMathTheme @@ -50,6 +52,8 @@ import io.noties.markwon.inlineparser.EntityInlineProcessor import io.noties.markwon.inlineparser.HtmlInlineProcessor import io.noties.markwon.inlineparser.MarkwonInlineParser import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin +import me.gujun.android.span.style.CustomTypefaceSpan +import org.commonmark.node.Emphasis import org.commonmark.node.Node import org.commonmark.parser.Parser import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl @@ -94,6 +98,12 @@ class EventHtmlRenderer @Inject constructor( // It needs to be in this specific format: https://noties.io/Markwon/docs/v4/ext-latex builder .usePlugin(object : AbstractMarkwonPlugin() { + override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { + builder.setFactory( + Emphasis::class.java + ) { _, _ -> CustomTypefaceSpan(Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)) } + } + override fun processMarkdown(markdown: String): String { return markdown .replace(Regex(""".*?""")) { matchResult -> From 65d898e3de69b8bbc4c392a42321c0cb19f65449 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 17 Nov 2022 12:57:08 +0100 Subject: [PATCH 041/173] version++ --- matrix-sdk-android/build.gradle | 2 +- vector-app/build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index f50b672077..60b0329fbc 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -62,7 +62,7 @@ android { // that the app's state is completely cleared between tests. testInstrumentationRunnerArguments clearPackageData: 'true' - buildConfigField "String", "SDK_VERSION", "\"1.5.8\"" + buildConfigField "String", "SDK_VERSION", "\"1.5.10\"" buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\"" buildConfigField "String", "GIT_SDK_REVISION_UNIX_DATE", "\"${gitRevisionUnixDate()}\"" diff --git a/vector-app/build.gradle b/vector-app/build.gradle index bff0193509..a9b16f8c6c 100644 --- a/vector-app/build.gradle +++ b/vector-app/build.gradle @@ -37,7 +37,7 @@ ext.versionMinor = 5 // Note: even values are reserved for regular release, odd values for hotfix release. // When creating a hotfix, you should decrease the value, since the current value // is the value for the next regular release. -ext.versionPatch = 8 +ext.versionPatch = 10 static def getGitTimestamp() { def cmd = 'git show -s --format=%ct' From ab94b21807b01c9a30d9214fb1191b0a3db8743e Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Thu, 17 Nov 2022 15:58:42 +0300 Subject: [PATCH 042/173] Fix the place of the span factory. --- changelog.d/5679.bugfix | 1 + .../im/vector/app/features/html/EventHtmlRenderer.kt | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 changelog.d/5679.bugfix diff --git a/changelog.d/5679.bugfix b/changelog.d/5679.bugfix new file mode 100644 index 0000000000..0394bc3e5d --- /dev/null +++ b/changelog.d/5679.bugfix @@ -0,0 +1 @@ +Fix italic text is truncated when bubble mode and markdown is enabled diff --git a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt index 9a2bdc791e..21fcbffb03 100644 --- a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt @@ -98,12 +98,6 @@ class EventHtmlRenderer @Inject constructor( // It needs to be in this specific format: https://noties.io/Markwon/docs/v4/ext-latex builder .usePlugin(object : AbstractMarkwonPlugin() { - override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { - builder.setFactory( - Emphasis::class.java - ) { _, _ -> CustomTypefaceSpan(Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)) } - } - override fun processMarkdown(markdown: String): String { return markdown .replace(Regex(""".*?""")) { matchResult -> @@ -133,6 +127,12 @@ class EventHtmlRenderer @Inject constructor( ) ) .usePlugin(object : AbstractMarkwonPlugin() { + override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { + builder.setFactory( + Emphasis::class.java + ) { _, _ -> CustomTypefaceSpan(Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)) } + } + override fun configureParser(builder: Parser.Builder) { /* Configuring the Markwon block formatting processor. * Default settings are all Markdown blocks. Turn those off. From 9901a43dc10ceb25663b69472fae81390a1360fb Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Thu, 17 Nov 2022 17:06:44 +0100 Subject: [PATCH 043/173] Add changelog entry --- changelog.d/7604.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7604.bugfix diff --git a/changelog.d/7604.bugfix b/changelog.d/7604.bugfix new file mode 100644 index 0000000000..0fbee55bce --- /dev/null +++ b/changelog.d/7604.bugfix @@ -0,0 +1 @@ +ANR on session start when sending client info is enabled From 74c945b7f0f338340a06f160476b0c779dc61821 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Thu, 17 Nov 2022 17:11:16 +0100 Subject: [PATCH 044/173] Launching the sending of the client info in a dedicated coroutine to avoid ANR on application start --- .../java/im/vector/app/core/di/ActiveSessionHolder.kt | 4 +--- .../core/session/ConfigureAndStartSessionUseCase.kt | 10 +++++++--- .../session/ConfigureAndStartSessionUseCaseTest.kt | 9 +++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/vector/src/main/java/im/vector/app/core/di/ActiveSessionHolder.kt b/vector/src/main/java/im/vector/app/core/di/ActiveSessionHolder.kt index 7e4f73e7a5..f1863cfa23 100644 --- a/vector/src/main/java/im/vector/app/core/di/ActiveSessionHolder.kt +++ b/vector/src/main/java/im/vector/app/core/di/ActiveSessionHolder.kt @@ -111,9 +111,7 @@ class ActiveSessionHolder @Inject constructor( } ?: sessionInitializer.tryInitialize(readCurrentSession = { activeSessionReference.get() }) { session -> setActiveSession(session) - runBlocking { - configureAndStartSessionUseCase.execute(session, startSyncing = startSync) - } + configureAndStartSessionUseCase.execute(session, startSyncing = startSync) } } diff --git a/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt b/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt index 71863b8642..c47769052c 100644 --- a/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt +++ b/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt @@ -22,7 +22,9 @@ import im.vector.app.core.extensions.startSyncing import im.vector.app.core.notification.EnableNotificationsSettingUpdater import im.vector.app.core.session.clientinfo.UpdateMatrixClientInfoUseCase import im.vector.app.features.call.webrtc.WebRtcCallManager +import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorPreferences +import kotlinx.coroutines.launch import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.sync.FilterService import timber.log.Timber @@ -36,7 +38,7 @@ class ConfigureAndStartSessionUseCase @Inject constructor( private val enableNotificationsSettingUpdater: EnableNotificationsSettingUpdater, ) { - suspend fun execute(session: Session, startSyncing: Boolean = true) { + fun execute(session: Session, startSyncing: Boolean = true) { Timber.i("Configure and start session for ${session.myUserId}. startSyncing: $startSyncing") session.open() session.filterService().setFilter(FilterService.FilterPreset.ElementFilter) @@ -45,8 +47,10 @@ class ConfigureAndStartSessionUseCase @Inject constructor( } session.pushersService().refreshPushers() webRtcCallManager.checkForProtocolsSupportIfNeeded() - if (vectorPreferences.isClientInfoRecordingEnabled()) { - updateMatrixClientInfoUseCase.execute(session) + session.coroutineScope.launch { + if (vectorPreferences.isClientInfoRecordingEnabled()) { + updateMatrixClientInfoUseCase.execute(session) + } } enableNotificationsSettingUpdater.onSessionsStarted(session) } diff --git a/vector/src/test/java/im/vector/app/core/session/ConfigureAndStartSessionUseCaseTest.kt b/vector/src/test/java/im/vector/app/core/session/ConfigureAndStartSessionUseCaseTest.kt index 861e59e0f1..760879b69d 100644 --- a/vector/src/test/java/im/vector/app/core/session/ConfigureAndStartSessionUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/core/session/ConfigureAndStartSessionUseCaseTest.kt @@ -18,6 +18,7 @@ package im.vector.app.core.session import im.vector.app.core.extensions.startSyncing import im.vector.app.core.session.clientinfo.UpdateMatrixClientInfoUseCase +import im.vector.app.features.session.coroutineScope import im.vector.app.test.fakes.FakeContext import im.vector.app.test.fakes.FakeEnableNotificationsSettingUpdater import im.vector.app.test.fakes.FakeSession @@ -32,6 +33,7 @@ import io.mockk.mockkStatic import io.mockk.runs import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before @@ -57,6 +59,7 @@ class ConfigureAndStartSessionUseCaseTest { @Before fun setup() { mockkStatic("im.vector.app.core.extensions.SessionKt") + mockkStatic("im.vector.app.features.session.SessionCoroutineScopesKt") } @After @@ -68,6 +71,7 @@ class ConfigureAndStartSessionUseCaseTest { fun `given start sync needed and client info recording enabled when execute then it should be configured properly`() = runTest { // Given val fakeSession = givenASession() + every { fakeSession.coroutineScope } returns this fakeWebRtcCallManager.givenCheckForProtocolsSupportIfNeededSucceeds() coJustRun { fakeUpdateMatrixClientInfoUseCase.execute(any()) } fakeVectorPreferences.givenIsClientInfoRecordingEnabled(isEnabled = true) @@ -75,6 +79,7 @@ class ConfigureAndStartSessionUseCaseTest { // When configureAndStartSessionUseCase.execute(fakeSession, startSyncing = true) + advanceUntilIdle() // Then verify { fakeSession.startSyncing(fakeContext.instance) } @@ -88,6 +93,7 @@ class ConfigureAndStartSessionUseCaseTest { fun `given start sync needed and client info recording disabled when execute then it should be configured properly`() = runTest { // Given val fakeSession = givenASession() + every { fakeSession.coroutineScope } returns this fakeWebRtcCallManager.givenCheckForProtocolsSupportIfNeededSucceeds() coJustRun { fakeUpdateMatrixClientInfoUseCase.execute(any()) } fakeVectorPreferences.givenIsClientInfoRecordingEnabled(isEnabled = false) @@ -95,6 +101,7 @@ class ConfigureAndStartSessionUseCaseTest { // When configureAndStartSessionUseCase.execute(fakeSession, startSyncing = true) + advanceUntilIdle() // Then verify { fakeSession.startSyncing(fakeContext.instance) } @@ -108,6 +115,7 @@ class ConfigureAndStartSessionUseCaseTest { fun `given a session and no start sync needed when execute then it should be configured properly`() = runTest { // Given val fakeSession = givenASession() + every { fakeSession.coroutineScope } returns this fakeWebRtcCallManager.givenCheckForProtocolsSupportIfNeededSucceeds() coJustRun { fakeUpdateMatrixClientInfoUseCase.execute(any()) } fakeVectorPreferences.givenIsClientInfoRecordingEnabled(isEnabled = true) @@ -115,6 +123,7 @@ class ConfigureAndStartSessionUseCaseTest { // When configureAndStartSessionUseCase.execute(fakeSession, startSyncing = false) + advanceUntilIdle() // Then verify(inverse = true) { fakeSession.startSyncing(fakeContext.instance) } From 8bf0ec297c915706419160e5450688d9388ae00e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Nov 2022 23:03:30 +0000 Subject: [PATCH 045/173] Bump firebase-appdistribution-gradle from 3.0.3 to 3.1.1 Bumps firebase-appdistribution-gradle from 3.0.3 to 3.1.1. --- updated-dependencies: - dependency-name: com.google.firebase:firebase-appdistribution-gradle dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 78cc9abb02..5648acfba0 100644 --- a/build.gradle +++ b/build.gradle @@ -24,7 +24,7 @@ buildscript { classpath libs.gradle.gradlePlugin classpath libs.gradle.kotlinPlugin classpath libs.gradle.hiltPlugin - classpath 'com.google.firebase:firebase-appdistribution-gradle:3.0.3' + classpath 'com.google.firebase:firebase-appdistribution-gradle:3.1.1' classpath 'com.google.gms:google-services:4.3.14' classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.5.0.2730' classpath 'com.google.android.gms:oss-licenses-plugin:0.10.5' From 7417241cd58d8738566304c91b11e918f6a472cf Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 18 Nov 2022 08:57:37 +0100 Subject: [PATCH 046/173] New RTE full screen implementation with BottomSheet (#7578) * RTE full screen editor using custom BottomSheet * Fix formatting menu item dimensions * Fix bug with insets when opening attachment menu * Clear the EditText for plain text mode when a message is sent * Set `MessageComposerMode.Special` as a sealed class * Fix insets issue on landscape * Fix small UI issues with rounded corners * Use simplified icons for full screen and minimise --- changelog.d/7577.feature | 1 + .../src/main/res/values/strings.xml | 3 + .../ui-styles/src/main/res/values/dimens.xml | 1 + .../src/main/res/values/styles_edit_text.xml | 11 +- .../utils/ExpandingBottomSheetBehavior.kt | 791 ++++++++++++++++++ .../JumpToBottomViewVisibilityManager.kt | 10 +- .../home/room/detail/RoomDetailActivity.kt | 7 + .../home/room/detail/TimelineFragment.kt | 46 +- .../detail/composer/MessageComposerAction.kt | 1 - .../composer/MessageComposerFragment.kt | 170 ++-- .../detail/composer/MessageComposerMode.kt | 28 + .../detail/composer/MessageComposerView.kt | 21 +- .../composer/PlainTextComposerLayout.kt | 198 +++-- .../detail/composer/RichTextComposerLayout.kt | 287 +++++-- .../composer/voice/VoiceMessageViews.kt | 6 +- .../bg_composer_rich_bottom_sheet.xml | 5 + .../bg_composer_rich_edit_text_expanded.xml | 13 - ...bg_composer_rich_edit_text_single_line.xml | 13 - .../main/res/drawable/bottomsheet_handle.xml | 6 + .../res/drawable/ic_composer_collapse.xml | 9 + .../res/drawable/ic_composer_full_screen.xml | 14 +- .../drawable/ic_composer_rich_mic_pressed.xml | 17 + .../ic_composer_rich_text_editor_close.xml | 9 + .../ic_composer_rich_text_editor_edit.xml | 12 + .../drawable/ic_composer_rich_text_save.xml | 16 + .../res/drawable/ic_rich_composer_add.xml | 15 + .../res/drawable/ic_rich_composer_send.xml | 12 + .../res/drawable/ic_voice_mic_recording.xml | 10 - .../src/main/res/layout/composer_layout.xml | 322 ++++--- ...composer_layout_constraint_set_compact.xml | 197 ----- ...omposer_layout_constraint_set_expanded.xml | 197 ----- .../res/layout/composer_rich_text_layout.xml | 346 ++++---- ...ich_text_layout_constraint_set_compact.xml | 233 ------ ...ch_text_layout_constraint_set_expanded.xml | 230 ----- ..._text_layout_constraint_set_fullscreen.xml | 234 ------ .../src/main/res/layout/fragment_composer.xml | 7 +- .../src/main/res/layout/fragment_timeline.xml | 364 ++++---- .../layout/fragment_timeline_fullscreen.xml | 258 ------ .../res/layout/view_rich_text_menu_button.xml | 4 +- .../layout/view_voice_message_recorder.xml | 18 +- 40 files changed, 1904 insertions(+), 2238 deletions(-) create mode 100644 changelog.d/7577.feature create mode 100644 vector/src/main/java/im/vector/app/core/utils/ExpandingBottomSheetBehavior.kt create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerMode.kt create mode 100644 vector/src/main/res/drawable/bg_composer_rich_bottom_sheet.xml delete mode 100644 vector/src/main/res/drawable/bg_composer_rich_edit_text_expanded.xml delete mode 100644 vector/src/main/res/drawable/bg_composer_rich_edit_text_single_line.xml create mode 100644 vector/src/main/res/drawable/bottomsheet_handle.xml create mode 100644 vector/src/main/res/drawable/ic_composer_collapse.xml create mode 100644 vector/src/main/res/drawable/ic_composer_rich_mic_pressed.xml create mode 100644 vector/src/main/res/drawable/ic_composer_rich_text_editor_close.xml create mode 100644 vector/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml create mode 100644 vector/src/main/res/drawable/ic_composer_rich_text_save.xml create mode 100644 vector/src/main/res/drawable/ic_rich_composer_add.xml create mode 100644 vector/src/main/res/drawable/ic_rich_composer_send.xml delete mode 100644 vector/src/main/res/drawable/ic_voice_mic_recording.xml delete mode 100644 vector/src/main/res/layout/composer_layout_constraint_set_compact.xml delete mode 100644 vector/src/main/res/layout/composer_layout_constraint_set_expanded.xml delete mode 100644 vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml delete mode 100644 vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml delete mode 100644 vector/src/main/res/layout/composer_rich_text_layout_constraint_set_fullscreen.xml delete mode 100644 vector/src/main/res/layout/fragment_timeline_fullscreen.xml diff --git a/changelog.d/7577.feature b/changelog.d/7577.feature new file mode 100644 index 0000000000..e21ccb13c0 --- /dev/null +++ b/changelog.d/7577.feature @@ -0,0 +1 @@ +New implementation of the full screen mode for the Rich Text Editor. diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index e503cb3fe7..ab98f7e141 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -1642,7 +1642,10 @@ It looks like you’re trying to connect to another homeserver. Do you want to sign out? Edit + Editing Reply + Replying to %s + Quoting Reply in thread View In Room diff --git a/library/ui-styles/src/main/res/values/dimens.xml b/library/ui-styles/src/main/res/values/dimens.xml index 22c2a3e62c..4c911c9e97 100644 --- a/library/ui-styles/src/main/res/values/dimens.xml +++ b/library/ui-styles/src/main/res/values/dimens.xml @@ -49,6 +49,7 @@ 1dp 28dp 14dp + 44dp 28dp 6dp diff --git a/library/ui-styles/src/main/res/values/styles_edit_text.xml b/library/ui-styles/src/main/res/values/styles_edit_text.xml index b640fc49d9..94f4d86160 100644 --- a/library/ui-styles/src/main/res/values/styles_edit_text.xml +++ b/library/ui-styles/src/main/res/values/styles_edit_text.xml @@ -4,7 +4,7 @@ diff --git a/vector/src/main/java/im/vector/app/core/utils/ExpandingBottomSheetBehavior.kt b/vector/src/main/java/im/vector/app/core/utils/ExpandingBottomSheetBehavior.kt new file mode 100644 index 0000000000..0474cdea7e --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/utils/ExpandingBottomSheetBehavior.kt @@ -0,0 +1,791 @@ +package im.vector.app.core.utils + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.VelocityTracker +import android.view.View +import android.view.View.MeasureSpec +import android.view.ViewGroup +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsAnimationCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.customview.widget.ViewDragHelper +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior +import timber.log.Timber +import java.lang.ref.WeakReference +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * BottomSheetBehavior that dynamically resizes its contents as it grows or shrinks. + * Most of the nested scrolling and touch events code is the same as in [BottomSheetBehavior], but we couldn't just extend it. + */ +class ExpandingBottomSheetBehavior : CoordinatorLayout.Behavior { + + companion object { + /** Gets a [ExpandingBottomSheetBehavior] from the passed [view] if it exists. */ + @Suppress("UNCHECKED_CAST") + fun from(view: V): ExpandingBottomSheetBehavior? { + val params = view.layoutParams as? CoordinatorLayout.LayoutParams ?: return null + return params.behavior as? ExpandingBottomSheetBehavior + } + } + + /** [Callback] to notify changes in dragging state and position. */ + interface Callback { + /** Called when the dragging state of the BottomSheet changes. */ + fun onStateChanged(state: State) {} + + /** Called when the position of the BottomSheet changes while dragging. */ + fun onSlidePositionChanged(view: View, yPosition: Float) {} + } + + /** Represents the 4 possible states of the BottomSheet. */ + enum class State(val value: Int) { + /** BottomSheet is at min height, collapsed at the bottom. */ + Collapsed(0), + + /** BottomSheet is being dragged by the user. */ + Dragging(1), + + /** BottomSheet has been released after being dragged by the user and is animating to its destination. */ + Settling(2), + + /** BottomSheet is at its max height. */ + Expanded(3); + + /** Returns whether the BottomSheet is being dragged or is settling after being dragged. */ + fun isDraggingOrSettling(): Boolean = this == Dragging || this == Settling + } + + /** Set to true to enable debug logging of sizes and offsets. Defaults to `false`. */ + var enableDebugLogs = false + + /** Current BottomSheet state. Default to [State.Collapsed]. */ + var state: State = State.Collapsed + private set + + /** Whether the BottomSheet can be dragged by the user or not. Defaults to `true`. */ + var isDraggable = true + + /** [Callback] to notify changes in dragging state and position. */ + var callback: Callback? = null + set(value) { + field = value + // Send initial state + value?.onStateChanged(state) + } + + /** Additional top offset in `dps` to add to the BottomSheet so it doesn't fill the whole screen. Defaults to `0`. */ + var topOffset = 0 + set(value) { + field = value + expandedOffset = -1 + } + + /** Whether the BottomSheet should be expanded up to the bottom of any [AppBarLayout] found in the parent [CoordinatorLayout]. Defaults to `false`. */ + var avoidAppBarLayout = false + set(value) { + field = value + expandedOffset = -1 + } + + /** + * Whether to add the [scrimView], a 'shadow layer' that will be displayed while dragging/expanded so it obscures the content below the BottomSheet. + * Defaults to `false`. + */ + var useScrimView = false + + /** Color to use for the [scrimView] shadow layer. */ + var scrimViewColor = 0x60000000 + + /** [View.TRANSLATION_Z] in `dps` to apply to the [scrimView]. Defaults to `0dp`. */ + var scrimViewTranslationZ = 0 + + /** Whether the content view should be layout to the top of the BottomSheet when it's collapsed. Defaults to true. */ + var applyInsetsToContentViewWhenCollapsed = true + + /** Lambda used to calculate a min collapsed when the view using the behavior should have a special 'collapsed' layout. It's null by default. */ + var minCollapsedHeight: (() -> Int)? = null + + // Internal BottomSheet implementation properties + private var ignoreEvents = false + private var touchingScrollingChild = false + + private var lastY: Int = -1 + private var collapsedOffset = -1 + private var expandedOffset = -1 + private var parentWidth = -1 + private var parentHeight = -1 + + private var activePointerId = -1 + + private var lastNestedScrollDy = -1 + private var isNestedScrolled = false + + private var viewRef: WeakReference? = null + private var nestedScrollingChildRef: WeakReference? = null + private var velocityTracker: VelocityTracker? = null + + private var dragHelper: ViewDragHelper? = null + private var scrimView: View? = null + + private val stateSettlingTracker = StateSettlingTracker() + private var prevState: State? = null + + private var insetBottom = 0 + private var insetTop = 0 + private var insetLeft = 0 + private var insetRight = 0 + + private var initialPaddingTop = 0 + private var initialPaddingBottom = 0 + private var initialPaddingLeft = 0 + private var initialPaddingRight = 0 + private val minCollapsedOffset: Int? + get() { + val minHeight = minCollapsedHeight?.invoke() ?: return null + if (minHeight == -1) return null + return parentHeight - minHeight - insetBottom + } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor() : super() + + override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean { + parentWidth = parent.width + parentHeight = parent.height + + if (viewRef == null) { + viewRef = WeakReference(child) + setWindowInsetsListener(child) + // Prevents clicking on overlapped items below the BottomSheet + child.isClickable = true + } + + parent.updatePadding(left = insetLeft, right = insetRight) + + ensureViewDragHelper(parent) + + // Top coordinate before this layout pass + val savedTop = child.top + + // Calculate default position of the BottomSheet's children + parent.onLayoutChild(child, layoutDirection) + + // This should optimise calculations when they're not needed + if (state == State.Collapsed) { + calculateCollapsedOffset(child) + } + calculateExpandedOffset(parent) + + // Apply top and bottom insets to contentView if needed + val appBar = findAppBarLayout(parent) + val contentView = parent.children.find { it !== appBar && it !== child && it !== scrimView } + if (applyInsetsToContentViewWhenCollapsed && state == State.Collapsed && contentView != null) { + val topOffset = appBar?.measuredHeight ?: 0 + val bottomOffset = parentHeight - collapsedOffset + insetTop + val params = contentView.layoutParams as CoordinatorLayout.LayoutParams + if (params.bottomMargin != bottomOffset || params.topMargin != topOffset) { + params.topMargin = topOffset + params.bottomMargin = bottomOffset + contentView.layoutParams = params + } + } + + // Add scrimView if needed + if (useScrimView && scrimView == null) { + val scrimView = View(parent.context) + scrimView.setBackgroundColor(scrimViewColor) + scrimView.translationZ = scrimViewTranslationZ * child.resources.displayMetrics.scaledDensity + scrimView.isVisible = false + val params = CoordinatorLayout.LayoutParams( + CoordinatorLayout.LayoutParams.MATCH_PARENT, + CoordinatorLayout.LayoutParams.MATCH_PARENT + ) + scrimView.layoutParams = params + val currentIndex = parent.children.indexOf(child) + parent.addView(scrimView, currentIndex) + this.scrimView = scrimView + } else if (!useScrimView && scrimView != null) { + parent.removeView(scrimView) + scrimView = null + } + + // Apply insets and resize child based on the current State + when (state) { + State.Collapsed -> { + scrimView?.alpha = 0f + val newHeight = parentHeight - collapsedOffset + insetTop + val params = child.layoutParams + if (params.height != newHeight) { + params.height = newHeight + child.layoutParams = params + } + // If the offset is < insetTop it will cover the status bar too + val newOffset = max(insetTop, collapsedOffset - insetTop) + ViewCompat.offsetTopAndBottom(child, newOffset) + log("State: Collapsed | Offset: $newOffset | Height: $newHeight") + } + State.Dragging, State.Settling -> { + val newOffset = savedTop - child.top + val percentage = max(0f, 1f - (newOffset.toFloat() / collapsedOffset.toFloat())) + scrimView?.let { + if (percentage == 0f) { + it.isVisible = false + } else { + it.alpha = percentage + it.isVisible = true + } + } + val params = child.layoutParams + params.height = parentHeight - savedTop + child.layoutParams = params + ViewCompat.offsetTopAndBottom(child, newOffset) + val stateStr = if (state == State.Dragging) "Dragging" else "Settling" + log("State: $stateStr | Offset: $newOffset | Percentage: $percentage") + } + State.Expanded -> { + val params = child.layoutParams + val newHeight = parentHeight - expandedOffset + if (params.height != newHeight) { + params.height = newHeight + child.layoutParams = params + } + ViewCompat.offsetTopAndBottom(child, expandedOffset) + log("State: Expanded | Offset: $expandedOffset | Height: $newHeight") + } + } + + // Find a nested scrolling child to take into account for touch events + if (nestedScrollingChildRef == null) { + nestedScrollingChildRef = findScrollingChild(child)?.let { WeakReference(it) } + } + + return true + } + + // region: Touch events + override fun onInterceptTouchEvent( + parent: CoordinatorLayout, + child: V, + ev: MotionEvent + ): Boolean { + // Almost everything inside here is verbatim to BottomSheetBehavior's onTouchEvent + if (viewRef != null && viewRef?.get() !== child) { + return true + } + val action = ev.actionMasked + + if (action == MotionEvent.ACTION_DOWN) { + resetTouchEventTracking() + } + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain() + } + velocityTracker?.addMovement(ev) + + when (action) { + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + touchingScrollingChild = false + activePointerId = MotionEvent.INVALID_POINTER_ID + if (ignoreEvents) { + ignoreEvents = false + return false + } + } + MotionEvent.ACTION_DOWN -> { + val x = ev.x.toInt() + lastY = ev.y.toInt() + + // Only intercept nested scrolling events here if the view not being moved by the + // ViewDragHelper. + val scroll = nestedScrollingChildRef?.get() + if (state != State.Settling) { + if (scroll != null && parent.isPointInChildBounds(scroll, x, lastY)) { + activePointerId = ev.getPointerId(ev.actionIndex) + touchingScrollingChild = true + } + } + ignoreEvents = (activePointerId == MotionEvent.INVALID_POINTER_ID && + !parent.isPointInChildBounds(child, x, lastY)) + } + else -> Unit + } + + if (!ignoreEvents && isDraggable && dragHelper?.shouldInterceptTouchEvent(ev) == true) { + return true + } + + // If using scrim view, a click on it should collapse the bottom sheet + if (useScrimView && state == State.Expanded && action == MotionEvent.ACTION_DOWN) { + val y = ev.y.toInt() + if (y <= expandedOffset) { + setState(State.Collapsed) + return true + } + } + + // We have to handle cases that the ViewDragHelper does not capture the bottom sheet because + // it is not the top most view of its parent. This is not necessary when the touch event is + // happening over the scrolling content as nested scrolling logic handles that case. + val scroll = nestedScrollingChildRef?.get() + return (action == MotionEvent.ACTION_MOVE && + scroll != null && + !ignoreEvents && + state != State.Dragging && + !parent.isPointInChildBounds(scroll, ev.x.toInt(), ev.y.toInt()) && + dragHelper != null && + abs(lastY - ev.y.toInt()) > (dragHelper?.touchSlop ?: 0)) + } + + override fun onTouchEvent(parent: CoordinatorLayout, child: V, ev: MotionEvent): Boolean { + // Almost everything inside here is verbatim to BottomSheetBehavior's onTouchEvent + val action = ev.actionMasked + if (state == State.Dragging && action == MotionEvent.ACTION_DOWN) { + return true + } + if (shouldHandleDraggingWithHelper()) { + dragHelper?.processTouchEvent(ev) + } + + // Record the velocity + if (action == MotionEvent.ACTION_DOWN) { + resetTouchEventTracking() + } + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain() + } + velocityTracker?.addMovement(ev) + + if (shouldHandleDraggingWithHelper() && action == MotionEvent.ACTION_MOVE && !ignoreEvents) { + if (abs(lastY - ev.y.toInt()) > (dragHelper?.touchSlop ?: 0)) { + dragHelper?.captureChildView(child, ev.getPointerId(ev.actionIndex)) + } + } + + return !ignoreEvents + } + + private fun resetTouchEventTracking() { + activePointerId = ViewDragHelper.INVALID_POINTER + velocityTracker?.recycle() + velocityTracker = null + } + // endregion + + override fun onAttachedToLayoutParams(params: CoordinatorLayout.LayoutParams) { + super.onAttachedToLayoutParams(params) + + viewRef = null + dragHelper = null + } + + override fun onDetachedFromLayoutParams() { + super.onDetachedFromLayoutParams() + + viewRef = null + dragHelper = null + } + + // region: Size measuring and utils + private fun calculateCollapsedOffset(child: View) { + val availableSpace = parentHeight - insetTop + child.measure( + MeasureSpec.makeMeasureSpec(parentWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(availableSpace, MeasureSpec.AT_MOST), + ) + collapsedOffset = parentHeight - child.measuredHeight + insetTop + } + + private fun calculateExpandedOffset(parent: CoordinatorLayout): Int { + expandedOffset = if (avoidAppBarLayout) { + findAppBarLayout(parent)?.measuredHeight ?: 0 + } else { + 0 + } + topOffset + insetTop + return expandedOffset + } + + private fun ensureViewDragHelper(parent: CoordinatorLayout) { + if (dragHelper == null) { + dragHelper = ViewDragHelper.create(parent, dragHelperCallback) + } + } + + private fun findAppBarLayout(view: View): AppBarLayout? { + return when (view) { + is AppBarLayout -> view + is ViewGroup -> view.children.firstNotNullOfOrNull { findAppBarLayout(it) } + else -> null + } + } + + private fun shouldHandleDraggingWithHelper(): Boolean { + return dragHelper != null && (isDraggable || state == State.Dragging) + } + + private fun log(contents: String, vararg args: Any) { + if (!enableDebugLogs) return + Timber.d(contents, args) + } + // endregion + + // region: State and delayed state settling + fun setState(state: State) { + if (state == this.state) { + return + } else if (viewRef?.get() == null) { + setInternalState(state) + } else { + viewRef?.get()?.let { child -> + runAfterLayout(child) { startSettling(child, state, false) } + } + } + } + + private fun setInternalState(state: State) { + if (!this.state.isDraggingOrSettling()) { + prevState = this.state + } + this.state = state + + viewRef?.get()?.requestLayout() + + callback?.onStateChanged(state) + } + + private fun startSettling(child: View, state: State, isReleasingView: Boolean) { + val top = getTopOffsetForState(state) + log("Settling to: $top") + val isSettling = dragHelper?.let { + if (isReleasingView) { + it.settleCapturedViewAt(child.left, top) + } else { + it.smoothSlideViewTo(child, child.left, top) + } + } ?: false + setInternalState(if (isSettling) State.Settling else state) + + if (isSettling) { + stateSettlingTracker.continueSettlingToState(state) + } + } + + private fun runAfterLayout(child: V, runnable: Runnable) { + if (isLayouting(child)) { + child.post(runnable) + } else { + runnable.run() + } + } + + private fun isLayouting(child: V): Boolean { + return child.parent != null && child.parent.isLayoutRequested && ViewCompat.isAttachedToWindow(child) + } + + private fun getTopOffsetForState(state: State): Int { + return when (state) { + State.Collapsed -> minCollapsedOffset ?: collapsedOffset + State.Expanded -> expandedOffset + else -> error("Cannot get offset for state $state") + } + } + // endregion + + // region: Nested scroll + override fun onStartNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: V, + directTargetChild: View, + target: View, + axes: Int, + type: Int + ): Boolean { + lastNestedScrollDy = 0 + isNestedScrolled = false + return (axes and ViewCompat.SCROLL_AXIS_VERTICAL) != 0 + } + + override fun onNestedPreScroll( + coordinatorLayout: CoordinatorLayout, + child: V, + target: View, + dx: Int, + dy: Int, + consumed: IntArray, + type: Int + ) { + if (type == ViewCompat.TYPE_NON_TOUCH) return + val scrollingChild = nestedScrollingChildRef?.get() + if (target != scrollingChild) return + + val currentTop = child.top + val newTop = currentTop - dy + if (dy > 0) { + // Upward scroll + if (newTop < expandedOffset) { + consumed[1] = currentTop - expandedOffset + ViewCompat.offsetTopAndBottom(child, -consumed[1]) + setInternalState(State.Expanded) + } else { + if (!isDraggable) return + + consumed[1] = dy + ViewCompat.offsetTopAndBottom(child, -dy) + setInternalState(State.Dragging) + } + } else if (dy < 0) { + // Scroll downward + if (!target.canScrollVertically(-1)) { + if (newTop <= collapsedOffset) { + if (!isDraggable) return + + consumed[1] = dy + ViewCompat.offsetTopAndBottom(child, -dy) + setInternalState(State.Dragging) + } else { + consumed[1] = currentTop - collapsedOffset + ViewCompat.offsetTopAndBottom(child, -consumed[1]) + setInternalState(State.Collapsed) + } + } + } + lastNestedScrollDy = dy + isNestedScrolled = true + } + + override fun onNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: V, + target: View, + dxConsumed: Int, + dyConsumed: Int, + dxUnconsumed: Int, + dyUnconsumed: Int, + type: Int, + consumed: IntArray + ) { + // Empty to avoid default behaviour + } + + override fun onNestedPreFling( + coordinatorLayout: CoordinatorLayout, + child: V, + target: View, + velocityX: Float, + velocityY: Float + ): Boolean { + return target == nestedScrollingChildRef?.get() && + (state != State.Expanded || super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY)) + } + + private fun findScrollingChild(view: View): View? { + return when { + !view.isVisible -> null + ViewCompat.isNestedScrollingEnabled(view) -> view + view is ViewGroup -> { + view.children.firstNotNullOfOrNull { findScrollingChild(it) } + } + else -> null + } + } + // endregion + + // region: Insets + private fun setWindowInsetsListener(view: View) { + // Create a snapshot of the view's padding state. + initialPaddingLeft = view.paddingLeft + initialPaddingTop = view.paddingTop + initialPaddingRight = view.paddingRight + initialPaddingBottom = view.paddingBottom + + // This should only be used to set initial insets and other edge cases where the insets can't be applied using an animation. + var applyInsetsFromAnimation = false + + // This will animated inset changes, making them look a lot better. However, it won't update initial insets. + ViewCompat.setWindowInsetsAnimationCallback(view, object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { + override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList): WindowInsetsCompat { + return applyInsets(view, insets) + } + + override fun onEnd(animation: WindowInsetsAnimationCompat) { + applyInsetsFromAnimation = false + view.requestApplyInsets() + } + }) + + ViewCompat.setOnApplyWindowInsetsListener(view) { _: View, insets: WindowInsetsCompat -> + if (!applyInsetsFromAnimation) { + applyInsetsFromAnimation = true + applyInsets(view, insets) + } else { + insets + } + } + + // Request to apply insets as soon as the view is attached to a window. + if (ViewCompat.isAttachedToWindow(view)) { + ViewCompat.requestApplyInsets(view) + } else { + view.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + v.removeOnAttachStateChangeListener(this) + ViewCompat.requestApplyInsets(v) + } + + override fun onViewDetachedFromWindow(v: View) = Unit + }) + } + } + + private fun applyInsets(view: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val insetsType = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime() + val imeInsets = insets.getInsets(insetsType) + insetTop = imeInsets.top + insetBottom = imeInsets.bottom + insetLeft = imeInsets.left + insetRight = imeInsets.right + + val bottomPadding = initialPaddingBottom + insetBottom + view.setPadding(initialPaddingLeft, initialPaddingTop, initialPaddingRight, bottomPadding) + if (state == State.Collapsed) { + val params = view.layoutParams + params.height = CoordinatorLayout.LayoutParams.WRAP_CONTENT + view.layoutParams = params + calculateCollapsedOffset(view) + } + return WindowInsetsCompat.CONSUMED + } + // endregion + + // Used to add dragging animations along with StateSettlingTracker, and set max and min dragging coordinates. + private val dragHelperCallback = object : ViewDragHelper.Callback() { + + override fun tryCaptureView(child: View, pointerId: Int): Boolean { + if (state == State.Dragging) { + return false + } + + if (touchingScrollingChild) { + return false + } + + if (state == State.Expanded && activePointerId == pointerId) { + val scroll = nestedScrollingChildRef?.get() + if (scroll?.canScrollVertically(-1) == true) { + return false + } + } + + return viewRef?.get() == child + } + + override fun onViewDragStateChanged(state: Int) { + if (state == ViewDragHelper.STATE_DRAGGING && isDraggable) { + setInternalState(State.Dragging) + } + } + + override fun onViewPositionChanged( + changedView: View, + left: Int, + top: Int, + dx: Int, + dy: Int + ) { + super.onViewPositionChanged(changedView, left, top, dx, dy) + + val params = changedView.layoutParams + params.height = parentHeight - top + insetBottom + insetTop + changedView.layoutParams = params + + val collapsedOffset = minCollapsedOffset ?: collapsedOffset + val percentage = 1f - (top - insetTop).toFloat() / collapsedOffset.toFloat() + + callback?.onSlidePositionChanged(changedView, percentage) + } + + override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) { + val actualCollapsedOffset = minCollapsedOffset ?: collapsedOffset + val targetState = if (yvel < 0) { + // Moving up + val currentTop = releasedChild.top + + val yPositionPercentage = currentTop * 100f / actualCollapsedOffset + if (yPositionPercentage >= 0.5f) { + State.Expanded + } else { + State.Collapsed + } + } else if (yvel == 0f || abs(xvel) > abs(yvel)) { + // If the Y velocity is 0 or the swipe was mostly horizontal indicated by the X velocity + // being greater than the Y velocity, settle to the nearest correct height. + + val currentTop = releasedChild.top + if (currentTop < actualCollapsedOffset / 2) { + State.Expanded + } else { + State.Collapsed + } + } else { + // Moving down + val currentTop = releasedChild.top + + val yPositionPercentage = currentTop * 100f / actualCollapsedOffset + if (yPositionPercentage >= 0.5f) { + State.Collapsed + } else { + State.Expanded + } + } + startSettling(releasedChild, targetState, true) + } + + override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int { + return child.left + } + + override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int { + val collapsed = minCollapsedOffset ?: collapsedOffset + val maxTop = max(top, insetTop) + return min(max(maxTop, expandedOffset), collapsed) + } + + override fun getViewVerticalDragRange(child: View): Int { + return minCollapsedOffset ?: collapsedOffset + } + } + + // Used to set the current State in a delayed way. + private inner class StateSettlingTracker { + private lateinit var targetState: State + private var isContinueSettlingRunnablePosted = false + + private val continueSettlingRunnable: Runnable = Runnable { + isContinueSettlingRunnablePosted = false + if (dragHelper?.continueSettling(true) == true) { + continueSettlingToState(targetState) + } else { + setInternalState(targetState) + } + } + + fun continueSettlingToState(state: State) { + val view = viewRef?.get() ?: return + + this.targetState = state + if (!isContinueSettlingRunnablePosted) { + ViewCompat.postOnAnimation(view, continueSettlingRunnable) + isContinueSettlingRunnablePosted = true + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt index 1368b71ec6..0f7dc251ae 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt @@ -34,8 +34,6 @@ class JumpToBottomViewVisibilityManager( private val layoutManager: LinearLayoutManager ) { - private var canShowButtonOnScroll = true - init { recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { @@ -45,7 +43,7 @@ class JumpToBottomViewVisibilityManager( if (scrollingToPast) { jumpToBottomView.hide() - } else if (canShowButtonOnScroll) { + } else { maybeShowJumpToBottomViewVisibility() } } @@ -68,13 +66,7 @@ class JumpToBottomViewVisibilityManager( } } - fun hideAndPreventVisibilityChangesWithScrolling() { - jumpToBottomView.hide() - canShowButtonOnScroll = false - } - private fun maybeShowJumpToBottomViewVisibility() { - canShowButtonOnScroll = true if (layoutManager.findFirstVisibleItemPosition() > 1) { jumpToBottomView.show() } else { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt index ecbea133df..2ed3bf8614 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt @@ -18,10 +18,12 @@ package im.vector.app.features.home.room.detail import android.content.Context import android.content.Intent +import android.graphics.Color import android.os.Bundle import android.view.View import android.widget.Toast import androidx.core.view.GravityCompat +import androidx.core.view.WindowCompat import androidx.drawerlayout.widget.DrawerLayout import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager @@ -98,6 +100,11 @@ class RoomDetailActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // For dealing with insets and status bar background color + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = Color.TRANSPARENT + supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, false) waitingView = views.waitingView.waitingView val timelineArgs: TimelineArgs = intent?.extras?.getParcelableCompat(EXTRA_ROOM_DETAIL_ARGS) ?: return diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index e1392b7580..9bed0aae04 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -35,16 +35,17 @@ import android.widget.TextView import androidx.activity.addCallback import androidx.annotation.StringRes import androidx.appcompat.view.menu.MenuBuilder -import androidx.constraintlayout.widget.ConstraintSet import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.core.net.toUri import androidx.core.text.toSpannable import androidx.core.util.Pair import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.forEach import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.core.view.updatePadding import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper @@ -67,7 +68,6 @@ import im.vector.app.core.dialogs.ConfirmationDialogBuilder import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper import im.vector.app.core.dialogs.GalleryOrCameraDialogHelperFactory import im.vector.app.core.epoxy.LayoutManagerStateRestorer -import im.vector.app.core.extensions.animateLayoutChange import im.vector.app.core.extensions.cleanup import im.vector.app.core.extensions.commitTransaction import im.vector.app.core.extensions.containsRtLOverride @@ -187,9 +187,7 @@ import im.vector.app.features.widgets.WidgetArgs import im.vector.app.features.widgets.WidgetKind import im.vector.app.features.widgets.permissions.RoomWidgetPermissionBottomSheet import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -418,20 +416,12 @@ class TimelineFragment : } } - if (savedInstanceState == null) { - handleSpaceShare() + ViewCompat.setOnApplyWindowInsetsListener(views.coordinatorLayout) { _, insets -> + val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars()) + views.appBarLayout.updatePadding(top = imeInsets.top) + views.voiceMessageRecorderContainer.updatePadding(bottom = imeInsets.bottom) + insets } - - views.scrim.setOnClickListener { - messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(false)) - } - - messageComposerViewModel.stateFlow.map { it.isFullScreen } - .distinctUntilChanged() - .onEach { isFullScreen -> - toggleFullScreenEditor(isFullScreen) - } - .launchIn(viewLifecycleOwner.lifecycleScope) } private fun setupBackPressHandling() { @@ -1048,13 +1038,7 @@ class TimelineFragment : override fun onLayoutCompleted(state: RecyclerView.State) { super.onLayoutCompleted(state) updateJumpToReadMarkerViewVisibility() - withState(messageComposerViewModel) { composerState -> - if (!composerState.isFullScreen) { - jumpToBottomViewVisibilityManager.maybeShowJumpToBottomViewVisibilityWithDelay() - } else { - jumpToBottomViewVisibilityManager.hideAndPreventVisibilityChangesWithScrolling() - } - } + jumpToBottomViewVisibilityManager.maybeShowJumpToBottomViewVisibilityWithDelay() } }.apply { // For local rooms, pin the view's content to the top edge (the layout is reversed) @@ -1170,7 +1154,6 @@ class TimelineFragment : if (mainState.tombstoneEvent == null) { views.composerContainer.isInvisible = !messageComposerState.isComposerVisible views.voiceMessageRecorderContainer.isVisible = messageComposerState.isVoiceMessageRecorderVisible - when (messageComposerState.canSendMessage) { CanSendStatus.Allowed -> { NotificationAreaView.State.Hidden @@ -2036,19 +2019,6 @@ class TimelineFragment : } } - private fun toggleFullScreenEditor(isFullScreen: Boolean) { - views.composerContainer.animateLayoutChange(200) - - val constraintSet = ConstraintSet() - val constraintSetId = if (isFullScreen) { - R.layout.fragment_timeline_fullscreen - } else { - R.layout.fragment_timeline - } - constraintSet.clone(requireContext(), constraintSetId) - constraintSet.applyTo(views.rootConstraintLayout) - } - /** * Returns true if the current room is a Thread room, false otherwise. */ diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt index 30437a016d..ffaaa235cf 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt @@ -33,7 +33,6 @@ sealed class MessageComposerAction : VectorViewModelAction { data class OnEntersBackground(val composerText: String) : MessageComposerAction() data class SlashCommandConfirmed(val parsedCommand: ParsedCommand) : MessageComposerAction() data class InsertUserDisplayName(val userId: String) : MessageComposerAction() - data class SetFullScreen(val isFullScreen: Boolean) : MessageComposerAction() // Voice Message diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index aaf63d7f41..d551850ff3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -24,7 +24,6 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.text.Spannable -import android.text.format.DateUtils import android.view.KeyEvent import android.view.LayoutInflater import android.view.View @@ -32,10 +31,7 @@ import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.widget.EditText import android.widget.Toast -import androidx.annotation.DrawableRes import androidx.annotation.RequiresApi -import androidx.annotation.StringRes -import androidx.core.content.ContextCompat import androidx.core.text.buildSpannedString import androidx.core.view.isGone import androidx.core.view.isInvisible @@ -51,7 +47,6 @@ import com.vanniktech.emoji.EmojiPopup import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.error.fatalError -import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.showKeyboard import im.vector.app.core.glide.GlideApp @@ -59,7 +54,7 @@ import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.lifecycleAwareLazy import im.vector.app.core.platform.showOptimizedSnackbar import im.vector.app.core.resources.BuildMeta -import im.vector.app.core.utils.DimensionConverter +import im.vector.app.core.utils.ExpandingBottomSheetBehavior import im.vector.app.core.utils.checkPermissions import im.vector.app.core.utils.onPermissionDeniedDialog import im.vector.app.core.utils.registerForPermissionsResult @@ -86,14 +81,9 @@ import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAc import im.vector.app.features.home.room.detail.TimelineViewModel import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel -import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider -import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet -import im.vector.app.features.html.EventHtmlRenderer import im.vector.app.features.html.PillImageSpan -import im.vector.app.features.html.PillsPostProcessor import im.vector.app.features.location.LocationSharingMode -import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.poll.PollMode import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.share.SharedData @@ -104,18 +94,9 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import org.commonmark.parser.Parser import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.content.ContentAttachmentData -import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent -import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent -import org.matrix.android.sdk.api.session.room.model.message.MessageContent -import org.matrix.android.sdk.api.session.room.model.message.MessageFormat -import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent -import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent -import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.MatrixItem -import org.matrix.android.sdk.api.util.toMatrixItem import reactivecircus.flowbinding.android.view.focusChanges import reactivecircus.flowbinding.android.widget.textChanges import timber.log.Timber @@ -130,12 +111,7 @@ class MessageComposerFragment : VectorBaseFragment(), A @Inject lateinit var autoCompleterFactory: AutoCompleter.Factory @Inject lateinit var avatarRenderer: AvatarRenderer - @Inject lateinit var matrixItemColorProvider: MatrixItemColorProvider - @Inject lateinit var eventHtmlRenderer: EventHtmlRenderer - @Inject lateinit var dimensionConverter: DimensionConverter - @Inject lateinit var imageContentRenderer: ImageContentRenderer @Inject lateinit var shareIntentHandler: ShareIntentHandler - @Inject lateinit var pillsPostProcessorFactory: PillsPostProcessor.Factory @Inject lateinit var vectorPreferences: VectorPreferences @Inject lateinit var vectorFeatures: VectorFeatures @Inject lateinit var buildMeta: BuildMeta @@ -147,10 +123,6 @@ class MessageComposerFragment : VectorBaseFragment(), A autoCompleterFactory.create(roomId, isThreadTimeLine()) } - private val pillsPostProcessor by lazy { - pillsPostProcessorFactory.create(roomId) - } - private val emojiPopup: EmojiPopup by lifecycleAwareLazy { createEmojiPopup() } @@ -166,6 +138,7 @@ class MessageComposerFragment : VectorBaseFragment(), A private lateinit var attachmentsHelper: AttachmentsHelper private lateinit var attachmentTypeSelector: AttachmentTypeSelectorView + private var bottomSheetBehavior: ExpandingBottomSheetBehavior? = null private val timelineViewModel: TimelineViewModel by parentFragmentViewModel() private val messageComposerViewModel: MessageComposerViewModel by parentFragmentViewModel() @@ -192,6 +165,7 @@ class MessageComposerFragment : VectorBaseFragment(), A attachmentsHelper = AttachmentsHelper(requireContext(), this, buildMeta).register() + setupBottomSheet() setupComposer() setupEmojiButton() @@ -217,22 +191,15 @@ class MessageComposerFragment : VectorBaseFragment(), A } } - messageComposerViewModel.stateFlow.map { it.isFullScreen } - .distinctUntilChanged() - .onEach { isFullScreen -> - composer.toggleFullScreen(isFullScreen) - } - .launchIn(viewLifecycleOwner.lifecycleScope) - messageComposerViewModel.onEach(MessageComposerViewState::sendMode, MessageComposerViewState::canSendMessage) { mode, canSend -> if (!canSend.boolean()) { return@onEach } when (mode) { is SendMode.Regular -> renderRegularMode(mode.text.toString()) - is SendMode.Edit -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text.toString()) - is SendMode.Quote -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.action_quote, mode.text.toString()) - is SendMode.Reply -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text.toString()) + is SendMode.Edit -> renderSpecialMode(MessageComposerMode.Edit(mode.timelineEvent, mode.text.toString())) + is SendMode.Quote -> renderSpecialMode(MessageComposerMode.Quote(mode.timelineEvent, mode.text.toString())) + is SendMode.Reply -> renderSpecialMode(MessageComposerMode.Reply(mode.timelineEvent, mode.text.toString())) is SendMode.Voice -> renderVoiceMessageMode(mode.text) } } @@ -242,6 +209,14 @@ class MessageComposerFragment : VectorBaseFragment(), A .onEach { onTypeSelected(it.attachmentType) } .launchIn(lifecycleScope) + messageComposerViewModel.stateFlow.map { it.isFullScreen } + .distinctUntilChanged() + .onEach { isFullScreen -> + val state = if (isFullScreen) ExpandingBottomSheetBehavior.State.Expanded else ExpandingBottomSheetBehavior.State.Collapsed + bottomSheetBehavior?.setState(state) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + if (savedInstanceState == null) { handleShareData() } @@ -280,11 +255,45 @@ class MessageComposerFragment : VectorBaseFragment(), A ) { mainState, messageComposerState, attachmentState -> if (mainState.tombstoneEvent != null) return@withState - composer.setInvisible(!messageComposerState.isComposerVisible) + (composer as? View)?.isInvisible = !messageComposerState.isComposerVisible composer.sendButton.isInvisible = !messageComposerState.isSendButtonVisible (composer as? RichTextComposerLayout)?.isTextFormattingEnabled = attachmentState.isTextFormattingEnabled } + private fun setupBottomSheet() { + val parentView = view?.parent as? View ?: return + bottomSheetBehavior = ExpandingBottomSheetBehavior.from(parentView)?.apply { + applyInsetsToContentViewWhenCollapsed = true + topOffset = 22 + useScrimView = true + scrimViewTranslationZ = 8 + minCollapsedHeight = { + (composer as? RichTextComposerLayout)?.estimateCollapsedHeight() ?: -1 + } + isDraggable = false + callback = object : ExpandingBottomSheetBehavior.Callback { + override fun onStateChanged(state: ExpandingBottomSheetBehavior.State) { + // Dragging is disabled while the composer is collapsed + bottomSheetBehavior?.isDraggable = state != ExpandingBottomSheetBehavior.State.Collapsed + + val setFullScreen = when (state) { + ExpandingBottomSheetBehavior.State.Collapsed -> false + ExpandingBottomSheetBehavior.State.Expanded -> true + else -> return + } + + (composer as? RichTextComposerLayout)?.setFullScreen(setFullScreen) + + messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(setFullScreen)) + } + + override fun onSlidePositionChanged(view: View, yPosition: Float) { + (composer as? RichTextComposerLayout)?.notifyIsBeingDragged(yPosition) + } + } + } + } + private fun setupComposer() { val composerEditText = composer.editText composerEditText.setHint(R.string.room_message_placeholder) @@ -382,8 +391,7 @@ class MessageComposerFragment : VectorBaseFragment(), A return } if (text.isNotBlank()) { - // We collapse ASAP, if not there will be a slight annoying delay - composer.collapse(true) + composer.renderComposerMode(MessageComposerMode.Normal("")) lockSendButton = true if (formattedText != null) { messageComposerViewModel.handle(MessageComposerAction.SendMessage(text, formattedText, false)) @@ -407,66 +415,12 @@ class MessageComposerFragment : VectorBaseFragment(), A private fun renderRegularMode(content: CharSequence) { autoCompleter.exitSpecialMode() - composer.collapse() - composer.setTextIfDifferent(content) - composer.sendButton.contentDescription = getString(R.string.action_send) + composer.renderComposerMode(MessageComposerMode.Normal(content)) } - private fun renderSpecialMode( - event: TimelineEvent, - @DrawableRes iconRes: Int, - @StringRes descriptionRes: Int, - defaultContent: CharSequence, - ) { + private fun renderSpecialMode(mode: MessageComposerMode.Special) { autoCompleter.enterSpecialMode() - // switch to expanded bar - composer.composerRelatedMessageTitle.apply { - text = event.senderInfo.disambiguatedDisplayName - setTextColor(matrixItemColorProvider.getColor(MatrixItem.UserItem(event.root.senderId ?: "@"))) - } - - val messageContent: MessageContent? = event.getVectorLastMessageContent() - val nonFormattedBody = when (messageContent) { - is MessageAudioContent -> getAudioContentBodyText(messageContent) - is MessagePollContent -> messageContent.getBestPollCreationInfo()?.question?.getBestQuestion() - is MessageBeaconInfoContent -> getString(R.string.live_location_description) - else -> messageContent?.body.orEmpty() - } - var formattedBody: CharSequence? = null - if (messageContent is MessageTextContent && messageContent.format == MessageFormat.FORMAT_MATRIX_HTML) { - val parser = Parser.builder().build() - val document = parser.parse(messageContent.formattedBody ?: messageContent.body) - formattedBody = eventHtmlRenderer.render(document, pillsPostProcessor) - } - composer.composerRelatedMessageContent.text = (formattedBody ?: nonFormattedBody) - - // Image Event - val data = event.buildImageContentRendererData(dimensionConverter.dpToPx(66)) - val isImageVisible = if (data != null) { - imageContentRenderer.render(data, ImageContentRenderer.Mode.THUMBNAIL, composer.composerRelatedMessageImage) - true - } else { - imageContentRenderer.clear(composer.composerRelatedMessageImage) - false - } - - composer.composerRelatedMessageImage.isVisible = isImageVisible - - composer.replaceFormattedContent(defaultContent) - - composer.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) - composer.sendButton.contentDescription = getString(descriptionRes) - - avatarRenderer.render(event.senderInfo.toMatrixItem(), composer.composerRelatedMessageAvatar) - - composer.expand { - if (isAdded) { - // need to do it here also when not using quick reply - focusComposerAndShowKeyboard() - composer.composerRelatedMessageImage.isVisible = isImageVisible - } - } - focusComposerAndShowKeyboard() + composer.renderComposerMode(mode) } private fun observerUserTyping() { @@ -489,7 +443,7 @@ class MessageComposerFragment : VectorBaseFragment(), A } private fun focusComposerAndShowKeyboard() { - if (composer.isVisible) { + if ((composer as? View)?.isVisible == true) { composer.editText.showKeyboard(andRequestFocus = true) } } @@ -499,7 +453,7 @@ class MessageComposerFragment : VectorBaseFragment(), A composer.sendButton.alpha = 0f composer.sendButton.isVisible = true composer.sendButton.animate().alpha(1f).setDuration(150).start() - } else if (!event.isVisible) { + } else { composer.sendButton.isInvisible = true } } @@ -510,15 +464,6 @@ class MessageComposerFragment : VectorBaseFragment(), A } } - private fun getAudioContentBodyText(messageContent: MessageAudioContent): String { - val formattedDuration = DateUtils.formatElapsedTime(((messageContent.audioInfo?.duration ?: 0) / 1000).toLong()) - return if (messageContent.voiceMessageIndicator != null) { - getString(R.string.voice_message_reply_content, formattedDuration) - } else { - getString(R.string.audio_message_reply_content, messageContent.body, formattedDuration) - } - } - private fun createEmojiPopup(): EmojiPopup { return EmojiPopup( rootView = views.root, @@ -840,11 +785,6 @@ class MessageComposerFragment : VectorBaseFragment(), A return displayName } - /** - * Returns the root thread event if we are in a thread room, otherwise returns null. - */ - fun getRootThreadEventId(): String? = withState(timelineViewModel) { it.rootThreadEventId } - /** * Returns true if the current room is a Thread room, false otherwise. */ diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerMode.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerMode.kt new file mode 100644 index 0000000000..a401f04bf5 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerMode.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.composer + +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent + +sealed interface MessageComposerMode { + data class Normal(val content: CharSequence?) : MessageComposerMode + + sealed class Special(open val event: TimelineEvent, open val defaultContent: CharSequence) : MessageComposerMode + data class Edit(override val event: TimelineEvent, override val defaultContent: CharSequence) : Special(event, defaultContent) + class Quote(override val event: TimelineEvent, override val defaultContent: CharSequence) : Special(event, defaultContent) + class Reply(override val event: TimelineEvent, override val defaultContent: CharSequence) : Special(event, defaultContent) +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt index b7e0e29679..44fcf22d4a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt @@ -19,35 +19,24 @@ package im.vector.app.features.home.room.detail.composer import android.text.Editable import android.widget.EditText import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView interface MessageComposerView { + companion object { + const val MAX_LINES_WHEN_COLLAPSED = 10 + } + val text: Editable? val formattedText: String? val editText: EditText val emojiButton: ImageButton? val sendButton: ImageButton val attachmentButton: ImageButton - val fullScreenButton: ImageButton? - val composerRelatedMessageTitle: TextView - val composerRelatedMessageContent: TextView - val composerRelatedMessageImage: ImageView - val composerRelatedMessageActionIcon: ImageView - val composerRelatedMessageAvatar: ImageView var callback: Callback? - var isVisible: Boolean - - fun collapse(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) - fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) fun setTextIfDifferent(text: CharSequence?): Boolean - fun replaceFormattedContent(text: CharSequence) - fun toggleFullScreen(newValue: Boolean) - - fun setInvisible(isInvisible: Boolean) + fun renderComposerMode(mode: MessageComposerMode) } interface Callback : ComposerEditText.Callback { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt index 939a59fcca..8f4dd9b71d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt @@ -19,44 +19,59 @@ package im.vector.app.features.home.room.detail.composer import android.content.Context import android.net.Uri import android.text.Editable +import android.text.format.DateUtils import android.util.AttributeSet -import android.view.ViewGroup import android.widget.EditText import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.constraintlayout.widget.ConstraintSet +import android.widget.LinearLayout +import androidx.core.content.ContextCompat import androidx.core.text.toSpannable -import androidx.core.view.isInvisible import androidx.core.view.isVisible -import androidx.transition.ChangeBounds -import androidx.transition.Fade -import androidx.transition.Transition -import androidx.transition.TransitionManager -import androidx.transition.TransitionSet +import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R -import im.vector.app.core.animations.SimpleTransitionListener +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.extensions.setTextIfDifferent +import im.vector.app.core.extensions.showKeyboard +import im.vector.app.core.utils.DimensionConverter import im.vector.app.databinding.ComposerLayoutBinding +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider +import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData +import im.vector.app.features.html.EventHtmlRenderer +import im.vector.app.features.html.PillsPostProcessor +import im.vector.app.features.media.ImageContentRenderer +import org.commonmark.parser.Parser +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageFormat +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent +import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent +import org.matrix.android.sdk.api.util.MatrixItem +import org.matrix.android.sdk.api.util.toMatrixItem +import javax.inject.Inject /** * Encapsulate the timeline composer UX. */ +@AndroidEntryPoint class PlainTextComposerLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr), MessageComposerView { +) : LinearLayout(context, attrs, defStyleAttr), MessageComposerView { + + @Inject lateinit var avatarRenderer: AvatarRenderer + @Inject lateinit var matrixItemColorProvider: MatrixItemColorProvider + @Inject lateinit var eventHtmlRenderer: EventHtmlRenderer + @Inject lateinit var dimensionConverter: DimensionConverter + @Inject lateinit var imageContentRenderer: ImageContentRenderer + @Inject lateinit var pillsPostProcessorFactory: PillsPostProcessor.Factory private val views: ComposerLayoutBinding override var callback: Callback? = null - private var currentConstraintSetId: Int = -1 - - private val animationDuration = 100L - override val text: Editable? get() = views.composerEditText.text @@ -65,37 +80,23 @@ class PlainTextComposerLayout @JvmOverloads constructor( override val editText: EditText get() = views.composerEditText + @Suppress("RedundantNullableReturnType") override val emojiButton: ImageButton? get() = views.composerEmojiButton override val sendButton: ImageButton get() = views.sendButton - override fun setInvisible(isInvisible: Boolean) { - this.isInvisible = isInvisible - } override val attachmentButton: ImageButton get() = views.attachmentButton - override val fullScreenButton: ImageButton? = null - override val composerRelatedMessageActionIcon: ImageView - get() = views.composerRelatedMessageActionIcon - override val composerRelatedMessageAvatar: ImageView - get() = views.composerRelatedMessageAvatar - override val composerRelatedMessageContent: TextView - get() = views.composerRelatedMessageContent - override val composerRelatedMessageImage: ImageView - get() = views.composerRelatedMessageImage - override val composerRelatedMessageTitle: TextView - get() = views.composerRelatedMessageTitle - override var isVisible: Boolean - get() = views.root.isVisible - set(value) { views.root.isVisible = value } init { inflate(context, R.layout.composer_layout, this) views = ComposerLayoutBinding.bind(this) - collapse(false) + views.composerEditText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED + + collapse() views.composerEditText.callback = object : ComposerEditText.Callback { override fun onRichContentSelected(contentUri: Uri): Boolean { @@ -121,27 +122,15 @@ class PlainTextComposerLayout @JvmOverloads constructor( } } - override fun replaceFormattedContent(text: CharSequence) { - setTextIfDifferent(text) - } - - override fun collapse(animate: Boolean, transitionComplete: (() -> Unit)?) { - if (currentConstraintSetId == R.layout.composer_layout_constraint_set_compact) { - // ignore we good - return - } - currentConstraintSetId = R.layout.composer_layout_constraint_set_compact - applyNewConstraintSet(animate, transitionComplete) + private fun collapse(transitionComplete: (() -> Unit)? = null) { + views.relatedMessageGroup.isVisible = false + transitionComplete?.invoke() callback?.onExpandOrCompactChange() } - override fun expand(animate: Boolean, transitionComplete: (() -> Unit)?) { - if (currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded) { - // ignore we good - return - } - currentConstraintSetId = R.layout.composer_layout_constraint_set_expanded - applyNewConstraintSet(animate, transitionComplete) + private fun expand(transitionComplete: (() -> Unit)? = null) { + views.relatedMessageGroup.isVisible = true + transitionComplete?.invoke() callback?.onExpandOrCompactChange() } @@ -149,35 +138,92 @@ class PlainTextComposerLayout @JvmOverloads constructor( return views.composerEditText.setTextIfDifferent(text) } - override fun toggleFullScreen(newValue: Boolean) { - // Plain text composer has no full screen + override fun renderComposerMode(mode: MessageComposerMode) { + val specialMode = mode as? MessageComposerMode.Special + if (specialMode != null) { + renderSpecialMode(specialMode) + } else if (mode is MessageComposerMode.Normal) { + collapse() + editText.setTextIfDifferent(mode.content) + } + + views.sendButton.apply { + if (mode is MessageComposerMode.Edit) { + contentDescription = resources.getString(R.string.action_save) + setImageResource(R.drawable.ic_composer_rich_text_save) + } else { + contentDescription = resources.getString(R.string.action_send) + setImageResource(R.drawable.ic_rich_composer_send) + } + } } - private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { - // val wasSendButtonInvisible = views.sendButton.isInvisible - if (animate) { - configureAndBeginTransition(transitionComplete) + private fun renderSpecialMode(specialMode: MessageComposerMode.Special) { + val event = specialMode.event + val defaultContent = specialMode.defaultContent + + val iconRes: Int = when (specialMode) { + is MessageComposerMode.Reply -> R.drawable.ic_reply + is MessageComposerMode.Edit -> R.drawable.ic_edit + is MessageComposerMode.Quote -> R.drawable.ic_quote } - ConstraintSet().also { - it.clone(context, currentConstraintSetId) - it.applyTo(this) + + val pillsPostProcessor = pillsPostProcessorFactory.create(event.roomId) + + // switch to expanded bar + views.composerRelatedMessageTitle.apply { + text = event.senderInfo.disambiguatedDisplayName + setTextColor(matrixItemColorProvider.getColor(MatrixItem.UserItem(event.root.senderId ?: "@"))) + } + + val messageContent: MessageContent? = event.getVectorLastMessageContent() + val nonFormattedBody = when (messageContent) { + is MessageAudioContent -> getAudioContentBodyText(messageContent) + is MessagePollContent -> messageContent.getBestPollCreationInfo()?.question?.getBestQuestion() + is MessageBeaconInfoContent -> resources.getString(R.string.live_location_description) + else -> messageContent?.body.orEmpty() + } + var formattedBody: CharSequence? = null + if (messageContent is MessageTextContent && messageContent.format == MessageFormat.FORMAT_MATRIX_HTML) { + val parser = Parser.builder().build() + val document = parser.parse(messageContent.formattedBody ?: messageContent.body) + formattedBody = eventHtmlRenderer.render(document, pillsPostProcessor) + } + views.composerRelatedMessageContent.text = (formattedBody ?: nonFormattedBody) + + // Image Event + val data = event.buildImageContentRendererData(dimensionConverter.dpToPx(66)) + val isImageVisible = if (data != null) { + imageContentRenderer.render(data, ImageContentRenderer.Mode.THUMBNAIL, views.composerRelatedMessageImage) + true + } else { + imageContentRenderer.clear(views.composerRelatedMessageImage) + false + } + + views.composerRelatedMessageImage.isVisible = isImageVisible + + views.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(context, iconRes)) + + avatarRenderer.render(event.senderInfo.toMatrixItem(), views.composerRelatedMessageAvatar) + + views.composerEditText.setText(defaultContent) + + expand { + // need to do it here also when not using quick reply + if (isVisible) { + showKeyboard(andRequestFocus = true) + } + views.composerRelatedMessageImage.isVisible = isImageVisible } - // Might be updated by view state just after, but avoid blinks - // views.sendButton.isInvisible = wasSendButtonInvisible } - private fun configureAndBeginTransition(transitionComplete: (() -> Unit)? = null) { - val transition = TransitionSet().apply { - ordering = TransitionSet.ORDERING_SEQUENTIAL - addTransition(ChangeBounds()) - addTransition(Fade(Fade.IN)) - duration = animationDuration - addListener(object : SimpleTransitionListener() { - override fun onTransitionEnd(transition: Transition) { - transitionComplete?.invoke() - } - }) + private fun getAudioContentBodyText(messageContent: MessageAudioContent): String { + val formattedDuration = DateUtils.formatElapsedTime(((messageContent.audioInfo?.duration ?: 0) / 1000).toLong()) + return if (messageContent.voiceMessageIndicator != null) { + resources.getString(R.string.voice_message_reply_content, formattedDuration) + } else { + resources.getString(R.string.audio_message_reply_content, messageContent.body, formattedDuration) } - TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt index 2d2a4a8cd2..85f163360f 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt @@ -16,25 +16,34 @@ package im.vector.app.features.home.room.detail.composer +import android.annotation.SuppressLint import android.content.Context +import android.content.res.ColorStateList +import android.content.res.Configuration +import android.graphics.Color import android.text.Editable import android.text.TextWatcher import android.util.AttributeSet +import android.util.TypedValue import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup import android.widget.EditText import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView +import android.widget.LinearLayout import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.constraintlayout.widget.ConstraintSet import androidx.core.text.toSpannable +import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import com.google.android.material.shape.MaterialShapeDrawable import im.vector.app.R -import im.vector.app.core.extensions.animateLayoutChange +import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.extensions.setTextIfDifferent +import im.vector.app.core.extensions.showKeyboard import im.vector.app.databinding.ComposerRichTextLayoutBinding import im.vector.app.databinding.ViewRichTextMenuButtonBinding import io.element.android.wysiwyg.EditorEditText @@ -46,23 +55,22 @@ class RichTextComposerLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr), MessageComposerView { +) : LinearLayout(context, attrs, defStyleAttr), MessageComposerView { private val views: ComposerRichTextLayoutBinding override var callback: Callback? = null - private var currentConstraintSetId: Int = -1 - private val animationDuration = 100L - private val maxEditTextLinesWhenCollapsed = 12 - - private val isFullScreen: Boolean get() = currentConstraintSetId == R.layout.composer_rich_text_layout_constraint_set_fullscreen + // There is no need to persist these values since they're always updated by the parent fragment + private var isFullScreen = false + private var hasRelatedMessage = false var isTextFormattingEnabled = true set(value) { if (field == value) return syncEditTexts() field = value + updateTextFieldBorder(isFullScreen) updateEditTextVisibility() } @@ -82,37 +90,94 @@ class RichTextComposerLayout @JvmOverloads constructor( get() = views.sendButton override val attachmentButton: ImageButton get() = views.attachmentButton - override val fullScreenButton: ImageButton? - get() = views.composerFullScreenButton - override val composerRelatedMessageActionIcon: ImageView - get() = views.composerRelatedMessageActionIcon - override val composerRelatedMessageAvatar: ImageView - get() = views.composerRelatedMessageAvatar - override val composerRelatedMessageContent: TextView - get() = views.composerRelatedMessageContent - override val composerRelatedMessageImage: ImageView - get() = views.composerRelatedMessageImage - override val composerRelatedMessageTitle: TextView - get() = views.composerRelatedMessageTitle - override var isVisible: Boolean - get() = views.root.isVisible - set(value) { views.root.isVisible = value } + + // Border of the EditText + private val borderShapeDrawable: MaterialShapeDrawable by lazy { + MaterialShapeDrawable().apply { + val typedData = TypedValue() + val lineColor = context.theme.obtainStyledAttributes(typedData.data, intArrayOf(R.attr.vctr_content_quaternary)) + .getColor(0, 0) + strokeColor = ColorStateList.valueOf(lineColor) + strokeWidth = 1 * resources.displayMetrics.scaledDensity + fillColor = ColorStateList.valueOf(Color.TRANSPARENT) + val cornerSize = resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line) + setCornerSize(cornerSize.toFloat()) + } + } + + fun setFullScreen(isFullScreen: Boolean) { + editText.updateLayoutParams { + height = if (isFullScreen) 0 else ViewGroup.LayoutParams.WRAP_CONTENT + } + + updateTextFieldBorder(isFullScreen) + updateEditTextVisibility() + + updateEditTextFullScreenState(views.richTextComposerEditText, isFullScreen) + updateEditTextFullScreenState(views.plainTextComposerEditText, isFullScreen) + + views.composerFullScreenButton.setImageResource( + if (isFullScreen) R.drawable.ic_composer_collapse else R.drawable.ic_composer_full_screen + ) + + views.bottomSheetHandle.isVisible = isFullScreen + if (isFullScreen) { + editText.showKeyboard(true) + } else { + editText.hideKeyboard() + } + this.isFullScreen = isFullScreen + } + + fun notifyIsBeingDragged(percentage: Float) { + // Calculate a new shape for the border according to the position in screen + val isSingleLine = editText.lineCount == 1 + val cornerSize = if (!isSingleLine || hasRelatedMessage) { + resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded).toFloat() + } else { + val multilineCornerSize = resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded) + val singleLineCornerSize = resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line) + val diff = singleLineCornerSize - multilineCornerSize + multilineCornerSize + diff * (1 - percentage) + } + if (cornerSize != borderShapeDrawable.bottomLeftCornerResolvedSize) { + borderShapeDrawable.setCornerSize(cornerSize) + } + + // Change maxLines while dragging, this should improve the smoothness of animations + val maxLines = if (percentage > 0.25f) { + Int.MAX_VALUE + } else { + MessageComposerView.MAX_LINES_WHEN_COLLAPSED + } + views.richTextComposerEditText.maxLines = maxLines + views.plainTextComposerEditText.maxLines = maxLines + + views.bottomSheetHandle.isVisible = true + } init { inflate(context, R.layout.composer_rich_text_layout, this) views = ComposerRichTextLayoutBinding.bind(this) - collapse(false) + // Workaround to avoid cut-off text caused by padding in scrolled TextView (there is no clipToPadding). + // In TextView, clipTop = padding, but also clipTop -= shadowRadius. So if we set the shadowRadius to padding, they cancel each other + views.richTextComposerEditText.setShadowLayer(views.richTextComposerEditText.paddingBottom.toFloat(), 0f, 0f, 0) + views.plainTextComposerEditText.setShadowLayer(views.richTextComposerEditText.paddingBottom.toFloat(), 0f, 0f, 0) + + renderComposerMode(MessageComposerMode.Normal(null)) views.richTextComposerEditText.addTextChangedListener( - TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder() }) + TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder(isFullScreen) }) ) views.plainTextComposerEditText.addTextChangedListener( - TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder() }) + TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder(isFullScreen) }) ) - views.composerRelatedMessageCloseButton.setOnClickListener { - collapse() + disallowParentInterceptTouchEvent(views.richTextComposerEditText) + disallowParentInterceptTouchEvent(views.plainTextComposerEditText) + + views.composerModeCloseView.setOnClickListener { callback?.onCloseRelatedMessage() } @@ -125,11 +190,19 @@ class RichTextComposerLayout @JvmOverloads constructor( callback?.onAddAttachment() } - views.composerFullScreenButton.setOnClickListener { - callback?.onFullScreenModeChanged() + views.composerFullScreenButton.apply { + // There's no point in having full screen in landscape since there's almost no vertical space + isInvisible = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + setOnClickListener { + callback?.onFullScreenModeChanged() + } } + views.composerEditTextOuterBorder.background = borderShapeDrawable + setupRichTextMenu() + + updateTextFieldBorder(isFullScreen) } private fun setupRichTextMenu() { @@ -147,6 +220,21 @@ class RichTextComposerLayout @JvmOverloads constructor( } } + @SuppressLint("ClickableViewAccessibility") + private fun disallowParentInterceptTouchEvent(view: View) { + view.setOnTouchListener { v, event -> + if (v.hasFocus()) { + v.parent?.requestDisallowInterceptTouchEvent(true) + val action = event.actionMasked + if (action == MotionEvent.ACTION_SCROLL) { + v.parent?.requestDisallowInterceptTouchEvent(false) + return@setOnTouchListener true + } + } + false + } + } + override fun onAttachedToWindow() { super.onAttachedToWindow() @@ -197,84 +285,99 @@ class RichTextComposerLayout @JvmOverloads constructor( button.isSelected = menuState.reversedActions.contains(action) } - private fun updateTextFieldBorder() { - val isExpanded = editText.editableText.lines().count() > 1 - val borderResource = if (isExpanded || isFullScreen) { - R.drawable.bg_composer_rich_edit_text_expanded + fun estimateCollapsedHeight(): Int { + val editText = this.editText + val originalLines = editText.maxLines + val originalParamsHeight = editText.layoutParams.height + editText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED + editText.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT + measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.UNSPECIFIED, + ) + val result = measuredHeight + editText.layoutParams.height = originalParamsHeight + editText.maxLines = originalLines + return result + } + + private fun updateTextFieldBorder(isFullScreen: Boolean) { + val isMultiline = editText.editableText.lines().count() > 1 || isFullScreen || hasRelatedMessage + val cornerSize = if (isMultiline) { + resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded) } else { - R.drawable.bg_composer_rich_edit_text_single_line - } - views.composerEditTextOuterBorder.setBackgroundResource(borderResource) + resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line) + }.toFloat() + borderShapeDrawable.setCornerSize(cornerSize) } - override fun replaceFormattedContent(text: CharSequence) { + private fun replaceFormattedContent(text: CharSequence) { views.richTextComposerEditText.setHtml(text.toString()) - } - - override fun collapse(animate: Boolean, transitionComplete: (() -> Unit)?) { - if (currentConstraintSetId == R.layout.composer_rich_text_layout_constraint_set_compact) { - // ignore we good - return - } - currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_compact - applyNewConstraintSet(animate, transitionComplete) - updateEditTextVisibility() - } - - override fun expand(animate: Boolean, transitionComplete: (() -> Unit)?) { - if (currentConstraintSetId == R.layout.composer_rich_text_layout_constraint_set_expanded) { - // ignore we good - return - } - currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_expanded - applyNewConstraintSet(animate, transitionComplete) - updateEditTextVisibility() + updateTextFieldBorder(isFullScreen) } override fun setTextIfDifferent(text: CharSequence?): Boolean { - return editText.setTextIfDifferent(text) - } - - override fun toggleFullScreen(newValue: Boolean) { - val constraintSetId = if (newValue) R.layout.composer_rich_text_layout_constraint_set_fullscreen else currentConstraintSetId - ConstraintSet().also { - it.clone(context, constraintSetId) - it.applyTo(this) - } - - updateTextFieldBorder() - updateEditTextVisibility() - - updateEditTextFullScreenState(views.richTextComposerEditText, newValue) - updateEditTextFullScreenState(views.plainTextComposerEditText, newValue) + val result = editText.setTextIfDifferent(text) + updateTextFieldBorder(isFullScreen) + return result } private fun updateEditTextFullScreenState(editText: EditText, isFullScreen: Boolean) { if (isFullScreen) { editText.maxLines = Int.MAX_VALUE - // This is a workaround to fix incorrect scroll position when maximised - post { editText.requestLayout() } } else { - editText.maxLines = maxEditTextLinesWhenCollapsed + editText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED } } - private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { - // val wasSendButtonInvisible = views.sendButton.isInvisible - if (animate) { - animateLayoutChange(animationDuration, transitionComplete) - } - ConstraintSet().also { - it.clone(context, currentConstraintSetId) - it.applyTo(this) + override fun renderComposerMode(mode: MessageComposerMode) { + if (mode is MessageComposerMode.Special) { + views.composerModeGroup.isVisible = true + replaceFormattedContent(mode.defaultContent) + hasRelatedMessage = true + editText.showKeyboard(andRequestFocus = true) + } else { + views.composerModeGroup.isGone = true + (mode as? MessageComposerMode.Normal)?.content?.let { text -> + if (isTextFormattingEnabled) { + replaceFormattedContent(text) + } else { + views.plainTextComposerEditText.setText(text) + } + } + views.sendButton.contentDescription = resources.getString(R.string.action_send) + hasRelatedMessage = false } - // Might be updated by view state just after, but avoid blinks - // views.sendButton.isInvisible = wasSendButtonInvisible - } + views.sendButton.apply { + if (mode is MessageComposerMode.Edit) { + contentDescription = resources.getString(R.string.action_save) + setImageResource(R.drawable.ic_composer_rich_text_save) + } else { + contentDescription = resources.getString(R.string.action_send) + setImageResource(R.drawable.ic_rich_composer_send) + } + } - override fun setInvisible(isInvisible: Boolean) { - this.isInvisible = isInvisible + updateTextFieldBorder(isFullScreen) + + when (mode) { + is MessageComposerMode.Edit -> { + views.composerModeTitleView.setText(R.string.editing) + views.composerModeIconView.setImageResource(R.drawable.ic_composer_rich_text_editor_edit) + } + is MessageComposerMode.Quote -> { + views.composerModeTitleView.setText(R.string.quoting) + views.composerModeIconView.setImageResource(R.drawable.ic_quote) + } + is MessageComposerMode.Reply -> { + val senderInfo = mode.event.senderInfo + val userName = senderInfo.displayName ?: senderInfo.disambiguatedDisplayName + views.composerModeTitleView.text = resources.getString(R.string.replying_to, userName) + views.composerModeIconView.setImageResource(R.drawable.ic_reply) + } + else -> Unit + } } private class TextChangeListener( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt index cd41219371..ca31c53bb3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt @@ -147,7 +147,8 @@ class VoiceMessageViews( } fun showRecordingViews() { - views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic_recording) + views.voiceMessageBackgroundView.isVisible = true + views.voiceMessageMicButton.setImageResource(R.drawable.ic_composer_rich_mic_pressed) views.voiceMessageMicButton.setAttributeTintedBackground(R.drawable.circle_with_halo, R.attr.colorPrimary) views.voiceMessageMicButton.updateLayoutParams { setMargins(0, 0, 0, 0) @@ -172,6 +173,7 @@ class VoiceMessageViews( fun hideRecordingViews(recordingState: RecordingUiState) { // We need to animate the lock image first + views.voiceMessageBackgroundView.isVisible = false if (recordingState !is RecordingUiState.Locked) { views.voiceMessageLockImage.isVisible = false views.voiceMessageLockImage.animate().translationY(0f).start() @@ -278,6 +280,7 @@ class VoiceMessageViews( fun showDraftViews() { hideRecordingViews(RecordingUiState.Idle) + views.voiceMessageBackgroundView.isVisible = true views.voiceMessageMicButton.isVisible = false views.voiceMessageSendButton.isVisible = true views.voiceMessagePlaybackLayout.isVisible = true @@ -288,6 +291,7 @@ class VoiceMessageViews( fun showRecordingLockedViews(recordingState: RecordingUiState) { hideRecordingViews(recordingState) + views.voiceMessageBackgroundView.isVisible = true views.voiceMessagePlaybackLayout.isVisible = true views.voiceMessagePlaybackTimerIndicator.isVisible = true views.voicePlaybackControlButton.isVisible = false diff --git a/vector/src/main/res/drawable/bg_composer_rich_bottom_sheet.xml b/vector/src/main/res/drawable/bg_composer_rich_bottom_sheet.xml new file mode 100644 index 0000000000..47364373f7 --- /dev/null +++ b/vector/src/main/res/drawable/bg_composer_rich_bottom_sheet.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/vector/src/main/res/drawable/bg_composer_rich_edit_text_expanded.xml b/vector/src/main/res/drawable/bg_composer_rich_edit_text_expanded.xml deleted file mode 100644 index 26d997e7db..0000000000 --- a/vector/src/main/res/drawable/bg_composer_rich_edit_text_expanded.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - diff --git a/vector/src/main/res/drawable/bg_composer_rich_edit_text_single_line.xml b/vector/src/main/res/drawable/bg_composer_rich_edit_text_single_line.xml deleted file mode 100644 index 7e2745a137..0000000000 --- a/vector/src/main/res/drawable/bg_composer_rich_edit_text_single_line.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - diff --git a/vector/src/main/res/drawable/bottomsheet_handle.xml b/vector/src/main/res/drawable/bottomsheet_handle.xml new file mode 100644 index 0000000000..89ccf57ed0 --- /dev/null +++ b/vector/src/main/res/drawable/bottomsheet_handle.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/vector/src/main/res/drawable/ic_composer_collapse.xml b/vector/src/main/res/drawable/ic_composer_collapse.xml new file mode 100644 index 0000000000..724a833761 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_collapse.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_composer_full_screen.xml b/vector/src/main/res/drawable/ic_composer_full_screen.xml index 394dc52279..de1862c09b 100644 --- a/vector/src/main/res/drawable/ic_composer_full_screen.xml +++ b/vector/src/main/res/drawable/ic_composer_full_screen.xml @@ -1,9 +1,9 @@ - + android:width="20dp" + android:height="20dp" + android:viewportWidth="20" + android:viewportHeight="20"> + diff --git a/vector/src/main/res/drawable/ic_composer_rich_mic_pressed.xml b/vector/src/main/res/drawable/ic_composer_rich_mic_pressed.xml new file mode 100644 index 0000000000..e9dbe610e4 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_rich_mic_pressed.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/vector/src/main/res/drawable/ic_composer_rich_text_editor_close.xml b/vector/src/main/res/drawable/ic_composer_rich_text_editor_close.xml new file mode 100644 index 0000000000..c461470de5 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_rich_text_editor_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml b/vector/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml new file mode 100644 index 0000000000..4556974221 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_composer_rich_text_save.xml b/vector/src/main/res/drawable/ic_composer_rich_text_save.xml new file mode 100644 index 0000000000..f270d6f8ae --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_rich_text_save.xml @@ -0,0 +1,16 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_rich_composer_add.xml b/vector/src/main/res/drawable/ic_rich_composer_add.xml new file mode 100644 index 0000000000..3a90a40902 --- /dev/null +++ b/vector/src/main/res/drawable/ic_rich_composer_add.xml @@ -0,0 +1,15 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_rich_composer_send.xml b/vector/src/main/res/drawable/ic_rich_composer_send.xml new file mode 100644 index 0000000000..0f99c1670e --- /dev/null +++ b/vector/src/main/res/drawable/ic_rich_composer_send.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_voice_mic_recording.xml b/vector/src/main/res/drawable/ic_voice_mic_recording.xml deleted file mode 100644 index a57852c92f..0000000000 --- a/vector/src/main/res/drawable/ic_voice_mic_recording.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/vector/src/main/res/layout/composer_layout.xml b/vector/src/main/res/layout/composer_layout.xml index fb0d80278a..7c465891c3 100644 --- a/vector/src/main/res/layout/composer_layout.xml +++ b/vector/src/main/res/layout/composer_layout.xml @@ -1,148 +1,210 @@ - + android:orientation="vertical"> - - - - - - - - - - + android:visibility="gone" + tools:visibility="visible"> - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/composer_layout_constraint_set_compact.xml b/vector/src/main/res/layout/composer_layout_constraint_set_compact.xml deleted file mode 100644 index 81b978caa6..0000000000 --- a/vector/src/main/res/layout/composer_layout_constraint_set_compact.xml +++ /dev/null @@ -1,197 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/layout/composer_layout_constraint_set_expanded.xml b/vector/src/main/res/layout/composer_layout_constraint_set_expanded.xml deleted file mode 100644 index 8cdb388bf9..0000000000 --- a/vector/src/main/res/layout/composer_layout_constraint_set_expanded.xml +++ /dev/null @@ -1,197 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/layout/composer_rich_text_layout.xml b/vector/src/main/res/layout/composer_rich_text_layout.xml index c5afe1eb44..5f37de2a3a 100644 --- a/vector/src/main/res/layout/composer_rich_text_layout.xml +++ b/vector/src/main/res/layout/composer_rich_text_layout.xml @@ -1,183 +1,201 @@ - - - - - - - - - - - - - - - - - - - - - - + android:orientation="vertical" + android:background="@drawable/bg_composer_rich_bottom_sheet"> - - + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent"> - - + - + - + - + - + + + + + android:layout_marginStart="6dp" + android:layout_marginTop="8dp" + android:paddingBottom="2dp" + android:fontFamily="sans-serif-medium" + tools:text="Editing" + style="@style/BottomSheetItemTime" + app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder" + app:layout_constraintStart_toEndOf="@id/composerModeIconView" /> - + - + - + - + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml deleted file mode 100644 index 1a3023a805..0000000000 --- a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml +++ /dev/null @@ -1,233 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml deleted file mode 100644 index b0380d2e13..0000000000 --- a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml +++ /dev/null @@ -1,230 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_fullscreen.xml b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_fullscreen.xml deleted file mode 100644 index 3105063933..0000000000 --- a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_fullscreen.xml +++ /dev/null @@ -1,234 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/layout/fragment_composer.xml b/vector/src/main/res/layout/fragment_composer.xml index 41c052367a..5038a9e179 100644 --- a/vector/src/main/res/layout/fragment_composer.xml +++ b/vector/src/main/res/layout/fragment_composer.xml @@ -4,12 +4,13 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="match_parent"> + android:layout_height="match_parent" + android:background="@android:color/transparent"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_width="match_parent" + android:layout_height="match_parent" + android:id="@+id/rootConstraintLayout"> - + app:layout_constraintTop_toTopOf="parent" /> - + + + + + app:layout_constraintTop_toBottomOf="@id/removeJitsiWidgetView" + tools:listitem="@layout/item_timeline_event_base" /> + + + + + + + + + + + + + + + + + + + + + + + - + + + + + diff --git a/vector/src/main/res/layout/fragment_timeline_fullscreen.xml b/vector/src/main/res/layout/fragment_timeline_fullscreen.xml deleted file mode 100644 index 373ca74f56..0000000000 --- a/vector/src/main/res/layout/fragment_timeline_fullscreen.xml +++ /dev/null @@ -1,258 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/layout/view_rich_text_menu_button.xml b/vector/src/main/res/layout/view_rich_text_menu_button.xml index 24b19c10b5..b99a29da2b 100644 --- a/vector/src/main/res/layout/view_rich_text_menu_button.xml +++ b/vector/src/main/res/layout/view_rich_text_menu_button.xml @@ -2,8 +2,8 @@ + + @@ -109,7 +118,7 @@ android:id="@+id/voiceMessageLockImage" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="28dp" + android:layout_marginTop="16dp" android:importantForAccessibility="no" android:src="@drawable/ic_voice_message_unlocked" android:visibility="gone" @@ -123,7 +132,6 @@ android:id="@+id/voiceMessageLockArrow" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="4dp" android:layout_marginBottom="14dp" android:importantForAccessibility="no" android:src="@drawable/ic_voice_lock_arrow" From 208bf6eb2e3378a3944ee54d80d8742904985c00 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 17 Nov 2022 14:32:37 +0100 Subject: [PATCH 047/173] First version of the release script. Release the APP. --- tools/release/releaseScript.sh | 239 +++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100755 tools/release/releaseScript.sh diff --git a/tools/release/releaseScript.sh b/tools/release/releaseScript.sh new file mode 100755 index 0000000000..f7f5bf17c1 --- /dev/null +++ b/tools/release/releaseScript.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash + +# +# Copyright (c) 2022 New Vector Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Ignore any error to not stop the script +set +e + +printf "\n" +printf "================================================================================\n" +printf "| Welcome to the release script! |\n" +printf "================================================================================\n" + +releaseScriptLocation="${RELEASE_SCRIPT_PATH}" + +if [[ -z "${releaseScriptLocation}" ]]; then + printf "Fatal: RELEASE_SCRIPT_PATH is not defined in the environment. Please set to the path of your local file 'releaseElement2.sh'.\n" + exit 1 +fi + +releaseScriptFullPath="${releaseScriptLocation}/releaseElement2.sh" + +if [[ ! -f ${releaseScriptFullPath} ]]; then + printf "Fatal: release script not found at ${releaseScriptFullPath}.\n" + exit 1 +fi + +# Guessing version to propose a default version +versionMajorCandidate=`grep "ext.versionMajor" ./vector-app/build.gradle | cut -d " " -f3` +versionMinorCandidate=`grep "ext.versionMinor" ./vector-app/build.gradle | cut -d " " -f3` +versionPatchCandidate=`grep "ext.versionPatch" ./vector-app/build.gradle | cut -d " " -f3` +versionCandidate="${versionMajorCandidate}.${versionMinorCandidate}.${versionPatchCandidate}" + +printf "\n" +read -p "Please enter the release version (example: ${versionCandidate}). Just press enter if ${versionCandidate} is correct. " version +version=${version:-${versionCandidate}} + +# extract major, minor and patch for future use +versionMajor=`echo ${version} | cut -d "." -f1` +versionMinor=`echo ${version} | cut -d "." -f2` +versionPatch=`echo ${version} | cut -d "." -f3` +nextPatchVersion=$((versionPatch + 2)) + +printf "\n================================================================================\n" +printf "Ensuring main and develop branches are up to date...\n" + +git checkout main +git pull +git checkout develop +git pull + +printf "\n================================================================================\n" +printf "Starting the release ${version}\n" +git flow release start ${version} + +# Note: in case the release is already started and the script is started again, checkout the release branch again. +ret=$? +if [[ $ret -ne 0 ]]; then + printf "Mmh, it seems that the release is already started. Checking out the release branch...\n" + git checkout "release/${version}" +fi + +# Ensure version is OK +cp ./vector-app/build.gradle ./vector-app/build.gradle.bak +sed "s/ext.versionMajor = .*/ext.versionMajor = ${versionMajor}/" ./vector-app/build.gradle.bak > ./vector-app/build.gradle +sed "s/ext.versionMinor = .*/ext.versionMinor = ${versionMinor}/" ./vector-app/build.gradle > ./vector-app/build.gradle.bak +sed "s/ext.versionPatch = .*/ext.versionPatch = ${patchVersion}/" ./vector-app/build.gradle.bak > ./vector-app/build.gradle +rm ./vector-app/build.gradle.bak +cp ./matrix-sdk-android/build.gradle ./matrix-sdk-android/build.gradle.bak +sed "s/\"SDK_VERSION\", .*$/\"SDK_VERSION\", \"\\\\\"${version}\\\\\"\"/" ./matrix-sdk-android/build.gradle.bak > ./matrix-sdk-android/build.gradle +rm ./matrix-sdk-android/build.gradle.bak + +# This commit may have no effect because generally we do not change the version during the release. +commit -a -m "Setting version for the release ${version}" + +printf "\n================================================================================\n" +read -p "Please check the crashes from the PlayStore. You can commit fixes if any on the release branch. Press enter when it's done." + +printf "\n================================================================================\n" +read -p "Please check the rageshake with the current dev version: https://github.com/matrix-org/element-android-rageshakes/labels/${version}-dev. You can commit fixes if any on the release branch. Press enter when it's done." + +printf "\n================================================================================\n" +read -p "Please make sure an emulator is running and press enter when it is ready." + +printf "\n================================================================================\n" +printf "Checking if Synapse is running...\n" +httpCode=`curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/_matrix/static` + +if [[ ${httpCode} -ne "302" ]]; then + read -p "Please make sure Synapse is running (open http://127.0.0.1:8080) and press enter when it is ready." +else + printf "Synapse is running!\n" +fi + +printf "\n================================================================================\n" +printf "Uninstalling previous test app if any...\n" +adb -e uninstall im.vector.app.debug.test + +printf "\n================================================================================\n" +printf "Running the integration test UiAllScreensSanityTest.allScreensTest()...\n" +./gradlew connectedGplayDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=im.vector.app.ui.UiAllScreensSanityTest + +printf "\n================================================================================\n" +printf "Building the app...\n" +./gradlew assembleGplayDebug + +printf "\n================================================================================\n" +printf "Uninstalling previous test app if any...\n" +adb -e uninstall im.vector.app.debug + +printf "\n================================================================================\n" +printf "Installing the app...\n" +adb -e install ./vector-app/build/outputs/apk/gplay/debug/vector-gplay-arm64-v8a-debug.apk + +printf "\n================================================================================\n" +printf "Running the app...\n" +# TODO This does not work, need to be fixed +adb -e shell am start -n im.vector.app.debug/im.vector.app.features.Alias -a android.intent.action.MAIN -c android.intent.category.LAUNCHER + +printf "\n================================================================================\n" +# TODO could build and deploy the APK to any emulator +read -p "Create an account on matrix.org and do some smoke tests that the sanity test does not cover like: 1-1 call, 1-1 video call, Jitsi call for instance. Press enter when it's done." + +printf "\n================================================================================\n" +printf "Running towncrier...\n" +yes | towncrier build --version "v${version}" + +printf "\n================================================================================\n" +read -p "Check the file CHANGES.md consistency. It's possible to reorder items (most important changes first) or change their section if relevant. Also an opportunity to fix some typo, or rewrite things. Do not commit your change. Press enter when it's done." + +printf "\n================================================================================\n" +printf "Committing...\n" +git commit -a -m "Changelog for version ${version}" + +printf "\n================================================================================\n" +printf "Creating fastlane file...\n" +printf -v versionMajor2Digits "%02d" ${versionMajor} +printf -v versionMinor2Digits "%02d" ${versionMinor} +printf -v versionPatch2Digits "%02d" ${versionPatch} +fastlaneFile="4${versionMajor2Digits}${versionMinor2Digits}${versionPatch2Digits}0.txt" +fastlanePathFile="./fastlane/metadata/android/en-US/changelogs/${fastlaneFile}" +printf "Main changes in this version: TODO.\nFull changelog: https://github.com/vector-im/element-android/releases" > ${fastlanePathFile} + +read -p "I have created the file ${fastlanePathFile}, please edit it and press enter when it's done." +git commit -a -m "Adding fastlane file for version ${version}" + +printf "\n================================================================================\n" +# We could propose to push the branch and create a PR +read -p "(optional) Push the branch and start a draft PR (will not be merged), to check that the CI is happy with all the changes. Press enter when it's done." + +printf "\n================================================================================\n" +printf "OK, finishing the release...\n" +git flow release finish "${version}" + +printf "\n================================================================================\n" +read -p "Done, push the branch 'main' and the new tag (yes/no) default to yes? " doPush +doPush=${doPush:-yes} + +if [ ${doPush} == "yes" ]; then + printf "Pushing branch 'main' and tag 'v${version}'...\n" + git push origin main + git push origin "v${version}" +else + printf "Not pushing, do not forget to push manually!\n" +fi + +printf "\n================================================================================\n" +printf "Checking out develop...\n" +git checkout develop + +# Set next version +printf "\n================================================================================\n" +printf "Setting next version on file './vector-app/build.gradle'...\n" +nextPatchVersion=$((versionPatch + 2)) +cp ./vector-app/build.gradle ./vector-app/build.gradle.bak +sed "s/ext.versionPatch = .*/ext.versionPatch = ${nextPatchVersion}/" ./vector-app/build.gradle.bak > ./vector-app/build.gradle +rm ./vector-app/build.gradle.bak + +printf "\n================================================================================\n" +printf "Setting next version on file './matrix-sdk-android/build.gradle'...\n" +nextVersion="${versionMajor}.${versionMinor}.${nextPatchVersion}" +cp ./matrix-sdk-android/build.gradle ./matrix-sdk-android/build.gradle.bak +sed "s/\"SDK_VERSION\", .*$/\"SDK_VERSION\", \"\\\\\"${nextVersion}\\\\\"\"/" ./matrix-sdk-android/build.gradle.bak > ./matrix-sdk-android/build.gradle +rm ./matrix-sdk-android/build.gradle.bak + +printf "\n================================================================================\n" +read -p "I have updated the versions to prepare the next release, please check that the change are correct and press enter so I can commit." + +printf "Committing...\n" +git commit -a -m 'version++' + +printf "\n================================================================================\n" +read -p "Done, push the branch 'develop' (yes/no) default to yes? (A rebase may be necessary in case develop got new commits)" doPush +doPush=${doPush:-yes} + +if [ ${doPush} == "yes" ]; then + printf "Pushing branch 'develop'...\n" + git push origin develop +else + printf "Not pushing, do not forget to push manually!\n" +fi + +printf "\n================================================================================\n" +read -p "Wait for Buildkite https://buildkite.com/matrix-dot-org/element-android/builds?branch=main to build the 'main' branch. Press enter when it's done." + +printf "\n================================================================================\n" +printf "Running the release script...\n" +cd ${releaseScriptLocation} +${releaseScriptFullPath} "v${version}" +cd - + +printf "\n================================================================================\n" +apkPath="${releaseScriptLocation}/Element/v${version}/vector-gplay-arm64-v8a-release-signed.apk" +printf "Installing apk on a real device...\n" +adb -d install ${apkPath} + +read -p "Please run the APK on your phone to check that the upgrade went well (no init sync, etc.). Press enter when it's done." +# TODO Get the block to copy from towncrier earlier (be may be edited by the release manager)? +read -p "Create the release on gitHub from the tag https://github.com/vector-im/element-android/tags, copy paste the block from the file CHANGES.md. Press enter when it's done." + +read -p "Add the 4 signed APKs to the GitHub release. Press enter when it's done." +read -p "Ping the Android Internal room. Press enter when it's done." + +printf "\n================================================================================\n" +printf "Congratulation! Kudos for using this script! Have a nice day!\n" +printf "================================================================================\n" From 7d8bbd6d66a041b8fa9cd7162de610d8e9b9fdad Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 18 Nov 2022 10:26:56 +0100 Subject: [PATCH 048/173] Update the issue template --- .github/ISSUE_TEMPLATE/release.yml | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/release.yml b/.github/ISSUE_TEMPLATE/release.yml index a84f4dfd3b..ff407ed8c1 100644 --- a/.github/ISSUE_TEMPLATE/release.yml +++ b/.github/ISSUE_TEMPLATE/release.yml @@ -24,27 +24,7 @@ body: ### Do the release - - [ ] Make sure `develop` and `main` are up to date and create a release with gitflow: `git checkout main; git pull; git checkout develop; git pull; git flow release start '1.2.3'` - - [ ] Check the crashes from the PlayStore - - [ ] Check the rageshake with the current dev version: https://github.com/matrix-org/element-android-rageshakes/labels/1.2.3-dev - - [ ] Run the integration test, and especially `UiAllScreensSanityTest.allScreensTest()` - - [ ] Create an account on matrix.org and do some smoke tests that the sanity test does not cover like: 1-1 call, 1-1 video call, Jitsi call for instance - - [ ] Run towncrier: `towncrier build --version v1.2.3 --draft` (remove `--draft` do write the file CHANGES.md) - - [ ] Check the file CHANGES.md consistency. It's possible to reorder items (most important changes first) or change their section if relevant. Also an opportunity to fix some typo, or rewrite things - - [ ] Add file for fastlane under ./fastlane/metadata/android/en-US/changelogs - - [ ] (optional) Push the branch and start a draft PR (will not be merged), to check that the CI is happy with all the changes. - - [ ] Finish release with gitflow, delete the draft PR (if created): `git flow release finish '1.2.3'` - - [ ] Push `main` and the new tag `v1.2.3` to origin: `git push origin main; git push origin 'v1.2.3'` - - [ ] Checkout `develop`: `git checkout develop` - - [ ] Increase version (versionPatch + 2) in `./vector/build.gradle` - - [ ] Change the value of SDK_VERSION in the file `./matrix-sdk-android/build.gradle` - - [ ] Commit and push `develop`: `git commit -m 'version++'; git push origin develop` - - [ ] Wait for [Buildkite](https://buildkite.com/matrix-dot-org/element-android/builds?branch=main) to build the `main` branch. - - [ ] Run the script `~/scripts/releaseElement.sh`. It will download the APKs from Buildkite check them and sign them. - - [ ] Install the APK on your phone to check that the upgrade went well (no init sync, etc.) - - [ ] Create the release on gitHub [from the tag](https://github.com/vector-im/element-android/tags), copy paste the block from the file CHANGES.md - - [ ] Add the 4 signed APKs to the GitHub release - - [ ] Ping the Android Internal room + - [ ] Run the script ./tools/release/releaseScript.sh and follow the steps. ### Once tested and validated internally From 729d420c2c153ac01225e618431336af19e45c33 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 18 Nov 2022 10:30:59 +0100 Subject: [PATCH 049/173] Propose message for internal room --- tools/release/releaseScript.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tools/release/releaseScript.sh b/tools/release/releaseScript.sh index f7f5bf17c1..dcb8fec40a 100755 --- a/tools/release/releaseScript.sh +++ b/tools/release/releaseScript.sh @@ -232,7 +232,11 @@ read -p "Please run the APK on your phone to check that the upgrade went well (n read -p "Create the release on gitHub from the tag https://github.com/vector-im/element-android/tags, copy paste the block from the file CHANGES.md. Press enter when it's done." read -p "Add the 4 signed APKs to the GitHub release. Press enter when it's done." -read -p "Ping the Android Internal room. Press enter when it's done." + +printf "\n================================================================================\n" +printf "Ping the Android Internal room. Here is an example of message which can be sent:\n\n" +printf "@room Element Android ${version} is ready to be tested. You can get if from https://github.com/vector-im/element-android/releases/tag/v${version}. Please report any feedback here. Thanks!\n\n" +read -p "Press enter when it's done." printf "\n================================================================================\n" printf "Congratulation! Kudos for using this script! Have a nice day!\n" From 7774f6931760badd4d4d47b6f701e7d177529bd1 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Fri, 18 Nov 2022 14:46:03 +0300 Subject: [PATCH 050/173] Fix unit test. --- .../src/androidTest/java/im/vector/app/core/utils/TestSpan.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt b/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt index 9e23e76f0c..e31dc6942c 100644 --- a/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt +++ b/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt @@ -29,6 +29,7 @@ import io.mockk.verify import io.noties.markwon.core.spans.EmphasisSpan import io.noties.markwon.core.spans.OrderedListItemSpan import io.noties.markwon.core.spans.StrongEmphasisSpan +import me.gujun.android.span.style.CustomTypefaceSpan fun Spannable.toTestSpan(): String { var output = toString() @@ -54,7 +55,7 @@ private fun Any.readTags(): SpanTags { OrderedListItemSpan::class -> SpanTags("[list item]", "[/list item]") HtmlCodeSpan::class -> SpanTags("[code]", "[/code]") StrongEmphasisSpan::class -> SpanTags("[bold]", "[/bold]") - EmphasisSpan::class -> SpanTags("[italic]", "[/italic]") + EmphasisSpan::class, CustomTypefaceSpan::class -> SpanTags("[italic]", "[/italic]") else -> throw IllegalArgumentException("Unknown ${this::class}") } } From 6c45490dd19034b407d07fcab60b5aff23074bf1 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Mon, 21 Nov 2022 18:44:45 +0300 Subject: [PATCH 051/173] Code review fixes. --- .../src/main/res/values/config-settings.xml | 1 + .../features/settings/VectorPreferences.kt | 10 ++++++ .../settings/devices/v2/DevicesViewModel.kt | 12 ++----- .../othersessions/OtherSessionsViewModel.kt | 12 ++----- .../v2/overview/SessionOverviewViewModel.kt | 12 ++----- .../main/res/layout/item_other_session.xml | 6 ++-- .../devices/v2/DevicesViewModelTest.kt | 33 ++++++++++++++++--- .../OtherSessionsViewModelTest.kt | 8 ++--- .../overview/SessionOverviewViewModelTest.kt | 8 ++--- .../app/test/fakes/FakeSharedPreferences.kt | 5 --- .../app/test/fakes/FakeVectorPreferences.kt | 4 +++ 11 files changed, 65 insertions(+), 46 deletions(-) diff --git a/vector-config/src/main/res/values/config-settings.xml b/vector-config/src/main/res/values/config-settings.xml index 504c587b8d..ef9695a080 100755 --- a/vector-config/src/main/res/values/config-settings.xml +++ b/vector-config/src/main/res/values/config-settings.xml @@ -51,6 +51,7 @@ false true false + false diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index 3424c2b54c..30b5ef9131 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -1231,4 +1231,14 @@ class VectorPreferences @Inject constructor( return vectorFeatures.isVoiceBroadcastEnabled() && defaultPrefs.getBoolean(SETTINGS_LABS_VOICE_BROADCAST_KEY, getDefault(R.bool.settings_labs_enable_voice_broadcast_default)) } + + fun showIpAddressInDeviceManagerScreens(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, getDefault(R.bool.settings_device_manager_show_ip_address)) + } + + fun setIpAddressVisibilityInDeviceManagerScreens(isVisible: Boolean) { + defaultPrefs.edit { + putBoolean(VectorPreferences.SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, isVisible) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt index b0ca32016f..971fb123f0 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt @@ -16,15 +16,12 @@ package im.vector.app.features.settings.devices.v2 -import android.content.SharedPreferences -import androidx.core.content.edit import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Success import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import im.vector.app.core.di.ActiveSessionHolder -import im.vector.app.core.di.DefaultPreferences import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.features.auth.PendingAuthHandler @@ -53,8 +50,7 @@ class DevicesViewModel @AssistedInject constructor( private val interceptSignoutFlowResponseUseCase: InterceptSignoutFlowResponseUseCase, private val pendingAuthHandler: PendingAuthHandler, refreshDevicesUseCase: RefreshDevicesUseCase, - @DefaultPreferences - private val sharedPreferences: SharedPreferences, + private val vectorPreferences: VectorPreferences, ) : VectorSessionsListViewModel(initialState, activeSessionHolder, refreshDevicesUseCase) { @AssistedFactory @@ -73,7 +69,7 @@ class DevicesViewModel @AssistedInject constructor( } private fun refreshIpAddressVisibility() { - val shouldShowIpAddress = sharedPreferences.getBoolean(VectorPreferences.SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, false) + val shouldShowIpAddress = vectorPreferences.showIpAddressInDeviceManagerScreens() setState { copy(isShowingIpAddress = shouldShowIpAddress) } @@ -135,9 +131,7 @@ class DevicesViewModel @AssistedInject constructor( setState { copy(isShowingIpAddress = !isShowingIpAddress) } - sharedPreferences.edit { - putBoolean(VectorPreferences.SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, !isShowingIpAddress) - } + vectorPreferences.setIpAddressVisibilityInDeviceManagerScreens(!isShowingIpAddress) } private fun handleVerifyCurrentSessionAction() { diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt index fb20f0fd31..0f1dcca4cc 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt @@ -16,15 +16,12 @@ package im.vector.app.features.settings.devices.v2.othersessions -import android.content.SharedPreferences -import androidx.core.content.edit import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Success import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import im.vector.app.core.di.ActiveSessionHolder -import im.vector.app.core.di.DefaultPreferences import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.features.auth.PendingAuthHandler @@ -47,8 +44,7 @@ class OtherSessionsViewModel @AssistedInject constructor( private val signoutSessionsUseCase: SignoutSessionsUseCase, private val pendingAuthHandler: PendingAuthHandler, refreshDevicesUseCase: RefreshDevicesUseCase, - @DefaultPreferences - private val sharedPreferences: SharedPreferences, + private val vectorPreferences: VectorPreferences, ) : VectorSessionsListViewModel( initialState, activeSessionHolder, refreshDevicesUseCase ) { @@ -68,7 +64,7 @@ class OtherSessionsViewModel @AssistedInject constructor( } private fun refreshIpAddressVisibility() { - val shouldShowIpAddress = sharedPreferences.getBoolean(VectorPreferences.SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, false) + val shouldShowIpAddress = vectorPreferences.showIpAddressInDeviceManagerScreens() setState { copy(isShowingIpAddress = shouldShowIpAddress) } @@ -108,9 +104,7 @@ class OtherSessionsViewModel @AssistedInject constructor( setState { copy(isShowingIpAddress = !isShowingIpAddress) } - sharedPreferences.edit { - putBoolean(VectorPreferences.SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, !isShowingIpAddress) - } + vectorPreferences.setIpAddressVisibilityInDeviceManagerScreens(!isShowingIpAddress) } private fun handleFilterDevices(action: OtherSessionsAction.FilterDevices) { diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt index a105c40e9c..6c159fd50e 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt @@ -16,15 +16,12 @@ package im.vector.app.features.settings.devices.v2.overview -import android.content.SharedPreferences -import androidx.core.content.edit import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Success import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import im.vector.app.core.di.ActiveSessionHolder -import im.vector.app.core.di.DefaultPreferences import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.features.auth.PendingAuthHandler @@ -58,8 +55,7 @@ class SessionOverviewViewModel @AssistedInject constructor( private val togglePushNotificationUseCase: TogglePushNotificationUseCase, private val getNotificationsStatusUseCase: GetNotificationsStatusUseCase, refreshDevicesUseCase: RefreshDevicesUseCase, - @DefaultPreferences - private val sharedPreferences: SharedPreferences, + private val vectorPreferences: VectorPreferences, ) : VectorSessionsListViewModel( initialState, activeSessionHolder, refreshDevicesUseCase ) { @@ -80,7 +76,7 @@ class SessionOverviewViewModel @AssistedInject constructor( } private fun refreshIpAddressVisibility() { - val shouldShowIpAddress = sharedPreferences.getBoolean(VectorPreferences.SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, false) + val shouldShowIpAddress = vectorPreferences.showIpAddressInDeviceManagerScreens() setState { copy(isShowingIpAddress = shouldShowIpAddress) } @@ -134,9 +130,7 @@ class SessionOverviewViewModel @AssistedInject constructor( setState { copy(isShowingIpAddress = !isShowingIpAddress) } - sharedPreferences.edit { - putBoolean(VectorPreferences.SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, !isShowingIpAddress) - } + vectorPreferences.setIpAddressVisibilityInDeviceManagerScreens(!isShowingIpAddress) } private fun handleVerifySessionAction() = withState { viewState -> diff --git a/vector/src/main/res/layout/item_other_session.xml b/vector/src/main/res/layout/item_other_session.xml index dee96d2b2f..a6205e7d50 100644 --- a/vector/src/main/res/layout/item_other_session.xml +++ b/vector/src/main/res/layout/item_other_session.xml @@ -13,7 +13,7 @@ android:layout_width="0dp" android:layout_height="0dp" android:background="@drawable/bg_other_session" - app:layout_constraintBottom_toBottomOf="@id/otherSessionVerificationStatusImageView" + app:layout_constraintBottom_toBottomOf="@id/otherSessionSeparator" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> @@ -53,11 +53,12 @@ android:layout_height="wrap_content" android:layout_marginStart="16dp" android:layout_marginEnd="8dp" + android:layout_marginTop="8dp" android:ellipsize="end" android:lines="1" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/otherSessionDeviceTypeImageView" - app:layout_constraintTop_toTopOf="@id/otherSessionDeviceTypeImageView" + app:layout_constraintTop_toTopOf="@id/otherSessionItemBackground" tools:text="Element Mobile: Android" /> () private val fakePendingAuthHandler = FakePendingAuthHandler() private val fakeRefreshDevicesUseCase = mockk(relaxUnitFun = true) - private val fakeSharedPreferences = FakeSharedPreferences() + private val fakeVectorPreferences = FakeVectorPreferences() private fun createViewModel(): DevicesViewModel { return DevicesViewModel( @@ -87,7 +89,7 @@ class DevicesViewModelTest { interceptSignoutFlowResponseUseCase = fakeInterceptSignoutFlowResponseUseCase, pendingAuthHandler = fakePendingAuthHandler.instance, refreshDevicesUseCase = fakeRefreshDevicesUseCase, - sharedPreferences = fakeSharedPreferences, + vectorPreferences = fakeVectorPreferences.instance, ) } @@ -100,7 +102,7 @@ class DevicesViewModelTest { givenVerificationService() givenCurrentSessionCrossSigningInfo() givenDeviceFullInfoList(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_DEVICE_ID_2) - fakeSharedPreferences.givenSessionManagerShowIpAddress(false) + fakeVectorPreferences.givenSessionManagerShowIpAddress(false) } private fun givenVerificationService(): FakeVerificationService { @@ -347,6 +349,29 @@ class DevicesViewModelTest { } } + @Test + fun `given the viewModel when initializing it then view state of ip address visibility is false`() { + // When + val viewModelTest = createViewModel().test() + + // Then + viewModelTest.assertLatestState { it.isShowingIpAddress == false } + viewModelTest.finish() + } + + @Test + fun `given the viewModel when toggleIpAddressVisibility action is triggered then view state and preference change accordingly`() { + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + viewModel.handle(DevicesAction.ToggleIpAddressVisibility) + + // Then + viewModelTest.assertLatestState { it.isShowingIpAddress == true } + every { fakeVectorPreferences.instance.setIpAddressVisibilityInDeviceManagerScreens(true) } just runs + viewModelTest.finish() + } + private fun givenCurrentSessionCrossSigningInfo(): CurrentSessionCrossSigningInfo { val currentSessionCrossSigningInfo = mockk() every { currentSessionCrossSigningInfo.deviceId } returns A_CURRENT_DEVICE_ID diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt index d6ed5a61a7..054369ec9f 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt @@ -25,8 +25,8 @@ import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType import im.vector.app.test.fakes.FakeActiveSessionHolder import im.vector.app.test.fakes.FakePendingAuthHandler -import im.vector.app.test.fakes.FakeSharedPreferences import im.vector.app.test.fakes.FakeSignoutSessionsUseCase +import im.vector.app.test.fakes.FakeVectorPreferences import im.vector.app.test.fakes.FakeVerificationService import im.vector.app.test.fixtures.aDeviceFullInfo import im.vector.app.test.test @@ -67,7 +67,7 @@ class OtherSessionsViewModelTest { private val fakeRefreshDevicesUseCase = mockk(relaxed = true) private val fakeSignoutSessionsUseCase = FakeSignoutSessionsUseCase() private val fakePendingAuthHandler = FakePendingAuthHandler() - private val fakeSharedPreferences = FakeSharedPreferences() + private val fakeVectorPreferences = FakeVectorPreferences() private fun createViewModel(viewState: OtherSessionsViewState = OtherSessionsViewState(defaultArgs)) = OtherSessionsViewModel( @@ -77,7 +77,7 @@ class OtherSessionsViewModelTest { signoutSessionsUseCase = fakeSignoutSessionsUseCase.instance, pendingAuthHandler = fakePendingAuthHandler.instance, refreshDevicesUseCase = fakeRefreshDevicesUseCase, - sharedPreferences = fakeSharedPreferences, + vectorPreferences = fakeVectorPreferences.instance, ) @Before @@ -87,7 +87,7 @@ class OtherSessionsViewModelTest { every { SystemClock.elapsedRealtime() } returns 1234 givenVerificationService() - fakeSharedPreferences.givenSessionManagerShowIpAddress(false) + fakeVectorPreferences.givenSessionManagerShowIpAddress(false) } private fun givenVerificationService(): FakeVerificationService { diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt index c4ab82b7e8..3f81abd483 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt @@ -28,9 +28,9 @@ import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSes import im.vector.app.test.fakes.FakeActiveSessionHolder import im.vector.app.test.fakes.FakeGetNotificationsStatusUseCase import im.vector.app.test.fakes.FakePendingAuthHandler -import im.vector.app.test.fakes.FakeSharedPreferences import im.vector.app.test.fakes.FakeSignoutSessionsUseCase import im.vector.app.test.fakes.FakeTogglePushNotificationUseCase +import im.vector.app.test.fakes.FakeVectorPreferences import im.vector.app.test.fakes.FakeVerificationService import im.vector.app.test.test import im.vector.app.test.testDispatcher @@ -78,7 +78,7 @@ class SessionOverviewViewModelTest { private val togglePushNotificationUseCase = FakeTogglePushNotificationUseCase() private val fakeGetNotificationsStatusUseCase = FakeGetNotificationsStatusUseCase() private val notificationsStatus = NotificationsStatus.ENABLED - private val fakeSharedPreferences = FakeSharedPreferences() + private val fakeVectorPreferences = FakeVectorPreferences() private fun createViewModel() = SessionOverviewViewModel( initialState = SessionOverviewViewState(args), @@ -91,7 +91,7 @@ class SessionOverviewViewModelTest { refreshDevicesUseCase = refreshDevicesUseCase, togglePushNotificationUseCase = togglePushNotificationUseCase.instance, getNotificationsStatusUseCase = fakeGetNotificationsStatusUseCase.instance, - sharedPreferences = fakeSharedPreferences, + vectorPreferences = fakeVectorPreferences.instance, ) @Before @@ -106,7 +106,7 @@ class SessionOverviewViewModelTest { A_SESSION_ID_1, notificationsStatus ) - fakeSharedPreferences.givenSessionManagerShowIpAddress(false) + fakeVectorPreferences.givenSessionManagerShowIpAddress(false) } private fun givenVerificationService(): FakeVerificationService { diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSharedPreferences.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSharedPreferences.kt index 0242bfe148..f9d525fd13 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeSharedPreferences.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSharedPreferences.kt @@ -18,7 +18,6 @@ package im.vector.app.test.fakes import android.content.SharedPreferences import im.vector.app.features.settings.FontScaleValue -import im.vector.app.features.settings.VectorPreferences.Companion.SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS import io.mockk.every import io.mockk.mockk @@ -33,8 +32,4 @@ class FakeSharedPreferences : SharedPreferences by mockk() { every { contains("APPLICATION_USE_SYSTEM_FONT_SCALE_KEY") } returns true every { getBoolean("APPLICATION_USE_SYSTEM_FONT_SCALE_KEY", any()) } returns useSystemScale } - - fun givenSessionManagerShowIpAddress(showIpAddress: Boolean) { - every { getBoolean(SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, any()) } returns showIpAddress - } } diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt index 4baa7e2b90..f05f5f1493 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt @@ -52,4 +52,8 @@ class FakeVectorPreferences { fun verifySetNotificationEnabledForDevice(enabled: Boolean, inverse: Boolean = false) { verify(inverse = inverse) { instance.setNotificationEnabledForDevice(enabled) } } + + fun givenSessionManagerShowIpAddress(showIpAddress: Boolean) { + every { instance.showIpAddressInDeviceManagerScreens() } returns showIpAddress + } } From 544c55444429d59bdffac2888e8e0b3987c35c2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Nov 2022 23:11:30 +0000 Subject: [PATCH 052/173] Bump io.gitlab.arturbosch.detekt from 1.21.0 to 1.22.0 Bumps io.gitlab.arturbosch.detekt from 1.21.0 to 1.22.0. --- updated-dependencies: - dependency-name: io.gitlab.arturbosch.detekt dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 5648acfba0..831abe0882 100644 --- a/build.gradle +++ b/build.gradle @@ -43,7 +43,7 @@ plugins { // ktlint Plugin id "org.jlleitschuh.gradle.ktlint" version "11.0.0" // Detekt - id "io.gitlab.arturbosch.detekt" version "1.21.0" + id "io.gitlab.arturbosch.detekt" version "1.22.0" // Ksp id "com.google.devtools.ksp" version "1.7.21-1.0.8" From b3965cae9dc4677e0e9725c7c4f6cd5f18f75d6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Nov 2022 07:53:58 +0100 Subject: [PATCH 053/173] Bump com.autonomousapps.dependency-analysis from 1.13.1 to 1.16.0 (#7622) Bumps com.autonomousapps.dependency-analysis from 1.13.1 to 1.16.0. --- updated-dependencies: - dependency-name: com.autonomousapps.dependency-analysis dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 5648acfba0..1380b6c2e9 100644 --- a/build.gradle +++ b/build.gradle @@ -48,7 +48,7 @@ plugins { id "com.google.devtools.ksp" version "1.7.21-1.0.8" // Dependency Analysis - id 'com.autonomousapps.dependency-analysis' version "1.13.1" + id 'com.autonomousapps.dependency-analysis' version "1.16.0" // Gradle doctor id "com.osacky.doctor" version "0.8.1" } From ab749eee6a174edc20dc371f8bc7479bfa02fc4f Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Tue, 22 Nov 2022 16:05:37 +0300 Subject: [PATCH 054/173] Code review fixes. --- .../settings/devices/v2/DevicesViewModel.kt | 29 +++++++++++++----- .../v2/ToggleIpAddressVisibilityUseCase.kt | 30 +++++++++++++++++++ .../othersessions/OtherSessionsViewModel.kt | 27 ++++++++++++----- .../v2/overview/SessionOverviewViewModel.kt | 26 +++++++++++----- .../devices/v2/DevicesViewModelTest.kt | 9 +++++- .../OtherSessionsViewModelTest.kt | 3 ++ .../overview/SessionOverviewViewModelTest.kt | 3 ++ 7 files changed, 105 insertions(+), 22 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/settings/devices/v2/ToggleIpAddressVisibilityUseCase.kt diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt index 971fb123f0..236237ddbb 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt @@ -16,6 +16,7 @@ package im.vector.app.features.settings.devices.v2 +import android.content.SharedPreferences import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Success import dagger.assisted.Assisted @@ -51,7 +52,11 @@ class DevicesViewModel @AssistedInject constructor( private val pendingAuthHandler: PendingAuthHandler, refreshDevicesUseCase: RefreshDevicesUseCase, private val vectorPreferences: VectorPreferences, -) : VectorSessionsListViewModel(initialState, activeSessionHolder, refreshDevicesUseCase) { + private val toggleIpAddressVisibilityUseCase: ToggleIpAddressVisibilityUseCase, +) : VectorSessionsListViewModel(initialState, activeSessionHolder, refreshDevicesUseCase), + SharedPreferences.OnSharedPreferenceChangeListener { @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { @@ -66,6 +71,20 @@ class DevicesViewModel @AssistedInject constructor( refreshDevicesOnCryptoDevicesChange() refreshDeviceList() refreshIpAddressVisibility() + observePreferences() + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + refreshIpAddressVisibility() + } + + private fun observePreferences() { + vectorPreferences.subscribeToChanges(this) + } + + override fun onCleared() { + vectorPreferences.unsubscribeToChanges(this) + super.onCleared() } private fun refreshIpAddressVisibility() { @@ -126,12 +145,8 @@ class DevicesViewModel @AssistedInject constructor( } } - private fun handleToggleIpAddressVisibility() = withState { state -> - val isShowingIpAddress = state.isShowingIpAddress - setState { - copy(isShowingIpAddress = !isShowingIpAddress) - } - vectorPreferences.setIpAddressVisibilityInDeviceManagerScreens(!isShowingIpAddress) + private fun handleToggleIpAddressVisibility() { + toggleIpAddressVisibilityUseCase.execute() } private fun handleVerifyCurrentSessionAction() { diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/ToggleIpAddressVisibilityUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/ToggleIpAddressVisibilityUseCase.kt new file mode 100644 index 0000000000..ef99d3489a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/ToggleIpAddressVisibilityUseCase.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.settings.devices.v2 + +import im.vector.app.features.settings.VectorPreferences +import javax.inject.Inject + +class ToggleIpAddressVisibilityUseCase @Inject constructor( + private val vectorPreferences: VectorPreferences, +) { + + fun execute() { + val currentVisibility = vectorPreferences.showIpAddressInDeviceManagerScreens() + vectorPreferences.setIpAddressVisibilityInDeviceManagerScreens(!currentVisibility) + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt index 0f1dcca4cc..bd6e31dd4d 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt @@ -16,6 +16,7 @@ package im.vector.app.features.settings.devices.v2.othersessions +import android.content.SharedPreferences import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Success import dagger.assisted.Assisted @@ -28,6 +29,7 @@ import im.vector.app.features.auth.PendingAuthHandler import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.devices.v2.GetDeviceFullInfoListUseCase import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase +import im.vector.app.features.settings.devices.v2.ToggleIpAddressVisibilityUseCase import im.vector.app.features.settings.devices.v2.VectorSessionsListViewModel import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsReAuthNeeded @@ -45,9 +47,10 @@ class OtherSessionsViewModel @AssistedInject constructor( private val pendingAuthHandler: PendingAuthHandler, refreshDevicesUseCase: RefreshDevicesUseCase, private val vectorPreferences: VectorPreferences, + private val toggleIpAddressVisibilityUseCase: ToggleIpAddressVisibilityUseCase, ) : VectorSessionsListViewModel( initialState, activeSessionHolder, refreshDevicesUseCase -) { +), SharedPreferences.OnSharedPreferenceChangeListener { @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { @@ -61,6 +64,20 @@ class OtherSessionsViewModel @AssistedInject constructor( init { observeDevices(initialState.currentFilter) refreshIpAddressVisibility() + observePreferences() + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + refreshIpAddressVisibility() + } + + private fun observePreferences() { + vectorPreferences.subscribeToChanges(this) + } + + override fun onCleared() { + vectorPreferences.unsubscribeToChanges(this) + super.onCleared() } private fun refreshIpAddressVisibility() { @@ -99,12 +116,8 @@ class OtherSessionsViewModel @AssistedInject constructor( } } - private fun handleToggleIpAddressVisibility() = withState { state -> - val isShowingIpAddress = state.isShowingIpAddress - setState { - copy(isShowingIpAddress = !isShowingIpAddress) - } - vectorPreferences.setIpAddressVisibilityInDeviceManagerScreens(!isShowingIpAddress) + private fun handleToggleIpAddressVisibility() { + toggleIpAddressVisibilityUseCase.execute() } private fun handleFilterDevices(action: OtherSessionsAction.FilterDevices) { diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt index 6c159fd50e..d423d4a743 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt @@ -16,6 +16,7 @@ package im.vector.app.features.settings.devices.v2.overview +import android.content.SharedPreferences import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Success import dagger.assisted.Assisted @@ -27,6 +28,7 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.features.auth.PendingAuthHandler import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase +import im.vector.app.features.settings.devices.v2.ToggleIpAddressVisibilityUseCase import im.vector.app.features.settings.devices.v2.VectorSessionsListViewModel import im.vector.app.features.settings.devices.v2.notification.GetNotificationsStatusUseCase import im.vector.app.features.settings.devices.v2.notification.TogglePushNotificationUseCase @@ -56,9 +58,10 @@ class SessionOverviewViewModel @AssistedInject constructor( private val getNotificationsStatusUseCase: GetNotificationsStatusUseCase, refreshDevicesUseCase: RefreshDevicesUseCase, private val vectorPreferences: VectorPreferences, + private val toggleIpAddressVisibilityUseCase: ToggleIpAddressVisibilityUseCase, ) : VectorSessionsListViewModel( initialState, activeSessionHolder, refreshDevicesUseCase -) { +), SharedPreferences.OnSharedPreferenceChangeListener { companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() @@ -73,8 +76,21 @@ class SessionOverviewViewModel @AssistedInject constructor( observeCurrentSessionInfo() observeNotificationsStatus(initialState.deviceId) refreshIpAddressVisibility() + observePreferences() } + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + refreshIpAddressVisibility() + } + + private fun observePreferences() { + vectorPreferences.subscribeToChanges(this) + } + + override fun onCleared() { + vectorPreferences.unsubscribeToChanges(this) + super.onCleared() + } private fun refreshIpAddressVisibility() { val shouldShowIpAddress = vectorPreferences.showIpAddressInDeviceManagerScreens() setState { @@ -125,12 +141,8 @@ class SessionOverviewViewModel @AssistedInject constructor( } } - private fun handleToggleIpAddressVisibility() = withState { state -> - val isShowingIpAddress = state.isShowingIpAddress - setState { - copy(isShowingIpAddress = !isShowingIpAddress) - } - vectorPreferences.setIpAddressVisibilityInDeviceManagerScreens(!isShowingIpAddress) + private fun handleToggleIpAddressVisibility() { + toggleIpAddressVisibilityUseCase.execute() } private fun handleVerifySessionAction() = withState { viewState -> diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt index e34dd8e180..30320806e0 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt @@ -16,6 +16,7 @@ package im.vector.app.features.settings.devices.v2 +import android.content.SharedPreferences import android.os.SystemClock import com.airbnb.mvrx.Success import com.airbnb.mvrx.test.MavericksTestRule @@ -76,6 +77,7 @@ class DevicesViewModelTest { private val fakePendingAuthHandler = FakePendingAuthHandler() private val fakeRefreshDevicesUseCase = mockk(relaxUnitFun = true) private val fakeVectorPreferences = FakeVectorPreferences() + private val toggleIpAddressVisibilityUseCase = mockk() private fun createViewModel(): DevicesViewModel { return DevicesViewModel( @@ -90,6 +92,7 @@ class DevicesViewModelTest { pendingAuthHandler = fakePendingAuthHandler.instance, refreshDevicesUseCase = fakeRefreshDevicesUseCase, vectorPreferences = fakeVectorPreferences.instance, + toggleIpAddressVisibilityUseCase = toggleIpAddressVisibilityUseCase, ) } @@ -364,11 +367,15 @@ class DevicesViewModelTest { // When val viewModel = createViewModel() val viewModelTest = viewModel.test() + every { toggleIpAddressVisibilityUseCase.execute() } just runs + every { fakeVectorPreferences.instance.setIpAddressVisibilityInDeviceManagerScreens(true) } just runs + every { fakeVectorPreferences.instance.showIpAddressInDeviceManagerScreens() } returns true + viewModel.handle(DevicesAction.ToggleIpAddressVisibility) + viewModel.onSharedPreferenceChanged(null, null) // Then viewModelTest.assertLatestState { it.isShowingIpAddress == true } - every { fakeVectorPreferences.instance.setIpAddressVisibilityInDeviceManagerScreens(true) } just runs viewModelTest.finish() } diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt index 054369ec9f..82f40d911d 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt @@ -22,6 +22,7 @@ import com.airbnb.mvrx.test.MavericksTestRule import im.vector.app.features.settings.devices.v2.DeviceFullInfo import im.vector.app.features.settings.devices.v2.GetDeviceFullInfoListUseCase import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase +import im.vector.app.features.settings.devices.v2.ToggleIpAddressVisibilityUseCase import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType import im.vector.app.test.fakes.FakeActiveSessionHolder import im.vector.app.test.fakes.FakePendingAuthHandler @@ -68,6 +69,7 @@ class OtherSessionsViewModelTest { private val fakeSignoutSessionsUseCase = FakeSignoutSessionsUseCase() private val fakePendingAuthHandler = FakePendingAuthHandler() private val fakeVectorPreferences = FakeVectorPreferences() + private val toggleIpAddressVisibilityUseCase = mockk() private fun createViewModel(viewState: OtherSessionsViewState = OtherSessionsViewState(defaultArgs)) = OtherSessionsViewModel( @@ -78,6 +80,7 @@ class OtherSessionsViewModelTest { pendingAuthHandler = fakePendingAuthHandler.instance, refreshDevicesUseCase = fakeRefreshDevicesUseCase, vectorPreferences = fakeVectorPreferences.instance, + toggleIpAddressVisibilityUseCase = toggleIpAddressVisibilityUseCase, ) @Before diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt index 3f81abd483..287bdd159c 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt @@ -22,6 +22,7 @@ import com.airbnb.mvrx.Success import com.airbnb.mvrx.test.MavericksTestRule import im.vector.app.features.settings.devices.v2.DeviceFullInfo import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase +import im.vector.app.features.settings.devices.v2.ToggleIpAddressVisibilityUseCase import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase @@ -79,6 +80,7 @@ class SessionOverviewViewModelTest { private val fakeGetNotificationsStatusUseCase = FakeGetNotificationsStatusUseCase() private val notificationsStatus = NotificationsStatus.ENABLED private val fakeVectorPreferences = FakeVectorPreferences() + private val toggleIpAddressVisibilityUseCase = mockk() private fun createViewModel() = SessionOverviewViewModel( initialState = SessionOverviewViewState(args), @@ -92,6 +94,7 @@ class SessionOverviewViewModelTest { togglePushNotificationUseCase = togglePushNotificationUseCase.instance, getNotificationsStatusUseCase = fakeGetNotificationsStatusUseCase.instance, vectorPreferences = fakeVectorPreferences.instance, + toggleIpAddressVisibilityUseCase = toggleIpAddressVisibilityUseCase, ) @Before From 5eb786b55f854ff7950115448655cd0a7f0303f5 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Tue, 22 Nov 2022 16:11:20 +0300 Subject: [PATCH 055/173] Code review fixes. --- .../app/features/settings/devices/v2/list/SessionInfoView.kt | 3 +-- .../app/features/settings/devices/v2/DevicesViewModelTest.kt | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt index 6e7e57fc49..c6044d04a4 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt @@ -188,8 +188,7 @@ class SessionInfoView @JvmOverloads constructor( } else { views.sessionInfoLastActivityTextView.isGone = true } - views.sessionInfoLastIPAddressTextView.setTextOrHide(deviceInfo.lastSeenIp?.takeIf { isLastSeenDetailsVisible }) - views.sessionInfoLastIPAddressTextView.isVisible = isShowingIpAddress + views.sessionInfoLastIPAddressTextView.setTextOrHide(deviceInfo.lastSeenIp?.takeIf { isLastSeenDetailsVisible && isShowingIpAddress }) } private fun renderDetailsButton(isDetailsButtonVisible: Boolean) { diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt index 30320806e0..16ccaab37f 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt @@ -16,7 +16,6 @@ package im.vector.app.features.settings.devices.v2 -import android.content.SharedPreferences import android.os.SystemClock import com.airbnb.mvrx.Success import com.airbnb.mvrx.test.MavericksTestRule From abea9b686a3407eea3e343061ae7dd5ab96ca38a Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Tue, 22 Nov 2022 18:02:48 +0300 Subject: [PATCH 056/173] Code review fixes. --- vector-config/src/main/res/values/config-settings.xml | 3 ++- .../java/im/vector/app/features/settings/VectorPreferences.kt | 4 ++-- .../app/features/settings/devices/v2/DevicesViewModel.kt | 2 +- .../settings/devices/v2/ToggleIpAddressVisibilityUseCase.kt | 2 +- .../devices/v2/othersessions/OtherSessionsViewModel.kt | 2 +- .../settings/devices/v2/overview/SessionOverviewViewModel.kt | 2 +- .../app/features/settings/devices/v2/DevicesViewModelTest.kt | 2 +- .../java/im/vector/app/test/fakes/FakeVectorPreferences.kt | 2 +- 8 files changed, 10 insertions(+), 9 deletions(-) diff --git a/vector-config/src/main/res/values/config-settings.xml b/vector-config/src/main/res/values/config-settings.xml index ef9695a080..ad9c16c214 100755 --- a/vector-config/src/main/res/values/config-settings.xml +++ b/vector-config/src/main/res/values/config-settings.xml @@ -51,12 +51,13 @@ false true false - false + + false diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index 30b5ef9131..447038d768 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -1232,8 +1232,8 @@ class VectorPreferences @Inject constructor( defaultPrefs.getBoolean(SETTINGS_LABS_VOICE_BROADCAST_KEY, getDefault(R.bool.settings_labs_enable_voice_broadcast_default)) } - fun showIpAddressInDeviceManagerScreens(): Boolean { - return defaultPrefs.getBoolean(SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, getDefault(R.bool.settings_device_manager_show_ip_address)) + fun showIpAddressInSessionManagerScreens(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, getDefault(R.bool.settings_session_manager_show_ip_address)) } fun setIpAddressVisibilityInDeviceManagerScreens(isVisible: Boolean) { diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt index 236237ddbb..f42d5af398 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt @@ -88,7 +88,7 @@ class DevicesViewModel @AssistedInject constructor( } private fun refreshIpAddressVisibility() { - val shouldShowIpAddress = vectorPreferences.showIpAddressInDeviceManagerScreens() + val shouldShowIpAddress = vectorPreferences.showIpAddressInSessionManagerScreens() setState { copy(isShowingIpAddress = shouldShowIpAddress) } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/ToggleIpAddressVisibilityUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/ToggleIpAddressVisibilityUseCase.kt index ef99d3489a..1e1dc19c96 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/ToggleIpAddressVisibilityUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/ToggleIpAddressVisibilityUseCase.kt @@ -24,7 +24,7 @@ class ToggleIpAddressVisibilityUseCase @Inject constructor( ) { fun execute() { - val currentVisibility = vectorPreferences.showIpAddressInDeviceManagerScreens() + val currentVisibility = vectorPreferences.showIpAddressInSessionManagerScreens() vectorPreferences.setIpAddressVisibilityInDeviceManagerScreens(!currentVisibility) } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt index bd6e31dd4d..a5282e7ba2 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt @@ -81,7 +81,7 @@ class OtherSessionsViewModel @AssistedInject constructor( } private fun refreshIpAddressVisibility() { - val shouldShowIpAddress = vectorPreferences.showIpAddressInDeviceManagerScreens() + val shouldShowIpAddress = vectorPreferences.showIpAddressInSessionManagerScreens() setState { copy(isShowingIpAddress = shouldShowIpAddress) } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt index d423d4a743..472e0a4269 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt @@ -92,7 +92,7 @@ class SessionOverviewViewModel @AssistedInject constructor( super.onCleared() } private fun refreshIpAddressVisibility() { - val shouldShowIpAddress = vectorPreferences.showIpAddressInDeviceManagerScreens() + val shouldShowIpAddress = vectorPreferences.showIpAddressInSessionManagerScreens() setState { copy(isShowingIpAddress = shouldShowIpAddress) } diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt index 16ccaab37f..03177aac47 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt @@ -368,7 +368,7 @@ class DevicesViewModelTest { val viewModelTest = viewModel.test() every { toggleIpAddressVisibilityUseCase.execute() } just runs every { fakeVectorPreferences.instance.setIpAddressVisibilityInDeviceManagerScreens(true) } just runs - every { fakeVectorPreferences.instance.showIpAddressInDeviceManagerScreens() } returns true + every { fakeVectorPreferences.instance.showIpAddressInSessionManagerScreens() } returns true viewModel.handle(DevicesAction.ToggleIpAddressVisibility) viewModel.onSharedPreferenceChanged(null, null) diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt index f05f5f1493..d89764a77e 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt @@ -54,6 +54,6 @@ class FakeVectorPreferences { } fun givenSessionManagerShowIpAddress(showIpAddress: Boolean) { - every { instance.showIpAddressInDeviceManagerScreens() } returns showIpAddress + every { instance.showIpAddressInSessionManagerScreens() } returns showIpAddress } } From 1fe790e46fd7f2e377e6378b7d5c6eb1b5af93db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Nov 2022 19:12:07 +0100 Subject: [PATCH 057/173] Bump wysiwyg from 0.4.0 to 0.7.0 (#7572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump wysiwyg from 0.4.0 to 0.6.0 Bumps [wysiwyg](https://github.com/matrix-org/matrix-wysiwyg) from 0.4.0 to 0.6.0. - [Release notes](https://github.com/matrix-org/matrix-wysiwyg/releases) - [Changelog](https://github.com/matrix-org/matrix-rich-text-editor/blob/main/RELEASE.md) - [Commits](https://github.com/matrix-org/matrix-wysiwyg/compare/0.4.0...0.6.0) --- updated-dependencies: - dependency-name: io.element.android:wysiwyg dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Update RTE library to 0.7.0 * Fix markdown -> html Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jorge Martín --- dependencies.gradle | 2 +- .../detail/composer/RichTextComposerLayout.kt | 30 +++++++++---------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/dependencies.gradle b/dependencies.gradle index 8fc38cbbab..ac65035b60 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -98,7 +98,7 @@ ext.libs = [ ], element : [ 'opusencoder' : "io.element.android:opusencoder:1.1.0", - 'wysiwyg' : "io.element.android:wysiwyg:0.4.0" + 'wysiwyg' : "io.element.android:wysiwyg:0.7.0" ], squareup : [ 'moshi' : "com.squareup.moshi:moshi:$moshi", diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt index 85f163360f..880ee2c031 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt @@ -48,8 +48,8 @@ import im.vector.app.databinding.ComposerRichTextLayoutBinding import im.vector.app.databinding.ViewRichTextMenuButtonBinding import io.element.android.wysiwyg.EditorEditText import io.element.android.wysiwyg.inputhandlers.models.InlineFormat +import uniffi.wysiwyg_composer.ActionState import uniffi.wysiwyg_composer.ComposerAction -import uniffi.wysiwyg_composer.MenuState class RichTextComposerLayout @JvmOverloads constructor( context: Context, @@ -206,16 +206,16 @@ class RichTextComposerLayout @JvmOverloads constructor( } private fun setupRichTextMenu() { - addRichTextMenuItem(R.drawable.ic_composer_bold, R.string.rich_text_editor_format_bold, ComposerAction.Bold) { + addRichTextMenuItem(R.drawable.ic_composer_bold, R.string.rich_text_editor_format_bold, ComposerAction.BOLD) { views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Bold) } - addRichTextMenuItem(R.drawable.ic_composer_italic, R.string.rich_text_editor_format_italic, ComposerAction.Italic) { + addRichTextMenuItem(R.drawable.ic_composer_italic, R.string.rich_text_editor_format_italic, ComposerAction.ITALIC) { views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Italic) } - addRichTextMenuItem(R.drawable.ic_composer_underlined, R.string.rich_text_editor_format_underline, ComposerAction.Underline) { + addRichTextMenuItem(R.drawable.ic_composer_underlined, R.string.rich_text_editor_format_underline, ComposerAction.UNDERLINE) { views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Underline) } - addRichTextMenuItem(R.drawable.ic_composer_strikethrough, R.string.rich_text_editor_format_strikethrough, ComposerAction.StrikeThrough) { + addRichTextMenuItem(R.drawable.ic_composer_strikethrough, R.string.rich_text_editor_format_strikethrough, ComposerAction.STRIKE_THROUGH) { views.richTextComposerEditText.toggleInlineFormat(InlineFormat.StrikeThrough) } } @@ -238,12 +238,9 @@ class RichTextComposerLayout @JvmOverloads constructor( override fun onAttachedToWindow() { super.onAttachedToWindow() - views.richTextComposerEditText.menuStateChangedListener = EditorEditText.OnMenuStateChangedListener { state -> - if (state is MenuState.Update) { - updateMenuStateFor(ComposerAction.Bold, state) - updateMenuStateFor(ComposerAction.Italic, state) - updateMenuStateFor(ComposerAction.Underline, state) - updateMenuStateFor(ComposerAction.StrikeThrough, state) + views.richTextComposerEditText.actionStatesChangedListener = EditorEditText.OnActionStatesChangedListener { state -> + for (action in state.keys) { + updateMenuStateFor(action, state) } } @@ -261,9 +258,9 @@ class RichTextComposerLayout @JvmOverloads constructor( */ private fun syncEditTexts() = if (isTextFormattingEnabled) { - views.plainTextComposerEditText.setText(views.richTextComposerEditText.getPlainText()) + views.plainTextComposerEditText.setText(views.richTextComposerEditText.getMarkdown()) } else { - views.richTextComposerEditText.setText(views.plainTextComposerEditText.text.toString()) + views.richTextComposerEditText.setMarkdown(views.plainTextComposerEditText.text.toString()) } private fun addRichTextMenuItem(@DrawableRes iconId: Int, @StringRes description: Int, action: ComposerAction, onClick: () -> Unit) { @@ -279,10 +276,11 @@ class RichTextComposerLayout @JvmOverloads constructor( } } - private fun updateMenuStateFor(action: ComposerAction, menuState: MenuState.Update) { + private fun updateMenuStateFor(action: ComposerAction, menuState: Map) { val button = findViewWithTag(action) ?: return - button.isEnabled = !menuState.disabledActions.contains(action) - button.isSelected = menuState.reversedActions.contains(action) + val stateForAction = menuState[action] + button.isEnabled = stateForAction != ActionState.DISABLED + button.isSelected = stateForAction == ActionState.REVERSED } fun estimateCollapsedHeight(): Int { From ed8fd345ce33a324ecece55358c7213407e60ebc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 23 Nov 2022 13:14:18 +0100 Subject: [PATCH 058/173] Add missing git command. --- tools/release/releaseScript.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/release/releaseScript.sh b/tools/release/releaseScript.sh index dcb8fec40a..685093d65e 100755 --- a/tools/release/releaseScript.sh +++ b/tools/release/releaseScript.sh @@ -84,7 +84,7 @@ sed "s/\"SDK_VERSION\", .*$/\"SDK_VERSION\", \"\\\\\"${version}\\\\\"\"/" ./matr rm ./matrix-sdk-android/build.gradle.bak # This commit may have no effect because generally we do not change the version during the release. -commit -a -m "Setting version for the release ${version}" +git commit -a -m "Setting version for the release ${version}" printf "\n================================================================================\n" read -p "Please check the crashes from the PlayStore. You can commit fixes if any on the release branch. Press enter when it's done." From 183570dce975fc933df6b104018d4a52efe0dc24 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 23 Nov 2022 13:16:40 +0100 Subject: [PATCH 059/173] Update the recipe for the SDK --- .github/ISSUE_TEMPLATE/release.yml | 33 ++---------------------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/release.yml b/.github/ISSUE_TEMPLATE/release.yml index ff407ed8c1..0c3542997a 100644 --- a/.github/ISSUE_TEMPLATE/release.yml +++ b/.github/ISSUE_TEMPLATE/release.yml @@ -61,29 +61,9 @@ body: The SDK2 and the sample app are released only when Element has been pushed to production. - - [ ] Checkout the `main` branch on Element Android project + - [ ] On the [SDK2 project](https://github.com/matrix-org/matrix-android-sdk2), run the script ./tools/releaseScript.sh and follow the instructions. - #### On the SDK2 project - - https://github.com/matrix-org/matrix-android-sdk2 - - - [ ] Create a release with GitFlow - - [ ] Update the value of VERSION_NAME in the file gradle.properties - - [ ] Update the files `./build.gradle` and `./gradle/gradle-wrapper.properties` manually, to use the latest version for the dependency. You can get inspired by the same files on Element Android project. - - [ ] Run the script `./tools/import_from_element.sh` - - [ ] Check the diff in the file `./matrix-sdk-android/build.gradle` and restore what may have been erased (in particular the line `apply plugin: "com.vanniktech.maven.publish"` and the line about the version) - - [ ] Let the script finish to build the library - - [ ] Update the file `CHANGES.md` - - [ ] Finish the release using GitFlow - - [ ] Push the branch `main`, the new tag and the branch `develop` to origin - - ##### Release on MavenCentral - - - [ ] Checkout the branch `main` - - [ ] Run the command `./gradlew publish --no-daemon --no-parallel`. You'll need some non-public element to do so - - [ ] Run the command `./gradlew closeAndReleaseRepository`. If it is working well, you can jump directly to the final step of this section. - - If `./gradlew closeAndReleaseRepository` fails (for instance, several repositories are waiting to be handled), you have to close and release the repository manually. Do the following steps: + Note: if the step `./gradlew closeAndReleaseRepository` fails (for instance, several repositories are waiting to be handled), you have to close and release the repository manually. Do the following steps: - [ ] Connect to https://s01.oss.sonatype.org - [ ] Click on Staging Repositories and check the the files have been uploaded @@ -91,15 +71,6 @@ body: - [ ] Wait (check Activity tab until step "Repository closed" is displayed) - [ ] Click on release. The staging repository will disappear - Final step - - - [ ] Check that the release is available in https://repo1.maven.org/maven2/org/matrix/android/matrix-android-sdk2/ (it can take a few minutes) - - ##### Release on GitHub - - - [ ] Create the release on GitHub from [the tag](https://github.com/matrix-org/matrix-android-sdk2/tags) - - [ ] Upload the AAR on the GitHub release - ### Android SDK2 sample https://github.com/matrix-org/matrix-android-sdk2-sample From 212074470f69b04397362cf8750299cb852172a6 Mon Sep 17 00:00:00 2001 From: waclaw66 Date: Wed, 16 Nov 2022 15:04:46 +0000 Subject: [PATCH 060/173] Translated using Weblate (Czech) Currently translated at 100.0% (2543 of 2543 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/cs/ --- library/ui-strings/src/main/res/values-cs/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/library/ui-strings/src/main/res/values-cs/strings.xml b/library/ui-strings/src/main/res/values-cs/strings.xml index b4f9698197..5ee8a6fd32 100644 --- a/library/ui-strings/src/main/res/values-cs/strings.xml +++ b/library/ui-strings/src/main/res/values-cs/strings.xml @@ -2908,4 +2908,5 @@ Odhlásit se z %1$d relací Odhlásit se + zbývá %1$s \ No newline at end of file From 778462dfc878e4783634ca6483833b023b41ea87 Mon Sep 17 00:00:00 2001 From: Vri Date: Tue, 15 Nov 2022 20:11:39 +0000 Subject: [PATCH 061/173] Translated using Weblate (German) Currently translated at 100.0% (2543 of 2543 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/de/ --- library/ui-strings/src/main/res/values-de/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/library/ui-strings/src/main/res/values-de/strings.xml b/library/ui-strings/src/main/res/values-de/strings.xml index 186533c8f9..4218f19f9f 100644 --- a/library/ui-strings/src/main/res/values-de/strings.xml +++ b/library/ui-strings/src/main/res/values-de/strings.xml @@ -2851,4 +2851,5 @@ Von %1$d Sitzungen abmelden Abmelden + %1$s übrig \ No newline at end of file From 9eb4eb6dcf01170465d60712642f5789c5ea7513 Mon Sep 17 00:00:00 2001 From: iaiz Date: Sat, 19 Nov 2022 00:39:09 +0000 Subject: [PATCH 062/173] Translated using Weblate (Spanish) Currently translated at 90.2% (2296 of 2543 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/es/ --- library/ui-strings/src/main/res/values-es/strings.xml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/library/ui-strings/src/main/res/values-es/strings.xml b/library/ui-strings/src/main/res/values-es/strings.xml index f73c4952c6..3d10997233 100644 --- a/library/ui-strings/src/main/res/values-es/strings.xml +++ b/library/ui-strings/src/main/res/values-es/strings.xml @@ -2649,4 +2649,10 @@ Crear sala Iniciar conversación Todas las conversaciones - + Seleccionar todo + De acuerdo + + %1$d seleccionado + %1$d seleccionados + + \ No newline at end of file From e9bd9ed788df6cf1b5903a41ed71580a55d2a6dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Tue, 15 Nov 2022 19:42:11 +0000 Subject: [PATCH 063/173] Translated using Weblate (Estonian) Currently translated at 99.6% (2535 of 2543 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/et/ --- library/ui-strings/src/main/res/values-et/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/library/ui-strings/src/main/res/values-et/strings.xml b/library/ui-strings/src/main/res/values-et/strings.xml index 4747f03fee..cf5f23a240 100644 --- a/library/ui-strings/src/main/res/values-et/strings.xml +++ b/library/ui-strings/src/main/res/values-et/strings.xml @@ -2843,4 +2843,5 @@ Logi välja %1$d\'st sessioonist Logi välja + jäänud %1$s \ No newline at end of file From 3c5bc7e6f2b9ad7b44617fa1af7cafaf0eef3e48 Mon Sep 17 00:00:00 2001 From: Danial Behzadi Date: Tue, 15 Nov 2022 20:07:39 +0000 Subject: [PATCH 064/173] Translated using Weblate (Persian) Currently translated at 99.6% (2533 of 2543 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/fa/ --- library/ui-strings/src/main/res/values-fa/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/library/ui-strings/src/main/res/values-fa/strings.xml b/library/ui-strings/src/main/res/values-fa/strings.xml index 313734290f..fb3651dfa2 100644 --- a/library/ui-strings/src/main/res/values-fa/strings.xml +++ b/library/ui-strings/src/main/res/values-fa/strings.xml @@ -2825,4 +2825,10 @@ ۳۰ ثانیه پیش‌روی ۳۰ ثانیه پس‌روی قالب‌بندی متن + خروج + + خروج از ۱ نشست + خروج از %1$d نشست + + %1$s مانده \ No newline at end of file From 32462b9056f03cb594e9d277c5a67c28b10831d5 Mon Sep 17 00:00:00 2001 From: Glandos Date: Wed, 16 Nov 2022 09:20:18 +0000 Subject: [PATCH 065/173] Translated using Weblate (French) Currently translated at 100.0% (2543 of 2543 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/fr/ --- library/ui-strings/src/main/res/values-fr/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/library/ui-strings/src/main/res/values-fr/strings.xml b/library/ui-strings/src/main/res/values-fr/strings.xml index a8b5eb28ae..ca462c592f 100644 --- a/library/ui-strings/src/main/res/values-fr/strings.xml +++ b/library/ui-strings/src/main/res/values-fr/strings.xml @@ -2852,4 +2852,5 @@ Déconnecter %1$d sessions Déconnecter + %1$s restant \ No newline at end of file From 5d1f6e0bbe4d6667e066f34647ed32edfb21e8a7 Mon Sep 17 00:00:00 2001 From: Linerly Date: Wed, 16 Nov 2022 17:26:45 +0000 Subject: [PATCH 066/173] Translated using Weblate (Indonesian) Currently translated at 100.0% (2543 of 2543 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/id/ --- library/ui-strings/src/main/res/values-in/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/library/ui-strings/src/main/res/values-in/strings.xml b/library/ui-strings/src/main/res/values-in/strings.xml index 8f7670d08f..7828ec8c13 100644 --- a/library/ui-strings/src/main/res/values-in/strings.xml +++ b/library/ui-strings/src/main/res/values-in/strings.xml @@ -2798,4 +2798,5 @@ Di masa mendatang proses verifikasi ini akan dimutakhirkan. Keluarkan %1$d sesi Keluarkan + %1$s tersisa \ No newline at end of file From b745ebe90a1977a8663707b7bda1e9e6c41a4058 Mon Sep 17 00:00:00 2001 From: random Date: Wed, 16 Nov 2022 10:47:26 +0000 Subject: [PATCH 067/173] Translated using Weblate (Italian) Currently translated at 100.0% (2543 of 2543 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/it/ --- library/ui-strings/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/library/ui-strings/src/main/res/values-it/strings.xml b/library/ui-strings/src/main/res/values-it/strings.xml index 4257d52c3e..2322561d05 100644 --- a/library/ui-strings/src/main/res/values-it/strings.xml +++ b/library/ui-strings/src/main/res/values-it/strings.xml @@ -2843,4 +2843,5 @@ Impossibile iniziare una nuova trasmissione vocale Manda avanti di 30 secondi Manda indietro di 30 secondi + %1$s rimasti \ No newline at end of file From f43a16ced42885f5aeb76bd148a17f1bbd170911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Romanik?= Date: Sat, 19 Nov 2022 01:58:12 +0000 Subject: [PATCH 068/173] Translated using Weblate (Polish) Currently translated at 91.8% (2336 of 2543 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/pl/ --- library/ui-strings/src/main/res/values-pl/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/ui-strings/src/main/res/values-pl/strings.xml b/library/ui-strings/src/main/res/values-pl/strings.xml index ab9c367824..1c01c82189 100644 --- a/library/ui-strings/src/main/res/values-pl/strings.xml +++ b/library/ui-strings/src/main/res/values-pl/strings.xml @@ -2715,7 +2715,7 @@ Preferencje interfejsu Przeglądaj pokoje Utwórz pokój - Zacznij rozmawiać + Rozpocznij czat Wszystkie rozmowy Nie zweryfikowano · Ostatnia aktywność %1$s Zweryfikowano · Ostatnia aktywność %1$s @@ -2743,4 +2743,4 @@ %s \nwygląda nieco pusto. Brak przestrzeni. - + \ No newline at end of file From b76b51265f10ae6456822f93f2aa4d71e5681e1e Mon Sep 17 00:00:00 2001 From: lvre <7uu3qrbvm@relay.firefox.com> Date: Wed, 16 Nov 2022 22:44:14 +0000 Subject: [PATCH 069/173] Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (2543 of 2543 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/pt_BR/ --- .../ui-strings/src/main/res/values-pt-rBR/strings.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/library/ui-strings/src/main/res/values-pt-rBR/strings.xml b/library/ui-strings/src/main/res/values-pt-rBR/strings.xml index d3061371fa..a916255d6f 100644 --- a/library/ui-strings/src/main/res/values-pt-rBR/strings.xml +++ b/library/ui-strings/src/main/res/values-pt-rBR/strings.xml @@ -2844,4 +2844,13 @@ Não dá pra começar um novo broadcast de voz Avançar rápido 30 segundos Retroceder 30 segundos + Sessões verificadas são onde quer que você está usando esta conta depois de entrar sua frasepasse ou confirmar sua identidade com uma outra sessão verificada. +\n +\nIsto significa que você tem todas as chaves necessitadas para destrancar suas mensagens encriptadas e confirmar a outras(os) usuárias(os) que você confia nesta sessão. + + Fazer signout de %1$d sessão + Fazer signout de %1$d sessões + + Fazer signout + %1$s restando \ No newline at end of file From 8296665b36a45967bbb7d231dd5fc0cdd419a9b7 Mon Sep 17 00:00:00 2001 From: Platon Terekhov Date: Sun, 20 Nov 2022 15:17:24 +0000 Subject: [PATCH 070/173] Translated using Weblate (Russian) Currently translated at 100.0% (2543 of 2543 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/ru/ --- library/ui-strings/src/main/res/values-ru/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/library/ui-strings/src/main/res/values-ru/strings.xml b/library/ui-strings/src/main/res/values-ru/strings.xml index fe71c0b596..8cc7bc54e0 100644 --- a/library/ui-strings/src/main/res/values-ru/strings.xml +++ b/library/ui-strings/src/main/res/values-ru/strings.xml @@ -2949,4 +2949,5 @@ ⚠ В этой комнате есть неподтверждённые устройства, они не смогут расшифровывать сообщения, отправленные вами. Дать разрешение Другие пользователи могут найти вас по %s + Осталось %1$s \ No newline at end of file From 1ef3e48ab6be423b433595cb3b148f05c906bf62 Mon Sep 17 00:00:00 2001 From: Jozef Gaal Date: Tue, 15 Nov 2022 23:49:40 +0000 Subject: [PATCH 071/173] Translated using Weblate (Slovak) Currently translated at 100.0% (2543 of 2543 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/sk/ --- library/ui-strings/src/main/res/values-sk/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/library/ui-strings/src/main/res/values-sk/strings.xml b/library/ui-strings/src/main/res/values-sk/strings.xml index 9c15c5ec0d..acd69a2024 100644 --- a/library/ui-strings/src/main/res/values-sk/strings.xml +++ b/library/ui-strings/src/main/res/values-sk/strings.xml @@ -2908,4 +2908,5 @@ Odhlásiť sa z %1$d relácií Odhlásiť sa + Ostáva %1$s \ No newline at end of file From 6e11874027e6965548a33bb3214245267a6aeae7 Mon Sep 17 00:00:00 2001 From: Ihor Hordiichuk Date: Tue, 15 Nov 2022 20:01:59 +0000 Subject: [PATCH 072/173] Translated using Weblate (Ukrainian) Currently translated at 100.0% (2543 of 2543 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/uk/ --- library/ui-strings/src/main/res/values-uk/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/library/ui-strings/src/main/res/values-uk/strings.xml b/library/ui-strings/src/main/res/values-uk/strings.xml index 8affc63685..27511255ee 100644 --- a/library/ui-strings/src/main/res/values-uk/strings.xml +++ b/library/ui-strings/src/main/res/values-uk/strings.xml @@ -2964,4 +2964,5 @@ Вийти з %1$d сеансів Вийти + Залишилося %1$s \ No newline at end of file From 2f69740e9b093a7474d0d3f7dd4382996a92d787 Mon Sep 17 00:00:00 2001 From: Jeff Huang Date: Wed, 16 Nov 2022 02:29:20 +0000 Subject: [PATCH 073/173] Translated using Weblate (Chinese (Traditional)) Currently translated at 100.0% (2543 of 2543 strings) Translation: Element Android/Element Android App Translate-URL: https://translate.element.io/projects/element-android/element-app/zh_Hant/ --- library/ui-strings/src/main/res/values-zh-rTW/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/library/ui-strings/src/main/res/values-zh-rTW/strings.xml b/library/ui-strings/src/main/res/values-zh-rTW/strings.xml index 6976854649..30db508f83 100644 --- a/library/ui-strings/src/main/res/values-zh-rTW/strings.xml +++ b/library/ui-strings/src/main/res/values-zh-rTW/strings.xml @@ -2796,4 +2796,5 @@ 登出 %1$d 個工作階段 登出 + 剩餘 %1$s \ No newline at end of file From 452cfd3327496f021527b2771940070335b76bda Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 23 Nov 2022 17:56:37 +0100 Subject: [PATCH 074/173] [RTE] Change layout based on plain text / rich text mode (#7621) --- changelog.d/7620.bugfix | 1 + .../detail/composer/RichTextComposerLayout.kt | 41 ++++++++++++++++++- .../res/layout/composer_rich_text_layout.xml | 1 + 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 changelog.d/7620.bugfix diff --git a/changelog.d/7620.bugfix b/changelog.d/7620.bugfix new file mode 100644 index 0000000000..55c0e423ad --- /dev/null +++ b/changelog.d/7620.bugfix @@ -0,0 +1 @@ +Make the plain text mode layout of the RTE more compact. diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt index 880ee2c031..4664c4213c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt @@ -34,6 +34,7 @@ import android.widget.ImageButton import android.widget.LinearLayout import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.constraintlayout.widget.ConstraintSet import androidx.core.text.toSpannable import androidx.core.view.isGone import androidx.core.view.isInvisible @@ -44,6 +45,7 @@ import im.vector.app.R import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.extensions.setTextIfDifferent import im.vector.app.core.extensions.showKeyboard +import im.vector.app.core.utils.DimensionConverter import im.vector.app.databinding.ComposerRichTextLayoutBinding import im.vector.app.databinding.ViewRichTextMenuButtonBinding import io.element.android.wysiwyg.EditorEditText @@ -72,6 +74,11 @@ class RichTextComposerLayout @JvmOverloads constructor( field = value updateTextFieldBorder(isFullScreen) updateEditTextVisibility() + updateFullScreenButtonVisibility() + // If formatting is no longer enabled and it's in full screen, minimise the editor + if (!value && isFullScreen) { + callback?.onFullScreenModeChanged() + } } override val text: Editable? @@ -105,6 +112,8 @@ class RichTextComposerLayout @JvmOverloads constructor( } } + private val dimensionConverter = DimensionConverter(resources) + fun setFullScreen(isFullScreen: Boolean) { editText.updateLayoutParams { height = if (isFullScreen) 0 else ViewGroup.LayoutParams.WRAP_CONTENT @@ -191,8 +200,7 @@ class RichTextComposerLayout @JvmOverloads constructor( } views.composerFullScreenButton.apply { - // There's no point in having full screen in landscape since there's almost no vertical space - isInvisible = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + updateFullScreenButtonVisibility() setOnClickListener { callback?.onFullScreenModeChanged() } @@ -251,6 +259,35 @@ class RichTextComposerLayout @JvmOverloads constructor( views.richTextComposerEditText.isVisible = isTextFormattingEnabled views.richTextMenu.isVisible = isTextFormattingEnabled views.plainTextComposerEditText.isVisible = !isTextFormattingEnabled + + // The layouts for formatted text mode and plain text mode are different, so we need to update the constraints + val dpToPx = { dp: Int -> dimensionConverter.dpToPx(dp) } + ConstraintSet().apply { + clone(views.composerLayoutContent) + clear(R.id.composerEditTextOuterBorder, ConstraintSet.TOP) + clear(R.id.composerEditTextOuterBorder, ConstraintSet.BOTTOM) + clear(R.id.composerEditTextOuterBorder, ConstraintSet.START) + clear(R.id.composerEditTextOuterBorder, ConstraintSet.END) + if (isTextFormattingEnabled) { + connect(R.id.composerEditTextOuterBorder, ConstraintSet.TOP, R.id.composerLayoutContent, ConstraintSet.TOP, dpToPx(8)) + connect(R.id.composerEditTextOuterBorder, ConstraintSet.BOTTOM, R.id.sendButton, ConstraintSet.TOP, 0) + connect(R.id.composerEditTextOuterBorder, ConstraintSet.START, R.id.composerLayoutContent, ConstraintSet.START, dpToPx(12)) + connect(R.id.composerEditTextOuterBorder, ConstraintSet.END, R.id.composerLayoutContent, ConstraintSet.END, dpToPx(12)) + } else { + connect(R.id.composerEditTextOuterBorder, ConstraintSet.TOP, R.id.composerLayoutContent, ConstraintSet.TOP, dpToPx(10)) + connect(R.id.composerEditTextOuterBorder, ConstraintSet.BOTTOM, R.id.composerLayoutContent, ConstraintSet.BOTTOM, dpToPx(10)) + connect(R.id.composerEditTextOuterBorder, ConstraintSet.START, R.id.attachmentButton, ConstraintSet.END, 0) + connect(R.id.composerEditTextOuterBorder, ConstraintSet.END, R.id.sendButton, ConstraintSet.START, 0) + } + applyTo(views.composerLayoutContent) + } + } + + private fun updateFullScreenButtonVisibility() { + val isLargeScreenDevice = resources.configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE) + val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + // There's no point in having full screen in landscape since there's almost no vertical space + views.composerFullScreenButton.isInvisible = !isTextFormattingEnabled || (isLandscape && !isLargeScreenDevice) } /** diff --git a/vector/src/main/res/layout/composer_rich_text_layout.xml b/vector/src/main/res/layout/composer_rich_text_layout.xml index 5f37de2a3a..88f96c528e 100644 --- a/vector/src/main/res/layout/composer_rich_text_layout.xml +++ b/vector/src/main/res/layout/composer_rich_text_layout.xml @@ -26,6 +26,7 @@ From ffb5edd2e4ef0ac22ab215bc02d7638b7a6d7fe6 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Thu, 24 Nov 2022 09:46:11 +0100 Subject: [PATCH 075/173] Set timeout for test CI jobs (#7598) * Set timeout for test CI jobs * Increase timeout to 1.5h: some Test jobs successfully finish > 1h --- .github/workflows/post-pr.yml | 5 +++-- .github/workflows/tests.yml | 37 ++++++++++++++++++----------------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/.github/workflows/post-pr.yml b/.github/workflows/post-pr.yml index aac4fffa4e..af854bf371 100644 --- a/.github/workflows/post-pr.yml +++ b/.github/workflows/post-pr.yml @@ -16,7 +16,7 @@ env: jobs: # More info on should-i-run: - # If this fails to run (the IF doesn't complete) then the needs will not be satisfied for any of the + # If this fails to run (the IF doesn't complete) then the needs will not be satisfied for any of the # other jobs below, so none will run. # except for the notification job at the bottom which will run all the time, unless should-i-run isn't # successful, or all the other jobs have succeeded @@ -27,11 +27,12 @@ jobs: if: github.event.pull_request.merged # Additionally require PR to have been completely merged. steps: - run: echo "Run those tests!" # no-op success - + ui-tests: name: UI Tests (Synapse) needs: should-i-run runs-on: buildjet-4vcpu-ubuntu-2204 + timeout-minutes: 90 # We might need to increase it if the time for tests grows strategy: fail-fast: false matrix: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bb16d8abe8..931ec2da45 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,6 +14,7 @@ jobs: tests: name: Runs all tests runs-on: buildjet-4vcpu-ubuntu-2204 + timeout-minutes: 90 # We might need to increase it if the time for tests grows strategy: matrix: api-level: [28] @@ -126,26 +127,26 @@ jobs: # Unneeded as part of the test suite above, kept around in case we want to re-enable them. # # # Build Android Tests -# build-android-tests: -# name: Build Android Tests -# runs-on: ubuntu-latest +# build-android-tests: +# name: Build Android Tests +# runs-on: ubuntu-latest # concurrency: # group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('build-android-tests-{0}', github.ref) }} # cancel-in-progress: true -# steps: -# - uses: actions/checkout@v3 -# - uses: actions/setup-java@v3 -# with: -# distribution: 'adopt' -# java-version: 11 -# - uses: actions/cache@v3 -# with: -# path: | -# ~/.gradle/caches -# ~/.gradle/wrapper -# key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} -# restore-keys: | -# ${{ runner.os }}-gradle- -# - name: Build Android Tests +# steps: +# - uses: actions/checkout@v3 +# - uses: actions/setup-java@v3 +# with: +# distribution: 'adopt' +# java-version: 11 +# - uses: actions/cache@v3 +# with: +# path: | +# ~/.gradle/caches +# ~/.gradle/wrapper +# key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} +# restore-keys: | +# ${{ runner.os }}-gradle- +# - name: Build Android Tests # run: ./gradlew clean assembleAndroidTest $CI_GRADLE_ARG_PROPERTIES From 3dccad9931572d98124481458778da607b83cfaa Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 24 Nov 2022 10:58:42 +0100 Subject: [PATCH 076/173] Detekt ComplexMethod has been renamed to CyclomaticComplexMethod --- tools/detekt/detekt.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/detekt/detekt.yml b/tools/detekt/detekt.yml index 96adb3d117..62a4fc408f 100644 --- a/tools/detekt/detekt.yml +++ b/tools/detekt/detekt.yml @@ -55,7 +55,7 @@ complexity: active: false LongParameterList: active: false - ComplexMethod: + CyclomaticComplexMethod: active: false NestedBlockDepth: active: false From ebbfca4ffddd275663b3ff191fe08c8ac56e7785 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 24 Nov 2022 11:06:00 +0100 Subject: [PATCH 077/173] Detekt: Use require() instead of throwing an IllegalArgumentException. [UseRequire] --- .../crypto/crosssigning/CrossSigningOlm.kt | 4 +- .../internal/crypto/tasks/EncryptEventTask.kt | 5 +- .../DefaultVerificationService.kt | 78 +++++++++---------- .../app/core/di/VectorViewModelFactory.kt | 4 +- 4 files changed, 40 insertions(+), 51 deletions(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/crosssigning/CrossSigningOlm.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/crosssigning/CrossSigningOlm.kt index 3218b99948..0f29404d4f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/crosssigning/CrossSigningOlm.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/crosssigning/CrossSigningOlm.kt @@ -83,9 +83,7 @@ internal class CrossSigningOlm @Inject constructor( val signaturesMadeByMyKey = signatures[myUserID] // Signatures made by me ?.get("ed25519:$pubKey") - if (signaturesMadeByMyKey.isNullOrBlank()) { - throw IllegalArgumentException("Not signed with my key $type") - } + require(signaturesMadeByMyKey.orEmpty().isNotBlank()) { "Not signed with my key $type" } // Check that Alice USK signature of Bob MSK is valid olmUtility.verifyEd25519Signature(signaturesMadeByMyKey, pubKey, JsonCanonicalizer.getCanonicalJson(Map::class.java, signable)) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/EncryptEventTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/EncryptEventTask.kt index f93da74507..5d2797a6af 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/EncryptEventTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/EncryptEventTask.kt @@ -47,9 +47,8 @@ internal class DefaultEncryptEventTask @Inject constructor( // don't want to wait for any query // if (!params.crypto.isRoomEncrypted(params.roomId)) return params.event val localEvent = params.event - if (localEvent.eventId == null || localEvent.type == null) { - throw IllegalArgumentException() - } + require(localEvent.eventId != null) + require(localEvent.type != null) localEchoRepository.updateSendState(localEvent.eventId, localEvent.roomId, SendState.ENCRYPTING) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/DefaultVerificationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/DefaultVerificationService.kt index 1a04ee0302..5b400aa63f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/DefaultVerificationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/DefaultVerificationService.kt @@ -1140,28 +1140,25 @@ internal class DefaultVerificationService @Inject constructor( override fun beginKeyVerification(method: VerificationMethod, otherUserId: String, otherDeviceId: String, transactionId: String?): String? { val txID = transactionId?.takeIf { it.isNotEmpty() } ?: createUniqueIDForTransaction(otherUserId, otherDeviceId) // should check if already one (and cancel it) - if (method == VerificationMethod.SAS) { - val tx = DefaultOutgoingSASDefaultVerificationTransaction( - setDeviceVerificationAction, - userId, - deviceId, - cryptoStore, - crossSigningService, - outgoingKeyRequestManager, - secretShareManager, - myDeviceInfoHolder.get().myDevice.fingerprint()!!, - txID, - otherUserId, - otherDeviceId - ) - tx.transport = verificationTransportToDeviceFactory.createTransport(tx) - addTransaction(tx) + require(method == VerificationMethod.SAS) { "Unknown verification method" } + val tx = DefaultOutgoingSASDefaultVerificationTransaction( + setDeviceVerificationAction, + userId, + deviceId, + cryptoStore, + crossSigningService, + outgoingKeyRequestManager, + secretShareManager, + myDeviceInfoHolder.get().myDevice.fingerprint()!!, + txID, + otherUserId, + otherDeviceId + ) + tx.transport = verificationTransportToDeviceFactory.createTransport(tx) + addTransaction(tx) - tx.start() - return txID - } else { - throw IllegalArgumentException("Unknown verification method") - } + tx.start() + return txID } override fun requestKeyVerificationInDMs( @@ -1343,28 +1340,25 @@ internal class DefaultVerificationService @Inject constructor( otherUserId: String, otherDeviceId: String ): String { - if (method == VerificationMethod.SAS) { - val tx = DefaultOutgoingSASDefaultVerificationTransaction( - setDeviceVerificationAction, - userId, - deviceId, - cryptoStore, - crossSigningService, - outgoingKeyRequestManager, - secretShareManager, - myDeviceInfoHolder.get().myDevice.fingerprint()!!, - transactionId, - otherUserId, - otherDeviceId - ) - tx.transport = verificationTransportRoomMessageFactory.createTransport(roomId, tx) - addTransaction(tx) + require(method == VerificationMethod.SAS) { "Unknown verification method" } + val tx = DefaultOutgoingSASDefaultVerificationTransaction( + setDeviceVerificationAction, + userId, + deviceId, + cryptoStore, + crossSigningService, + outgoingKeyRequestManager, + secretShareManager, + myDeviceInfoHolder.get().myDevice.fingerprint()!!, + transactionId, + otherUserId, + otherDeviceId + ) + tx.transport = verificationTransportRoomMessageFactory.createTransport(roomId, tx) + addTransaction(tx) - tx.start() - return transactionId - } else { - throw IllegalArgumentException("Unknown verification method") - } + tx.start() + return transactionId } override fun readyPendingVerificationInDMs( diff --git a/vector/src/main/java/im/vector/app/core/di/VectorViewModelFactory.kt b/vector/src/main/java/im/vector/app/core/di/VectorViewModelFactory.kt index ded2a562c5..a14b12e7a5 100644 --- a/vector/src/main/java/im/vector/app/core/di/VectorViewModelFactory.kt +++ b/vector/src/main/java/im/vector/app/core/di/VectorViewModelFactory.kt @@ -37,9 +37,7 @@ class VectorViewModelFactory @Inject constructor( } } } - if (creator == null) { - throw IllegalArgumentException("Unknown model class: $modelClass") - } + require(creator != null) { "Unknown model class: $modelClass" } try { @Suppress("UNCHECKED_CAST") return creator.get() as T From 891709ef41b2cf22548e2302e583d5cbb4ec2c42 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 26 Oct 2022 17:05:31 +0200 Subject: [PATCH 078/173] better replace handling --- .../events/model/AggregatedAnnotation.kt | 1 - .../events/model/AggregatedRelations.kt | 1 + .../session/events/model/AggregatedReplace.kt | 33 ++ .../events/model/ValidDecryptedEvent.kt | 53 +++ .../room/model/EditAggregatedSummary.kt | 4 +- .../session/room/timeline/TimelineEvent.kt | 17 +- .../sdk/internal/database/AsyncTransaction.kt | 6 +- .../database/RealmSessionStoreMigration.kt | 4 +- .../database/helper/ChunkEntityHelper.kt | 1 - .../database/helper/ThreadSummaryHelper.kt | 27 -- .../mapper/EventAnnotationsSummaryMapper.kt | 20 +- .../database/migration/MigrateSessionTo008.kt | 6 +- .../database/migration/MigrateSessionTo043.kt | 43 ++ .../model/EditAggregatedSummaryEntity.kt | 5 +- .../model/EventAnnotationsSummaryEntity.kt | 16 - .../session/EventInsertLiveProcessor.kt | 2 +- .../session/call/CallEventProcessor.kt | 2 +- .../session/room/EventEditValidator.kt | 115 ++++++ .../EventRelationsAggregationProcessor.kt | 191 ++++----- .../room/create/CreateLocalRoomTask.kt | 10 +- .../room/create/RoomCreateEventProcessor.kt | 2 +- ...iveLocationShareRedactionEventProcessor.kt | 2 +- .../room/prune/RedactionEventProcessor.kt | 2 +- .../tombstone/RoomTombstoneEventProcessor.kt | 2 +- .../android/sdk/internal/util/Monarchy.kt | 2 +- .../session/room/EditValidationTest.kt | 366 ++++++++++++++++++ .../android/sdk/test/fakes/FakeMonarchy.kt | 2 +- .../sdk/test/fakes/FakeRealmConfiguration.kt | 2 +- .../app/core/extensions/TimelineEvent.kt | 4 +- .../timeline/TimelineEventController.kt | 1 + .../timeline/factory/MessageItemFactory.kt | 2 +- .../factory/TimelineItemFactoryParams.kt | 2 + .../helper/MessageInformationDataFactory.kt | 40 +- 33 files changed, 774 insertions(+), 212 deletions(-) create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedReplace.kt create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/ValidDecryptedEvent.kt create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt create mode 100644 matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EditValidationTest.kt diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedAnnotation.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedAnnotation.kt index 239f749993..5b2ab77467 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedAnnotation.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedAnnotation.kt @@ -38,5 +38,4 @@ data class AggregatedAnnotation( override val limited: Boolean? = false, override val count: Int? = 0, val chunk: List? = null - ) : UnsignedRelationInfo diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt index ae8ed3941f..1dedcce8b6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt @@ -50,5 +50,6 @@ import com.squareup.moshi.JsonClass data class AggregatedRelations( @Json(name = "m.annotation") val annotations: AggregatedAnnotation? = null, @Json(name = "m.reference") val references: DefaultUnsignedRelationInfo? = null, + @Json(name = "m.replace") val replaces: AggregatedReplace? = null, @Json(name = RelationType.THREAD) val latestThread: LatestThreadUnsignedRelation? = null ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedReplace.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedReplace.kt new file mode 100644 index 0000000000..2ae091a1a4 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedReplace.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.events.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Note that there can be multiple events with an m.replace relationship to a given event (for example, if an event is edited multiple times). + * These should be aggregated by the homeserver. + * https://spec.matrix.org/v1.4/client-server-api/#server-side-aggregation-of-mreplace-relationships + * + */ +@JsonClass(generateAdapter = true) +data class AggregatedReplace( + @Json(name = "event_id") val eventId: String? = null, + @Json(name = "origin_server_ts") val originServerTs: Long? = null, + @Json(name = "sender") val senderId: String? = null, +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/ValidDecryptedEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/ValidDecryptedEvent.kt new file mode 100644 index 0000000000..0cee077807 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/ValidDecryptedEvent.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.events.model + +data class ValidDecryptedEvent( + val type: String, + val eventId: String, + val clearContent: Content, + val prevContent: Content? = null, + val originServerTs: Long, + val cryptoSenderKey: String, + val roomId: String, + val unsignedData: UnsignedData? = null, + val redacts: String? = null, + val algorithm: String, +) + +fun Event.toValidDecryptedEvent(): ValidDecryptedEvent? { + if (!this.isEncrypted()) return null + val decryptedContent = this.getDecryptedContent() ?: return null + val eventId = this.eventId ?: return null + val roomId = this.roomId ?: return null + val type = this.getDecryptedType() ?: return null + val senderKey = this.getSenderKey() ?: return null + val algorithm = this.content?.get("algorithm") as? String ?: return null + + return ValidDecryptedEvent( + type = type, + eventId = eventId, + clearContent = decryptedContent, + prevContent = this.prevContent, + originServerTs = this.originServerTs ?: 0, + cryptoSenderKey = senderKey, + roomId = roomId, + unsignedData = this.unsignedData, + redacts = this.redacts, + algorithm = algorithm + ) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/EditAggregatedSummary.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/EditAggregatedSummary.kt index 67bab626cb..7d445a5cc6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/EditAggregatedSummary.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/EditAggregatedSummary.kt @@ -15,10 +15,10 @@ */ package org.matrix.android.sdk.api.session.room.model -import org.matrix.android.sdk.api.session.events.model.Content +import org.matrix.android.sdk.api.session.events.model.Event data class EditAggregatedSummary( - val latestContent: Content? = null, + val latestEdit: Event? = null, // The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk) val sourceEvents: List, val localEchos: List, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt index 6f4049de36..6ceced59d7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt @@ -30,12 +30,8 @@ import org.matrix.android.sdk.api.session.events.model.isSticker import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary import org.matrix.android.sdk.api.session.room.model.ReadReceipt -import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent -import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody -import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent -import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent import org.matrix.android.sdk.api.session.room.sender.SenderInfo @@ -140,13 +136,12 @@ fun TimelineEvent.getEditedEventId(): String? { * Get last MessageContent, after a possible edition. */ fun TimelineEvent.getLastMessageContent(): MessageContent? { - return when (root.getClearType()) { - EventType.STICKER -> root.getClearContent().toModel() - in EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() - in EventType.STATE_ROOM_BEACON_INFO -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() - in EventType.BEACON_LOCATION_DATA -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() - else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() - } + return ( + annotations?.editSummary?.latestEdit + ?.getClearContent()?.toModel()?.newContent + ?: root.getClearContent() + ) + .toModel() } /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/AsyncTransaction.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/AsyncTransaction.kt index 7d263f1937..a1ea88a70c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/AsyncTransaction.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/AsyncTransaction.kt @@ -26,17 +26,17 @@ import kotlinx.coroutines.withContext import timber.log.Timber import kotlin.system.measureTimeMillis -internal fun CoroutineScope.asyncTransaction(monarchy: Monarchy, transaction: suspend (realm: Realm) -> T) { +internal fun CoroutineScope.asyncTransaction(monarchy: Monarchy, transaction: (realm: Realm) -> T) { asyncTransaction(monarchy.realmConfiguration, transaction) } -internal fun CoroutineScope.asyncTransaction(realmConfiguration: RealmConfiguration, transaction: suspend (realm: Realm) -> T) { +internal fun CoroutineScope.asyncTransaction(realmConfiguration: RealmConfiguration, transaction: (realm: Realm) -> T) { launch { awaitTransaction(realmConfiguration, transaction) } } -internal suspend fun awaitTransaction(config: RealmConfiguration, transaction: suspend (realm: Realm) -> T): T { +internal suspend fun awaitTransaction(config: RealmConfiguration, transaction: (realm: Realm) -> T): T { return withContext(Realm.WRITE_EXECUTOR.asCoroutineDispatcher()) { Realm.getInstance(config).use { bgRealm -> bgRealm.beginTransaction() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt index 30836c027e..388f962454 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt @@ -59,6 +59,7 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo039 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo040 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo041 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo042 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo043 import org.matrix.android.sdk.internal.util.Normalizer import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration import javax.inject.Inject @@ -67,7 +68,7 @@ internal class RealmSessionStoreMigration @Inject constructor( private val normalizer: Normalizer ) : MatrixRealmMigration( dbName = "Session", - schemaVersion = 42L, + schemaVersion = 43L, ) { /** * Forces all RealmSessionStoreMigration instances to be equal. @@ -119,5 +120,6 @@ internal class RealmSessionStoreMigration @Inject constructor( if (oldVersion < 40) MigrateSessionTo040(realm).perform() if (oldVersion < 41) MigrateSessionTo041(realm).perform() if (oldVersion < 42) MigrateSessionTo042(realm).perform() + if (oldVersion < 43) MigrateSessionTo043(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt index 221abe0df5..149a2eebfe 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt @@ -83,7 +83,6 @@ internal fun ChunkEntity.addTimelineEvent( this.eventId = eventId this.roomId = roomId this.annotations = EventAnnotationsSummaryEntity.where(realm, roomId, eventId).findFirst() - ?.also { it.cleanUp(eventEntity.sender) } this.readReceipts = readReceiptsSummaryEntity this.displayIndex = displayIndex this.ownedByThreadChunk = ownedByThreadChunk diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt index 193710f962..0ac8dc7902 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt @@ -19,7 +19,6 @@ package org.matrix.android.sdk.internal.database.helper import io.realm.Realm import io.realm.RealmQuery import io.realm.Sort -import io.realm.kotlin.createObject import kotlinx.coroutines.runBlocking import org.matrix.android.sdk.api.session.crypto.CryptoService import org.matrix.android.sdk.api.session.crypto.MXCryptoError @@ -103,32 +102,6 @@ internal fun ThreadSummaryEntity.updateThreadSummaryLatestEvent( } } -private fun EventEntity.toTimelineEventEntity(roomMemberContentsByUser: HashMap): TimelineEventEntity { - val roomId = roomId - val eventId = eventId - val localId = TimelineEventEntity.nextId(realm) - val senderId = sender ?: "" - - val timelineEventEntity = realm.createObject().apply { - this.localId = localId - this.root = this@toTimelineEventEntity - this.eventId = eventId - this.roomId = roomId - this.annotations = EventAnnotationsSummaryEntity.where(realm, roomId, eventId).findFirst() - ?.also { it.cleanUp(sender) } - this.ownedByThreadChunk = true // To skip it from the original event flow - val roomMemberContent = roomMemberContentsByUser[senderId] - this.senderAvatar = roomMemberContent?.avatarUrl - this.senderName = roomMemberContent?.displayName - isUniqueDisplayName = if (roomMemberContent?.displayName != null) { - computeIsUnique(realm, roomId, false, roomMemberContent, roomMemberContentsByUser) - } else { - true - } - } - return timelineEventEntity -} - internal fun ThreadSummaryEntity.Companion.createOrUpdate( threadSummaryType: ThreadSummaryUpdateType, realm: Realm, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventAnnotationsSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventAnnotationsSummaryMapper.kt index 6bbeb17fdd..5fb70ad1ee 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventAnnotationsSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventAnnotationsSummaryMapper.kt @@ -20,6 +20,7 @@ import org.matrix.android.sdk.api.session.room.model.EditAggregatedSummary import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary import org.matrix.android.sdk.api.session.room.model.ReactionAggregatedSummary import org.matrix.android.sdk.api.session.room.model.ReferencesAggregatedSummary +import org.matrix.android.sdk.internal.database.model.EditionOfEvent import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity internal object EventAnnotationsSummaryMapper { @@ -36,13 +37,22 @@ internal object EventAnnotationsSummaryMapper { ) }, editSummary = annotationsSummary.editSummary - ?.let { - val latestEdition = it.editions.maxByOrNull { editionOfEvent -> editionOfEvent.timestamp } ?: return@let null + ?.let { summary -> + /** + * The most recent event is determined by comparing origin_server_ts; + * if two or more replacement events have identical origin_server_ts, + * the event with the lexicographically largest event_id is treated as more recent. + */ + val latestEdition = summary.editions.sortedWith(compareBy { it.timestamp }.thenBy { it.eventId }) + .lastOrNull() ?: return@let null + // get the event and validate? + val editEvent = latestEdition.event + EditAggregatedSummary( - latestContent = ContentMapper.map(latestEdition.content), - sourceEvents = it.editions.filter { editionOfEvent -> !editionOfEvent.isLocalEcho } + latestEdit = editEvent?.asDomain(), + sourceEvents = summary.editions.filter { editionOfEvent -> !editionOfEvent.isLocalEcho } .map { editionOfEvent -> editionOfEvent.eventId }, - localEchos = it.editions.filter { editionOfEvent -> editionOfEvent.isLocalEcho } + localEchos = summary.editions.filter { editionOfEvent -> editionOfEvent.isLocalEcho } .map { editionOfEvent -> editionOfEvent.eventId }, lastEditTs = latestEdition.timestamp ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo008.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo008.kt index b61bf7e6fa..42a47a9a27 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo008.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo008.kt @@ -25,11 +25,11 @@ internal class MigrateSessionTo008(realm: DynamicRealm) : RealmMigrator(realm, 8 override fun doMigrate(realm: DynamicRealm) { val editionOfEventSchema = realm.schema.create("EditionOfEvent") - .addField(EditionOfEventFields.CONTENT, String::class.java) + .addField("content"/**EditionOfEventFields.CONTENT*/, String::class.java) .addField(EditionOfEventFields.EVENT_ID, String::class.java) .setRequired(EditionOfEventFields.EVENT_ID, true) - .addField(EditionOfEventFields.SENDER_ID, String::class.java) - .setRequired(EditionOfEventFields.SENDER_ID, true) + .addField("senderId" /*EditionOfEventFields.SENDER_ID*/, String::class.java) + .setRequired("senderId" /*EditionOfEventFields.SENDER_ID*/, true) .addField(EditionOfEventFields.TIMESTAMP, Long::class.java) .addField(EditionOfEventFields.IS_LOCAL_ECHO, Boolean::class.java) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt new file mode 100644 index 0000000000..a27d4fda3a --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.database.model.EditionOfEventFields +import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.EventEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +internal class MigrateSessionTo043(realm: DynamicRealm) : RealmMigrator(realm, 43) { + + override fun doMigrate(realm: DynamicRealm) { + // content(string) & senderId(string) have been removed and replaced by a link to the actual event + realm.schema.get("EditionOfEvent") + ?.removeField("senderId") + ?.removeField("content") + ?.addRealmObjectField(EditionOfEventFields.EVENT.`$`, realm.schema.get("EventEntity")!!) + ?.transform { dynamicObject -> + realm.where(EventEntity::javaClass.name) + .equalTo(EventEntityFields.EVENT_ID, dynamicObject.getString(EditionOfEventFields.EVENT_ID)) + .equalTo(EventEntityFields.SENDER, dynamicObject.getString("senderId")) + .findFirst() + .let { + dynamicObject.setObject(EditionOfEventFields.EVENT.`$`, it) + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EditAggregatedSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EditAggregatedSummaryEntity.kt index 61acd51dd4..7b7b90f82d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EditAggregatedSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EditAggregatedSummaryEntity.kt @@ -32,9 +32,8 @@ internal open class EditAggregatedSummaryEntity( @RealmClass(embedded = true) internal open class EditionOfEvent( - var senderId: String = "", var eventId: String = "", - var content: String? = null, var timestamp: Long = 0, - var isLocalEcho: Boolean = false + var isLocalEcho: Boolean = false, + var event: EventEntity? = null, ) : RealmObject() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventAnnotationsSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventAnnotationsSummaryEntity.kt index 645998d0c0..9a201ab4e8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventAnnotationsSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventAnnotationsSummaryEntity.kt @@ -19,7 +19,6 @@ import io.realm.RealmList import io.realm.RealmObject import io.realm.annotations.PrimaryKey import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity -import timber.log.Timber internal open class EventAnnotationsSummaryEntity( @PrimaryKey @@ -32,21 +31,6 @@ internal open class EventAnnotationsSummaryEntity( var liveLocationShareAggregatedSummary: LiveLocationShareAggregatedSummaryEntity? = null, ) : RealmObject() { - /** - * Cleanup undesired editions, done by users different from the originalEventSender. - */ - fun cleanUp(originalEventSenderId: String?) { - originalEventSenderId ?: return - - editSummary?.editions?.filter { - it.senderId != originalEventSenderId - } - ?.forEach { - Timber.w("Deleting an edition from ${it.senderId} of event sent by $originalEventSenderId") - it.deleteFromRealm() - } - } - companion object } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/EventInsertLiveProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/EventInsertLiveProcessor.kt index a650fa2d64..9741a7bd15 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/EventInsertLiveProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/EventInsertLiveProcessor.kt @@ -24,7 +24,7 @@ internal interface EventInsertLiveProcessor { fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean - suspend fun process(realm: Realm, event: Event) + fun process(realm: Realm, event: Event) /** * Called after transaction. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallEventProcessor.kt index b15a647421..6a4abe9d34 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallEventProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallEventProcessor.kt @@ -54,7 +54,7 @@ internal class CallEventProcessor @Inject constructor(private val callSignalingH return allowedTypes.contains(eventType) } - override suspend fun process(realm: Realm, event: Event) { + override fun process(realm: Realm, event: Event) { eventsToPostProcess.add(event) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt new file mode 100644 index 0000000000..dcf6ad54a0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room + +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore +import javax.inject.Inject + +internal class EventEditValidator @Inject constructor(val cryptoStore: IMXCryptoStore) { + + sealed class EditValidity { + object Valid : EditValidity() + data class Invalid(val reason: String) : EditValidity() + object Unknown : EditValidity() + } + + /** + *There are a number of requirements on replacement events, which must be satisfied for the replacement to be considered valid: + * As with all event relationships, the original event and replacement event must have the same room_id + * (i.e. you cannot send an event in one room and then an edited version in a different room). + * The original event and replacement event must have the same sender (i.e. you cannot edit someone else’s messages). + * The replacement and original events must have the same type (i.e. you cannot change the original event’s type). + * The replacement and original events must not have a state_key property (i.e. you cannot edit state events at all). + * The original event must not, itself, have a rel_type of m.replace (i.e. you cannot edit an edit — though you can send multiple edits for a single original event). + * The replacement event (once decrypted, if appropriate) must have an m.new_content property. + * + * If the original event was encrypted, the replacement should be too. + */ + fun validateEdit(originalEvent: Event?, replaceEvent: Event): EditValidity { + // we might not know the original event at that time. In this case we can't perform the validation + // Edits should be revalidated when the original event is received + if (originalEvent == null) { + return EditValidity.Unknown + } + + if (originalEvent.roomId != replaceEvent.roomId) { + return EditValidity.Invalid("original event and replacement event must have the same room_id") + } + if (originalEvent.isStateEvent() || replaceEvent.isStateEvent()) { + return EditValidity.Invalid("replacement and original events must not have a state_key property") + } + // check it's from same sender + + if (originalEvent.isEncrypted()) { + if (!replaceEvent.isEncrypted()) return EditValidity.Invalid("If the original event was encrypted, the replacement should be too") + val originalDecrypted = originalEvent.toValidDecryptedEvent() + ?: return EditValidity.Unknown // UTD can't decide + val replaceDecrypted = replaceEvent.toValidDecryptedEvent() + ?: return EditValidity.Unknown // UTD can't decide + + val originalCryptoSenderId = cryptoStore.deviceWithIdentityKey(originalDecrypted.cryptoSenderKey)?.userId + val editCryptoSenderId = cryptoStore.deviceWithIdentityKey(replaceDecrypted.cryptoSenderKey)?.userId + + if (originalDecrypted.clearContent.toModel()?.relatesTo?.type == RelationType.REPLACE) { + return EditValidity.Invalid("The original event must not, itself, have a rel_type of m.replace ") + } + + if (originalCryptoSenderId == null || editCryptoSenderId == null) { + // mm what can we do? we don't know if it's cryptographically from same user? + // let valid and UI should display send by deleted device warning? + val bestEffortOriginal = originalCryptoSenderId ?: originalEvent.senderId + val bestEffortEdit = editCryptoSenderId ?: replaceEvent.senderId + if (bestEffortOriginal != bestEffortEdit) { + return EditValidity.Invalid("original event and replacement event must have the same sender") + } + } else { + if (originalCryptoSenderId != editCryptoSenderId) { + return EditValidity.Invalid("Crypto: original event and replacement event must have the same sender") + } + } + + if (originalDecrypted.type != replaceDecrypted.type) { + return EditValidity.Invalid("replacement and original events must have the same type") + } + if (replaceDecrypted.clearContent.toModel()?.newContent == null) { + return EditValidity.Invalid("replacement event must have an m.new_content property") + } + } else { + if (originalEvent.content.toModel()?.relatesTo?.type == RelationType.REPLACE) { + return EditValidity.Invalid("The original event must not, itself, have a rel_type of m.replace ") + } + + // check the sender + if (originalEvent.senderId != replaceEvent.senderId) { + return EditValidity.Invalid("original event and replacement event must have the same sender") + } + if (originalEvent.type != replaceEvent.type) { + return EditValidity.Invalid("replacement and original events must have the same type") + } + if (replaceEvent.content.toModel()?.newContent == null) { + return EditValidity.Invalid("replacement event must have an m.new_content property") + } + } + + return EditValidity.Valid + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt index 24d4975eb9..ef1d8c1430 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt @@ -23,7 +23,6 @@ import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.LocalEcho import org.matrix.android.sdk.api.session.events.model.RelationType -import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent import org.matrix.android.sdk.api.session.events.model.getRelationContent import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel @@ -42,6 +41,7 @@ import org.matrix.android.sdk.internal.crypto.verification.toState import org.matrix.android.sdk.internal.database.helper.findRootThreadEvent import org.matrix.android.sdk.internal.database.mapper.ContentMapper import org.matrix.android.sdk.internal.database.mapper.EventMapper +import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntity import org.matrix.android.sdk.internal.database.model.EditionOfEvent import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity @@ -72,6 +72,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor( private val sessionManager: SessionManager, private val liveLocationAggregationProcessor: LiveLocationAggregationProcessor, private val pollAggregationProcessor: PollAggregationProcessor, + private val editValidator: EventEditValidator, private val clock: Clock, ) : EventInsertLiveProcessor { @@ -79,13 +80,15 @@ internal class EventRelationsAggregationProcessor @Inject constructor( EventType.MESSAGE, EventType.REDACTION, EventType.REACTION, + // The aggregator handles verification events but just to render tiles in the timeline + // It's not participating in verfication it's self, just timeline display EventType.KEY_VERIFICATION_DONE, EventType.KEY_VERIFICATION_CANCEL, EventType.KEY_VERIFICATION_ACCEPT, EventType.KEY_VERIFICATION_START, EventType.KEY_VERIFICATION_MAC, // TODO Add ? - // EventType.KEY_VERIFICATION_READY, + EventType.KEY_VERIFICATION_READY, EventType.KEY_VERIFICATION_KEY, EventType.ENCRYPTED ) + EventType.POLL_START + EventType.POLL_RESPONSE + EventType.POLL_END + EventType.STATE_ROOM_BEACON_INFO + EventType.BEACON_LOCATION_DATA @@ -94,7 +97,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor( return allowedTypes.contains(eventType) } - override suspend fun process(realm: Realm, event: Event) { + override fun process(realm: Realm, event: Event) { try { // Temporary catch, should be removed val roomId = event.roomId if (roomId == null) { @@ -102,7 +105,31 @@ internal class EventRelationsAggregationProcessor @Inject constructor( return } val isLocalEcho = LocalEcho.isLocalEchoId(event.eventId ?: "") - when (event.type) { + + // It might be a late decryption of the original event or a event received when back paginating? + // let's check if there is already a summary for it and do some cleaning + if (!isLocalEcho) { + EventAnnotationsSummaryEntity.where(realm, roomId, event.eventId.orEmpty()) + .findFirst() + ?.editSummary + ?.editions + ?.forEach { editionOfEvent -> + EventEntity.where(realm, editionOfEvent.eventId).findFirst()?.asDomain()?.let { editEvent -> + when (editValidator.validateEdit(event, editEvent)) { + is EventEditValidator.EditValidity.Invalid -> { + // delete it, it was invalid + Timber.v("## Replace: Removing a previously accepted edit for event ${event.eventId}") + editionOfEvent.deleteFromRealm() + } + else -> { + // nop + } + } + } + } + } + + when (event.getClearType()) { EventType.REACTION -> { // we got a reaction!! Timber.v("###REACTION in room $roomId , reaction eventID ${event.eventId}") @@ -113,21 +140,17 @@ internal class EventRelationsAggregationProcessor @Inject constructor( Timber.v("###REACTION Aggregation in room $roomId for event ${event.eventId}") handleInitialAggregatedRelations(realm, event, roomId, event.unsignedData.relations.annotations) - EventAnnotationsSummaryEntity.where(realm, roomId, event.eventId ?: "").findFirst() - ?.let { - TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findAll() - ?.forEach { tet -> tet.annotations = it } - } + // XXX do something for aggregated edits? + // it's a bit strange as it would require to do a server query to get the edition? } - val content: MessageContent? = event.content.toModel() - if (content?.relatesTo?.type == RelationType.REPLACE) { + val relationContent = event.getRelationContent() + if (relationContent?.type == RelationType.REPLACE) { Timber.v("###REPLACE in room $roomId for event ${event.eventId}") // A replace! - handleReplace(realm, event, content, roomId, isLocalEcho) + handleReplace(realm, event, roomId, isLocalEcho, relationContent.eventId) } } - EventType.KEY_VERIFICATION_DONE, EventType.KEY_VERIFICATION_CANCEL, EventType.KEY_VERIFICATION_ACCEPT, @@ -142,73 +165,30 @@ internal class EventRelationsAggregationProcessor @Inject constructor( } } } - + // As for now Live event processors are not receiving UTD events. + // They will get an update if the event is decrypted later EventType.ENCRYPTED -> { - // Relation type is in clear - val encryptedEventContent = event.content.toModel() - if (encryptedEventContent?.relatesTo?.type == RelationType.REPLACE || - encryptedEventContent?.relatesTo?.type == RelationType.RESPONSE - ) { - event.getClearContent().toModel()?.let { - if (encryptedEventContent.relatesTo.type == RelationType.REPLACE) { - Timber.v("###REPLACE in room $roomId for event ${event.eventId}") - // A replace! - handleReplace(realm, event, it, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) - } else if (event.getClearType() in EventType.POLL_RESPONSE) { - sessionManager.getSessionComponent(sessionId)?.session()?.let { session -> - pollAggregationProcessor.handlePollResponseEvent(session, realm, event) - } - } - } - } else if (encryptedEventContent?.relatesTo?.type == RelationType.REFERENCE) { - when (event.getClearType()) { - EventType.KEY_VERIFICATION_DONE, - EventType.KEY_VERIFICATION_CANCEL, - EventType.KEY_VERIFICATION_ACCEPT, - EventType.KEY_VERIFICATION_START, - EventType.KEY_VERIFICATION_MAC, - EventType.KEY_VERIFICATION_READY, - EventType.KEY_VERIFICATION_KEY -> { - Timber.v("## SAS REF in room $roomId for event ${event.eventId}") - encryptedEventContent.relatesTo.eventId?.let { - handleVerification(realm, event, roomId, isLocalEcho, it) - } - } - in EventType.POLL_RESPONSE -> { - event.getClearContent().toModel(catchError = true)?.let { - sessionManager.getSessionComponent(sessionId)?.session()?.let { session -> - pollAggregationProcessor.handlePollResponseEvent(session, realm, event) - } - } - } - in EventType.POLL_END -> { - sessionManager.getSessionComponent(sessionId)?.session()?.let { session -> - getPowerLevelsHelper(event.roomId)?.let { - pollAggregationProcessor.handlePollEndEvent(session, it, realm, event) - } - } - } - in EventType.BEACON_LOCATION_DATA -> { - handleBeaconLocationData(event, realm, roomId, isLocalEcho) - } - } - } else if (encryptedEventContent?.relatesTo?.type == RelationType.ANNOTATION) { - // Reaction - if (event.getClearType() == EventType.REACTION) { - // we got a reaction!! - Timber.v("###REACTION e2e in room $roomId , reaction eventID ${event.eventId}") - handleReaction(realm, event, roomId, isLocalEcho) - } - } - // HandleInitialAggregatedRelations should also be applied in encrypted messages with annotations -// else if (event.unsignedData?.relations?.annotations != null) { -// Timber.v("###REACTION e2e Aggregation in room $roomId for event ${event.eventId}") -// handleInitialAggregatedRelations(realm, event, roomId, event.unsignedData.relations.annotations) -// EventAnnotationsSummaryEntity.where(realm, roomId, event.eventId ?: "").findFirst() -// ?.let { -// TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findAll() -// ?.forEach { tet -> tet.annotations = it } -// } +// // Relation type is in clear, it might be possible to do some things? +// // Notice that if the event is decrypted later, process be called again +// val encryptedEventContent = event.content.toModel() +// when (encryptedEventContent?.relatesTo?.type) { +// RelationType.REPLACE -> { +// Timber.v("###REPLACE in room $roomId for event ${event.eventId}") +// // A replace! +// handleReplace(realm, event, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) +// } +// RelationType.RESPONSE -> { +// // can we / should we do we something for UTD response?? +// Timber.w("## UTD response in room $roomId related to ${encryptedEventContent.relatesTo.eventId}") +// } +// RelationType.REFERENCE -> { +// // can we / should we do we something for UTD reference?? +// Timber.w("## UTD reference in room $roomId related to ${encryptedEventContent.relatesTo.eventId}") +// } +// RelationType.ANNOTATION -> { +// // can we / should we do we something for UTD annotation?? +// Timber.w("## UTD annotation in room $roomId related to ${encryptedEventContent.relatesTo.eventId}") +// } // } } EventType.REDACTION -> { @@ -217,9 +197,6 @@ internal class EventRelationsAggregationProcessor @Inject constructor( when (eventToPrune.type) { EventType.MESSAGE -> { Timber.d("REDACTION for message ${eventToPrune.eventId}") -// val unsignedData = EventMapper.map(eventToPrune).unsignedData -// ?: UnsignedData(null, null) - // was this event a m.replace val contentModel = ContentMapper.map(eventToPrune.content)?.toModel() if (RelationType.REPLACE == contentModel?.relatesTo?.type && contentModel.relatesTo?.eventId != null) { @@ -236,7 +213,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor( if (content?.relatesTo?.type == RelationType.REPLACE) { Timber.v("###REPLACE in room $roomId for event ${event.eventId}") // A replace! - handleReplace(realm, event, content, roomId, isLocalEcho) + handleReplace(realm, event, roomId, isLocalEcho, content?.relatesTo.eventId) } } in EventType.POLL_RESPONSE -> { @@ -274,23 +251,22 @@ internal class EventRelationsAggregationProcessor @Inject constructor( private fun handleReplace( realm: Realm, event: Event, - content: MessageContent, roomId: String, isLocalEcho: Boolean, - relatedEventId: String? = null + relatedEventId: String? ) { val eventId = event.eventId ?: return - val targetEventId = relatedEventId ?: content.relatesTo?.eventId ?: return - val newContent = content.newContent ?: return - - // Check that the sender is the same + val targetEventId = relatedEventId ?: return // ?: content.relatesTo?.eventId ?: return val editedEvent = EventEntity.where(realm, targetEventId).findFirst() - if (editedEvent == null) { - // We do not know yet about the edited event - } else if (editedEvent.sender != event.senderId) { - // Edited by someone else, ignore - Timber.w("Ignore edition by someone else") - return + + when (val validity = editValidator.validateEdit(editedEvent?.asDomain(), event)) { + is EventEditValidator.EditValidity.Invalid -> return Unit.also { + Timber.w("Dropping invalid edit ${event.eventId}, reason:${validity.reason}") + } + EventEditValidator.EditValidity.Unknown, // we can't drop the source event might be unknown, will be validated later + EventEditValidator.EditValidity.Valid -> { + // continue + } } // ok, this is a replace @@ -305,11 +281,10 @@ internal class EventRelationsAggregationProcessor @Inject constructor( .also { editSummary -> editSummary.editions.add( EditionOfEvent( - senderId = event.senderId ?: "", eventId = event.eventId, - content = ContentMapper.map(newContent), - timestamp = if (isLocalEcho) 0 else event.originServerTs ?: 0, - isLocalEcho = isLocalEcho + event = EventEntity.where(realm, eventId).findFirst(), + timestamp = if (isLocalEcho) clock.epochMillis() else event.originServerTs ?: clock.epochMillis(), + isLocalEcho = isLocalEcho, ) ) } @@ -334,9 +309,8 @@ internal class EventRelationsAggregationProcessor @Inject constructor( Timber.v("###REPLACE Computing aggregated edit summary (isLocalEcho:$isLocalEcho)") existingSummary.editions.add( EditionOfEvent( - senderId = event.senderId ?: "", eventId = event.eventId, - content = ContentMapper.map(newContent), + event = EventEntity.where(realm, eventId).findFirst(), timestamp = if (isLocalEcho) { clock.epochMillis() } else { @@ -369,8 +343,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor( * @param editions list of edition of event */ private fun handleThreadSummaryEdition( - editedEvent: EventEntity?, - replaceEvent: TimelineEventEntity?, + editedEvent: EventEntity?, replaceEvent: TimelineEventEntity?, editions: List? ) { replaceEvent ?: return @@ -599,12 +572,12 @@ internal class EventRelationsAggregationProcessor @Inject constructor( private fun handleBeaconLocationData(event: Event, realm: Realm, roomId: String, isLocalEcho: Boolean) { event.getClearContent().toModel(catchError = true)?.let { liveLocationAggregationProcessor.handleBeaconLocationData( - realm, - event, - it, - roomId, - event.getRelationContent()?.eventId, - isLocalEcho + realm = realm, + event = event, + content = it, + roomId = roomId, + relatedEventId = event.getRelationContent()?.eventId, + isLocalEcho = isLocalEcho ) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomTask.kt index 03c2b2a47e..0cda6eca99 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomTask.kt @@ -21,6 +21,7 @@ import io.realm.Realm import io.realm.RealmConfiguration import io.realm.kotlin.createObject import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.runBlocking import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.room.failure.CreateRoomFailure import org.matrix.android.sdk.api.session.room.model.Membership @@ -95,7 +96,7 @@ internal class DefaultCreateLocalRoomTask @Inject constructor( * Create a local room entity from the given room creation params. * This will also generate and store in database the chunk and the events related to the room params in order to retrieve and display the local room. */ - private suspend fun createLocalRoomEntity(realm: Realm, roomId: String, createRoomBody: CreateRoomBody) { + private fun createLocalRoomEntity(realm: Realm, roomId: String, createRoomBody: CreateRoomBody) { RoomEntity.getOrCreate(realm, roomId).apply { membership = Membership.JOIN chunks.add(createLocalRoomChunk(realm, roomId, createRoomBody)) @@ -148,13 +149,16 @@ internal class DefaultCreateLocalRoomTask @Inject constructor( * * @return a chunk entity */ - private suspend fun createLocalRoomChunk(realm: Realm, roomId: String, createRoomBody: CreateRoomBody): ChunkEntity { + private fun createLocalRoomChunk(realm: Realm, roomId: String, createRoomBody: CreateRoomBody): ChunkEntity { val chunkEntity = realm.createObject().apply { isLastBackward = true isLastForward = true } - val eventList = createLocalRoomStateEventsTask.execute(CreateLocalRoomStateEventsTask.Params(createRoomBody)) + // Can't suspend when using realm as it could jump thread + val eventList = runBlocking { + createLocalRoomStateEventsTask.execute(CreateLocalRoomStateEventsTask.Params(createRoomBody)) + } val roomMemberContentsByUser = HashMap() for (event in eventList) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/RoomCreateEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/RoomCreateEventProcessor.kt index eb966b684c..8b5fde6ab7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/RoomCreateEventProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/RoomCreateEventProcessor.kt @@ -30,7 +30,7 @@ import javax.inject.Inject internal class RoomCreateEventProcessor @Inject constructor() : EventInsertLiveProcessor { - override suspend fun process(realm: Realm, event: Event) { + override fun process(realm: Realm, event: Event) { val createRoomContent = event.getClearContent().toModel() val predecessorRoomId = createRoomContent?.predecessor?.roomId ?: return diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessor.kt index fa3479ed3c..9041ef2677 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessor.kt @@ -40,7 +40,7 @@ internal class LiveLocationShareRedactionEventProcessor @Inject constructor() : return eventType == EventType.REDACTION && insertType != EventInsertType.LOCAL_ECHO } - override suspend fun process(realm: Realm, event: Event) { + override fun process(realm: Realm, event: Event) { if (event.redacts.isNullOrBlank() || LocalEcho.isLocalEchoId(event.eventId.orEmpty())) { return } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt index cc86679cbc..7968eabd30 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt @@ -46,7 +46,7 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr return eventType == EventType.REDACTION } - override suspend fun process(realm: Realm, event: Event) { + override fun process(realm: Realm, event: Event) { pruneEvent(realm, event) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/tombstone/RoomTombstoneEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/tombstone/RoomTombstoneEventProcessor.kt index 2b404775f0..3684bec167 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/tombstone/RoomTombstoneEventProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/tombstone/RoomTombstoneEventProcessor.kt @@ -30,7 +30,7 @@ import javax.inject.Inject internal class RoomTombstoneEventProcessor @Inject constructor() : EventInsertLiveProcessor { - override suspend fun process(realm: Realm, event: Event) { + override fun process(realm: Realm, event: Event) { if (event.roomId == null) return val createRoomContent = event.getClearContent().toModel() if (createRoomContent?.replacementRoomId == null) return diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/Monarchy.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/Monarchy.kt index 6152eacae5..af3ba80fe4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/Monarchy.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/Monarchy.kt @@ -22,7 +22,7 @@ import io.realm.RealmModel import org.matrix.android.sdk.internal.database.awaitTransaction import java.util.concurrent.atomic.AtomicReference -internal suspend fun Monarchy.awaitTransaction(transaction: suspend (realm: Realm) -> T): T { +internal suspend fun Monarchy.awaitTransaction(transaction: (realm: Realm) -> T): T { return awaitTransaction(realmConfiguration, transaction) } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EditValidationTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EditValidationTest.kt new file mode 100644 index 0000000000..99942d967e --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EditValidationTest.kt @@ -0,0 +1,366 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room + +import io.mockk.every +import io.mockk.mockk +import org.amshove.kluent.shouldBeInstanceOf +import org.junit.Test +import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo +import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore + +class EditValidationTest { + + private val mockTextEvent = Event( + type = EventType.MESSAGE, + eventId = "\$WX8WlNC2reiXrwHIA_CQHmU_pSR-jhOA2xKPRcJN9wQ", + roomId = "!GXKhWsRwiWWvbQDBpe:example.com", + content = mapOf( + "body" to "some message", + "msgtype" to "m.text" + ), + originServerTs = 1000, + senderId = "@alice:example.com", + ) + + private val mockEdit = Event( + type = EventType.MESSAGE, + eventId = "\$-SF7RWLPzRzCbHqK3ZAhIrX5Auh3B2lS5AqJiypt1p0", + roomId = "!GXKhWsRwiWWvbQDBpe:example.com", + content = mapOf( + "body" to "* some message edited", + "msgtype" to "m.text", + "m.new_content" to mapOf( + "body" to "some message edited", + "msgtype" to "m.text" + ), + "m.relates_to" to mapOf( + "rel_type" to "m.replace", + "event_id" to mockTextEvent.eventId + ) + ), + originServerTs = 2000, + senderId = "@alice:example.com", + ) + + @Test + fun `edit should be valid`() { + val mockCryptoStore = mockk() + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit(mockTextEvent, mockEdit) shouldBeInstanceOf EventEditValidator.EditValidity.Valid::class + } + + @Test + fun `original event and replacement event must have the same sender`() { + val mockCryptoStore = mockk() + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit( + mockTextEvent, + mockEdit.copy(senderId = "@bob:example.com") + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + } + + @Test + fun `original event and replacement event must have the same room_id`() { + val mockCryptoStore = mockk() + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit( + mockTextEvent, + mockEdit.copy(roomId = "!someotherroom") + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + + validator + .validateEdit( + encryptedEvent, + encryptedEditEvent.copy(roomId = "!someotherroom") + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + } + + @Test + fun `replacement and original events must not have a state_key property`() { + val mockCryptoStore = mockk() + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit( + mockTextEvent, + mockEdit.copy(stateKey = "") + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + + validator + .validateEdit( + mockTextEvent.copy(stateKey = ""), + mockEdit + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + } + + @Test + fun `replacement event must have an new_content property`() { + val mockCryptoStore = mockk { + every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns + mockk { + every { userId } returns "@alice:example.com" + } + } + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit(mockTextEvent, mockEdit.copy( + content = mockEdit.content!!.toMutableMap().apply { + this.remove("m.new_content") + } + )) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + + validator + .validateEdit( + encryptedEvent, + encryptedEditEvent.copy().apply { + mxDecryptionResult = encryptedEditEvent.mxDecryptionResult!!.copy( + payload = mapOf( + "type" to EventType.MESSAGE, + "content" to mapOf( + "body" to "* some message edited", + "msgtype" to "m.text", + "m.relates_to" to mapOf( + "rel_type" to "m.replace", + "event_id" to mockTextEvent.eventId + ) + ) + ) + ) + } + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + } + + @Test + fun `The original event must not itself have a rel_type of m_replace`() { + val mockCryptoStore = mockk { + every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns + mockk { + every { userId } returns "@alice:example.com" + } + } + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit( + mockTextEvent.copy( + content = mockTextEvent.content!!.toMutableMap().apply { + this["m.relates_to"] = mapOf( + "rel_type" to "m.replace", + "event_id" to mockTextEvent.eventId + ) + } + ), + mockEdit + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + + validator + .validateEdit( + encryptedEvent.copy().apply { + mxDecryptionResult = encryptedEditEvent.mxDecryptionResult!!.copy( + payload = mapOf( + "type" to EventType.MESSAGE, + "content" to mapOf( + "body" to "some message", + "msgtype" to "m.text", + "m.relates_to" to mapOf( + "rel_type" to "m.replace", + "event_id" to mockTextEvent.eventId + ) + ), + ) + ) + }, + encryptedEditEvent + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + } + + @Test + fun `valid e2ee edit`() { + val mockCryptoStore = mockk { + every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns + mockk { + every { userId } returns "@alice:example.com" + } + } + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit( + encryptedEvent, + encryptedEditEvent + ) shouldBeInstanceOf EventEditValidator.EditValidity.Valid::class + } + + @Test + fun `If the original event was encrypted, the replacement should be too`() { + val mockCryptoStore = mockk { + every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns + mockk { + every { userId } returns "@alice:example.com" + } + } + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit( + encryptedEvent, + mockEdit + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + } + + @Test + fun `encrypted, original event and replacement event must have the same sender`() { + val mockCryptoStore = mockk { + every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns + mockk { + every { userId } returns "@alice:example.com" + } + every { deviceWithIdentityKey("7V5e/2O93mf4GeW7Mtq4YWcRNpYS9NhQbdJMgdnIPUI") } returns + mockk { + every { userId } returns "@bob:example.com" + } + } + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit( + encryptedEvent, + encryptedEditEvent.copy().apply { + mxDecryptionResult = encryptedEditEvent.mxDecryptionResult!!.copy( + senderKey = "7V5e/2O93mf4GeW7Mtq4YWcRNpYS9NhQbdJMgdnIPUI" + ) + } + + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + + // if sent fom a deleted device it should use the event claimed sender id + } + + @Test + fun `encrypted, sent fom a deleted device, original event and replacement event must have the same sender`() { + val mockCryptoStore = mockk { + every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns + mockk { + every { userId } returns "@alice:example.com" + } + every { deviceWithIdentityKey("7V5e/2O93mf4GeW7Mtq4YWcRNpYS9NhQbdJMgdnIPUI") } returns + null + } + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit( + encryptedEvent, + encryptedEditEvent.copy().apply { + mxDecryptionResult = encryptedEditEvent.mxDecryptionResult!!.copy( + senderKey = "7V5e/2O93mf4GeW7Mtq4YWcRNpYS9NhQbdJMgdnIPUI" + ) + } + + ) shouldBeInstanceOf EventEditValidator.EditValidity.Valid::class + + validator + .validateEdit( + encryptedEvent, + encryptedEditEvent.copy( + senderId = "bob@example.com" + ).apply { + mxDecryptionResult = encryptedEditEvent.mxDecryptionResult!!.copy( + senderKey = "7V5e/2O93mf4GeW7Mtq4YWcRNpYS9NhQbdJMgdnIPUI" + ) + } + + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + } + + private val encryptedEditEvent = Event( + type = EventType.ENCRYPTED, + eventId = "\$-SF7RWLPzRzCbHqK3ZAhIrX5Auh3B2lS5AqJiypt1p0", + roomId = "!GXKhWsRwiWWvbQDBpe:example.com", + content = mapOf( + "algorithm" to "m.megolm.v1.aes-sha2", + "sender_key" to "R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo", + "session_id" to "7tOd6xon/R2zJpy2LlSKcKWIek2jvkim0sNdnZZCWMQ", + "device_id" to "QDHBLWOTSN", + "ciphertext" to "AwgXErAC6TgQ4bV6NFldlffTWuUV1gsBYH6JLQMqG...deLfCQOSPunSSNDFdWuDkB8Cg", + "m.relates_to" to mapOf( + "rel_type" to "m.replace", + "event_id" to mockTextEvent.eventId + ) + ), + originServerTs = 2000, + senderId = "@alice:example.com", + ).apply { + mxDecryptionResult = OlmDecryptionResult( + payload = mapOf( + "type" to EventType.MESSAGE, + "content" to mapOf( + "body" to "* some message edited", + "msgtype" to "m.text", + "m.new_content" to mapOf( + "body" to "some message edited", + "msgtype" to "m.text" + ), + "m.relates_to" to mapOf( + "rel_type" to "m.replace", + "event_id" to mockTextEvent.eventId + ) + ) + ), + senderKey = "R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo", + isSafe = true + ) + } + + private val encryptedEvent = Event( + type = EventType.ENCRYPTED, + eventId = "\$WX8WlNC2reiXrwHIA_CQHmU_pSR-jhOA2xKPRcJN9wQ", + roomId = "!GXKhWsRwiWWvbQDBpe:example.com", + content = mapOf( + "algorithm" to "m.megolm.v1.aes-sha2", + "sender_key" to "R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo", + "session_id" to "7tOd6xon/R2zJpy2LlSKcKWIek2jvkim0sNdnZZCWMQ", + "device_id" to "QDHBLWOTSN", + "ciphertext" to "AwgXErAC6TgQ4bV6NFldlffTWuUV1gsBYH6JLQMqG+4Vr...Yf0gYyhVWZY4SedF3fTMwkjmTuel4fwrmq", + ), + originServerTs = 2000, + senderId = "@alice:example.com", + ).apply { + mxDecryptionResult = OlmDecryptionResult( + payload = mapOf( + "type" to EventType.MESSAGE, + "content" to mapOf( + "body" to "some message", + "msgtype" to "m.text" + ), + ), + senderKey = "R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo", + isSafe = true + ) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt index 93999458c6..87868e91d1 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt @@ -38,7 +38,7 @@ internal class FakeMonarchy { init { mockkStatic("org.matrix.android.sdk.internal.util.MonarchyKt") coEvery { - instance.awaitTransaction(any Any>()) + instance.awaitTransaction(any<(Realm) -> Any>()) } coAnswers { secondArg Any>().invoke(fakeRealm.instance) } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt index 15a9823c79..38b67b5261 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt @@ -33,7 +33,7 @@ internal class FakeRealmConfiguration { val instance = mockk() fun givenAwaitTransaction(realm: Realm) { - val transaction = slot T>() + val transaction = slot<(Realm) -> T>() coEvery { awaitTransaction(instance, capture(transaction)) } coAnswers { secondArg T>().invoke(realm) } diff --git a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt index 5c3393416b..d907f39ee3 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt @@ -19,6 +19,7 @@ package im.vector.app.core.extensions import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.send.SendState @@ -40,7 +41,8 @@ fun TimelineEvent.getVectorLastMessageContent(): MessageContent? { // Iterate on event types which are not part of the matrix sdk, otherwise fallback to the sdk method return when (root.getClearType()) { VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> { - (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() + (annotations?.editSummary?.latestEdit?.getClearContent()?.toModel().toContent().toModel() + ?: root.getClearContent().toModel()) } else -> getLastMessageContent() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt index 57ad4331ce..1f079e420b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt @@ -442,6 +442,7 @@ class TimelineEventController @Inject constructor( val timelineEventsGroup = timelineEventsGroups.getOrNull(event) val params = TimelineItemFactoryParams( event = event, + lastEdit = event.annotations?.editSummary?.latestEdit, prevEvent = prevEvent, prevDisplayableEvent = prevDisplayableEvent, nextEvent = nextEvent, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index 373410775b..d5d38f47a3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -161,7 +161,7 @@ class MessageItemFactory @Inject constructor( val callback = params.callback event.root.eventId ?: return null roomId = event.roomId - val informationData = messageInformationDataFactory.create(params) + val informationData = messageInformationDataFactory.create(params, params.event.annotations?.editSummary?.latestEdit) val threadDetails = if (params.isFromThreadTimeline()) null else event.root.threadDetails if (event.root.isRedacted()) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactoryParams.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactoryParams.kt index 7c02b6f058..dd4494a613 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactoryParams.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactoryParams.kt @@ -19,10 +19,12 @@ package im.vector.app.features.home.room.detail.timeline.factory import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventsGroup import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryEvents +import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent data class TimelineItemFactoryParams( val event: TimelineEvent, + val lastEdit: Event? = null, val prevEvent: TimelineEvent? = null, val prevDisplayableEvent: TimelineEvent? = null, val nextEvent: TimelineEvent? = null, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index 50b4366e98..a969c294f5 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -31,11 +31,13 @@ import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLay import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.verification.VerificationState +import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.getMsgType import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage import org.matrix.android.sdk.api.session.events.model.isSticker import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent import org.matrix.android.sdk.api.session.room.model.ReferencesAggregatedContent import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.message.MessageType @@ -55,7 +57,7 @@ class MessageInformationDataFactory @Inject constructor( private val reactionsSummaryFactory: ReactionsSummaryFactory ) { - fun create(params: TimelineItemFactoryParams): MessageInformationData { + fun create(params: TimelineItemFactoryParams, lastEdit: Event? = null): MessageInformationData { val event = params.event val nextDisplayableEvent = params.nextDisplayableEvent val prevDisplayableEvent = params.prevDisplayableEvent @@ -72,8 +74,14 @@ class MessageInformationDataFactory @Inject constructor( prevDisplayableEvent?.root?.localDateTime()?.toLocalDate() != date.toLocalDate() val time = dateFormatter.format(event.root.originServerTs, DateFormatKind.MESSAGE_SIMPLE) - val e2eDecoration = getE2EDecoration(roomSummary, event) - + val e2eDecoration = getE2EDecoration(roomSummary, lastEdit ?: event.root) + val senderId = if (event.isEncrypted()) { + event.root.toValidDecryptedEvent()?.let { + session.cryptoService().deviceWithIdentityKey(it.cryptoSenderKey, it.algorithm)?.userId + } ?: event.root.senderId.orEmpty() + } else { + event.root.senderId.orEmpty() + } // SendState Decoration val sendStateDecoration = if (isSentByMe) { getSendStateDecoration( @@ -89,7 +97,7 @@ class MessageInformationDataFactory @Inject constructor( return MessageInformationData( eventId = eventId, - senderId = event.root.senderId ?: "", + senderId = senderId, sendState = event.root.sendState, time = time, ageLocalTS = event.root.ageLocalTs, @@ -148,34 +156,34 @@ class MessageInformationDataFactory @Inject constructor( } } - private fun getE2EDecoration(roomSummary: RoomSummary?, event: TimelineEvent): E2EDecoration { + private fun getE2EDecoration(roomSummary: RoomSummary?, event: Event): E2EDecoration { if (roomSummary?.isEncrypted != true) { // No decoration for clear room // Questionable? what if the event is E2E? return E2EDecoration.NONE } - if (event.root.sendState != SendState.SYNCED) { + if (event.sendState != SendState.SYNCED) { // we don't display e2e decoration if event not synced back return E2EDecoration.NONE } val userCrossSigningInfo = session.cryptoService() .crossSigningService() - .getUserCrossSigningKeys(event.root.senderId.orEmpty()) + .getUserCrossSigningKeys(event.senderId.orEmpty()) if (userCrossSigningInfo?.isTrusted() == true) { return if (event.isEncrypted()) { // Do not decorate failed to decrypt, or redaction (we lost sender device info) - if (event.root.getClearType() == EventType.ENCRYPTED || event.root.isRedacted()) { + if (event.getClearType() == EventType.ENCRYPTED || event.isRedacted()) { E2EDecoration.NONE } else { - val sendingDevice = event.root.getSenderKey() + val sendingDevice = event.getSenderKey() ?.let { session.cryptoService().deviceWithIdentityKey( it, - event.root.content?.get("algorithm") as? String ?: "" + event.content?.get("algorithm") as? String ?: "" ) } - if (event.root.mxDecryptionResult?.isSafe == false) { + if (event.mxDecryptionResult?.isSafe == false) { E2EDecoration.WARN_UNSAFE_KEY } else { when { @@ -202,8 +210,8 @@ class MessageInformationDataFactory @Inject constructor( } else { return if (!event.isEncrypted()) { e2EDecorationForClearEventInE2ERoom(event, roomSummary) - } else if (event.root.mxDecryptionResult != null) { - if (event.root.mxDecryptionResult?.isSafe == true) { + } else if (event.mxDecryptionResult != null) { + if (event.mxDecryptionResult?.isSafe == true) { E2EDecoration.NONE } else { E2EDecoration.WARN_UNSAFE_KEY @@ -214,13 +222,13 @@ class MessageInformationDataFactory @Inject constructor( } } - private fun e2EDecorationForClearEventInE2ERoom(event: TimelineEvent, roomSummary: RoomSummary) = - if (event.root.isStateEvent()) { + private fun e2EDecorationForClearEventInE2ERoom(event: Event, roomSummary: RoomSummary) = + if (event.isStateEvent()) { // Do not warn for state event, they are always in clear E2EDecoration.NONE } else { val ts = roomSummary.encryptionEventTs ?: 0 - val eventTs = event.root.originServerTs ?: 0 + val eventTs = event.originServerTs ?: 0 // If event is in clear after the room enabled encryption we should warn if (eventTs > ts) E2EDecoration.WARN_IN_CLEAR else E2EDecoration.NONE } From e66a0541bea50e1e8d320b657a6bfd5f882c3599 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 16 Nov 2022 10:56:10 +0100 Subject: [PATCH 079/173] Add changelog, some cleaning --- changelog.d/7594.misc | 1 + .../sdk/internal/database/migration/MigrateSessionTo043.kt | 2 +- .../android/sdk/internal/session/room/EventEditValidator.kt | 6 ++++-- .../session/room/EventRelationsAggregationProcessor.kt | 4 ++-- .../android/sdk/internal/session/room/EditValidationTest.kt | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 changelog.d/7594.misc diff --git a/changelog.d/7594.misc b/changelog.d/7594.misc new file mode 100644 index 0000000000..5c5771d8d0 --- /dev/null +++ b/changelog.d/7594.misc @@ -0,0 +1 @@ +Better validation of edits diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt index a27d4fda3a..d11a671c55 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt index dcf6ad54a0..940da25f11 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt @@ -33,13 +33,15 @@ internal class EventEditValidator @Inject constructor(val cryptoStore: IMXCrypto } /** - *There are a number of requirements on replacement events, which must be satisfied for the replacement to be considered valid: + * There are a number of requirements on replacement events, which must be satisfied for the replacement + * to be considered valid: * As with all event relationships, the original event and replacement event must have the same room_id * (i.e. you cannot send an event in one room and then an edited version in a different room). * The original event and replacement event must have the same sender (i.e. you cannot edit someone else’s messages). * The replacement and original events must have the same type (i.e. you cannot change the original event’s type). * The replacement and original events must not have a state_key property (i.e. you cannot edit state events at all). - * The original event must not, itself, have a rel_type of m.replace (i.e. you cannot edit an edit — though you can send multiple edits for a single original event). + * The original event must not, itself, have a rel_type of m.replace + * (i.e. you cannot edit an edit — though you can send multiple edits for a single original event). * The replacement event (once decrypted, if appropriate) must have an m.new_content property. * * If the original event was encrypted, the replacement should be too. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt index ef1d8c1430..837d00720b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt @@ -213,7 +213,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor( if (content?.relatesTo?.type == RelationType.REPLACE) { Timber.v("###REPLACE in room $roomId for event ${event.eventId}") // A replace! - handleReplace(realm, event, roomId, isLocalEcho, content?.relatesTo.eventId) + handleReplace(realm, event, roomId, isLocalEcho, content.relatesTo.eventId) } } in EventType.POLL_RESPONSE -> { @@ -474,7 +474,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor( } val sourceToDiscard = eventSummary.editSummary?.editions?.firstOrNull { it.eventId == redacted.eventId } if (sourceToDiscard == null) { - Timber.w("Redaction of a replace that was not known in aggregation $sourceToDiscard") + Timber.w("Redaction of a replace that was not known in aggregation") return } // Need to remove this event from the edition list diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EditValidationTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EditValidationTest.kt index 99942d967e..429e6625ab 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EditValidationTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EditValidationTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From 3746ede49a8b2b4d1ac6eff96e26b1f21d233d1a Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 16 Nov 2022 11:34:08 +0100 Subject: [PATCH 080/173] Fix test --- .../session/room/send/LocalEchoEventFactoryTests.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactoryTests.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactoryTests.kt index b30428e5e1..19f58d690f 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactoryTests.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactoryTests.kt @@ -23,8 +23,6 @@ import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel -import org.matrix.android.sdk.api.session.room.model.EditAggregatedSummary -import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody import org.matrix.android.sdk.api.session.room.sender.SenderInfo @@ -226,16 +224,14 @@ class LocalEchoEventFactoryTests { ).toMessageTextContent().toContent() } return TimelineEvent( - root = A_START_EVENT, + root = A_START_EVENT.copy( + type = EventType.MESSAGE, + content = textContent + ), localId = 1234, eventId = AN_EVENT_ID, displayIndex = 0, senderInfo = SenderInfo(A_USER_ID_1, A_USER_ID_1, true, null), - annotations = if (textContent != null) { - EventAnnotationsSummary( - editSummary = EditAggregatedSummary(latestContent = textContent, emptyList(), emptyList()) - ) - } else null ) } } From 8b47bf004ee34292db71c1b8a3a75769c46956ae Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 16 Nov 2022 13:45:52 +0100 Subject: [PATCH 081/173] Fix broken polls states --- .../session/room/timeline/TimelineEvent.kt | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt index 6ceced59d7..6b4a0226a0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.api.session.room.timeline import org.matrix.android.sdk.BuildConfig import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.RelationType @@ -30,8 +31,12 @@ import org.matrix.android.sdk.api.session.events.model.isSticker import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary import org.matrix.android.sdk.api.session.room.model.ReadReceipt +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent +import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent import org.matrix.android.sdk.api.session.room.sender.SenderInfo @@ -136,12 +141,21 @@ fun TimelineEvent.getEditedEventId(): String? { * Get last MessageContent, after a possible edition. */ fun TimelineEvent.getLastMessageContent(): MessageContent? { - return ( - annotations?.editSummary?.latestEdit - ?.getClearContent()?.toModel()?.newContent - ?: root.getClearContent() - ) - .toModel() + return when (root.getClearType()) { + EventType.STICKER -> root.getClearContent().toModel() + // XXX + // Polls/Beacon are not message contents like others as there is no msgtype subtype to discriminate moshi parsing + // so toModel won't parse them correctly + // It's discriminated on event type instead. Maybe it shouldn't be MessageContent at all to avoid confusion? + in EventType.POLL_START -> (getLastEditNewContent() ?: root.getClearContent()).toModel() + in EventType.STATE_ROOM_BEACON_INFO -> (getLastEditNewContent() ?: root.getClearContent()).toModel() + in EventType.BEACON_LOCATION_DATA -> (getLastEditNewContent() ?: root.getClearContent()).toModel() + else -> (getLastEditNewContent() ?: root.getClearContent()).toModel() + } +} + +fun TimelineEvent.getLastEditNewContent(): Content? { + return annotations?.editSummary?.latestEdit?.getClearContent()?.toModel()?.newContent } /** From d759f26db6756850e021f4d6aa313f190c7178c5 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 16 Nov 2022 14:10:37 +0100 Subject: [PATCH 082/173] fix fake awaitTx --- .../java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt | 4 ++-- .../matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt index 87868e91d1..76ede75910 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt @@ -39,8 +39,8 @@ internal class FakeMonarchy { mockkStatic("org.matrix.android.sdk.internal.util.MonarchyKt") coEvery { instance.awaitTransaction(any<(Realm) -> Any>()) - } coAnswers { - secondArg Any>().invoke(fakeRealm.instance) + } answers { + secondArg<(Realm) -> Any>().invoke(fakeRealm.instance) } coEvery { instance.doWithRealm(any()) diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt index 38b67b5261..f5545b7e76 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt @@ -34,8 +34,8 @@ internal class FakeRealmConfiguration { fun givenAwaitTransaction(realm: Realm) { val transaction = slot<(Realm) -> T>() - coEvery { awaitTransaction(instance, capture(transaction)) } coAnswers { - secondArg T>().invoke(realm) + coEvery { awaitTransaction(instance, capture(transaction)) } answers { + secondArg<(Realm) -> T>().invoke(realm) } } } From e5d3206b6ff4392f5280d257529761afa0a2ad1b Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 23 Nov 2022 19:08:17 +0100 Subject: [PATCH 083/173] code review --- .../events/model/AggregatedRelations.kt | 4 +- .../events/model/ValidDecryptedEvent.kt | 26 +--- .../EditAggregatedSummaryEntityMapper.kt | 45 ++++++ .../mapper/EventAnnotationsSummaryMapper.kt | 24 +--- .../database/migration/MigrateSessionTo008.kt | 6 +- .../sdk/internal/session/events/EventExt.kt | 30 ++++ .../session/room/EventEditValidator.kt | 15 +- .../EventRelationsAggregationProcessor.kt | 58 ++++---- .../EditAggregationSummaryMapperTest.kt | 78 ++++++++++ .../session/event/ValidDecryptedEventTest.kt | 134 ++++++++++++++++++ ...ationTest.kt => EventEditValidatorTest.kt} | 18 ++- .../sdk/test/fakes/FakeRealmConfiguration.kt | 4 +- .../timeline/factory/MessageItemFactory.kt | 2 +- .../helper/MessageInformationDataFactory.kt | 22 +-- 14 files changed, 366 insertions(+), 100 deletions(-) create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EditAggregatedSummaryEntityMapper.kt create mode 100644 matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregationSummaryMapperTest.kt create mode 100644 matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/event/ValidDecryptedEventTest.kt rename matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/{EditValidationTest.kt => EventEditValidatorTest.kt} (96%) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt index 1dedcce8b6..7f043dc0f7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt @@ -19,7 +19,7 @@ import com.squareup.moshi.Json import com.squareup.moshi.JsonClass /** - * + * ``` * { * "m.annotation": { * "chunk": [ @@ -43,7 +43,7 @@ import com.squareup.moshi.JsonClass * "count": 1 * } * } - * + * ``` */ @JsonClass(generateAdapter = true) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/ValidDecryptedEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/ValidDecryptedEvent.kt index 0cee077807..b305bf19b0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/ValidDecryptedEvent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/ValidDecryptedEvent.kt @@ -16,6 +16,9 @@ package org.matrix.android.sdk.api.session.events.model +import org.matrix.android.sdk.api.session.room.model.message.MessageRelationContent +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent + data class ValidDecryptedEvent( val type: String, val eventId: String, @@ -29,25 +32,6 @@ data class ValidDecryptedEvent( val algorithm: String, ) -fun Event.toValidDecryptedEvent(): ValidDecryptedEvent? { - if (!this.isEncrypted()) return null - val decryptedContent = this.getDecryptedContent() ?: return null - val eventId = this.eventId ?: return null - val roomId = this.roomId ?: return null - val type = this.getDecryptedType() ?: return null - val senderKey = this.getSenderKey() ?: return null - val algorithm = this.content?.get("algorithm") as? String ?: return null - - return ValidDecryptedEvent( - type = type, - eventId = eventId, - clearContent = decryptedContent, - prevContent = this.prevContent, - originServerTs = this.originServerTs ?: 0, - cryptoSenderKey = senderKey, - roomId = roomId, - unsignedData = this.unsignedData, - redacts = this.redacts, - algorithm = algorithm - ) +fun ValidDecryptedEvent.getRelationContent(): RelationDefaultContent? { + return clearContent.toModel()?.relatesTo } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EditAggregatedSummaryEntityMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EditAggregatedSummaryEntityMapper.kt new file mode 100644 index 0000000000..8c209f2f2a --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EditAggregatedSummaryEntityMapper.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.mapper + +import org.matrix.android.sdk.api.session.room.model.EditAggregatedSummary +import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntity +import org.matrix.android.sdk.internal.database.model.EditionOfEvent + +internal object EditAggregatedSummaryEntityMapper { + + fun map(summary: EditAggregatedSummaryEntity?): EditAggregatedSummary? { + summary ?: return null + /** + * The most recent event is determined by comparing origin_server_ts; + * if two or more replacement events have identical origin_server_ts, + * the event with the lexicographically largest event_id is treated as more recent. + */ + val latestEdition = summary.editions.sortedWith(compareBy { it.timestamp }.thenBy { it.eventId }) + .lastOrNull() ?: return null + val editEvent = latestEdition.event + + return EditAggregatedSummary( + latestEdit = editEvent?.asDomain(), + sourceEvents = summary.editions.filter { editionOfEvent -> !editionOfEvent.isLocalEcho } + .map { editionOfEvent -> editionOfEvent.eventId }, + localEchos = summary.editions.filter { editionOfEvent -> editionOfEvent.isLocalEcho } + .map { editionOfEvent -> editionOfEvent.eventId }, + lastEditTs = latestEdition.timestamp + ) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventAnnotationsSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventAnnotationsSummaryMapper.kt index 5fb70ad1ee..d4bb5791a0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventAnnotationsSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventAnnotationsSummaryMapper.kt @@ -16,11 +16,9 @@ package org.matrix.android.sdk.internal.database.mapper -import org.matrix.android.sdk.api.session.room.model.EditAggregatedSummary import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary import org.matrix.android.sdk.api.session.room.model.ReactionAggregatedSummary import org.matrix.android.sdk.api.session.room.model.ReferencesAggregatedSummary -import org.matrix.android.sdk.internal.database.model.EditionOfEvent import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity internal object EventAnnotationsSummaryMapper { @@ -36,27 +34,7 @@ internal object EventAnnotationsSummaryMapper { it.sourceLocalEcho.toList() ) }, - editSummary = annotationsSummary.editSummary - ?.let { summary -> - /** - * The most recent event is determined by comparing origin_server_ts; - * if two or more replacement events have identical origin_server_ts, - * the event with the lexicographically largest event_id is treated as more recent. - */ - val latestEdition = summary.editions.sortedWith(compareBy { it.timestamp }.thenBy { it.eventId }) - .lastOrNull() ?: return@let null - // get the event and validate? - val editEvent = latestEdition.event - - EditAggregatedSummary( - latestEdit = editEvent?.asDomain(), - sourceEvents = summary.editions.filter { editionOfEvent -> !editionOfEvent.isLocalEcho } - .map { editionOfEvent -> editionOfEvent.eventId }, - localEchos = summary.editions.filter { editionOfEvent -> editionOfEvent.isLocalEcho } - .map { editionOfEvent -> editionOfEvent.eventId }, - lastEditTs = latestEdition.timestamp - ) - }, + editSummary = EditAggregatedSummaryEntityMapper.map(annotationsSummary.editSummary), referencesAggregatedSummary = annotationsSummary.referencesSummaryEntity?.let { ReferencesAggregatedSummary( ContentMapper.map(it.content), diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo008.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo008.kt index 42a47a9a27..f85a0661c2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo008.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo008.kt @@ -25,11 +25,11 @@ internal class MigrateSessionTo008(realm: DynamicRealm) : RealmMigrator(realm, 8 override fun doMigrate(realm: DynamicRealm) { val editionOfEventSchema = realm.schema.create("EditionOfEvent") - .addField("content"/**EditionOfEventFields.CONTENT*/, String::class.java) + .addField("content", String::class.java) .addField(EditionOfEventFields.EVENT_ID, String::class.java) .setRequired(EditionOfEventFields.EVENT_ID, true) - .addField("senderId" /*EditionOfEventFields.SENDER_ID*/, String::class.java) - .setRequired("senderId" /*EditionOfEventFields.SENDER_ID*/, true) + .addField("senderId", String::class.java) + .setRequired("senderId", true) .addField(EditionOfEventFields.TIMESTAMP, Long::class.java) .addField(EditionOfEventFields.IS_LOCAL_ECHO, Boolean::class.java) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/events/EventExt.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/events/EventExt.kt index 91e709e464..63409a15bb 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/events/EventExt.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/events/EventExt.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.session.events import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.ValidDecryptedEvent import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.RoomMemberContent @@ -33,3 +34,32 @@ internal fun Event.getFixedRoomMemberContent(): RoomMemberContent? { content } } + +fun Event.toValidDecryptedEvent(): ValidDecryptedEvent? { + if (!this.isEncrypted()) return null + val decryptedContent = this.getDecryptedContent() ?: return null + val eventId = this.eventId ?: return null + val roomId = this.roomId ?: return null + val type = this.getDecryptedType() ?: return null + val senderKey = this.getSenderKey() ?: return null + val algorithm = this.content?.get("algorithm") as? String ?: return null + + // copy the relation as it's in clear in the encrypted content + val updatedContent = this.content.get("m.relates_to")?.let { + decryptedContent.toMutableMap().apply { + put("m.relates_to", it) + } + } ?: decryptedContent + return ValidDecryptedEvent( + type = type, + eventId = eventId, + clearContent = updatedContent, + prevContent = this.prevContent, + originServerTs = this.originServerTs ?: 0, + cryptoSenderKey = senderKey, + roomId = roomId, + unsignedData = this.unsignedData, + redacts = this.redacts, + algorithm = algorithm + ) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt index 940da25f11..ea14aacfe4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt @@ -17,11 +17,14 @@ package org.matrix.android.sdk.internal.session.room import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.LocalEcho import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.getRelationContent import org.matrix.android.sdk.api.session.events.model.toModel -import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore +import org.matrix.android.sdk.internal.session.events.toValidDecryptedEvent +import timber.log.Timber import javax.inject.Inject internal class EventEditValidator @Inject constructor(val cryptoStore: IMXCryptoStore) { @@ -47,12 +50,18 @@ internal class EventEditValidator @Inject constructor(val cryptoStore: IMXCrypto * If the original event was encrypted, the replacement should be too. */ fun validateEdit(originalEvent: Event?, replaceEvent: Event): EditValidity { + Timber.v("###REPLACE valide event $originalEvent replaced $replaceEvent") // we might not know the original event at that time. In this case we can't perform the validation // Edits should be revalidated when the original event is received if (originalEvent == null) { return EditValidity.Unknown } + if (LocalEcho.isLocalEchoId(replaceEvent.eventId.orEmpty())) { + // Don't validate local echo + return EditValidity.Unknown + } + if (originalEvent.roomId != replaceEvent.roomId) { return EditValidity.Invalid("original event and replacement event must have the same room_id") } @@ -71,7 +80,7 @@ internal class EventEditValidator @Inject constructor(val cryptoStore: IMXCrypto val originalCryptoSenderId = cryptoStore.deviceWithIdentityKey(originalDecrypted.cryptoSenderKey)?.userId val editCryptoSenderId = cryptoStore.deviceWithIdentityKey(replaceDecrypted.cryptoSenderKey)?.userId - if (originalDecrypted.clearContent.toModel()?.relatesTo?.type == RelationType.REPLACE) { + if (originalDecrypted.getRelationContent()?.type == RelationType.REPLACE) { return EditValidity.Invalid("The original event must not, itself, have a rel_type of m.replace ") } @@ -96,7 +105,7 @@ internal class EventEditValidator @Inject constructor(val cryptoStore: IMXCrypto return EditValidity.Invalid("replacement event must have an m.new_content property") } } else { - if (originalEvent.content.toModel()?.relatesTo?.type == RelationType.REPLACE) { + if (originalEvent.getRelationContent()?.type == RelationType.REPLACE) { return EditValidity.Invalid("The original event must not, itself, have a rel_type of m.replace ") } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt index 837d00720b..48e75821bd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt @@ -23,6 +23,7 @@ import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.LocalEcho import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent import org.matrix.android.sdk.api.session.events.model.getRelationContent import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel @@ -81,13 +82,12 @@ internal class EventRelationsAggregationProcessor @Inject constructor( EventType.REDACTION, EventType.REACTION, // The aggregator handles verification events but just to render tiles in the timeline - // It's not participating in verfication it's self, just timeline display + // It's not participating in verification itself, just timeline display EventType.KEY_VERIFICATION_DONE, EventType.KEY_VERIFICATION_CANCEL, EventType.KEY_VERIFICATION_ACCEPT, EventType.KEY_VERIFICATION_START, EventType.KEY_VERIFICATION_MAC, - // TODO Add ? EventType.KEY_VERIFICATION_READY, EventType.KEY_VERIFICATION_KEY, EventType.ENCRYPTED @@ -168,28 +168,28 @@ internal class EventRelationsAggregationProcessor @Inject constructor( // As for now Live event processors are not receiving UTD events. // They will get an update if the event is decrypted later EventType.ENCRYPTED -> { -// // Relation type is in clear, it might be possible to do some things? -// // Notice that if the event is decrypted later, process be called again -// val encryptedEventContent = event.content.toModel() -// when (encryptedEventContent?.relatesTo?.type) { -// RelationType.REPLACE -> { -// Timber.v("###REPLACE in room $roomId for event ${event.eventId}") -// // A replace! -// handleReplace(realm, event, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) -// } -// RelationType.RESPONSE -> { -// // can we / should we do we something for UTD response?? -// Timber.w("## UTD response in room $roomId related to ${encryptedEventContent.relatesTo.eventId}") -// } -// RelationType.REFERENCE -> { -// // can we / should we do we something for UTD reference?? -// Timber.w("## UTD reference in room $roomId related to ${encryptedEventContent.relatesTo.eventId}") -// } -// RelationType.ANNOTATION -> { -// // can we / should we do we something for UTD annotation?? -// Timber.w("## UTD annotation in room $roomId related to ${encryptedEventContent.relatesTo.eventId}") -// } -// } + // Relation type is in clear, it might be possible to do some things? + // Notice that if the event is decrypted later, process be called again + val encryptedEventContent = event.content.toModel() + when (encryptedEventContent?.relatesTo?.type) { + RelationType.REPLACE -> { + Timber.v("###REPLACE in room $roomId for event ${event.eventId}") + // A replace! + handleReplace(realm, event, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) + } + RelationType.RESPONSE -> { + // can we / should we do we something for UTD response?? + Timber.w("## UTD response in room $roomId related to ${encryptedEventContent.relatesTo.eventId}") + } + RelationType.REFERENCE -> { + // can we / should we do we something for UTD reference?? + Timber.w("## UTD reference in room $roomId related to ${encryptedEventContent.relatesTo.eventId}") + } + RelationType.ANNOTATION -> { + // can we / should we do we something for UTD annotation?? + Timber.w("## UTD annotation in room $roomId related to ${encryptedEventContent.relatesTo.eventId}") + } + } } EventType.REDACTION -> { val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() } @@ -256,7 +256,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor( relatedEventId: String? ) { val eventId = event.eventId ?: return - val targetEventId = relatedEventId ?: return // ?: content.relatesTo?.eventId ?: return + val targetEventId = relatedEventId ?: return val editedEvent = EventEntity.where(realm, targetEventId).findFirst() when (val validity = editValidator.validateEdit(editedEvent?.asDomain(), event)) { @@ -301,15 +301,16 @@ internal class EventRelationsAggregationProcessor @Inject constructor( // ok it has already been managed Timber.v("###REPLACE Receiving remote echo of edit (edit already done)") existingSummary.editions.firstOrNull { it.eventId == txId }?.let { - it.eventId = event.eventId + it.eventId = eventId it.timestamp = event.originServerTs ?: clock.epochMillis() it.isLocalEcho = false + it.event = EventEntity.where(realm, eventId).findFirst() } } else { Timber.v("###REPLACE Computing aggregated edit summary (isLocalEcho:$isLocalEcho)") existingSummary.editions.add( EditionOfEvent( - eventId = event.eventId, + eventId = eventId, event = EventEntity.where(realm, eventId).findFirst(), timestamp = if (isLocalEcho) { clock.epochMillis() @@ -343,7 +344,8 @@ internal class EventRelationsAggregationProcessor @Inject constructor( * @param editions list of edition of event */ private fun handleThreadSummaryEdition( - editedEvent: EventEntity?, replaceEvent: TimelineEventEntity?, + editedEvent: EventEntity?, + replaceEvent: TimelineEventEntity?, editions: List? ) { replaceEvent ?: return diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregationSummaryMapperTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregationSummaryMapperTest.kt new file mode 100644 index 0000000000..12ff9c1d37 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregationSummaryMapperTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.mapper + +import io.mockk.every +import io.mockk.mockk +import io.realm.RealmList +import org.amshove.kluent.shouldBe +import org.amshove.kluent.shouldNotBe +import org.junit.Test +import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntity +import org.matrix.android.sdk.internal.database.model.EditionOfEvent +import org.matrix.android.sdk.internal.database.model.EventEntity + +class EditAggregationSummaryMapperTest { + + @Test + fun test() { + val edits = RealmList( + EditionOfEvent( + timestamp = 0L, + eventId = "e0", + isLocalEcho = false, + event = mockEvent("e0") + ), + EditionOfEvent( + timestamp = 1L, + eventId = "e1", + isLocalEcho = false, + event = mockEvent("e1") + ), + EditionOfEvent( + timestamp = 30L, + eventId = "e2", + isLocalEcho = true, + event = mockEvent("e2") + ) + ) + val fakeSummaryEntity = mockk { + every { editions } returns edits + } + + val mapped = EditAggregatedSummaryEntityMapper.map(fakeSummaryEntity) + mapped shouldNotBe null + mapped!!.sourceEvents.size shouldBe 2 + mapped.localEchos.size shouldBe 1 + mapped.localEchos.first() shouldBe "e2" + + mapped.lastEditTs shouldBe 30L + mapped.latestEdit?.eventId shouldBe "e2" + } + + private fun mockEvent(eventId: String): EventEntity { + return EventEntity().apply { + this.eventId = eventId + this.content = """ + { + "body" : "Hello", + "msgtype": "text" + } + """.trimIndent() + } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/event/ValidDecryptedEventTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/event/ValidDecryptedEventTest.kt new file mode 100644 index 0000000000..8dead42f60 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/event/ValidDecryptedEventTest.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.event + +import org.amshove.kluent.shouldBe +import org.amshove.kluent.shouldNotBe +import org.junit.Test +import org.matrix.android.sdk.api.crypto.MXCRYPTO_ALGORITHM_MEGOLM +import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent +import org.matrix.android.sdk.internal.session.events.toValidDecryptedEvent + +class ValidDecryptedEventTest { + + val fakeEvent = Event( + type = EventType.ENCRYPTED, + eventId = "\$eventId", + roomId = "!fakeRoom", + content = EncryptedEventContent( + algorithm = MXCRYPTO_ALGORITHM_MEGOLM, + ciphertext = "AwgBEpACQEKOkd4Gp0+gSXG4M+btcrnPgsF23xs/lUmS2I4YjmqF...", + sessionId = "TO2G4u2HlnhtbIJk", + senderKey = "5e3EIqg3JfooZnLQ2qHIcBarbassQ4qXblai0", + deviceId = "FAKEE" + ).toContent() + ) + + @Test + fun `A failed to decrypt message should give a null validated decrypted event`() { + fakeEvent.toValidDecryptedEvent() shouldBe null + } + + @Test + fun `Mismatch sender key detection`() { + val decryptedEvent = fakeEvent + .apply { + mxDecryptionResult = OlmDecryptionResult( + payload = mapOf( + "type" to EventType.MESSAGE, + "content" to mapOf( + "body" to "some message", + "msgtype" to "m.text" + ), + ), + senderKey = "the_real_sender_key", + ) + } + + val validDecryptedEvent = decryptedEvent.toValidDecryptedEvent() + validDecryptedEvent shouldNotBe null + + fakeEvent.content!!["senderKey"] shouldNotBe "the_real_sender_key" + validDecryptedEvent!!.cryptoSenderKey shouldBe "the_real_sender_key" + } + + @Test + fun `Mixed content event should be detected`() { + val mixedEvent = Event( + type = EventType.ENCRYPTED, + eventId = "\$eventd ", + roomId = "!fakeRoo", + content = mapOf( + "algorithm" to "m.megolm.v1.aes-sha2", + "ciphertext" to "AwgBEpACQEKOkd4Gp0+gSXG4M+btcrnPgsF23xs/lUmS2I4YjmqF...", + "sessionId" to "TO2G4u2HlnhtbIJk", + "senderKey" to "5e3EIqg3JfooZnLQ2qHIcBarbassQ4qXblai0", + "deviceId" to "FAKEE", + "body" to "some message", + "msgtype" to "m.text" + ).toContent() + ) + + val unValidatedContent = mixedEvent.getClearContent().toModel() + unValidatedContent?.body shouldBe "some message" + + mixedEvent.toValidDecryptedEvent()?.clearContent?.toModel() shouldBe null + } + + @Test + fun `Basic field validation`() { + val decryptedEvent = fakeEvent + .apply { + mxDecryptionResult = OlmDecryptionResult( + payload = mapOf( + "type" to EventType.MESSAGE, + "content" to mapOf( + "body" to "some message", + "msgtype" to "m.text" + ), + ), + senderKey = "the_real_sender_key", + ) + } + + decryptedEvent.toValidDecryptedEvent() shouldNotBe null + decryptedEvent.copy(roomId = null).toValidDecryptedEvent() shouldBe null + decryptedEvent.copy(eventId = null).toValidDecryptedEvent() shouldBe null + } + + @Test + fun `A clear event is not a valid decrypted event`() { + val mockTextEvent = Event( + type = EventType.MESSAGE, + eventId = "eventId", + roomId = "!fooe:example.com", + content = mapOf( + "body" to "some message", + "msgtype" to "m.text" + ), + originServerTs = 1000, + senderId = "@anne:example.com", + ) + mockTextEvent.toValidDecryptedEvent() shouldBe null + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EditValidationTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EventEditValidatorTest.kt similarity index 96% rename from matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EditValidationTest.kt rename to matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EventEditValidatorTest.kt index 429e6625ab..0ae712bff1 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EditValidationTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EventEditValidatorTest.kt @@ -26,7 +26,7 @@ import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore -class EditValidationTest { +class EventEditValidatorTest { private val mockTextEvent = Event( type = EventType.MESSAGE, @@ -180,17 +180,23 @@ class EditValidationTest { validator .validateEdit( - encryptedEvent.copy().apply { + encryptedEvent.copy( + content = encryptedEvent.content!!.toMutableMap().apply { + put( + "m.relates_to", + mapOf( + "rel_type" to "m.replace", + "event_id" to mockTextEvent.eventId + ) + ) + } + ).apply { mxDecryptionResult = encryptedEditEvent.mxDecryptionResult!!.copy( payload = mapOf( "type" to EventType.MESSAGE, "content" to mapOf( "body" to "some message", "msgtype" to "m.text", - "m.relates_to" to mapOf( - "rel_type" to "m.replace", - "event_id" to mockTextEvent.eventId - ) ), ) ) diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt index f5545b7e76..9ad7032262 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt @@ -19,7 +19,6 @@ package org.matrix.android.sdk.test.fakes import io.mockk.coEvery import io.mockk.mockk import io.mockk.mockkStatic -import io.mockk.slot import io.realm.Realm import io.realm.RealmConfiguration import org.matrix.android.sdk.internal.database.awaitTransaction @@ -33,8 +32,7 @@ internal class FakeRealmConfiguration { val instance = mockk() fun givenAwaitTransaction(realm: Realm) { - val transaction = slot<(Realm) -> T>() - coEvery { awaitTransaction(instance, capture(transaction)) } answers { + coEvery { awaitTransaction(instance, any<(Realm) -> T>()) } answers { secondArg<(Realm) -> T>().invoke(realm) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index d5d38f47a3..373410775b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -161,7 +161,7 @@ class MessageItemFactory @Inject constructor( val callback = params.callback event.root.eventId ?: return null roomId = event.roomId - val informationData = messageInformationDataFactory.create(params, params.event.annotations?.editSummary?.latestEdit) + val informationData = messageInformationDataFactory.create(params) val threadDetails = if (params.isFromThreadTimeline()) null else event.root.threadDetails if (event.root.isRedacted()) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index a969c294f5..3c8b342a1a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -37,7 +37,6 @@ import org.matrix.android.sdk.api.session.events.model.getMsgType import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage import org.matrix.android.sdk.api.session.events.model.isSticker import org.matrix.android.sdk.api.session.events.model.toModel -import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent import org.matrix.android.sdk.api.session.room.model.ReferencesAggregatedContent import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.message.MessageType @@ -45,6 +44,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageVerification import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited +import org.matrix.android.sdk.internal.session.events.toValidDecryptedEvent import javax.inject.Inject /** @@ -57,7 +57,7 @@ class MessageInformationDataFactory @Inject constructor( private val reactionsSummaryFactory: ReactionsSummaryFactory ) { - fun create(params: TimelineItemFactoryParams, lastEdit: Event? = null): MessageInformationData { + fun create(params: TimelineItemFactoryParams): MessageInformationData { val event = params.event val nextDisplayableEvent = params.nextDisplayableEvent val prevDisplayableEvent = params.prevDisplayableEvent @@ -74,14 +74,8 @@ class MessageInformationDataFactory @Inject constructor( prevDisplayableEvent?.root?.localDateTime()?.toLocalDate() != date.toLocalDate() val time = dateFormatter.format(event.root.originServerTs, DateFormatKind.MESSAGE_SIMPLE) - val e2eDecoration = getE2EDecoration(roomSummary, lastEdit ?: event.root) - val senderId = if (event.isEncrypted()) { - event.root.toValidDecryptedEvent()?.let { - session.cryptoService().deviceWithIdentityKey(it.cryptoSenderKey, it.algorithm)?.userId - } ?: event.root.senderId.orEmpty() - } else { - event.root.senderId.orEmpty() - } + val e2eDecoration = getE2EDecoration(roomSummary, params.lastEdit ?: event.root) + val senderId = getSenderId(event) // SendState Decoration val sendStateDecoration = if (isSentByMe) { getSendStateDecoration( @@ -139,6 +133,14 @@ class MessageInformationDataFactory @Inject constructor( ) } + private fun getSenderId(event: TimelineEvent) = if (event.isEncrypted()) { + event.root.toValidDecryptedEvent()?.let { + session.cryptoService().deviceWithIdentityKey(it.cryptoSenderKey, it.algorithm)?.userId + } ?: event.root.senderId.orEmpty() + } else { + event.root.senderId.orEmpty() + } + private fun getSendStateDecoration( event: TimelineEvent, lastSentEventWithoutReadReceipts: String?, From 2819957585a1a70a1a3cea2636f38dd73040b2ae Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 23 Nov 2022 23:45:35 +0100 Subject: [PATCH 084/173] fix edit display flicker with local echo --- .../sync/handler/room/RoomSyncHandler.kt | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt index 2825be8291..4001ae2ccf 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt @@ -25,6 +25,8 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.getRelationContent import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService import org.matrix.android.sdk.api.session.room.model.Membership @@ -49,6 +51,7 @@ import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.mapper.toEntity import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity +import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventInsertType import org.matrix.android.sdk.internal.database.model.RoomEntity @@ -486,23 +489,41 @@ internal class RoomSyncHandler @Inject constructor( cryptoService.onLiveEvent(roomEntity.roomId, event, isInitialSync) // Try to remove local echo - event.unsignedData?.transactionId?.also { - val sendingEventEntity = roomEntity.sendingTimelineEvents.find(it) + event.unsignedData?.transactionId?.also { txId -> + val sendingEventEntity = roomEntity.sendingTimelineEvents.find(txId) if (sendingEventEntity != null) { - Timber.v("Remove local echo for tx:$it") + Timber.v("Remove local echo for tx:$txId") roomEntity.sendingTimelineEvents.remove(sendingEventEntity) if (event.isEncrypted() && event.content?.get("algorithm") as? String == MXCRYPTO_ALGORITHM_MEGOLM) { - // updated with echo decryption, to avoid seeing it decrypt again + // updated with echo decryption, to avoid seeing txId decrypt again val adapter = MoshiProvider.providesMoshi().adapter(OlmDecryptionResult::class.java) sendingEventEntity.root?.decryptionResultJson?.let { json -> eventEntity.decryptionResultJson = json event.mxDecryptionResult = adapter.fromJson(json) } } + // also update potential edit that could refer to that event? + // If not display will flicker :/ + val relationContent = event.getRelationContent() + if (relationContent?.type == RelationType.REPLACE) { + relationContent.eventId?.let { targetId -> + EventAnnotationsSummaryEntity.where(realm, roomId, targetId) + .findFirst() + ?.editSummary + ?.editions + ?.forEach { + if (it.eventId == txId) { + // just do that, the aggregation processor will to the rest + it.event = eventEntity + } + } + } + } + // Finally delete the local echo sendingEventEntity.deleteOnCascade(true) } else { - Timber.v("Can't find corresponding local echo for tx:$it") + Timber.v("Can't find corresponding local echo for tx:$txId") } } } From ca907df94b9fc3b2764b5972fc31c652498cd3d8 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 23 Nov 2022 23:47:45 +0100 Subject: [PATCH 085/173] kdoc fix --- .../android/sdk/api/session/events/model/AggregatedRelations.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt index 7f043dc0f7..6577a9b41e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt @@ -19,6 +19,7 @@ import com.squareup.moshi.Json import com.squareup.moshi.JsonClass /** + * Server side relation aggregation. * ``` * { * "m.annotation": { From c06eca69360f0bd2b180b04677133e44a46ba0a8 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 24 Nov 2022 01:38:03 +0100 Subject: [PATCH 086/173] Migration test and cleaning --- .../src/androidTest/assets/session_42.realm | Bin 0 -> 270336 bytes .../RealmSessionStoreMigration43Test.kt | 130 ++++++++++++ .../database/SessionSanityMigrationTest.kt | 64 ++++++ .../database/TestRealmConfigurationFactory.kt | 196 ++++++++++++++++++ .../sdk/api/session/events/model/EventExt.kt | 46 ++++ .../database/migration/MigrateSessionTo043.kt | 8 +- .../sdk/internal/session/events/EventExt.kt | 30 --- .../session/room/EventEditValidator.kt | 2 +- .../EditAggregationSummaryMapperTest.kt | 2 +- .../session/event/ValidDecryptedEventTest.kt | 4 +- .../helper/MessageInformationDataFactory.kt | 2 +- 11 files changed, 445 insertions(+), 39 deletions(-) create mode 100644 matrix-sdk-android/src/androidTest/assets/session_42.realm create mode 100644 matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration43Test.kt create mode 100644 matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/SessionSanityMigrationTest.kt create mode 100644 matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/TestRealmConfigurationFactory.kt create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventExt.kt diff --git a/matrix-sdk-android/src/androidTest/assets/session_42.realm b/matrix-sdk-android/src/androidTest/assets/session_42.realm new file mode 100644 index 0000000000000000000000000000000000000000..b92d13dab2fa6d44a38ee3443ae8cad85340db23 GIT binary patch literal 270336 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

~n*FN$6ddX2RG}!itYe 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%0DTX_=9PRTAUr6@kN4E#1dFf1 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 L00KbZ|0M7~<4-Vn literal 0 HcmV?d00001 diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration43Test.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration43Test.kt new file mode 100644 index 0000000000..e74aa52495 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration43Test.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import io.realm.Realm +import org.amshove.kluent.fail +import org.amshove.kluent.shouldBe +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldNotBe +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.internal.database.mapper.EventMapper +import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity +import org.matrix.android.sdk.internal.database.model.SessionRealmModule +import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.util.Normalizer + +@RunWith(AndroidJUnit4::class) +class RealmSessionStoreMigration43Test { + + @get:Rule val configurationFactory = TestRealmConfigurationFactory() + + lateinit var context: Context + var realm: Realm? = null + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().context + } + + @After + fun tearDown() { + realm?.close() + } + + @Test + fun migrationShouldBeNeeed() { + val realmName = "session_42.realm" + val realmConfiguration = configurationFactory.createConfiguration( + realmName, + "efa9ab2c77ae06b0e767ffdb1c45b12be3c77d48d94f1ac41a7cd1d637fc59ac41f869a250453074e21ce13cfe7ed535593e7d150c08ce2bad7a2ab8c7b841f0", + SessionRealmModule(), + 43, + null + ) + configurationFactory.copyRealmFromAssets(context, realmName, realmName) + + try { + realm = Realm.getInstance(realmConfiguration) + fail("Should need a migration") + } catch (failure: Throwable) { + // nop + } + } + + // Database key for alias `session_db_e00482619b2597069b1f192b86de7da9`: efa9ab2c77ae06b0e767ffdb1c45b12be3c77d48d94f1ac41a7cd1d637fc59ac41f869a250453074e21ce13cfe7ed535593e7d150c08ce2bad7a2ab8c7b841f0 + // $WEJ8U6Zsx3TDZx3qmHIOKh-mXe5kqL_MnPcIkStEwwI + // $11EtAQ8RYcudJVtw7e6B5Vm4ufCqKTOWKblY2U_wrpo + @Test + fun testMigration43() { + val realmName = "session_42.realm" + val migration = RealmSessionStoreMigration(Normalizer()) + val realmConfiguration = configurationFactory.createConfiguration( + realmName, + "efa9ab2c77ae06b0e767ffdb1c45b12be3c77d48d94f1ac41a7cd1d637fc59ac41f869a250453074e21ce13cfe7ed535593e7d150c08ce2bad7a2ab8c7b841f0", + SessionRealmModule(), + 43, + migration + ) + configurationFactory.copyRealmFromAssets(context, realmName, realmName) + + realm = Realm.getInstance(realmConfiguration) + + // assert that the edit from 42 are migrated + val editions = EventAnnotationsSummaryEntity + .where(realm!!, "\$WEJ8U6Zsx3TDZx3qmHIOKh-mXe5kqL_MnPcIkStEwwI") + .findFirst() + ?.editSummary + ?.editions + + editions shouldNotBe null + editions!!.size shouldBe 1 + val firstEdition = editions.first() + firstEdition?.eventId shouldBeEqualTo "\$DvOyA8vJxwGfTaJG3OEJVcL4isShyaVDnprihy38W28" + firstEdition?.isLocalEcho shouldBeEqualTo false + + val editEvent = EventMapper.map(firstEdition!!.event!!) + val body = editEvent.content.toModel()?.body + body shouldBeEqualTo "* Message 2 with edit" + + // assert that the edit from 42 are migrated + val editionsOfE2E = EventAnnotationsSummaryEntity + .where(realm!!, "\$11EtAQ8RYcudJVtw7e6B5Vm4ufCqKTOWKblY2U_wrpo") + .findFirst() + ?.editSummary + ?.editions + + editionsOfE2E shouldNotBe null + editionsOfE2E!!.size shouldBe 1 + val firstEditionE2E = editionsOfE2E.first() + firstEditionE2E?.eventId shouldBeEqualTo "\$HUwJOQRCJwfPv7XSKvBPcvncjM0oR3q2tGIIIdv9Zts" + firstEditionE2E?.isLocalEcho shouldBeEqualTo false + + val editEventE2E = EventMapper.map(firstEditionE2E!!.event!!) + val body2 = editEventE2E.getClearContent().toModel()?.body + body2 shouldBeEqualTo "* Message 2, e2e edit" + } +} diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/SessionSanityMigrationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/SessionSanityMigrationTest.kt new file mode 100644 index 0000000000..fc1a78835b --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/SessionSanityMigrationTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import io.realm.Realm +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.matrix.android.sdk.internal.database.model.SessionRealmModule +import org.matrix.android.sdk.internal.util.Normalizer + +@RunWith(AndroidJUnit4::class) +class SessionSanityMigrationTest { + + @get:Rule val configurationFactory = TestRealmConfigurationFactory() + + lateinit var context: Context + var realm: Realm? = null + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().context + } + + @After + fun tearDown() { + realm?.close() + } + + @Test + fun sessionDatabaseShouldMigrateGracefully() { + val realmName = "session_42.realm" + val migration = RealmSessionStoreMigration(Normalizer()) + val realmConfiguration = configurationFactory.createConfiguration( + realmName, + "efa9ab2c77ae06b0e767ffdb1c45b12be3c77d48d94f1ac41a7cd1d637fc59ac41f869a250453074e21ce13cfe7ed535593e7d150c08ce2bad7a2ab8c7b841f0", + SessionRealmModule(), + migration.schemaVersion, + migration + ) + configurationFactory.copyRealmFromAssets(context, realmName, realmName) + + realm = Realm.getInstance(realmConfiguration) + } +} diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/TestRealmConfigurationFactory.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/TestRealmConfigurationFactory.kt new file mode 100644 index 0000000000..fc5a017287 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/TestRealmConfigurationFactory.kt @@ -0,0 +1,196 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.matrix.android.sdk.internal.database + +import android.content.Context +import androidx.test.platform.app.InstrumentationRegistry +import io.realm.Realm +import io.realm.RealmConfiguration +import io.realm.RealmMigration +import org.junit.rules.TemporaryFolder +import org.junit.runner.Description +import org.junit.runners.model.Statement +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.lang.IllegalStateException +import java.util.Collections +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import kotlin.Throws + +/** + * Based on https://github.com/realm/realm-java/blob/master/realm/realm-library/src/testUtils/java/io/realm/TestRealmConfigurationFactory.java + */ +class TestRealmConfigurationFactory : TemporaryFolder() { + private val map: Map = ConcurrentHashMap() + private val configurations = Collections.newSetFromMap(map) + @get:Synchronized private var isUnitTestFailed = false + private var testName = "" + private var tempFolder: File? = null + + override fun apply(base: Statement, description: Description): Statement { + return object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + setTestName(description) + before() + try { + base.evaluate() + } catch (throwable: Throwable) { + setUnitTestFailed() + throw throwable + } finally { + after() + } + } + } + } + + @Throws(Throwable::class) + override fun before() { + Realm.init(InstrumentationRegistry.getInstrumentation().targetContext) + super.before() + } + + override fun after() { + try { + for (configuration in configurations) { + Realm.deleteRealm(configuration) + } + } catch (e: IllegalStateException) { + // Only throws the exception caused by deleting the opened Realm if the test case itself doesn't throw. + if (!isUnitTestFailed) { + throw e + } + } finally { + // This will delete the temp directory. + super.after() + } + } + + @Throws(IOException::class) + override fun create() { + super.create() + tempFolder = File(super.getRoot(), testName) + check(!(tempFolder!!.exists() && !tempFolder!!.delete())) { "Could not delete folder: " + tempFolder!!.absolutePath } + check(tempFolder!!.mkdir()) { "Could not create folder: " + tempFolder!!.absolutePath } + } + + override fun getRoot(): File { + checkNotNull(tempFolder) { "the temporary folder has not yet been created" } + return tempFolder!! + } + + /** + * To be called in the [.apply]. + */ + protected fun setTestName(description: Description) { + testName = description.displayName + } + + @Synchronized + fun setUnitTestFailed() { + isUnitTestFailed = true + } + + // This builder creates a configuration that is *NOT* managed. + // You have to delete it yourself. + private fun createConfigurationBuilder(): RealmConfiguration.Builder { + return RealmConfiguration.Builder().directory(root) + } + + fun String.decodeHex(): ByteArray { + check(length % 2 == 0) { "Must have an even length" } + return chunked(2) + .map { it.toInt(16).toByte() } + .toByteArray() + } + + fun createConfiguration( + name: String, + key: String?, + module: Any, + schemaVersion: Long, + migration: RealmMigration? + ): RealmConfiguration { + val builder = createConfigurationBuilder() + builder + .directory(root) + .name(name) + .apply { + if (key != null) { + encryptionKey(key.decodeHex()) + } + } + .modules(module) + // Allow writes on UI + .allowWritesOnUiThread(true) + .schemaVersion(schemaVersion) + .apply { + migration?.let { migration(it) } + } + val configuration = builder.build() + configurations.add(configuration) + return configuration + } + + // Copies a Realm file from assets to temp dir + @Throws(IOException::class) + fun copyRealmFromAssets(context: Context, realmPath: String, newName: String) { + val config = RealmConfiguration.Builder() + .directory(root) + .name(newName) + .build() + copyRealmFromAssets(context, realmPath, config) + } + + @Throws(IOException::class) + fun copyRealmFromAssets(context: Context, realmPath: String, config: RealmConfiguration) { + check(!File(config.path).exists()) { String.format(Locale.ENGLISH, "%s exists!", config.path) } + val outFile = File(config.realmDirectory, config.realmFileName) + copyFileFromAssets(context, realmPath, outFile) + } + + @Throws(IOException::class) + fun copyFileFromAssets(context: Context, assetPath: String?, outFile: File?) { + var stream: InputStream? = null + var os: FileOutputStream? = null + try { + stream = context.assets.open(assetPath!!) + os = FileOutputStream(outFile) + val buf = ByteArray(1024) + var bytesRead: Int + while (stream.read(buf).also { bytesRead = it } > -1) { + os.write(buf, 0, bytesRead) + } + } finally { + if (stream != null) { + try { + stream.close() + } catch (ignore: IOException) { + } + } + if (os != null) { + try { + os.close() + } catch (ignore: IOException) { + } + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventExt.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventExt.kt new file mode 100644 index 0000000000..32d5ebed8c --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventExt.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.events.model + +fun Event.toValidDecryptedEvent(): ValidDecryptedEvent? { + if (!this.isEncrypted()) return null + val decryptedContent = this.getDecryptedContent() ?: return null + val eventId = this.eventId ?: return null + val roomId = this.roomId ?: return null + val type = this.getDecryptedType() ?: return null + val senderKey = this.getSenderKey() ?: return null + val algorithm = this.content?.get("algorithm") as? String ?: return null + + // copy the relation as it's in clear in the encrypted content + val updatedContent = this.content.get("m.relates_to")?.let { + decryptedContent.toMutableMap().apply { + put("m.relates_to", it) + } + } ?: decryptedContent + return ValidDecryptedEvent( + type = type, + eventId = eventId, + clearContent = updatedContent, + prevContent = this.prevContent, + originServerTs = this.originServerTs ?: 0, + cryptoSenderKey = senderKey, + roomId = roomId, + unsignedData = this.unsignedData, + redacts = this.redacts, + algorithm = algorithm + ) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt index d11a671c55..c7fda61671 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt @@ -18,8 +18,8 @@ package org.matrix.android.sdk.internal.database.migration import io.realm.DynamicRealm import org.matrix.android.sdk.internal.database.model.EditionOfEventFields -import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventEntityFields +import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.util.database.RealmMigrator internal class MigrateSessionTo043(realm: DynamicRealm) : RealmMigrator(realm, 43) { @@ -27,11 +27,9 @@ internal class MigrateSessionTo043(realm: DynamicRealm) : RealmMigrator(realm, 4 override fun doMigrate(realm: DynamicRealm) { // content(string) & senderId(string) have been removed and replaced by a link to the actual event realm.schema.get("EditionOfEvent") - ?.removeField("senderId") - ?.removeField("content") ?.addRealmObjectField(EditionOfEventFields.EVENT.`$`, realm.schema.get("EventEntity")!!) ?.transform { dynamicObject -> - realm.where(EventEntity::javaClass.name) + realm.where("EventEntity") .equalTo(EventEntityFields.EVENT_ID, dynamicObject.getString(EditionOfEventFields.EVENT_ID)) .equalTo(EventEntityFields.SENDER, dynamicObject.getString("senderId")) .findFirst() @@ -39,5 +37,7 @@ internal class MigrateSessionTo043(realm: DynamicRealm) : RealmMigrator(realm, 4 dynamicObject.setObject(EditionOfEventFields.EVENT.`$`, it) } } + ?.removeField("senderId") + ?.removeField("content") } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/events/EventExt.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/events/EventExt.kt index 63409a15bb..91e709e464 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/events/EventExt.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/events/EventExt.kt @@ -17,7 +17,6 @@ package org.matrix.android.sdk.internal.session.events import org.matrix.android.sdk.api.session.events.model.Event -import org.matrix.android.sdk.api.session.events.model.ValidDecryptedEvent import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.RoomMemberContent @@ -34,32 +33,3 @@ internal fun Event.getFixedRoomMemberContent(): RoomMemberContent? { content } } - -fun Event.toValidDecryptedEvent(): ValidDecryptedEvent? { - if (!this.isEncrypted()) return null - val decryptedContent = this.getDecryptedContent() ?: return null - val eventId = this.eventId ?: return null - val roomId = this.roomId ?: return null - val type = this.getDecryptedType() ?: return null - val senderKey = this.getSenderKey() ?: return null - val algorithm = this.content?.get("algorithm") as? String ?: return null - - // copy the relation as it's in clear in the encrypted content - val updatedContent = this.content.get("m.relates_to")?.let { - decryptedContent.toMutableMap().apply { - put("m.relates_to", it) - } - } ?: decryptedContent - return ValidDecryptedEvent( - type = type, - eventId = eventId, - clearContent = updatedContent, - prevContent = this.prevContent, - originServerTs = this.originServerTs ?: 0, - cryptoSenderKey = senderKey, - roomId = roomId, - unsignedData = this.unsignedData, - redacts = this.redacts, - algorithm = algorithm - ) -} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt index ea14aacfe4..41d0c3f6ab 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt @@ -21,9 +21,9 @@ import org.matrix.android.sdk.api.session.events.model.LocalEcho import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.getRelationContent import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore -import org.matrix.android.sdk.internal.session.events.toValidDecryptedEvent import timber.log.Timber import javax.inject.Inject diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregationSummaryMapperTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregationSummaryMapperTest.kt index 12ff9c1d37..861f343179 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregationSummaryMapperTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregationSummaryMapperTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright 2022 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/event/ValidDecryptedEventTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/event/ValidDecryptedEventTest.kt index 8dead42f60..1d1933df84 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/event/ValidDecryptedEventTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/event/ValidDecryptedEventTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright 2022 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,8 +26,8 @@ import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent -import org.matrix.android.sdk.internal.session.events.toValidDecryptedEvent class ValidDecryptedEventTest { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index 3c8b342a1a..57a4388f74 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -37,6 +37,7 @@ import org.matrix.android.sdk.api.session.events.model.getMsgType import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage import org.matrix.android.sdk.api.session.events.model.isSticker import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent import org.matrix.android.sdk.api.session.room.model.ReferencesAggregatedContent import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.message.MessageType @@ -44,7 +45,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageVerification import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited -import org.matrix.android.sdk.internal.session.events.toValidDecryptedEvent import javax.inject.Inject /** From bec8b5f71ecd6d8c2ab2771c6fc7926af82c5acb Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 24 Nov 2022 12:45:11 +0100 Subject: [PATCH 087/173] code review --- ... EditAggregatedSummaryEntityMapperTest.kt} | 52 ++++++++++++++++--- .../session/event/ValidDecryptedEventTest.kt | 2 +- 2 files changed, 45 insertions(+), 9 deletions(-) rename matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/{EditAggregationSummaryMapperTest.kt => EditAggregatedSummaryEntityMapperTest.kt} (57%) diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregationSummaryMapperTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregatedSummaryEntityMapperTest.kt similarity index 57% rename from matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregationSummaryMapperTest.kt rename to matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregatedSummaryEntityMapperTest.kt index 861f343179..7ad5bb40e3 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregationSummaryMapperTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregatedSummaryEntityMapperTest.kt @@ -19,17 +19,17 @@ package org.matrix.android.sdk.internal.database.mapper import io.mockk.every import io.mockk.mockk import io.realm.RealmList -import org.amshove.kluent.shouldBe +import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldNotBe import org.junit.Test import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntity import org.matrix.android.sdk.internal.database.model.EditionOfEvent import org.matrix.android.sdk.internal.database.model.EventEntity -class EditAggregationSummaryMapperTest { +class EditAggregatedSummaryEntityMapperTest { @Test - fun test() { + fun `test mapping summary entity to model`() { val edits = RealmList( EditionOfEvent( timestamp = 0L, @@ -56,12 +56,48 @@ class EditAggregationSummaryMapperTest { val mapped = EditAggregatedSummaryEntityMapper.map(fakeSummaryEntity) mapped shouldNotBe null - mapped!!.sourceEvents.size shouldBe 2 - mapped.localEchos.size shouldBe 1 - mapped.localEchos.first() shouldBe "e2" + mapped!!.sourceEvents.size shouldBeEqualTo 2 + mapped.localEchos.size shouldBeEqualTo 1 + mapped.localEchos.first() shouldBeEqualTo "e2" - mapped.lastEditTs shouldBe 30L - mapped.latestEdit?.eventId shouldBe "e2" + mapped.lastEditTs shouldBeEqualTo 30L + mapped.latestEdit?.eventId shouldBeEqualTo "e2" + } + + @Test + fun `event with lexicographically largest event_id is treated as more recent`() { + val lowerId = "\$Albatross" + val higherId = "\$Zebra" + + (higherId > lowerId) shouldBeEqualTo true + val timestamp = 1669288766745L + val edits = RealmList( + EditionOfEvent( + timestamp = timestamp, + eventId = lowerId, + isLocalEcho = false, + event = mockEvent(lowerId) + ), + EditionOfEvent( + timestamp = timestamp, + eventId = higherId, + isLocalEcho = false, + event = mockEvent(higherId) + ), + EditionOfEvent( + timestamp = 1L, + eventId = "e2", + isLocalEcho = true, + event = mockEvent("e2") + ) + ) + + val fakeSummaryEntity = mockk { + every { editions } returns edits + } + val mapped = EditAggregatedSummaryEntityMapper.map(fakeSummaryEntity) + mapped!!.lastEditTs shouldBeEqualTo timestamp + mapped.latestEdit?.eventId shouldBeEqualTo higherId } private fun mockEvent(eventId: String): EventEntity { diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/event/ValidDecryptedEventTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/event/ValidDecryptedEventTest.kt index 1d1933df84..5fda242b90 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/event/ValidDecryptedEventTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/event/ValidDecryptedEventTest.kt @@ -31,7 +31,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent class ValidDecryptedEventTest { - val fakeEvent = Event( + private val fakeEvent = Event( type = EventType.ENCRYPTED, eventId = "\$eventId", roomId = "!fakeRoom", From 9ca7415f5a8f143bc397c0e4ebfa109274ebd57b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 14 Nov 2022 15:02:01 +0100 Subject: [PATCH 088/173] Remove usage of Buildkite. Build number is just removed. Related script will need to be updated separately. --- README.md | 6 +++--- tools/gradle/doctor.gradle | 2 +- tools/install/installFromBuildkite.sh | 3 +++ tools/release/download_buildkite_artifacts.py | 4 ++++ tools/release/releaseScript.sh | 2 +- vector-app/build.gradle | 14 ++------------ vector-app/signature/README.md | 4 ---- .../main/java/im/vector/app/VectorApplication.kt | 2 +- .../java/im/vector/app/core/di/SingletonModule.kt | 1 - vector/src/main/AndroidManifest.xml | 4 ++-- .../im/vector/app/core/resources/BuildMeta.kt | 1 - .../app/features/login/LoginSplashFragment.kt | 3 +-- .../ftueauth/FtueAuthSplashCarouselFragment.kt | 2 +- .../onboarding/ftueauth/FtueAuthSplashFragment.kt | 3 +-- .../vector/app/features/rageshake/BugReporter.kt | 7 +------ .../rageshake/VectorUncaughtExceptionHandler.kt | 2 +- .../settings/VectorSettingsHelpAboutFragment.kt | 2 +- .../app/features/version/VersionProvider.kt | 15 +++------------ 18 files changed, 26 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index e351b64927..e8fceb2eb2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Buildkite](https://badge.buildkite.com/ad0065c1b70f557cd3b1d3d68f9c2154010f83c4d6f71706a9.svg?branch=develop)](https://buildkite.com/matrix-dot-org/element-android/builds?branch=develop) +[![Latest build](https://github.com/vector-im/element-android/actions/workflows/build.yml/badge.svg?query=branch%3Adevelop)](https://github.com/vector-im/element-android/actions/workflows/build.yml?query=branch%3Adevelop) [![Weblate](https://translate.element.io/widgets/element-android/-/svg-badge.svg)](https://translate.element.io/engage/element-android/?utm_source=widget) [![Element Android Matrix room #element-android:matrix.org](https://img.shields.io/matrix/element-android:matrix.org.svg?label=%23element-android:matrix.org&logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#element-android:matrix.org) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=vector-im_element-android&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=vector-im_element-android) @@ -14,7 +14,7 @@ It is a total rewrite of [Riot-Android](https://github.com/vector-im/riot-androi [Get it on Google Play](https://play.google.com/store/apps/details?id=im.vector.app) [Get it on F-Droid](https://f-droid.org/app/im.vector.app) -Nightly build: [![Buildkite](https://badge.buildkite.com/ad0065c1b70f557cd3b1d3d68f9c2154010f83c4d6f71706a9.svg?branch=develop)](https://buildkite.com/matrix-dot-org/element-android/builds?branch=develop) Nightly test status: [![allScreensTest](https://github.com/vector-im/element-android/actions/workflows/nightly.yml/badge.svg)](https://github.com/vector-im/element-android/actions/workflows/nightly.yml) +Build of develop branch: [![GitHub Action](https://github.com/vector-im/element-android/actions/workflows/build.yml/badge.svg?query=branch%3Adevelop)](https://github.com/vector-im/element-android/actions/workflows/build.yml?query=branch%3Adevelop) Nightly test status: [![allScreensTest](https://github.com/vector-im/element-android/actions/workflows/nightly.yml/badge.svg)](https://github.com/vector-im/element-android/actions/workflows/nightly.yml) # New Android SDK @@ -40,7 +40,7 @@ If you would like to receive releases more quickly (bearing in mind that they ma 1. [Sign up to receive beta releases](https://play.google.com/apps/testing/im.vector.app) via the Google Play Store. 2. Install a [release APK](https://github.com/vector-im/element-android/releases) directly - download the relevant .apk file and allow installing from untrusted sources in your device settings. Note: these releases are the Google Play version, which depend on some Google services. If you prefer to avoid that, try the latest dev builds, and choose the F-Droid version. -3. If you're really brave, install the [very latest dev build](https://buildkite.com/matrix-dot-org/element-android/builds/latest?branch=develop&state=passed) - click on *Assemble (GPlay or FDroid) Debug version* then on *Artifacts*. +3. If you're really brave, install the [very latest dev build](https://github.com/vector-im/element-android/actions/workflows/build.yml?query=branch%3Adevelop) - pick a build, then click on `Summary` to download the APKs from there: `vector-Fdroid-debug` and `vector-Gplay-debug` contains the APK for the desired store. Each file contains 5 APKs. 4 APKs for every supported specific architecture of device. In doubt you can install the `universal` APK. ## Contributing diff --git a/tools/gradle/doctor.gradle b/tools/gradle/doctor.gradle index 7a7adad062..edd3069402 100644 --- a/tools/gradle/doctor.gradle +++ b/tools/gradle/doctor.gradle @@ -1,6 +1,6 @@ // Default configuration copied from https://runningcode.github.io/gradle-doctor/configuration/ -def isCiBuild = System.env.BUILDKITE == "true" || System.env.GITHUB_ACTIONS == "true" +def isCiBuild = System.env.GITHUB_ACTIONS == "true" println "Is CI build: $isCiBuild" doctor { diff --git a/tools/install/installFromBuildkite.sh b/tools/install/installFromBuildkite.sh index e47902e31b..759a2a0035 100755 --- a/tools/install/installFromBuildkite.sh +++ b/tools/install/installFromBuildkite.sh @@ -3,6 +3,9 @@ # Exit on any error set -e +echo "Sorry, this script needs to be updated to download APKs from GitHub action. Buildkite is not building APKs anymore." +exit 1 + if [[ "$#" -ne 1 ]]; then echo "Usage: $0 BUILDKITE_TOKEN" >&2 exit 1 diff --git a/tools/release/download_buildkite_artifacts.py b/tools/release/download_buildkite_artifacts.py index 7a824be806..00f93a52e5 100755 --- a/tools/release/download_buildkite_artifacts.py +++ b/tools/release/download_buildkite_artifacts.py @@ -31,6 +31,10 @@ PIPELINE_SLUG = "element-android" ### Arguments +print("Sorry, this script needs to be updated to download APKs from GitHub action. Buildkite is not building APKs anymore.") +exit(1) + + parser = argparse.ArgumentParser(description='Download artifacts from Buildkite.') parser.add_argument('-t', '--token', diff --git a/tools/release/releaseScript.sh b/tools/release/releaseScript.sh index 685093d65e..943e9771f2 100755 --- a/tools/release/releaseScript.sh +++ b/tools/release/releaseScript.sh @@ -214,7 +214,7 @@ else fi printf "\n================================================================================\n" -read -p "Wait for Buildkite https://buildkite.com/matrix-dot-org/element-android/builds?branch=main to build the 'main' branch. Press enter when it's done." +read -p "Wait for the GitHub action https://github.com/vector-im/element-android/actions/workflows/build.yml?query=branch%3Amain to build the 'main' branch. Press enter when it's done." printf "\n================================================================================\n" printf "Running the release script...\n" diff --git a/vector-app/build.gradle b/vector-app/build.gradle index a9b16f8c6c..86b94a8497 100644 --- a/vector-app/build.gradle +++ b/vector-app/build.gradle @@ -76,15 +76,8 @@ static def gitRevisionDate() { } static def gitBranchName() { - def fromEnv = System.env.BUILDKITE_BRANCH as String ?: "" - - if (!fromEnv.isEmpty()) { - return fromEnv - } else { - // Note: this command return "HEAD" on Buildkite, so use the system env 'BUILDKITE_BRANCH' content first - def cmd = "git rev-parse --abbrev-ref HEAD" - return cmd.execute().text.trim() - } + def cmd = "git rev-parse --abbrev-ref HEAD" + return cmd.execute().text.trim() } // For Google Play build, build on any other branch than main will have a "-dev" suffix @@ -122,8 +115,6 @@ project.android.buildTypes.all { buildType -> // 64 bits have greater value than 32 bits ext.abiVersionCodes = ["armeabi-v7a": 1, "arm64-v8a": 2, "x86": 3, "x86_64": 4].withDefault { 0 } -def buildNumber = System.env.BUILDKITE_BUILD_NUMBER as Integer ?: 0 - android { namespace "im.vector.application" // Due to a bug introduced in Android gradle plugin 3.6.0, we have to specify the ndk version to use @@ -155,7 +146,6 @@ android { buildConfigField "String", "GIT_REVISION", "\"${gitRevision()}\"" buildConfigField "String", "GIT_REVISION_DATE", "\"${gitRevisionDate()}\"" buildConfigField "String", "GIT_BRANCH_NAME", "\"${gitBranchName()}\"" - buildConfigField "String", "BUILD_NUMBER", "\"${buildNumber}\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/vector-app/signature/README.md b/vector-app/signature/README.md index 7d9005f1f4..34d40b45bd 100644 --- a/vector-app/signature/README.md +++ b/vector-app/signature/README.md @@ -1,10 +1,6 @@ ## Debug signature -Buildkite CI tool uses docker images to build the Android application, and it looks like the debug signature is changed at each build. - -So it's not possible for user to upgrade the application with the last build from buildkite without uninstalling the application. - This folder contains a debug signature, and the debug build will uses this signature to build the APK. The validity of the signature is 30 years. So it has to be replaced before June 2049 :). diff --git a/vector-app/src/main/java/im/vector/app/VectorApplication.kt b/vector-app/src/main/java/im/vector/app/VectorApplication.kt index ec0a6cb2a4..b89529715c 100644 --- a/vector-app/src/main/java/im/vector/app/VectorApplication.kt +++ b/vector-app/src/main/java/im/vector/app/VectorApplication.kt @@ -239,7 +239,7 @@ class VectorApplication : } private fun logInfo() { - val appVersion = versionProvider.getVersion(longFormat = true, useBuildNumber = true) + val appVersion = versionProvider.getVersion(longFormat = true) val sdkVersion = Matrix.getSdkVersion() val date = SimpleDateFormat("MM-dd HH:mm:ss.SSSZ", Locale.US).format(Date()) diff --git a/vector-app/src/main/java/im/vector/app/core/di/SingletonModule.kt b/vector-app/src/main/java/im/vector/app/core/di/SingletonModule.kt index 3c1cea57ec..28ca761ace 100644 --- a/vector-app/src/main/java/im/vector/app/core/di/SingletonModule.kt +++ b/vector-app/src/main/java/im/vector/app/core/di/SingletonModule.kt @@ -221,7 +221,6 @@ import javax.inject.Singleton gitRevision = BuildConfig.GIT_REVISION, gitRevisionDate = BuildConfig.GIT_REVISION_DATE, gitBranchName = BuildConfig.GIT_BRANCH_NAME, - buildNumber = BuildConfig.BUILD_NUMBER, flavorDescription = BuildConfig.FLAVOR_DESCRIPTION, flavorShortDescription = BuildConfig.SHORT_FLAVOR_DESCRIPTION, ) diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index d28312ac1c..a26be23456 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -117,7 +117,7 @@ android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" /> - + - + (VectorPreferences.SETTINGS_VERSION_PREFERENCE_KEY)!!.let { it.summary = buildString { - append(versionProvider.getVersion(longFormat = false, useBuildNumber = true)) + append(versionProvider.getVersion(longFormat = false)) if (buildMeta.isDebug) { append(" ") append(buildMeta.gitBranchName) diff --git a/vector/src/main/java/im/vector/app/features/version/VersionProvider.kt b/vector/src/main/java/im/vector/app/features/version/VersionProvider.kt index 4c8188dc8b..2b7406813f 100644 --- a/vector/src/main/java/im/vector/app/features/version/VersionProvider.kt +++ b/vector/src/main/java/im/vector/app/features/version/VersionProvider.kt @@ -25,7 +25,7 @@ class VersionProvider @Inject constructor( private val buildMeta: BuildMeta, ) { - fun getVersion(longFormat: Boolean, useBuildNumber: Boolean): String { + fun getVersion(longFormat: Boolean): String { var result = "${buildMeta.versionName} [${versionCodeProvider.getVersionCode()}]" var flavor = buildMeta.flavorShortDescription @@ -34,19 +34,10 @@ class VersionProvider @Inject constructor( flavor += "-" } - var gitVersion = buildMeta.gitRevision + val gitVersion = buildMeta.gitRevision val gitRevisionDate = buildMeta.gitRevisionDate - val buildNumber = buildMeta.buildNumber - var useLongFormat = longFormat - - if (useBuildNumber && buildNumber != "0") { - // It's a build from CI - gitVersion = "b$buildNumber" - useLongFormat = false - } - - result += if (useLongFormat) { + result += if (longFormat) { " ($flavor$gitVersion-$gitRevisionDate)" } else { " ($flavor$gitVersion)" From cccfad03cebc8b4cac7af266546159cceafa7f46 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 14 Nov 2022 15:28:04 +0100 Subject: [PATCH 089/173] changelog --- changelog.d/7583.misc | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7583.misc diff --git a/changelog.d/7583.misc b/changelog.d/7583.misc new file mode 100644 index 0000000000..3c63aeaadf --- /dev/null +++ b/changelog.d/7583.misc @@ -0,0 +1 @@ +Remove usage of Buildkite. From 8795ddb3c2654bd14059bdb8690de598e23d6d68 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 16 Nov 2022 14:51:45 +0100 Subject: [PATCH 090/173] Add git commit sha to the version details (splash screen and preference screen) --- .../java/im/vector/app/features/login/LoginSplashFragment.kt | 2 +- .../onboarding/ftueauth/FtueAuthSplashCarouselFragment.kt | 2 +- .../app/features/onboarding/ftueauth/FtueAuthSplashFragment.kt | 2 +- .../app/features/settings/VectorSettingsHelpAboutFragment.kt | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/login/LoginSplashFragment.kt b/vector/src/main/java/im/vector/app/features/login/LoginSplashFragment.kt index cf89475617..dd563483ec 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginSplashFragment.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginSplashFragment.kt @@ -65,7 +65,7 @@ class LoginSplashFragment : views.loginSplashVersion.isVisible = true @SuppressLint("SetTextI18n") views.loginSplashVersion.text = "Version : ${buildMeta.versionName}\n" + - "Branch: ${buildMeta.gitBranchName}" + "Branch: ${buildMeta.gitBranchName} ${buildMeta.gitRevision}" views.loginSplashVersion.debouncedClicks { navigator.openDebug(requireContext()) } } } diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashCarouselFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashCarouselFragment.kt index bbb52a2bf5..3d23d2b4c3 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashCarouselFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashCarouselFragment.kt @@ -95,7 +95,7 @@ class FtueAuthSplashCarouselFragment : views.loginSplashVersion.isVisible = true @SuppressLint("SetTextI18n") views.loginSplashVersion.text = "Version : ${buildMeta.versionName}\n" + - "Branch: ${buildMeta.gitBranchName}" + "Branch: ${buildMeta.gitBranchName} ${buildMeta.gitRevision}" views.loginSplashVersion.debouncedClicks { navigator.openDebug(requireContext()) } } views.splashCarousel.registerAutomaticUntilInteractionTransitions() diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashFragment.kt index 71c7b1f1f6..3c8f3c25d9 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashFragment.kt @@ -67,7 +67,7 @@ class FtueAuthSplashFragment : views.loginSplashVersion.isVisible = true @SuppressLint("SetTextI18n") views.loginSplashVersion.text = "Version : ${buildMeta.versionName}\n" + - "Branch: ${buildMeta.gitBranchName}" + "Branch: ${buildMeta.gitBranchName} ${buildMeta.gitRevision}" views.loginSplashVersion.debouncedClicks { navigator.openDebug(requireContext()) } } } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsHelpAboutFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsHelpAboutFragment.kt index aaf6dd208c..f1a9b724e2 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsHelpAboutFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsHelpAboutFragment.kt @@ -73,6 +73,8 @@ class VectorSettingsHelpAboutFragment : if (buildMeta.isDebug) { append(" ") append(buildMeta.gitBranchName) + append(" ") + append(buildMeta.gitRevision) } } From 83a10c37a64c90ea40d958a3eff8645e98982755 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 16 Nov 2022 17:31:35 +0100 Subject: [PATCH 091/173] Install app from GitHub action --- tools/install/installFromGitHub.sh | 94 +++++++++++++ tools/release/download_github_artifacts.py | 154 +++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100755 tools/install/installFromGitHub.sh create mode 100755 tools/release/download_github_artifacts.py diff --git a/tools/install/installFromGitHub.sh b/tools/install/installFromGitHub.sh new file mode 100755 index 0000000000..c6af9c376d --- /dev/null +++ b/tools/install/installFromGitHub.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash + +# Exit on any error +set -e + +if [[ "$#" -ne 1 ]]; then + echo "Usage: $0 GitHub_token" >&2 + exit 1 +fi + +gitHubToken=$1 + +# Path where the app is cloned (it's where this project has been cloned) +appPath=$(dirname $(dirname $(dirname $0))) +# Path where the APK will be downloaded from CI (it's a dir) +baseImportPath="${appPath}/tmp/DebugApks" + +# Select device +serialNumber=$(${appPath}/tools/install/androidSelectDevice.sh) + +# Detect device architecture +arch=$(adb -s ${serialNumber} shell getprop ro.product.cpu.abi) + +echo +echo "Will install the application on device ${serialNumber} with arch ${arch}" + +# Artifact URL +echo +read -p "Artifact url (ex: https://github.com/vector-im/element-android/suites/9293388174/artifacts/435942121)? " artifactUrl + +## Example of default value for Gplay +#artifactUrl=${artifactUrl:-https://github.com/vector-im/element-android/suites/9293388174/artifacts/435942121} +## Example of default value for FDroid +# artifactUrl=${artifactUrl:-https://github.com/vector-im/element-android/suites/9293388174/artifacts/435942119} + +artifactId=$(echo ${artifactUrl} | rev | cut -d'/' -f1 | rev) + +# Download files +targetPath=${baseImportPath}/${artifactId} + +filename="artifact.zip" + +fullFilePath="${targetPath}/${filename}" + +# Check if file already exists +if test -f "$fullFilePath"; then + read -p "$fullFilePath already exists. Override (yes/no) default to no ? " download + download=${download:-no} +else + download="yes" +fi + +# Ignore error from now +set +e + +if [ ${download} == "yes" ]; then + echo "Downloading ${filename} to ${targetPath}..." + python3 ${appPath}/tools/release/download_github_artifacts.py \ + --token ${gitHubToken} \ + --artifactUrl ${artifactUrl} \ + --directory ${targetPath} \ + --filename ${filename} \ + --ignoreErrors +fi + +echo "Unzipping ${filename}..." +unzip $fullFilePath -d ${targetPath} + +## gplay or fdroid +if test -d "${targetPath}/gplay"; then + variant="gplay" +elif test -d "${targetPath}/fdroid"; then + variant="fdroid" +else + echo "No variant found" + exit 1 +fi + +fullApkPath="${targetPath}/${variant}/debug/vector-${variant}-${arch}-debug.apk" + +echo "Installing ${fullApkPath} to device ${serialNumber}..." +adb -s ${serialNumber} install -r ${fullApkPath} + +# Check error and propose to uninstall and retry installing +if [[ "$?" -ne 0 ]]; then + read -p "Error, do you want to uninstall the application then retry (yes/no) default to no ? " retry + retry=${retry:-no} + if [ ${retry} == "yes" ]; then + echo "Uninstalling..." + adb -s ${serialNumber} uninstall im.vector.app.debug + echo "Installing again..." + adb -s ${serialNumber} install -r ${fullApkPath} + fi +fi diff --git a/tools/release/download_github_artifacts.py b/tools/release/download_github_artifacts.py new file mode 100755 index 0000000000..892a4affa6 --- /dev/null +++ b/tools/release/download_github_artifacts.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +# +# Copyright 2022 New Vector Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import argparse +import hashlib +import json +import os +# Run `pip3 install requests` if not installed yet +import requests + +# This script downloads artifacts from GitHub. +# Ref: https://docs.github.com/en/rest/actions/artifacts#get-an-artifact + +error = False + +### Arguments + +parser = argparse.ArgumentParser(description='Download artifacts from GitHub.') +parser.add_argument('-t', + '--token', + required=True, + help='The GitHub token with read access.') +parser.add_argument('-a', + '--artifactUrl', + required=True, + help='the artifact_url from GitHub.') +parser.add_argument('-f', + '--filename', + help='the filename, if not provided, will use the artifact name.') +parser.add_argument('-i', + '--ignoreErrors', + help='Ignore errors that can be ignored. Build state and number of artifacts.', + action="store_true") +parser.add_argument('-d', + '--directory', + default="", + help='the target directory, where files will be downloaded. If not provided the build number will be used to create a directory.') +parser.add_argument('-v', + '--verbose', + help="increase output verbosity.", + action="store_true") +parser.add_argument('-s', + '--simulate', + help="simulate action, do not create folder or download any file.", + action="store_true") + +args = parser.parse_args() + +if args.verbose: + print("Argument:") + print(args) + +# Split the artifact URL to get information +# Ex: https://github.com/vector-im/element-android/suites/9293388174/artifacts/435942121 +artifactUrl = args.artifactUrl +if not artifactUrl.startswith('https://github.com/'): + print("❌ Invalid parameter --artifactUrl %s. Must start with 'https://github.com/'" % artifactUrl) + exit(1) +if "/artifacts/" not in artifactUrl: + print("❌ Invalid parameter --artifactUrl %s. Must contain '/artifacts/'" % artifactUrl) + exit(1) +artifactItems = artifactUrl.split("/") +if len(artifactItems) != 9: + print("❌ Invalid parameter --artifactUrl %s. Please check the format." % (artifactUrl)) + exit(1) + +gitHubRepoOwner = artifactItems[3] +gitHubRepo = artifactItems[4] +artifactId = artifactItems[8] + +if args.verbose: + print("gitHubRepoOwner: %s, gitHubRepo: %s, artifactId: %s" % (gitHubRepoOwner, gitHubRepo, artifactId)) + +headers = { + 'Authorization': "Bearer %s" % args.token, + 'Accept': 'application/vnd.github+json' +} +base_url = "https://api.github.com/repos/%s/%s/actions/artifacts/%s" % (gitHubRepoOwner, gitHubRepo, artifactId) + +### Fetch build state + +print("Getting artifacts data of project '%s/%s' artifactId '%s'..." % (gitHubRepoOwner, gitHubRepo, artifactId)) + +if args.verbose: + print("Url: %s" % base_url) + +r = requests.get(base_url, headers=headers) +data = json.loads(r.content.decode()) + +if args.verbose: + print("Json data:") + print(data) + +if args.verbose: + print("Create subfolder %s to download artifacts..." % artifactId) + +if args.directory == "": + targetDir = artifactId +else: + targetDir = args.directory + +if not args.simulate: + os.makedirs(targetDir, exist_ok=True) + +url = data.get("archive_download_url") +if args.filename is not None: + filename = args.filename +else: + filename = data.get("name") + ".zip" + +## Print some info about the artifact origin +commitLink = "https://github.com/%s/%s/commit/%s" % (gitHubRepoOwner, gitHubRepo, data.get("workflow_run").get("head_sha")) +print("Preparing to download artifact `%s`, built from branch: `%s` (commit %s)" % (data.get("name"), data.get("workflow_run").get("head_branch"), commitLink)) + +if args.verbose: + print() + print("Artifact url: %s" % url) + +target = targetDir + "/" + filename +sizeInBytes = data.get("size_in_bytes") +print("Downloading %s to '%s' (file size is %s bytes, this may take a while)..." % (filename, targetDir, sizeInBytes)) +if not args.simulate: + # open file to write in binary mode + with open(target, "wb") as file: + # get request + response = requests.get(url, headers=headers) + # write to file + file.write(response.content) + print("Verifying file size...") + # get the file size + size = os.path.getsize(target) + if sizeInBytes != size: + # error = True + print("Warning, file size mismatch: expecting %s and get %s. This is just a warning for now..." % (sizeInBytes, size)) + +if error: + print("❌ Error(s) occurred, please check the log") + exit(1) +else: + print("Done!") From 451df7558dfcaaa502cc0a7655f11e0ec66da952 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 16 Nov 2022 17:41:41 +0100 Subject: [PATCH 092/173] Buildkite scripts can still be used. --- tools/install/installFromBuildkite.sh | 3 --- tools/release/download_buildkite_artifacts.py | 4 ---- 2 files changed, 7 deletions(-) diff --git a/tools/install/installFromBuildkite.sh b/tools/install/installFromBuildkite.sh index 759a2a0035..e47902e31b 100755 --- a/tools/install/installFromBuildkite.sh +++ b/tools/install/installFromBuildkite.sh @@ -3,9 +3,6 @@ # Exit on any error set -e -echo "Sorry, this script needs to be updated to download APKs from GitHub action. Buildkite is not building APKs anymore." -exit 1 - if [[ "$#" -ne 1 ]]; then echo "Usage: $0 BUILDKITE_TOKEN" >&2 exit 1 diff --git a/tools/release/download_buildkite_artifacts.py b/tools/release/download_buildkite_artifacts.py index 00f93a52e5..7a824be806 100755 --- a/tools/release/download_buildkite_artifacts.py +++ b/tools/release/download_buildkite_artifacts.py @@ -31,10 +31,6 @@ PIPELINE_SLUG = "element-android" ### Arguments -print("Sorry, this script needs to be updated to download APKs from GitHub action. Buildkite is not building APKs anymore.") -exit(1) - - parser = argparse.ArgumentParser(description='Download artifacts from Buildkite.') parser.add_argument('-t', '--token', From 211c0c2dc52e3988b9aa8154f5019fefbd49c263 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 24 Nov 2022 15:36:13 +0100 Subject: [PATCH 093/173] Add doc for script installFromGitHub.sh --- docs/installing_from_ci.md | 51 ++++++++++++++++++++++++++++++ tools/install/installFromGitHub.sh | 3 +- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 docs/installing_from_ci.md diff --git a/docs/installing_from_ci.md b/docs/installing_from_ci.md new file mode 100644 index 0000000000..42f4b56e28 --- /dev/null +++ b/docs/installing_from_ci.md @@ -0,0 +1,51 @@ +## Installing from CI + + + + * [Installing from Buildkite](#installing-from-buildkite) + * [Installing from GitHub](#installing-from-github) + * [Create a GitHub token](#create-a-github-token) + * [Provide artifact URL](#provide-artifact-url) + * [Next steps](#next-steps) + + + +Installing APK build by the CI is possible + +### Installing from Buildkite + +The script `./tools/install/installFromBuildkite.sh` can be used, but Builkite will be removed soon. See next section. + +### Installing from GitHub + +To install an APK built by a GitHub action, run the script `./tools/install/installFromGitHub.sh`. You will need to pass a GitHub token to do so. + +#### Create a GitHub token + +You can create a GitHub token going to your Github account, at this page: [https://github.com/settings/tokens](https://github.com/settings/tokens). + +You need to create a token (classic) with the scope `repo/public_repo`. So just check the corresponding checkbox. +Validity can be long since the scope of this token is limited. You will still be able to delete the token and generate a new one. +Click on Generate token and save the token locally. + +### Provide artifact URL + +The script will ask for an artifact URL. You can get this artifact URL by following these steps: + +- open the pull request +- in the check at the bottom, click on `APK Build / Build debug APKs` +- click on `Summary` +- scroll to the bottom of the page +- copy the link `vector-Fdroid-debug` if you want the F-Droid variant or `vector-Gplay-debug` if you want the Gplay variant. + +The copied link can be provided to the script. + +### Next steps + +The script will download the artifact, unzip it and install the correct version (regarding arch) on your device. + +Files will be added to the folder `./tmp/DebugApks`. Feel free to cleanup this folder from time to time, the script will not delete files. + +### Future improvement + +The script could ask the user for a Pull Request number and Gplay/Fdroid choice like it was done with Buildkite script. Using GitHub API may be possible to do that. diff --git a/tools/install/installFromGitHub.sh b/tools/install/installFromGitHub.sh index c6af9c376d..6928003773 100755 --- a/tools/install/installFromGitHub.sh +++ b/tools/install/installFromGitHub.sh @@ -4,7 +4,8 @@ set -e if [[ "$#" -ne 1 ]]; then - echo "Usage: $0 GitHub_token" >&2 + echo "Usage: $0 GitHub_Token" >&2 + echo "Read more about this script in the doc ./docs/installing_from_ci.md" exit 1 fi From deb4730d40017e28d2ff891ee045e5f0686c47b1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 24 Nov 2022 16:43:37 +0100 Subject: [PATCH 094/173] Add section about knit tool --- CONTRIBUTING.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e3c784dac..62762d7231 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,6 +126,23 @@ Note that you can run For ktlint to fix some detected errors for you (you still have to check and commit the fix of course) +#### knit + +[knit](https://github.com/Kotlin/kotlinx-knit) is a tool which checks markdown files on the project. Also it generates/updates the table of content (toc) of the markdown files. + +So everytime the toc should be updated, just run +

+./gradlew knit
+
+ +and commit the changes. + +The CI will check that markdown files are up to date by running + +
+./gradlew knitCheck
+
+ #### lint

From fa3b440a22d4c2cd618c8c05b45c40171dcac216 Mon Sep 17 00:00:00 2001
From: Benoit Marty 
Date: Thu, 24 Nov 2022 16:44:07 +0100
Subject: [PATCH 095/173] Run knit.

---
 CONTRIBUTING.md            | 1 +
 docs/installing_from_ci.md | 1 +
 2 files changed, 2 insertions(+)

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 62762d7231..40ae848415 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -13,6 +13,7 @@
   * [Code quality](#code-quality)
     * [Internal tool](#internal-tool)
     * [ktlint](#ktlint)
+    * [knit](#knit)
     * [lint](#lint)
   * [Unit tests](#unit-tests)
   * [Tests](#tests)
diff --git a/docs/installing_from_ci.md b/docs/installing_from_ci.md
index 42f4b56e28..01fb4afef2 100644
--- a/docs/installing_from_ci.md
+++ b/docs/installing_from_ci.md
@@ -7,6 +7,7 @@
     * [Create a GitHub token](#create-a-github-token)
   * [Provide artifact URL](#provide-artifact-url)
   * [Next steps](#next-steps)
+  * [Future improvement](#future-improvement)
 
 
 

From 492e8424106ecb2f895dea906f050f0c64467744 Mon Sep 17 00:00:00 2001
From: Benoit Marty 
Date: Thu, 24 Nov 2022 18:05:24 +0100
Subject: [PATCH 096/173] Fix the fixture.

---
 .../test/java/im/vector/app/test/fixtures/BuildMetaFixture.kt    | 1 -
 1 file changed, 1 deletion(-)

diff --git a/vector/src/test/java/im/vector/app/test/fixtures/BuildMetaFixture.kt b/vector/src/test/java/im/vector/app/test/fixtures/BuildMetaFixture.kt
index 4f4649106d..e4dee0e474 100644
--- a/vector/src/test/java/im/vector/app/test/fixtures/BuildMetaFixture.kt
+++ b/vector/src/test/java/im/vector/app/test/fixtures/BuildMetaFixture.kt
@@ -26,7 +26,6 @@ fun aBuildMeta() = BuildMeta(
         gitRevision = "abcdef",
         gitRevisionDate = "01-01-01",
         gitBranchName = "a-branch-name",
-        buildNumber = "100",
         flavorDescription = "Gplay",
         flavorShortDescription = "",
 )

From 18bcc83a4603bb15c750e5de3721ce80e585de62 Mon Sep 17 00:00:00 2001
From: Nikita Fedrunov <66663241+fedrunov@users.noreply.github.com>
Date: Fri, 25 Nov 2022 09:49:06 +0100
Subject: [PATCH 097/173] added read receipts for threads (#7474)

---
 changelog.d/6996.sdk                          |  1 +
 .../org/matrix/android/sdk/flow/FlowRoom.kt   |  4 +-
 .../sdk/api/session/room/model/ReadReceipt.kt |  3 +-
 .../sdk/api/session/room/read/ReadService.kt  | 14 +++--
 .../database/helper/ChunkEntityHelper.kt      |  2 +-
 .../database/helper/ThreadEventsHelper.kt     | 58 +++++++++----------
 .../mapper/ReadReceiptsSummaryMapper.kt       |  2 +-
 .../database/model/ReadReceiptEntity.kt       |  1 +
 .../database/model/TimelineEventEntity.kt     |  5 ++
 .../internal/database/query/ReadQueries.kt    | 26 ++++++---
 .../query/ReadReceiptEntityQueries.kt         | 38 +++++++++---
 .../sdk/internal/session/room/RoomAPI.kt      |  3 +-
 .../session/room/read/DefaultReadService.kt   | 34 ++++++++---
 .../internal/session/room/read/ReadBody.kt    | 25 ++++++++
 .../session/room/read/SetReadMarkersTask.kt   | 22 +++++--
 .../room/summary/RoomSummaryUpdater.kt        |  8 ++-
 .../session/room/timeline/DefaultTimeline.kt  |  2 +-
 .../sync/handler/room/ReadReceiptHandler.kt   | 26 ++++++---
 .../home/room/detail/TimelineViewModel.kt     |  7 ++-
 .../timeline/TimelineEventController.kt       | 13 ++++-
 .../factory/ReadReceiptsItemFactory.kt        | 11 +++-
 .../NotificationBroadcastReceiver.kt          |  2 +-
 22 files changed, 214 insertions(+), 93 deletions(-)
 create mode 100644 changelog.d/6996.sdk
 create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/ReadBody.kt

diff --git a/changelog.d/6996.sdk b/changelog.d/6996.sdk
new file mode 100644
index 0000000000..588ec160d7
--- /dev/null
+++ b/changelog.d/6996.sdk
@@ -0,0 +1 @@
+Added support for read receipts in threads. Now user in a room can have multiple read receipts (one per thread + one in main thread + one without threadId)
diff --git a/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt b/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt
index a6b4cc98a6..7ad342b22f 100644
--- a/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt
+++ b/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt
@@ -100,8 +100,8 @@ class FlowRoom(private val room: Room) {
         return room.readService().getReadMarkerLive().asFlow()
     }
 
-    fun liveReadReceipt(): Flow> {
-        return room.readService().getMyReadReceiptLive().asFlow()
+    fun liveReadReceipt(threadId: String?): Flow> {
+        return room.readService().getMyReadReceiptLive(threadId).asFlow()
     }
 
     fun liveEventReadReceipts(eventId: String): Flow> {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/ReadReceipt.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/ReadReceipt.kt
index 5639730219..da7e4ea928 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/ReadReceipt.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/ReadReceipt.kt
@@ -18,5 +18,6 @@ package org.matrix.android.sdk.api.session.room.model
 
 data class ReadReceipt(
         val roomMember: RoomMemberSummary,
-        val originServerTs: Long
+        val originServerTs: Long,
+        val threadId: String?
 )
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/read/ReadService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/read/ReadService.kt
index dac1a1a773..83680ec2d8 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/read/ReadService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/read/ReadService.kt
@@ -34,12 +34,14 @@ interface ReadService {
     /**
      * Force the read marker to be set on the latest event.
      */
-    suspend fun markAsRead(params: MarkAsReadParams = MarkAsReadParams.BOTH)
+    suspend fun markAsRead(params: MarkAsReadParams = MarkAsReadParams.BOTH, mainTimeLineOnly: Boolean = true)
 
     /**
      * Set the read receipt on the event with provided eventId.
+     * @param eventId the id of the event where read receipt will be set
+     * @param threadId the id of the thread in which read receipt will be set. For main thread use [ReadService.THREAD_ID_MAIN] constant
      */
-    suspend fun setReadReceipt(eventId: String)
+    suspend fun setReadReceipt(eventId: String, threadId: String)
 
     /**
      * Set the read marker on the event with provided eventId.
@@ -59,10 +61,10 @@ interface ReadService {
     /**
      * Returns a live read receipt id for the room.
      */
-    fun getMyReadReceiptLive(): LiveData>
+    fun getMyReadReceiptLive(threadId: String?): LiveData>
 
     /**
-     * Get the eventId where the read receipt for the provided user is.
+     * Get the eventId from the main timeline where the read receipt for the provided user is.
      * @param userId the id of the user to look for
      *
      * @return the eventId where the read receipt for the provided user is attached, or null if not found
@@ -74,4 +76,8 @@ interface ReadService {
      * @param eventId the event
      */
     fun getEventReadReceiptsLive(eventId: String): LiveData>
+
+    companion object {
+        const val THREAD_ID_MAIN = "main"
+    }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt
index 149a2eebfe..43f84e771a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt
@@ -132,7 +132,7 @@ private fun handleReadReceipts(realm: Realm, roomId: String, eventEntity: EventE
     val originServerTs = eventEntity.originServerTs
     if (originServerTs != null) {
         val timestampOfEvent = originServerTs.toDouble()
-        val readReceiptOfSender = ReadReceiptEntity.getOrCreate(realm, roomId = roomId, userId = senderId)
+        val readReceiptOfSender = ReadReceiptEntity.getOrCreate(realm, roomId = roomId, userId = senderId, threadId = eventEntity.rootThreadEventId)
         // If the synced RR is older, update
         if (timestampOfEvent > readReceiptOfSender.originServerTs) {
             val previousReceiptsSummary = ReadReceiptsSummaryEntity.where(realm, eventId = readReceiptOfSender.eventId).findFirst()
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt
index dfac7f6708..7999a2ea14 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt
@@ -65,11 +65,11 @@ internal fun Map.updateThreadSummaryIfNeeded(
                     inThreadMessages = inThreadMessages,
                     latestMessageTimelineEventEntity = latestEventInThread
             )
-        }
-    }
 
-    if (shouldUpdateNotifications) {
-        updateNotificationsNew(roomId, realm, currentUserId)
+            if (shouldUpdateNotifications) {
+                updateThreadNotifications(roomId, realm, currentUserId, rootThreadEventId)
+            }
+        }
     }
 }
 
@@ -273,8 +273,8 @@ internal fun TimelineEventEntity.Companion.isUserMentionedInThread(realm: Realm,
 /**
  * Find the read receipt for the current user.
  */
-internal fun findMyReadReceipt(realm: Realm, roomId: String, userId: String): String? =
-        ReadReceiptEntity.where(realm, roomId = roomId, userId = userId)
+internal fun findMyReadReceipt(realm: Realm, roomId: String, userId: String, threadId: String?): String? =
+        ReadReceiptEntity.where(realm, roomId = roomId, userId = userId, threadId = threadId)
                 .findFirst()
                 ?.eventId
 
@@ -293,28 +293,29 @@ internal fun isUserMentioned(currentUserId: String, timelineEventEntity: Timelin
  * Important: It will work only with the latest chunk, while read marker will be changed
  * immediately so we should not display wrong notifications
  */
-internal fun updateNotificationsNew(roomId: String, realm: Realm, currentUserId: String) {
-    val readReceipt = findMyReadReceipt(realm, roomId, currentUserId) ?: return
+internal fun updateThreadNotifications(roomId: String, realm: Realm, currentUserId: String, rootThreadEventId: String) {
+    val readReceipt = findMyReadReceipt(realm, roomId, currentUserId, threadId = rootThreadEventId) ?: return
 
     val readReceiptChunk = ChunkEntity
             .findIncludingEvent(realm, readReceipt) ?: return
 
-    val readReceiptChunkTimelineEvents = readReceiptChunk
+    val readReceiptChunkThreadEvents = readReceiptChunk
             .timelineEvents
             .where()
             .equalTo(TimelineEventEntityFields.ROOM_ID, roomId)
+            .equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId)
             .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.ASCENDING)
             .findAll() ?: return
 
-    val readReceiptChunkPosition = readReceiptChunkTimelineEvents.indexOfFirst { it.eventId == readReceipt }
+    val readReceiptChunkPosition = readReceiptChunkThreadEvents.indexOfFirst { it.eventId == readReceipt }
 
     if (readReceiptChunkPosition == -1) return
 
-    if (readReceiptChunkPosition < readReceiptChunkTimelineEvents.lastIndex) {
+    if (readReceiptChunkPosition < readReceiptChunkThreadEvents.lastIndex) {
         // If the read receipt is found inside the chunk
 
-        val threadEventsAfterReadReceipt = readReceiptChunkTimelineEvents
-                .slice(readReceiptChunkPosition..readReceiptChunkTimelineEvents.lastIndex)
+        val threadEventsAfterReadReceipt = readReceiptChunkThreadEvents
+                .slice(readReceiptChunkPosition..readReceiptChunkThreadEvents.lastIndex)
                 .filter { it.root?.isThread() == true }
 
         // In order for the below code to work for old events, we should save the previous read receipt
@@ -343,26 +344,21 @@ internal fun updateNotificationsNew(roomId: String, realm: Realm, currentUserId:
                     it.root?.rootThreadEventId
                 }
 
-        // Find the root events in the new thread events
-        val rootThreads = threadEventsAfterReadReceipt.distinctBy { it.root?.rootThreadEventId }.mapNotNull { it.root?.rootThreadEventId }
+        // Update root thread event only if the user have participated in
+        val isUserParticipating = TimelineEventEntity.isUserParticipatingInThread(
+                realm = realm,
+                roomId = roomId,
+                rootThreadEventId = rootThreadEventId,
+                senderId = currentUserId
+        )
+        val rootThreadEventEntity = EventEntity.where(realm, rootThreadEventId).findFirst()
 
-        // Update root thread events only if the user have participated in
-        rootThreads.forEach { eventId ->
-            val isUserParticipating = TimelineEventEntity.isUserParticipatingInThread(
-                    realm = realm,
-                    roomId = roomId,
-                    rootThreadEventId = eventId,
-                    senderId = currentUserId
-            )
-            val rootThreadEventEntity = EventEntity.where(realm, eventId).findFirst()
+        if (isUserParticipating) {
+            rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_MESSAGE
+        }
 
-            if (isUserParticipating) {
-                rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_MESSAGE
-            }
-
-            if (userMentionsList.contains(eventId)) {
-                rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE
-            }
+        if (userMentionsList.contains(rootThreadEventId)) {
+            rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE
         }
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ReadReceiptsSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ReadReceiptsSummaryMapper.kt
index 2be4510b6f..3b71ae3dea 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ReadReceiptsSummaryMapper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ReadReceiptsSummaryMapper.kt
@@ -50,7 +50,7 @@ internal class ReadReceiptsSummaryMapper @Inject constructor(
                 .mapNotNull {
                     val roomMember = RoomMemberSummaryEntity.where(realm, roomId = it.roomId, userId = it.userId).findFirst()
                             ?: return@mapNotNull null
-                    ReadReceipt(roomMember.asDomain(), it.originServerTs.toLong())
+                    ReadReceipt(roomMember.asDomain(), it.originServerTs.toLong(), it.threadId)
                 }
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ReadReceiptEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ReadReceiptEntity.kt
index 9623c95359..cedd5e7424 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ReadReceiptEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ReadReceiptEntity.kt
@@ -26,6 +26,7 @@ internal open class ReadReceiptEntity(
         var eventId: String = "",
         var roomId: String = "",
         var userId: String = "",
+        var threadId: String? = null,
         var originServerTs: Double = 0.0
 ) : RealmObject() {
     companion object
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt
index c8f22dc2cc..1deca47b70 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt
@@ -20,6 +20,7 @@ import io.realm.RealmObject
 import io.realm.RealmResults
 import io.realm.annotations.Index
 import io.realm.annotations.LinkingObjects
+import org.matrix.android.sdk.api.session.room.read.ReadService
 import org.matrix.android.sdk.internal.extensions.assertIsManaged
 
 internal open class TimelineEventEntity(
@@ -52,3 +53,7 @@ internal fun TimelineEventEntity.deleteOnCascade(canDeleteRoot: Boolean) {
     }
     deleteFromRealm()
 }
+
+internal fun TimelineEventEntity.getThreadId(): String {
+    return root?.rootThreadEventId ?: ReadService.THREAD_ID_MAIN
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt
index 0b0f01a67d..ebfe23105e 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt
@@ -18,17 +18,20 @@ package org.matrix.android.sdk.internal.database.query
 import io.realm.Realm
 import io.realm.RealmConfiguration
 import org.matrix.android.sdk.api.session.events.model.LocalEcho
+import org.matrix.android.sdk.api.session.room.read.ReadService
 import org.matrix.android.sdk.internal.database.helper.isMoreRecentThan
 import org.matrix.android.sdk.internal.database.model.ChunkEntity
 import org.matrix.android.sdk.internal.database.model.ReadMarkerEntity
 import org.matrix.android.sdk.internal.database.model.ReadReceiptEntity
 import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
+import org.matrix.android.sdk.internal.database.model.getThreadId
 
 internal fun isEventRead(
         realmConfiguration: RealmConfiguration,
         userId: String?,
         roomId: String?,
-        eventId: String?
+        eventId: String?,
+        shouldCheckIfReadInEventsThread: Boolean
 ): Boolean {
     if (userId.isNullOrBlank() || roomId.isNullOrBlank() || eventId.isNullOrBlank()) {
         return false
@@ -45,7 +48,8 @@ internal fun isEventRead(
             eventToCheck.root?.sender == userId -> true
             // If new event exists and the latest event is from ourselves we can infer the event is read
             latestEventIsFromSelf(realm, roomId, userId) -> true
-            eventToCheck.isBeforeLatestReadReceipt(realm, roomId, userId) -> true
+            eventToCheck.isBeforeLatestReadReceipt(realm, roomId, userId, null) -> true
+            (shouldCheckIfReadInEventsThread && eventToCheck.isBeforeLatestReadReceipt(realm, roomId, userId, eventToCheck.getThreadId())) -> true
             else -> false
         }
     }
@@ -54,27 +58,33 @@ internal fun isEventRead(
 private fun latestEventIsFromSelf(realm: Realm, roomId: String, userId: String) = TimelineEventEntity.latestEvent(realm, roomId, true)
         ?.root?.sender == userId
 
-private fun TimelineEventEntity.isBeforeLatestReadReceipt(realm: Realm, roomId: String, userId: String): Boolean {
-    return ReadReceiptEntity.where(realm, roomId, userId).findFirst()?.let { readReceipt ->
+private fun TimelineEventEntity.isBeforeLatestReadReceipt(realm: Realm, roomId: String, userId: String, threadId: String?): Boolean {
+    val isMoreRecent = ReadReceiptEntity.where(realm, roomId, userId, threadId).findFirst()?.let { readReceipt ->
         val readReceiptEvent = TimelineEventEntity.where(realm, roomId, readReceipt.eventId).findFirst()
         readReceiptEvent?.isMoreRecentThan(this)
     } ?: false
+    return isMoreRecent
 }
 
 /**
  * Missing events can be caused by the latest timeline chunk no longer contain an older event or
  * by fast lane eagerly displaying events before the database has finished updating.
  */
-private fun hasReadMissingEvent(realm: Realm, latestChunkEntity: ChunkEntity, roomId: String, userId: String, eventId: String): Boolean {
-    return realm.doesEventExistInChunkHistory(eventId) && realm.hasReadReceiptInLatestChunk(latestChunkEntity, roomId, userId)
+private fun hasReadMissingEvent(realm: Realm,
+                                latestChunkEntity: ChunkEntity,
+                                roomId: String,
+                                userId: String,
+                                eventId: String,
+                                threadId: String? = ReadService.THREAD_ID_MAIN): Boolean {
+    return realm.doesEventExistInChunkHistory(eventId) && realm.hasReadReceiptInLatestChunk(latestChunkEntity, roomId, userId, threadId)
 }
 
 private fun Realm.doesEventExistInChunkHistory(eventId: String): Boolean {
     return ChunkEntity.findIncludingEvent(this, eventId) != null
 }
 
-private fun Realm.hasReadReceiptInLatestChunk(latestChunkEntity: ChunkEntity, roomId: String, userId: String): Boolean {
-    return ReadReceiptEntity.where(this, roomId = roomId, userId = userId).findFirst()?.let {
+private fun Realm.hasReadReceiptInLatestChunk(latestChunkEntity: ChunkEntity, roomId: String, userId: String, threadId: String?): Boolean {
+    return ReadReceiptEntity.where(this, roomId = roomId, userId = userId, threadId = threadId).findFirst()?.let {
         latestChunkEntity.timelineEvents.find(it.eventId)
     } != null
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadReceiptEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadReceiptEntityQueries.kt
index 170814d3f2..0f9f56b938 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadReceiptEntityQueries.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadReceiptEntityQueries.kt
@@ -20,12 +20,20 @@ import io.realm.Realm
 import io.realm.RealmQuery
 import io.realm.kotlin.createObject
 import io.realm.kotlin.where
+import org.matrix.android.sdk.api.session.room.read.ReadService
 import org.matrix.android.sdk.internal.database.model.ReadReceiptEntity
 import org.matrix.android.sdk.internal.database.model.ReadReceiptEntityFields
 
-internal fun ReadReceiptEntity.Companion.where(realm: Realm, roomId: String, userId: String): RealmQuery {
+internal fun ReadReceiptEntity.Companion.where(realm: Realm, roomId: String, userId: String, threadId: String?): RealmQuery {
     return realm.where()
-            .equalTo(ReadReceiptEntityFields.PRIMARY_KEY, buildPrimaryKey(roomId, userId))
+            .equalTo(ReadReceiptEntityFields.PRIMARY_KEY, buildPrimaryKey(roomId, userId, threadId))
+}
+
+internal fun ReadReceiptEntity.Companion.forMainTimelineWhere(realm: Realm, roomId: String, userId: String): RealmQuery {
+    return realm.where()
+            .equalTo(ReadReceiptEntityFields.PRIMARY_KEY, buildPrimaryKey(roomId, userId, ReadService.THREAD_ID_MAIN))
+            .or()
+            .equalTo(ReadReceiptEntityFields.PRIMARY_KEY, buildPrimaryKey(roomId, userId, null))
 }
 
 internal fun ReadReceiptEntity.Companion.whereUserId(realm: Realm, userId: String): RealmQuery {
@@ -38,23 +46,37 @@ internal fun ReadReceiptEntity.Companion.whereRoomId(realm: Realm, roomId: Strin
             .equalTo(ReadReceiptEntityFields.ROOM_ID, roomId)
 }
 
-internal fun ReadReceiptEntity.Companion.createUnmanaged(roomId: String, eventId: String, userId: String, originServerTs: Double): ReadReceiptEntity {
+internal fun ReadReceiptEntity.Companion.createUnmanaged(
+        roomId: String,
+        eventId: String,
+        userId: String,
+        threadId: String?,
+        originServerTs: Double
+): ReadReceiptEntity {
     return ReadReceiptEntity().apply {
-        this.primaryKey = "${roomId}_$userId"
+        this.primaryKey = buildPrimaryKey(roomId, userId, threadId)
         this.eventId = eventId
         this.roomId = roomId
         this.userId = userId
+        this.threadId = threadId
         this.originServerTs = originServerTs
     }
 }
 
-internal fun ReadReceiptEntity.Companion.getOrCreate(realm: Realm, roomId: String, userId: String): ReadReceiptEntity {
-    return ReadReceiptEntity.where(realm, roomId, userId).findFirst()
-            ?: realm.createObject(buildPrimaryKey(roomId, userId))
+internal fun ReadReceiptEntity.Companion.getOrCreate(realm: Realm, roomId: String, userId: String, threadId: String?): ReadReceiptEntity {
+    return ReadReceiptEntity.where(realm, roomId, userId, threadId).findFirst()
+            ?: realm.createObject(buildPrimaryKey(roomId, userId, threadId))
                     .apply {
                         this.roomId = roomId
                         this.userId = userId
+                        this.threadId = threadId
                     }
 }
 
-private fun buildPrimaryKey(roomId: String, userId: String) = "${roomId}_$userId"
+private fun buildPrimaryKey(roomId: String, userId: String, threadId: String?): String {
+    return if (threadId == null) {
+        "${roomId}_${userId}"
+    } else {
+        "${roomId}_${userId}_${threadId}"
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt
index 9bcb7b8e4c..31bed90b62 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt
@@ -33,6 +33,7 @@ import org.matrix.android.sdk.internal.session.room.membership.RoomMembersRespon
 import org.matrix.android.sdk.internal.session.room.membership.admin.UserIdAndReason
 import org.matrix.android.sdk.internal.session.room.membership.joining.InviteBody
 import org.matrix.android.sdk.internal.session.room.membership.threepid.ThreePidInviteBody
+import org.matrix.android.sdk.internal.session.room.read.ReadBody
 import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse
 import org.matrix.android.sdk.internal.session.room.reporting.ReportContentBody
 import org.matrix.android.sdk.internal.session.room.send.SendResponse
@@ -173,7 +174,7 @@ internal interface RoomAPI {
             @Path("roomId") roomId: String,
             @Path("receiptType") receiptType: String,
             @Path("eventId") eventId: String,
-            @Body body: JsonDict = emptyMap()
+            @Body body: ReadBody
     )
 
     /**
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/DefaultReadService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/DefaultReadService.kt
index b30c66c82e..36ec5e8dac 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/DefaultReadService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/DefaultReadService.kt
@@ -30,17 +30,20 @@ import org.matrix.android.sdk.internal.database.mapper.ReadReceiptsSummaryMapper
 import org.matrix.android.sdk.internal.database.model.ReadMarkerEntity
 import org.matrix.android.sdk.internal.database.model.ReadReceiptEntity
 import org.matrix.android.sdk.internal.database.model.ReadReceiptsSummaryEntity
+import org.matrix.android.sdk.internal.database.query.forMainTimelineWhere
 import org.matrix.android.sdk.internal.database.query.isEventRead
 import org.matrix.android.sdk.internal.database.query.where
 import org.matrix.android.sdk.internal.di.SessionDatabase
 import org.matrix.android.sdk.internal.di.UserId
+import org.matrix.android.sdk.internal.session.homeserver.HomeServerCapabilitiesDataSource
 
 internal class DefaultReadService @AssistedInject constructor(
         @Assisted private val roomId: String,
         @SessionDatabase private val monarchy: Monarchy,
         private val setReadMarkersTask: SetReadMarkersTask,
         private val readReceiptsSummaryMapper: ReadReceiptsSummaryMapper,
-        @UserId private val userId: String
+        @UserId private val userId: String,
+        private val homeServerCapabilitiesDataSource: HomeServerCapabilitiesDataSource
 ) : ReadService {
 
     @AssistedFactory
@@ -48,17 +51,28 @@ internal class DefaultReadService @AssistedInject constructor(
         fun create(roomId: String): DefaultReadService
     }
 
-    override suspend fun markAsRead(params: ReadService.MarkAsReadParams) {
+    override suspend fun markAsRead(params: ReadService.MarkAsReadParams, mainTimeLineOnly: Boolean) {
+        val readReceiptThreadId = if (homeServerCapabilitiesDataSource.getHomeServerCapabilities()?.canUseThreadReadReceiptsAndNotifications == true) {
+            if (mainTimeLineOnly) ReadService.THREAD_ID_MAIN else null
+        } else {
+            null
+        }
         val taskParams = SetReadMarkersTask.Params(
                 roomId = roomId,
                 forceReadMarker = params.forceReadMarker(),
-                forceReadReceipt = params.forceReadReceipt()
+                forceReadReceipt = params.forceReadReceipt(),
+                readReceiptThreadId = readReceiptThreadId
         )
         setReadMarkersTask.execute(taskParams)
     }
 
-    override suspend fun setReadReceipt(eventId: String) {
-        val params = SetReadMarkersTask.Params(roomId, fullyReadEventId = null, readReceiptEventId = eventId)
+    override suspend fun setReadReceipt(eventId: String, threadId: String) {
+        val readReceiptThreadId = if (homeServerCapabilitiesDataSource.getHomeServerCapabilities()?.canUseThreadReadReceiptsAndNotifications == true) {
+            threadId
+        } else {
+            null
+        }
+        val params = SetReadMarkersTask.Params(roomId, fullyReadEventId = null, readReceiptEventId = eventId, readReceiptThreadId = readReceiptThreadId)
         setReadMarkersTask.execute(params)
     }
 
@@ -68,7 +82,8 @@ internal class DefaultReadService @AssistedInject constructor(
     }
 
     override fun isEventRead(eventId: String): Boolean {
-        return isEventRead(monarchy.realmConfiguration, userId, roomId, eventId)
+        val shouldCheckIfReadInEventsThread = homeServerCapabilitiesDataSource.getHomeServerCapabilities()?.canUseThreadReadReceiptsAndNotifications == true
+        return isEventRead(monarchy.realmConfiguration, userId, roomId, eventId, shouldCheckIfReadInEventsThread)
     }
 
     override fun getReadMarkerLive(): LiveData> {
@@ -81,9 +96,9 @@ internal class DefaultReadService @AssistedInject constructor(
         }
     }
 
-    override fun getMyReadReceiptLive(): LiveData> {
+    override fun getMyReadReceiptLive(threadId: String?): LiveData> {
         val liveRealmData = monarchy.findAllMappedWithChanges(
-                { ReadReceiptEntity.where(it, roomId = roomId, userId = userId) },
+                { ReadReceiptEntity.where(it, roomId = roomId, userId = userId, threadId = threadId) },
                 { it.eventId }
         )
         return Transformations.map(liveRealmData) {
@@ -94,10 +109,11 @@ internal class DefaultReadService @AssistedInject constructor(
     override fun getUserReadReceipt(userId: String): String? {
         var eventId: String? = null
         monarchy.doWithRealm {
-            eventId = ReadReceiptEntity.where(it, roomId = roomId, userId = userId)
+            eventId = ReadReceiptEntity.forMainTimelineWhere(it, roomId = roomId, userId = userId)
                     .findFirst()
                     ?.eventId
         }
+
         return eventId
     }
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/ReadBody.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/ReadBody.kt
new file mode 100644
index 0000000000..9374de5d5f
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/ReadBody.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2020 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.internal.session.room.read
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+internal data class ReadBody(
+        @Json(name = "thread_id") val threadId: String?,
+)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/SetReadMarkersTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/SetReadMarkersTask.kt
index a124a8a4c2..8e7592a8b4 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/SetReadMarkersTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/SetReadMarkersTask.kt
@@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.session.room.read
 import com.zhuinden.monarchy.Monarchy
 import io.realm.Realm
 import org.matrix.android.sdk.api.session.events.model.LocalEcho
+import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
 import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity
 import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
 import org.matrix.android.sdk.internal.database.query.isEventRead
@@ -45,8 +46,9 @@ internal interface SetReadMarkersTask : Task {
             val roomId: String,
             val fullyReadEventId: String? = null,
             val readReceiptEventId: String? = null,
+            val readReceiptThreadId: String? = null,
             val forceReadReceipt: Boolean = false,
-            val forceReadMarker: Boolean = false
+            val forceReadMarker: Boolean = false,
     )
 }
 
@@ -61,12 +63,14 @@ internal class DefaultSetReadMarkersTask @Inject constructor(
         @UserId private val userId: String,
         private val globalErrorReceiver: GlobalErrorReceiver,
         private val clock: Clock,
+        private val homeServerCapabilitiesService: HomeServerCapabilitiesService,
 ) : SetReadMarkersTask {
 
     override suspend fun execute(params: SetReadMarkersTask.Params) {
         val markers = mutableMapOf()
         Timber.v("Execute set read marker with params: $params")
         val latestSyncedEventId = latestSyncedEventId(params.roomId)
+        val readReceiptThreadId = params.readReceiptThreadId
         val fullyReadEventId = if (params.forceReadMarker) {
             latestSyncedEventId
         } else {
@@ -77,6 +81,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(
         } else {
             params.readReceiptEventId
         }
+
         if (fullyReadEventId != null && !isReadMarkerMoreRecent(monarchy.realmConfiguration, params.roomId, fullyReadEventId)) {
             if (LocalEcho.isLocalEchoId(fullyReadEventId)) {
                 Timber.w("Can't set read marker for local event $fullyReadEventId")
@@ -84,8 +89,12 @@ internal class DefaultSetReadMarkersTask @Inject constructor(
                 markers[READ_MARKER] = fullyReadEventId
             }
         }
+
+        val shouldCheckIfReadInEventsThread = readReceiptThreadId != null &&
+                homeServerCapabilitiesService.getHomeServerCapabilities().canUseThreadReadReceiptsAndNotifications
+
         if (readReceiptEventId != null &&
-                !isEventRead(monarchy.realmConfiguration, userId, params.roomId, readReceiptEventId)) {
+                !isEventRead(monarchy.realmConfiguration, userId, params.roomId, readReceiptEventId, shouldCheckIfReadInEventsThread)) {
             if (LocalEcho.isLocalEchoId(readReceiptEventId)) {
                 Timber.w("Can't set read receipt for local event $readReceiptEventId")
             } else {
@@ -95,7 +104,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(
 
         val shouldUpdateRoomSummary = readReceiptEventId != null && readReceiptEventId == latestSyncedEventId
         if (markers.isNotEmpty() || shouldUpdateRoomSummary) {
-            updateDatabase(params.roomId, markers, shouldUpdateRoomSummary)
+            updateDatabase(params.roomId, readReceiptThreadId, markers, shouldUpdateRoomSummary)
         }
         if (markers.isNotEmpty()) {
             executeRequest(
@@ -104,7 +113,8 @@ internal class DefaultSetReadMarkersTask @Inject constructor(
             ) {
                 if (markers[READ_MARKER] == null) {
                     if (readReceiptEventId != null) {
-                        roomAPI.sendReceipt(params.roomId, READ_RECEIPT, readReceiptEventId)
+                        val readBody = ReadBody(threadId = params.readReceiptThreadId)
+                        roomAPI.sendReceipt(params.roomId, READ_RECEIPT, readReceiptEventId, readBody)
                     }
                 } else {
                     // "m.fully_read" value is mandatory to make this call
@@ -119,7 +129,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(
                 TimelineEventEntity.latestEvent(realm, roomId = roomId, includesSending = false)?.eventId
             }
 
-    private suspend fun updateDatabase(roomId: String, markers: Map, shouldUpdateRoomSummary: Boolean) {
+    private suspend fun updateDatabase(roomId: String, readReceiptThreadId: String?, markers: Map, shouldUpdateRoomSummary: Boolean) {
         monarchy.awaitTransaction { realm ->
             val readMarkerId = markers[READ_MARKER]
             val readReceiptId = markers[READ_RECEIPT]
@@ -127,7 +137,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(
                 roomFullyReadHandler.handle(realm, roomId, FullyReadContent(readMarkerId))
             }
             if (readReceiptId != null) {
-                val readReceiptContent = ReadReceiptHandler.createContent(userId, readReceiptId, clock.epochMillis())
+                val readReceiptContent = ReadReceiptHandler.createContent(userId, readReceiptId, readReceiptThreadId, clock.epochMillis())
                 readReceiptHandler.handle(realm, roomId, readReceiptContent, false, null)
             }
             if (shouldUpdateRoomSummary) {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt
index 7c83a4afa7..21a0862c65 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt
@@ -24,6 +24,7 @@ import org.matrix.android.sdk.api.extensions.tryOrNull
 import org.matrix.android.sdk.api.session.events.model.EventType
 import org.matrix.android.sdk.api.session.events.model.content.EncryptionEventContent
 import org.matrix.android.sdk.api.session.events.model.toModel
+import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
 import org.matrix.android.sdk.api.session.room.accountdata.RoomAccountDataTypes
 import org.matrix.android.sdk.api.session.room.model.Membership
 import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent
@@ -75,7 +76,8 @@ internal class RoomSummaryUpdater @Inject constructor(
         private val roomAvatarResolver: RoomAvatarResolver,
         private val eventDecryptor: EventDecryptor,
         private val crossSigningService: DefaultCrossSigningService,
-        private val roomAccountDataDataSource: RoomAccountDataDataSource
+        private val roomAccountDataDataSource: RoomAccountDataDataSource,
+        private val homeServerCapabilitiesService: HomeServerCapabilitiesService,
 ) {
 
     fun refreshLatestPreviewContent(realm: Realm, roomId: String) {
@@ -151,9 +153,11 @@ internal class RoomSummaryUpdater @Inject constructor(
             latestPreviewableEvent.attemptToDecrypt()
         }
 
+        val shouldCheckIfReadInEventsThread = homeServerCapabilitiesService.getHomeServerCapabilities().canUseThreadReadReceiptsAndNotifications
+
         roomSummaryEntity.hasUnreadMessages = roomSummaryEntity.notificationCount > 0 ||
                 // avoid this call if we are sure there are unread events
-                latestPreviewableEvent?.let { !isEventRead(realm.configuration, userId, roomId, it.eventId) } ?: false
+                latestPreviewableEvent?.let { !isEventRead(realm.configuration, userId, roomId, it.eventId, shouldCheckIfReadInEventsThread) } ?: false
 
         roomSummaryEntity.setDisplayName(roomDisplayNameResolver.resolve(realm, roomId))
         roomSummaryEntity.avatarUrl = roomAvatarResolver.resolve(realm, roomId)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt
index c380ccf14f..0854cc5cf4 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt
@@ -411,7 +411,7 @@ internal class DefaultTimeline(
     private fun ensureReadReceiptAreLoaded(realm: Realm) {
         readReceiptHandler.getContentFromInitSync(roomId)
                 ?.also {
-                    Timber.w("INIT_SYNC Insert when opening timeline RR for room $roomId")
+                    Timber.d("INIT_SYNC Insert when opening timeline RR for room $roomId")
                 }
                 ?.let { readReceiptContent ->
                     realm.executeTransactionAsync {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ReadReceiptHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ReadReceiptHandler.kt
index 7329611a01..7f12ce653c 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ReadReceiptHandler.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ReadReceiptHandler.kt
@@ -33,10 +33,11 @@ import javax.inject.Inject
 // value : dict key $UserId
 //              value dict key ts
 //                    dict value ts value
-internal typealias ReadReceiptContent = Map>>>
+internal typealias ReadReceiptContent = Map>>>
 
 private const val READ_KEY = "m.read"
 private const val TIMESTAMP_KEY = "ts"
+private const val THREAD_ID_KEY = "thread_id"
 
 internal class ReadReceiptHandler @Inject constructor(
         private val roomSyncEphemeralTemporaryStore: RoomSyncEphemeralTemporaryStore
@@ -47,14 +48,19 @@ internal class ReadReceiptHandler @Inject constructor(
         fun createContent(
                 userId: String,
                 eventId: String,
+                threadId: String?,
                 currentTimeMillis: Long
         ): ReadReceiptContent {
+            val userReadReceipt = mutableMapOf(
+                    TIMESTAMP_KEY to currentTimeMillis.toDouble(),
+            )
+            threadId?.let {
+                userReadReceipt.put(THREAD_ID_KEY, threadId)
+            }
             return mapOf(
                     eventId to mapOf(
                             READ_KEY to mapOf(
-                                    userId to mapOf(
-                                            TIMESTAMP_KEY to currentTimeMillis.toDouble()
-                                    )
+                                    userId to userReadReceipt
                             )
                     )
             )
@@ -98,8 +104,9 @@ internal class ReadReceiptHandler @Inject constructor(
             val readReceiptsSummary = ReadReceiptsSummaryEntity(eventId = eventId, roomId = roomId)
 
             for ((userId, paramsDict) in userIdsDict) {
-                val ts = paramsDict[TIMESTAMP_KEY] ?: 0.0
-                val receiptEntity = ReadReceiptEntity.createUnmanaged(roomId, eventId, userId, ts)
+                val ts = paramsDict[TIMESTAMP_KEY] as? Double ?: 0.0
+                val threadId = paramsDict[THREAD_ID_KEY] as String?
+                val receiptEntity = ReadReceiptEntity.createUnmanaged(roomId, eventId, userId, threadId, ts)
                 readReceiptsSummary.readReceipts.add(receiptEntity)
             }
             readReceiptSummaries.add(readReceiptsSummary)
@@ -115,7 +122,7 @@ internal class ReadReceiptHandler @Inject constructor(
     ) {
         // First check if we have data from init sync to handle
         getContentFromInitSync(roomId)?.let {
-            Timber.w("INIT_SYNC Insert during incremental sync RR for room $roomId")
+            Timber.d("INIT_SYNC Insert during incremental sync RR for room $roomId")
             doIncrementalSyncStrategy(realm, roomId, it)
             aggregator?.ephemeralFilesToDelete?.add(roomId)
         }
@@ -132,8 +139,9 @@ internal class ReadReceiptHandler @Inject constructor(
                     }
 
             for ((userId, paramsDict) in userIdsDict) {
-                val ts = paramsDict[TIMESTAMP_KEY] ?: 0.0
-                val receiptEntity = ReadReceiptEntity.getOrCreate(realm, roomId, userId)
+                val ts = paramsDict[TIMESTAMP_KEY] as? Double ?: 0.0
+                val threadId = paramsDict[THREAD_ID_KEY] as String?
+                val receiptEntity = ReadReceiptEntity.getOrCreate(realm, roomId, userId, threadId)
                 // ensure new ts is superior to the previous one
                 if (ts > receiptEntity.originServerTs) {
                     ReadReceiptsSummaryEntity.where(realm, receiptEntity.eventId).findFirst()?.also {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
index ef238d56e6..02782783b8 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
@@ -217,7 +217,7 @@ class TimelineViewModel @AssistedInject constructor(
         observePowerLevel()
         setupPreviewUrlObservers()
         viewModelScope.launch(Dispatchers.IO) {
-            tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT) }
+            tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = true) }
         }
         // Inform the SDK that the room is displayed
         viewModelScope.launch(Dispatchers.IO) {
@@ -1103,7 +1103,8 @@ class TimelineViewModel @AssistedInject constructor(
                     }
                     bufferedMostRecentDisplayedEvent.root.eventId?.let { eventId ->
                         session.coroutineScope.launch {
-                            tryOrNull { room.readService().setReadReceipt(eventId) }
+                            val threadId = initialState.rootThreadEventId ?: ReadService.THREAD_ID_MAIN
+                            tryOrNull { room.readService().setReadReceipt(eventId, threadId = threadId) }
                         }
                     }
                 }
@@ -1121,7 +1122,7 @@ class TimelineViewModel @AssistedInject constructor(
         if (room == null) return
         setState { copy(unreadState = UnreadState.HasNoUnread) }
         viewModelScope.launch {
-            tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.BOTH) }
+            tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.BOTH, mainTimeLineOnly = true) }
         }
     }
 
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt
index 1f079e420b..f845a42dcd 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt
@@ -74,6 +74,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomSummary
 import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
 import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent
 import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
+import org.matrix.android.sdk.api.session.room.read.ReadService
 import org.matrix.android.sdk.api.session.room.timeline.Timeline
 import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
 import timber.log.Timber
@@ -516,7 +517,7 @@ class TimelineEventController @Inject constructor(
                         event.eventId,
                         readReceipts,
                         callback,
-                        partialState.isFromThreadTimeline()
+                        partialState.isFromThreadTimeline(),
                 ),
                 formattedDayModel = formattedDayModel,
                 mergedHeaderModel = mergedHeaderModel
@@ -559,7 +560,7 @@ class TimelineEventController @Inject constructor(
             val event = itr.previous()
             timelineEventsGroups.addOrIgnore(event)
             val currentReadReceipts = ArrayList(event.readReceipts).filter {
-                it.roomMember.userId != session.myUserId
+                it.roomMember.userId != session.myUserId && it.isVisibleInThisThread()
             }
             if (timelineEventVisibilityHelper.shouldShowEvent(
                             timelineEvent = event,
@@ -577,6 +578,14 @@ class TimelineEventController @Inject constructor(
         }
     }
 
+    private fun ReadReceipt.isVisibleInThisThread(): Boolean {
+        return if (partialState.isFromThreadTimeline()) {
+            this.threadId == partialState.rootThreadEventId
+        } else {
+            this.threadId == null || this.threadId == ReadService.THREAD_ID_MAIN
+        }
+    }
+
     private fun buildDaySeparatorItem(originServerTs: Long?): DaySeparatorItem {
         val formattedDay = dateFormatter.format(originServerTs, DateFormatKind.TIMELINE_DAY_DIVIDER)
         return DaySeparatorItem_().formattedDay(formattedDay).id(formattedDay)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt
index 6a711ec2dc..8607af6891 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt
@@ -21,16 +21,20 @@ import im.vector.app.features.home.room.detail.timeline.TimelineEventController
 import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData
 import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem
 import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem_
+import org.matrix.android.sdk.api.session.Session
 import org.matrix.android.sdk.api.session.room.model.ReadReceipt
 import javax.inject.Inject
 
-class ReadReceiptsItemFactory @Inject constructor(private val avatarRenderer: AvatarRenderer) {
+class ReadReceiptsItemFactory @Inject constructor(
+        private val avatarRenderer: AvatarRenderer,
+        private val session: Session
+) {
 
     fun create(
             eventId: String,
             readReceipts: List,
             callback: TimelineEventController.Callback?,
-            isFromThreadTimeLine: Boolean
+            isFromThreadTimeLine: Boolean,
     ): ReadReceiptsItem? {
         if (readReceipts.isEmpty()) {
             return null
@@ -40,12 +44,13 @@ class ReadReceiptsItemFactory @Inject constructor(private val avatarRenderer: Av
                     ReadReceiptData(it.roomMember.userId, it.roomMember.avatarUrl, it.roomMember.displayName, it.originServerTs)
                 }
                 .sortedByDescending { it.timestamp }
+        val threadReadReceiptsSupported = session.homeServerCapabilitiesService().getHomeServerCapabilities().canUseThreadReadReceiptsAndNotifications
         return ReadReceiptsItem_()
                 .id("read_receipts_$eventId")
                 .eventId(eventId)
                 .readReceipts(readReceiptsData)
                 .avatarRenderer(avatarRenderer)
-                .shouldHideReadReceipts(isFromThreadTimeLine)
+                .shouldHideReadReceipts(isFromThreadTimeLine && !threadReadReceiptsSupported)
                 .clickListener {
                     callback?.onReadReceiptsClicked(readReceiptsData)
                 }
diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt
index 3fe0898eb4..180351f806 100644
--- a/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt
+++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt
@@ -109,7 +109,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
             val room = session.getRoom(roomId)
             if (room != null) {
                 session.coroutineScope.launch {
-                    tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT) }
+                    tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = false) }
                 }
             }
         }

From 53cd55df6547cf3979c90d96954f030fd5a04115 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 25 Nov 2022 10:09:50 +0100
Subject: [PATCH 098/173] Bump wysiwyg from 0.7.0 to 0.7.0.1 (#7636)

Bumps [wysiwyg](https://github.com/matrix-org/matrix-wysiwyg) from 0.7.0 to 0.7.0.1.
- [Release notes](https://github.com/matrix-org/matrix-wysiwyg/releases)
- [Changelog](https://github.com/matrix-org/matrix-rich-text-editor/blob/main/CHANGELOG.md)
- [Commits](https://github.com/matrix-org/matrix-wysiwyg/commits)

---
updated-dependencies:
- dependency-name: io.element.android:wysiwyg
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] 

Signed-off-by: dependabot[bot] 
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
 dependencies.gradle | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/dependencies.gradle b/dependencies.gradle
index ac65035b60..93098952e6 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -98,7 +98,7 @@ ext.libs = [
         ],
         element     : [
                 'opusencoder'             : "io.element.android:opusencoder:1.1.0",
-                'wysiwyg'                 : "io.element.android:wysiwyg:0.7.0"
+                'wysiwyg'                 : "io.element.android:wysiwyg:0.7.0.1"
         ],
         squareup    : [
                 'moshi'                  : "com.squareup.moshi:moshi:$moshi",

From ce6efa1f72def2a2676137e3eca6cf1e0f51ff05 Mon Sep 17 00:00:00 2001
From: Christina Klaas 
Date: Fri, 25 Nov 2022 11:06:58 +0000
Subject: [PATCH 099/173] Translated using Weblate (German)

Currently translated at 100.0% (2556 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/de/
---
 .../src/main/res/values-de/strings.xml        | 28 +++++++++----------
 1 file changed, 14 insertions(+), 14 deletions(-)

diff --git a/library/ui-strings/src/main/res/values-de/strings.xml b/library/ui-strings/src/main/res/values-de/strings.xml
index 4218f19f9f..ba0db71024 100644
--- a/library/ui-strings/src/main/res/values-de/strings.xml
+++ b/library/ui-strings/src/main/res/values-de/strings.xml
@@ -2232,13 +2232,13 @@
         %1$s weitere Optionen benötigt
     
     Frage darf nicht leer sein
-    ABSTIMMUNG ERSTELLEN
+    Umfrage erstellen
     NEUE OPTION
     Option %1$d
     Optionen hinzufügen
     Frage oder Thema
     Abstimmungsthema oder Frage
-    Abstimmung erstellen
+    Umfrage erstellen
     Umfrage
     Auffindungseinstellungen öffnen
     Sitzung abgemeldet!
@@ -2306,21 +2306,21 @@
     Richtlinie deines Identitäts-Servers
     Richtlinie deines Heim-Servers
     Richtlinie von ${app_name}
-    Abstimmung erstellen
+    Umfrage erstellen
     Kontakte öffnen
     Sticker verschicken
     Datei hochladen
     Verschicke Fotos und Videos
     Kamera öffnen
-    Willst du diese Abstimmung wirklich entfernen\? Du wirst sie nicht wiederherstellen können.
-    Abstimmung entfernen
-    Abstimmung beendet
+    Willst du diese Umfrage wirklich entfernen\? Du wirst sie nicht wiederherstellen können.
+    Umfrage entfernen
+    Umfrage beendet
     Stimme abgegeben
-    Abstimmung beenden
+    Umfrage beenden
     Dies verhindert, dass andere Personen abstimmen können, und zeigt die Endergebnisse der Umfrage an.
-    Diese Abstimmung beenden\?
+    Diese Umfrage beenden\?
     Gewinneroption
-    Abstimmung beenden
+    Umfrage beenden
     
         Endgültiges Ergebnis basiert auf %1$d Stimme
         Endgültiges Ergebnis basiert auf %1$d Stimmen
@@ -2333,11 +2333,11 @@
     ${app_name} konnte nicht auf deinen Standort zugreifen
     Standort
     Die Ergebnisse werden erst sichtbar, sobald du die Umfrage beendest
-    Abgeschlossene Abstimmung
+    Abgeschlossene Umfrage
     Abstimmende können die Ergebnisse nach Stimmabgabe sehen
-    Laufende Abstimmung
-    Abstimmungsart
-    Abstimmung bearbeiten
+    Laufende Umfrage
+    Umfragetyp
+    Umfrage bearbeiten
     Keine Stimmen abgegeben
     Konto erstellen
     Kommunikation für dein Team.
@@ -2527,7 +2527,7 @@
     Server-Richtlinien
     Folge den Anweisungen, die an %s gesendet wurden
     E-Mail bestätigen
-    Ergebnisse werden nach Abschluss der Abstimmung sichtbar sein
+    Ergebnisse werden nach Abschluss der Umfrage sichtbar sein
     Prüfe deine E-Mails.
     Passwort zurücksetzen
     Gib mindestens 8 Zeichen ein.

From 62bc22a03a8182a3aa3fa44e6d63d834c807ea00 Mon Sep 17 00:00:00 2001
From: Vri 
Date: Wed, 23 Nov 2022 16:12:42 +0000
Subject: [PATCH 100/173] Translated using Weblate (German)

Currently translated at 100.0% (2556 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/de/
---
 .../ui-strings/src/main/res/values-de/strings.xml   | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/library/ui-strings/src/main/res/values-de/strings.xml b/library/ui-strings/src/main/res/values-de/strings.xml
index ba0db71024..8eb6ba3da3 100644
--- a/library/ui-strings/src/main/res/values-de/strings.xml
+++ b/library/ui-strings/src/main/res/values-de/strings.xml
@@ -2852,4 +2852,17 @@
     
     Abmelden
     %1$s übrig
+    Zitieren
+    Bearbeiten
+    erstellte eine Abstimmung.
+    sandte einen Sticker.
+    sandte ein Video.
+    sandte ein Bild.
+    sandte eine Sprachnachricht.
+    sandte eine Audiodatei.
+    sandte eine Datei.
+    Als Antwort auf
+    %s antworten
+    IP-Adresse ausblenden
+    IP-Adresse anzeigen
 
\ No newline at end of file

From f1251140f5b9cb84e52cec6b75d70cfbcbdd32d8 Mon Sep 17 00:00:00 2001
From: waclaw66 
Date: Wed, 23 Nov 2022 15:57:46 +0000
Subject: [PATCH 101/173] Translated using Weblate (Czech)

Currently translated at 100.0% (2556 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/cs/
---
 .../ui-strings/src/main/res/values-cs/strings.xml   | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/library/ui-strings/src/main/res/values-cs/strings.xml b/library/ui-strings/src/main/res/values-cs/strings.xml
index 5ee8a6fd32..f260a129fc 100644
--- a/library/ui-strings/src/main/res/values-cs/strings.xml
+++ b/library/ui-strings/src/main/res/values-cs/strings.xml
@@ -2909,4 +2909,17 @@
     
     Odhlásit se
     zbývá %1$s
+    vytvořil hlasování.
+    poslal nálepku.
+    poslal video.
+    poslal obrázek.
+    poslal hlasovou zprávu.
+    poslal zvukový soubor.
+    odeslal soubor.
+    V odpovědi na
+    Skrýt IP adresu
+    Zobrazit IP adresu
+    Citace
+    Odpovídám na %s
+    Úpravy
 
\ No newline at end of file

From cc951969de55ab1f66576db167d18302f90e471d Mon Sep 17 00:00:00 2001
From: Christina Klaas 
Date: Fri, 25 Nov 2022 11:07:07 +0000
Subject: [PATCH 102/173] Translated using Weblate (German)

Currently translated at 100.0% (2556 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/de/
---
 library/ui-strings/src/main/res/values-de/strings.xml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/library/ui-strings/src/main/res/values-de/strings.xml b/library/ui-strings/src/main/res/values-de/strings.xml
index 8eb6ba3da3..75d77ea718 100644
--- a/library/ui-strings/src/main/res/values-de/strings.xml
+++ b/library/ui-strings/src/main/res/values-de/strings.xml
@@ -2854,7 +2854,7 @@
     %1$s übrig
     Zitieren
     Bearbeiten
-    erstellte eine Abstimmung.
+    erstellte eine Umfrage.
     sandte einen Sticker.
     sandte ein Video.
     sandte ein Bild.

From b0ec6abbea180145a43fe0a043574944e9df14d1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= 
Date: Wed, 23 Nov 2022 17:37:04 +0000
Subject: [PATCH 103/173] Translated using Weblate (Estonian)

Currently translated at 99.6% (2548 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/et/
---
 .../ui-strings/src/main/res/values-et/strings.xml   | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/library/ui-strings/src/main/res/values-et/strings.xml b/library/ui-strings/src/main/res/values-et/strings.xml
index cf5f23a240..156221379d 100644
--- a/library/ui-strings/src/main/res/values-et/strings.xml
+++ b/library/ui-strings/src/main/res/values-et/strings.xml
@@ -2844,4 +2844,17 @@
     
     Logi välja
     jäänud %1$s
+    Muudan sõnumit
+    Vastan sõnumile %s
+    Tsiteerides
+    Näita IP-aadressi
+    Peida IP-aadress
+    Vastuseks kasutajale
+    saatis faili.
+    saatis helifaili.
+    saatis häälsõnumi.
+    saatis pildi.
+    saatis video.
+    saatis kleepsu.
+    koostas küsitluse.
 
\ No newline at end of file

From 205db447e63e48051d42a7e727ef300da04d0340 Mon Sep 17 00:00:00 2001
From: Danial Behzadi 
Date: Thu, 24 Nov 2022 09:32:25 +0000
Subject: [PATCH 104/173] Translated using Weblate (Persian)

Currently translated at 99.6% (2546 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/fa/
---
 .../ui-strings/src/main/res/values-fa/strings.xml   | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/library/ui-strings/src/main/res/values-fa/strings.xml b/library/ui-strings/src/main/res/values-fa/strings.xml
index fb3651dfa2..cc8d60a87b 100644
--- a/library/ui-strings/src/main/res/values-fa/strings.xml
+++ b/library/ui-strings/src/main/res/values-fa/strings.xml
@@ -2831,4 +2831,17 @@
         خروج از %1$d نشست
     
     %1$s مانده
+    پیام صوتی‌ای فرستاد.
+    پروندهٔ صوتی‌ای فرستاد.
+    نظرسنجی‌ای ایجاد کرد.
+    عکس‌برگردانی فرستاد.
+    ویدیویی فرستاد.
+    تصویری فرستاد.
+    پرونده‌ای فرستاد.
+    در پاسخ به
+    نهفتن نشانی آی‌پی
+    نمایش نشانی آی‌پی
+    نقل کردن
+    پاسخ دادن به %s
+    ویرایش کردن
 
\ No newline at end of file

From eb2910401f4a0efe5a7a6dca5302f9788a4eeafc Mon Sep 17 00:00:00 2001
From: Linerly 
Date: Wed, 23 Nov 2022 23:23:35 +0000
Subject: [PATCH 105/173] Translated using Weblate (Indonesian)

Currently translated at 100.0% (2556 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/id/
---
 .../ui-strings/src/main/res/values-in/strings.xml   | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/library/ui-strings/src/main/res/values-in/strings.xml b/library/ui-strings/src/main/res/values-in/strings.xml
index 7828ec8c13..ce9e524067 100644
--- a/library/ui-strings/src/main/res/values-in/strings.xml
+++ b/library/ui-strings/src/main/res/values-in/strings.xml
@@ -2799,4 +2799,17 @@ Di masa mendatang proses verifikasi ini akan dimutakhirkan.
     
     Keluarkan
     %1$s tersisa
+    membuat pemungutan suara.
+    mengirim stiker.
+    mengirim video.
+    mengirim gambar.
+    mengirim file.
+    mengirim file audio.
+    mengirim pesan suara.
+    Membalas ke
+    Sembunyikan alamat IP
+    Mengutip
+    Mengedit
+    Tampilkan alamat IP
+    Membalas ke %s
 
\ No newline at end of file

From f5534363b993f3adbe01451bbb4e2294f8d17b10 Mon Sep 17 00:00:00 2001
From: random 
Date: Wed, 23 Nov 2022 15:44:40 +0000
Subject: [PATCH 106/173] Translated using Weblate (Italian)

Currently translated at 100.0% (2556 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/it/
---
 .../ui-strings/src/main/res/values-it/strings.xml   | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/library/ui-strings/src/main/res/values-it/strings.xml b/library/ui-strings/src/main/res/values-it/strings.xml
index 2322561d05..d244f26a43 100644
--- a/library/ui-strings/src/main/res/values-it/strings.xml
+++ b/library/ui-strings/src/main/res/values-it/strings.xml
@@ -2844,4 +2844,17 @@
     Manda avanti di 30 secondi
     Manda indietro di 30 secondi
     %1$s rimasti
+    creato un sondaggio.
+    inviato un adesivo.
+    inviato un video.
+    inviata un\'immagine.
+    inviato un messaggio vocale.
+    inviato un file audio.
+    inviato un file.
+    In risposta a
+    Nascondi indirizzo IP
+    Mostra indirizzo IP
+    Citazione
+    Risposta a %s
+    Modifica
 
\ No newline at end of file

From e1dc05d5edd6f2e9d30784b336157f09705122e6 Mon Sep 17 00:00:00 2001
From: lvre <7uu3qrbvm@relay.firefox.com>
Date: Fri, 25 Nov 2022 07:02:00 +0000
Subject: [PATCH 107/173] Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (2556 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/pt_BR/
---
 .../src/main/res/values-pt-rBR/strings.xml          | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/library/ui-strings/src/main/res/values-pt-rBR/strings.xml b/library/ui-strings/src/main/res/values-pt-rBR/strings.xml
index a916255d6f..8baba5df53 100644
--- a/library/ui-strings/src/main/res/values-pt-rBR/strings.xml
+++ b/library/ui-strings/src/main/res/values-pt-rBR/strings.xml
@@ -2853,4 +2853,17 @@
     
     Fazer signout
     %1$s restando
+    criou uma sondagem.
+    enviou um sticker.
+    enviou um vídeo.
+    enviou uma imagem.
+    enviou uma mensagem de voz.
+    enviou um arquivo de áudio.
+    enviou um arquivo.
+    Em resposta a
+    Esconder endereço de IP
+    Mostrar endereço de IP
+    Citando
+    Respondendo a %s
+    Editando
 
\ No newline at end of file

From 6b9381f5a36316f1700b69cba822276e57c2a70b Mon Sep 17 00:00:00 2001
From: Jozef Gaal 
Date: Wed, 23 Nov 2022 19:22:12 +0000
Subject: [PATCH 108/173] Translated using Weblate (Slovak)

Currently translated at 100.0% (2556 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/sk/
---
 .../ui-strings/src/main/res/values-sk/strings.xml   | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/library/ui-strings/src/main/res/values-sk/strings.xml b/library/ui-strings/src/main/res/values-sk/strings.xml
index acd69a2024..078ffc44eb 100644
--- a/library/ui-strings/src/main/res/values-sk/strings.xml
+++ b/library/ui-strings/src/main/res/values-sk/strings.xml
@@ -2909,4 +2909,17 @@
     
     Odhlásiť sa
     Ostáva %1$s
+    Cituje
+    vytvoril/a anketu.
+    poslal/a nálepku.
+    poslal/a video.
+    poslal/a obrázok.
+    poslal/a zvukovú správu.
+    poslal/a zvukový súbor.
+    poslal súbor.
+    V odpovedi na
+    Skryť IP adresu
+    Zobraziť IP adresu
+    Odpoveď na %s
+    Úprava
 
\ No newline at end of file

From aa2c6e175b2e4173dc48b76401705e232721ad02 Mon Sep 17 00:00:00 2001
From: Besnik Bleta 
Date: Thu, 24 Nov 2022 17:20:32 +0000
Subject: [PATCH 109/173] Translated using Weblate (Albanian)

Currently translated at 99.3% (2540 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/sq/
---
 .../src/main/res/values-sq/strings.xml        | 29 +++++++++++++++++++
 1 file changed, 29 insertions(+)

diff --git a/library/ui-strings/src/main/res/values-sq/strings.xml b/library/ui-strings/src/main/res/values-sq/strings.xml
index 773454c39f..800ec17dcf 100644
--- a/library/ui-strings/src/main/res/values-sq/strings.xml
+++ b/library/ui-strings/src/main/res/values-sq/strings.xml
@@ -2822,4 +2822,33 @@
     Ky kod QR duket i formuar keq. Ju lutemi, provoni ta verifikoni me tjetër metodë.
     🔒 Keni aktivizuar fshehtëzim për sesionie të verifikuar vetëm për krejt dhomat, që nga Rregullime Sigurie.
     Luaj figura të animuara te rrjedha kohora sapo zënë të duken
+    krijoi një pyetësor.
+    dërgoi një ngjitës.
+    dërgoi një video.
+    dërgoi një figurë.
+    dërgoi një mesazh zanor.
+    dërgoi një kartelë audio.
+    dërgoi një kartelë.
+    Në përgjigje të
+    Hyni/Dilni nga mënyra “Sa krejt ekrani”
+    Sesionet e verifikuar janë kudo ku përdorni këtë llogari pas dhënies së frazëkalimit tuaj, apo ripohimit të identitetit tuaj me një sesion tjetër të verifikuar.
+\n
+\nKjo do të thotë se keni krejt kyçet e nevojshëm për të shkyçur mesazhet tuaj të fshehtëzuar dhe për të ripohuar se e besoni këtë sesion.
+    Fshihe adresën IP
+    Shfaq adresë IP
+    
+        Dilni nga %1$d sesion
+        Dilni nga %1$d sesione
+    
+    Dilni
+    Formatim teksti
+    Edhe %1$s
+    Jeni duke incizuar tashmë një transmetim zanor. Ju lutemi, që të nisni një të ri, përfundoni transmetimin tuaj aktual zanor.
+    Dikush tjetër është tashmë duke incizuar një transmetim zanor. Prisni që të përfundojë transmetimi zanor i tij, pa të filloni një të ri.
+    S’keni lejet e domosdoshme për të nisur një transmetim zanor në këtë dhomë. Lidhuni me një përgjegjës dhome që të përmirësojë lejet tuaja.
+    S’mund të niset një transmetim i ri zanor
+    Shtyrje përpara 30 sekonda
+    Kthim prapa 30 sekonda
+    Si përgjigje për %s
+    Aktivizo MD të lënë për më vonë
 
\ No newline at end of file

From c424a87d817cc5a29357273a5e039d1436672b0a Mon Sep 17 00:00:00 2001
From: Jeff Huang 
Date: Thu, 24 Nov 2022 02:36:14 +0000
Subject: [PATCH 110/173] Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (2556 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/zh_Hant/
---
 .../src/main/res/values-zh-rTW/strings.xml          | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/library/ui-strings/src/main/res/values-zh-rTW/strings.xml b/library/ui-strings/src/main/res/values-zh-rTW/strings.xml
index 30db508f83..dc5f6d85e3 100644
--- a/library/ui-strings/src/main/res/values-zh-rTW/strings.xml
+++ b/library/ui-strings/src/main/res/values-zh-rTW/strings.xml
@@ -2797,4 +2797,17 @@
     
     登出
     剩餘 %1$s
+    已建立投票。
+    已傳送貼圖。
+    已傳送影片。
+    已傳送圖片。
+    已傳送語音訊息。
+    已傳送音訊檔。
+    已傳送檔案。
+    回覆給
+    隱藏 IP 位置
+    顯示 IP 位置
+    引用
+    回覆給 %s
+    正在編輯
 
\ No newline at end of file

From bc4ca1d33d061718cbc23445d0b8310b4676e7f3 Mon Sep 17 00:00:00 2001
From: Vri 
Date: Wed, 23 Nov 2022 15:46:01 +0000
Subject: [PATCH 111/173] Translated using Weblate (German)

Currently translated at 100.0% (82 of 82 strings)

Translation: Element Android/Element Android Store
Translate-URL: https://translate.element.io/projects/element-android/element-store/de/
---
 fastlane/metadata/android/de-DE/changelogs/40105080.txt | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 fastlane/metadata/android/de-DE/changelogs/40105080.txt

diff --git a/fastlane/metadata/android/de-DE/changelogs/40105080.txt b/fastlane/metadata/android/de-DE/changelogs/40105080.txt
new file mode 100644
index 0000000000..0422f9cd4f
--- /dev/null
+++ b/fastlane/metadata/android/de-DE/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Die wichtigsten Änderungen in dieser Version: Fehlerbehebungen und Verbesserungen.
+Vollständiges Änderungsprotokoll: https://github.com/vector-im/element-android/releases

From fe8415d788f47afc324e334d33ae1dd8331511cb Mon Sep 17 00:00:00 2001
From: lvre <7uu3qrbvm@relay.firefox.com>
Date: Fri, 25 Nov 2022 06:59:02 +0000
Subject: [PATCH 112/173] Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (82 of 82 strings)

Translation: Element Android/Element Android Store
Translate-URL: https://translate.element.io/projects/element-android/element-store/pt_BR/
---
 fastlane/metadata/android/pt-BR/changelogs/40105080.txt | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 fastlane/metadata/android/pt-BR/changelogs/40105080.txt

diff --git a/fastlane/metadata/android/pt-BR/changelogs/40105080.txt b/fastlane/metadata/android/pt-BR/changelogs/40105080.txt
new file mode 100644
index 0000000000..6e85b9ba6a
--- /dev/null
+++ b/fastlane/metadata/android/pt-BR/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Principais mudanças nesta versão: consertos de bugs e melhorias.
+Changelog completo: https://github.com/vector-im/element-android/releases

From 5811f1c77f02c46cfc092c20697d4f6a09764399 Mon Sep 17 00:00:00 2001
From: Jozef Gaal 
Date: Wed, 23 Nov 2022 19:19:35 +0000
Subject: [PATCH 113/173] Translated using Weblate (Slovak)

Currently translated at 100.0% (82 of 82 strings)

Translation: Element Android/Element Android Store
Translate-URL: https://translate.element.io/projects/element-android/element-store/sk/
---
 fastlane/metadata/android/sk/changelogs/40105080.txt | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 fastlane/metadata/android/sk/changelogs/40105080.txt

diff --git a/fastlane/metadata/android/sk/changelogs/40105080.txt b/fastlane/metadata/android/sk/changelogs/40105080.txt
new file mode 100644
index 0000000000..56daa3b4b7
--- /dev/null
+++ b/fastlane/metadata/android/sk/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Hlavné zmeny v tejto verzii: opravy chýb a vylepšenia.
+Úplný zoznam zmien: https://github.com/vector-im/element-android/releases

From 0a77b8f7d772340955f12d98a836224786d05228 Mon Sep 17 00:00:00 2001
From: Ihor Hordiichuk 
Date: Thu, 24 Nov 2022 16:05:37 +0000
Subject: [PATCH 114/173] Translated using Weblate (Ukrainian)

Currently translated at 100.0% (82 of 82 strings)

Translation: Element Android/Element Android Store
Translate-URL: https://translate.element.io/projects/element-android/element-store/uk/
---
 fastlane/metadata/android/uk/changelogs/40105080.txt | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 fastlane/metadata/android/uk/changelogs/40105080.txt

diff --git a/fastlane/metadata/android/uk/changelogs/40105080.txt b/fastlane/metadata/android/uk/changelogs/40105080.txt
new file mode 100644
index 0000000000..e6f6384a5f
--- /dev/null
+++ b/fastlane/metadata/android/uk/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Основні зміни в цій версії: усування вад і вдосконалення.
+Перелік усіх змін: https://github.com/vector-im/element-android/releases

From 9b6c57f5fdddad2d3aa4d7afeb6d08c449d81791 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= 
Date: Wed, 23 Nov 2022 17:34:25 +0000
Subject: [PATCH 115/173] Translated using Weblate (Estonian)

Currently translated at 100.0% (82 of 82 strings)

Translation: Element Android/Element Android Store
Translate-URL: https://translate.element.io/projects/element-android/element-store/et/
---
 fastlane/metadata/android/et/changelogs/40105080.txt | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 fastlane/metadata/android/et/changelogs/40105080.txt

diff --git a/fastlane/metadata/android/et/changelogs/40105080.txt b/fastlane/metadata/android/et/changelogs/40105080.txt
new file mode 100644
index 0000000000..37b9a2cfe5
--- /dev/null
+++ b/fastlane/metadata/android/et/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Põhilised muutused selles versioonis: erinevate vigade parandused ja kohendused.
+Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases

From ac24bee0a6ae2dab2b401b71c2eb694438069cad Mon Sep 17 00:00:00 2001
From: random 
Date: Wed, 23 Nov 2022 15:45:10 +0000
Subject: [PATCH 116/173] Translated using Weblate (Italian)

Currently translated at 100.0% (82 of 82 strings)

Translation: Element Android/Element Android Store
Translate-URL: https://translate.element.io/projects/element-android/element-store/it/
---
 fastlane/metadata/android/it-IT/changelogs/40105080.txt | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 fastlane/metadata/android/it-IT/changelogs/40105080.txt

diff --git a/fastlane/metadata/android/it-IT/changelogs/40105080.txt b/fastlane/metadata/android/it-IT/changelogs/40105080.txt
new file mode 100644
index 0000000000..a3d49ca1b7
--- /dev/null
+++ b/fastlane/metadata/android/it-IT/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Modifiche principali in questa versione: correzione di errori e miglioramenti.
+Cronologia completa: https://github.com/vector-im/element-android/releases

From 78025ddc18b0d2a2f44c3e8b76ccd06db066d3ac Mon Sep 17 00:00:00 2001
From: Danial Behzadi 
Date: Thu, 24 Nov 2022 09:34:13 +0000
Subject: [PATCH 117/173] Translated using Weblate (Persian)

Currently translated at 100.0% (82 of 82 strings)

Translation: Element Android/Element Android Store
Translate-URL: https://translate.element.io/projects/element-android/element-store/fa/
---
 fastlane/metadata/android/fa/changelogs/40105080.txt | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 fastlane/metadata/android/fa/changelogs/40105080.txt

diff --git a/fastlane/metadata/android/fa/changelogs/40105080.txt b/fastlane/metadata/android/fa/changelogs/40105080.txt
new file mode 100644
index 0000000000..91385addde
--- /dev/null
+++ b/fastlane/metadata/android/fa/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+تغییرات عمده در این نگارش: رفع اشکال‌ها و بهبود.
+گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases

From de9a6c6c34ae6c03d4d8be9236da9d9cb426b562 Mon Sep 17 00:00:00 2001
From: Jeff Huang 
Date: Thu, 24 Nov 2022 02:34:27 +0000
Subject: [PATCH 118/173] Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (82 of 82 strings)

Translation: Element Android/Element Android Store
Translate-URL: https://translate.element.io/projects/element-android/element-store/zh_Hant/
---
 fastlane/metadata/android/zh-TW/changelogs/40105080.txt | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 fastlane/metadata/android/zh-TW/changelogs/40105080.txt

diff --git a/fastlane/metadata/android/zh-TW/changelogs/40105080.txt b/fastlane/metadata/android/zh-TW/changelogs/40105080.txt
new file mode 100644
index 0000000000..2a368ec8be
--- /dev/null
+++ b/fastlane/metadata/android/zh-TW/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+此版本中的主要變動:臭蟲修復與改善。
+完整的變更紀錄:https://github.com/vector-im/element-android/releases

From a5467062a154f101e0aec02dd0868ae7d3da878f Mon Sep 17 00:00:00 2001
From: waclaw66 
Date: Wed, 23 Nov 2022 15:58:50 +0000
Subject: [PATCH 119/173] Translated using Weblate (Czech)

Currently translated at 100.0% (82 of 82 strings)

Translation: Element Android/Element Android Store
Translate-URL: https://translate.element.io/projects/element-android/element-store/cs/
---
 fastlane/metadata/android/cs-CZ/changelogs/40105080.txt | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 fastlane/metadata/android/cs-CZ/changelogs/40105080.txt

diff --git a/fastlane/metadata/android/cs-CZ/changelogs/40105080.txt b/fastlane/metadata/android/cs-CZ/changelogs/40105080.txt
new file mode 100644
index 0000000000..90210199a1
--- /dev/null
+++ b/fastlane/metadata/android/cs-CZ/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Hlavní změny v této verzi: opravy různých chyb a vylepšení.
+Úplný seznam změn: https://github.com/vector-im/element-android/releases

From 5bfb83985e9743f742c6601a87cb3fd660cf8729 Mon Sep 17 00:00:00 2001
From: Linerly 
Date: Wed, 23 Nov 2022 23:24:30 +0000
Subject: [PATCH 120/173] Translated using Weblate (Indonesian)

Currently translated at 100.0% (82 of 82 strings)

Translation: Element Android/Element Android Store
Translate-URL: https://translate.element.io/projects/element-android/element-store/id/
---
 fastlane/metadata/android/id/changelogs/40105080.txt | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 fastlane/metadata/android/id/changelogs/40105080.txt

diff --git a/fastlane/metadata/android/id/changelogs/40105080.txt b/fastlane/metadata/android/id/changelogs/40105080.txt
new file mode 100644
index 0000000000..8384716bbc
--- /dev/null
+++ b/fastlane/metadata/android/id/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Perubahan utama dalam versi ini: perbaikan kutu dan fitur
+Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases

From 9349b1ae15e9b323d2fd7b73d9bc48bc44f8a90b Mon Sep 17 00:00:00 2001
From: Nikita Fedrunov <66663241+fedrunov@users.noreply.github.com>
Date: Fri, 25 Nov 2022 14:24:14 +0100
Subject: [PATCH 121/173] read receipt migration added (#7640)

---
 .../database/RealmSessionStoreMigration.kt    |  4 ++-
 .../database/migration/MigrateSessionTo044.kt | 29 +++++++++++++++++++
 2 files changed, 32 insertions(+), 1 deletion(-)
 create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo044.kt

diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
index 388f962454..1529064b96 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
@@ -60,6 +60,7 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo040
 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo041
 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo042
 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo043
+import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo044
 import org.matrix.android.sdk.internal.util.Normalizer
 import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration
 import javax.inject.Inject
@@ -68,7 +69,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
         private val normalizer: Normalizer
 ) : MatrixRealmMigration(
         dbName = "Session",
-        schemaVersion = 43L,
+        schemaVersion = 44L,
 ) {
     /**
      * Forces all RealmSessionStoreMigration instances to be equal.
@@ -121,5 +122,6 @@ internal class RealmSessionStoreMigration @Inject constructor(
         if (oldVersion < 41) MigrateSessionTo041(realm).perform()
         if (oldVersion < 42) MigrateSessionTo042(realm).perform()
         if (oldVersion < 43) MigrateSessionTo043(realm).perform()
+        if (oldVersion < 44) MigrateSessionTo044(realm).perform()
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo044.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo044.kt
new file mode 100644
index 0000000000..2d3efc8338
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo044.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.internal.database.migration
+
+import io.realm.DynamicRealm
+import org.matrix.android.sdk.internal.database.model.ReadReceiptEntityFields
+import org.matrix.android.sdk.internal.util.database.RealmMigrator
+
+internal class MigrateSessionTo044(realm: DynamicRealm) : RealmMigrator(realm, 44) {
+
+    override fun doMigrate(realm: DynamicRealm) {
+        realm.schema.get("ReadReceiptEntity")
+                ?.addField(ReadReceiptEntityFields.THREAD_ID, String::class.java)
+    }
+}

From f4b948af9d1103e3da9d6276895bf550d7d18a51 Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Wed, 16 Nov 2022 10:12:27 +0100
Subject: [PATCH 122/173] Voice Broadcast - hide voice messages and state
 events behind hidden events

---
 .../detail/timeline/factory/MessageItemFactory.kt |  7 +++++--
 .../timeline/factory/VoiceBroadcastItemFactory.kt |  8 ++++++--
 .../timeline/format/NoticeEventFormatter.kt       |  4 +++-
 .../helper/TimelineEventVisibilityHelper.kt       | 15 +++++++++++++++
 4 files changed, 29 insertions(+), 5 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt
index 373410775b..42e031a3c4 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt
@@ -45,6 +45,7 @@ import im.vector.app.features.home.room.detail.timeline.helper.MessageInformatio
 import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory
 import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
 import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
+import im.vector.app.features.home.room.detail.timeline.item.BaseEventItem
 import im.vector.app.features.home.room.detail.timeline.item.MessageAudioItem
 import im.vector.app.features.home.room.detail.timeline.item.MessageAudioItem_
 import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem
@@ -324,9 +325,11 @@ class MessageItemFactory @Inject constructor(
             informationData: MessageInformationData,
             highlight: Boolean,
             attributes: AbsMessageItem.Attributes
-    ): MessageVoiceItem? {
+    ): BaseEventItem<*>? {
         // Do not display voice broadcast messages
-        if (params.event.root.asMessageAudioEvent().isVoiceBroadcast()) return null
+        if (params.event.root.asMessageAudioEvent().isVoiceBroadcast()) {
+            return noticeItemFactory.create(params)
+        }
 
         val fileUrl = getAudioFileUrl(messageContent, informationData)
         val playbackControlButtonClickListener = createOnPlaybackButtonClickListener(messageContent, informationData, params)
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt
index e4f7bed72f..cc3a015120 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt
@@ -23,6 +23,7 @@ import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvide
 import im.vector.app.features.home.room.detail.timeline.helper.VoiceBroadcastEventsGroup
 import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
 import im.vector.app.features.home.room.detail.timeline.item.AbsMessageVoiceBroadcastItem
+import im.vector.app.features.home.room.detail.timeline.item.BaseEventItem
 import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastListeningItem
 import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastListeningItem_
 import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastRecordingItem
@@ -47,6 +48,7 @@ class VoiceBroadcastItemFactory @Inject constructor(
         private val voiceBroadcastRecorder: VoiceBroadcastRecorder?,
         private val voiceBroadcastPlayer: VoiceBroadcastPlayer,
         private val playbackTracker: AudioMessagePlaybackTracker,
+        private val noticeItemFactory: NoticeItemFactory,
 ) {
 
     fun create(
@@ -54,9 +56,11 @@ class VoiceBroadcastItemFactory @Inject constructor(
             messageContent: MessageVoiceBroadcastInfoContent,
             highlight: Boolean,
             attributes: AbsMessageItem.Attributes,
-    ): AbsMessageVoiceBroadcastItem<*>? {
+    ): BaseEventItem<*>? {
         // Only display item of the initial event with updated data
-        if (messageContent.voiceBroadcastState != VoiceBroadcastState.STARTED) return null
+        if (messageContent.voiceBroadcastState != VoiceBroadcastState.STARTED) {
+            return noticeItemFactory.create(params)
+        }
 
         val voiceBroadcastEventsGroup = params.eventsGroup?.let { VoiceBroadcastEventsGroup(it) } ?: return null
         val voiceBroadcastEvent = voiceBroadcastEventsGroup.getLastDisplayableEvent().root.asVoiceBroadcastEvent() ?: return null
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt
index 19f9fc17a3..def3fb1a44 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt
@@ -21,6 +21,7 @@ import im.vector.app.R
 import im.vector.app.core.resources.StringProvider
 import im.vector.app.features.roomprofile.permissions.RoleFormatter
 import im.vector.app.features.settings.VectorPreferences
+import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
 import org.matrix.android.sdk.api.crypto.MXCRYPTO_ALGORITHM_MEGOLM
 import org.matrix.android.sdk.api.extensions.appendNl
 import org.matrix.android.sdk.api.extensions.orFalse
@@ -108,7 +109,8 @@ class NoticeEventFormatter @Inject constructor(
             EventType.STICKER,
             in EventType.POLL_RESPONSE,
             in EventType.POLL_END,
-            in EventType.BEACON_LOCATION_DATA -> formatDebug(timelineEvent.root)
+            in EventType.BEACON_LOCATION_DATA,
+            VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> formatDebug(timelineEvent.root)
             else -> {
                 Timber.v("Type $type not handled by this formatter")
                 null
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt
index d22b649b36..cce20d102c 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt
@@ -18,6 +18,11 @@ package im.vector.app.features.home.room.detail.timeline.helper
 
 import im.vector.app.core.extensions.localDateTime
 import im.vector.app.core.resources.UserPreferencesProvider
+import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
+import im.vector.app.features.voicebroadcast.isVoiceBroadcast
+import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
+import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
+import org.matrix.android.sdk.api.extensions.orFalse
 import org.matrix.android.sdk.api.session.events.model.Event
 import org.matrix.android.sdk.api.session.events.model.EventType
 import org.matrix.android.sdk.api.session.events.model.RelationType
@@ -28,6 +33,7 @@ import org.matrix.android.sdk.api.session.events.model.toModel
 import org.matrix.android.sdk.api.session.room.model.Membership
 import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
 import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho
+import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
 import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
 import javax.inject.Inject
 
@@ -246,6 +252,15 @@ class TimelineEventVisibilityHelper @Inject constructor(
             return !root.isRedacted()
         }
 
+        if (root.getClearType() == VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO &&
+                root.asVoiceBroadcastEvent()?.content?.voiceBroadcastState != VoiceBroadcastState.STARTED) {
+            return true
+        }
+
+        if (root.asMessageAudioEvent()?.isVoiceBroadcast().orFalse()) {
+            return true
+        }
+
         return false
     }
 

From eb12b1c99bff4faa10e336e12969812667e7148f Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Fri, 18 Nov 2022 16:28:46 +0100
Subject: [PATCH 123/173] Use StableUnstableId object for some event types

---
 .../room/timeline/PollAggregationTest.kt      |  2 +-
 .../sdk/api/session/events/model/Event.kt     | 10 +++++-----
 .../sdk/api/session/events/model/EventType.kt | 19 +++++++------------
 .../room/summary/RoomSummaryConstants.kt      |  4 +++-
 .../session/room/timeline/TimelineEvent.kt    |  6 +++---
 .../session/call/CallEventProcessor.kt        |  5 ++---
 .../session/call/CallSignalingHandler.kt      |  3 +--
 .../pushrules/ProcessEventForPushTask.kt      |  4 ++--
 .../EventRelationsAggregationProcessor.kt     | 19 ++++++++++++-------
 .../GetActiveBeaconInfoForUserTask.kt         |  2 +-
 ...iveLocationShareRedactionEventProcessor.kt |  2 +-
 .../location/StartLiveLocationShareTask.kt    |  2 +-
 .../location/StopLiveLocationShareTask.kt     |  2 +-
 .../room/prune/RedactionEventProcessor.kt     |  6 +++---
 .../room/send/LocalEchoEventFactory.kt        | 10 +++++-----
 .../aggregation/poll/PollEventsTestData.kt    |  6 +++---
 ...faultGetActiveBeaconInfoForUserTaskTest.kt |  2 +-
 .../DefaultStartLiveLocationShareTaskTest.kt  |  2 +-
 .../DefaultStopLiveLocationShareTaskTest.kt   |  2 +-
 ...ocationShareRedactionEventProcessorTest.kt |  2 +-
 .../app/core/extensions/TimelineEvent.kt      |  2 +-
 .../home/room/detail/TimelineFragment.kt      |  2 +-
 .../action/CheckIfCanRedactEventUseCase.kt    |  4 ++--
 .../action/CheckIfCanReplyEventUseCase.kt     |  2 +-
 .../action/MessageActionsViewModel.kt         | 14 +++++++-------
 .../timeline/factory/TimelineItemFactory.kt   | 10 +++++-----
 .../format/DisplayableEventFormatter.kt       | 16 ++++++++--------
 .../timeline/format/NoticeEventFormatter.kt   |  6 +++---
 .../helper/TimelineDisplayableEvents.kt       |  6 +++---
 .../helper/TimelineEventVisibilityHelper.kt   |  2 +-
 .../style/TimelineMessageLayoutFactory.kt     |  6 ++++--
 .../location/LocationSharingViewModel.kt      |  2 +-
 .../notifications/NotifiableEventResolver.kt  |  2 +-
 .../CheckIfCanRedactEventUseCaseTest.kt       |  9 +++++++--
 .../action/CheckIfCanReplyEventUseCaseTest.kt |  2 +-
 .../test/fakes/FakeCreatePollViewStates.kt    |  6 +++---
 36 files changed, 104 insertions(+), 97 deletions(-)

diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/PollAggregationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/PollAggregationTest.kt
index a37d2ce015..a52e3cd7c7 100644
--- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/PollAggregationTest.kt
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/PollAggregationTest.kt
@@ -66,7 +66,7 @@ class PollAggregationTest : InstrumentedTest {
 
         val aliceEventsListener = object : Timeline.Listener {
             override fun onTimelineUpdated(snapshot: List) {
-                snapshot.firstOrNull { it.root.getClearType() in EventType.POLL_START }?.let { pollEvent ->
+                snapshot.firstOrNull { it.root.getClearType() in EventType.POLL_START.values }?.let { pollEvent ->
                     val pollEventId = pollEvent.eventId
                     val pollContent = pollEvent.root.content?.toModel()
                     val pollSummary = pollEvent.annotations?.pollResponseSummary
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt
index 1e32437727..f84e9858a5 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt
@@ -390,11 +390,11 @@ fun Event.isLocationMessage(): Boolean {
     }
 }
 
-fun Event.isPoll(): Boolean = getClearType() in EventType.POLL_START || getClearType() in EventType.POLL_END
+fun Event.isPoll(): Boolean = getClearType() in EventType.POLL_START.values || getClearType() in EventType.POLL_END.values
 
 fun Event.isSticker(): Boolean = getClearType() == EventType.STICKER
 
-fun Event.isLiveLocation(): Boolean = getClearType() in EventType.STATE_ROOM_BEACON_INFO
+fun Event.isLiveLocation(): Boolean = getClearType() in EventType.STATE_ROOM_BEACON_INFO.values
 
 fun Event.getRelationContent(): RelationDefaultContent? {
     return if (isEncrypted()) {
@@ -404,7 +404,7 @@ fun Event.getRelationContent(): RelationDefaultContent? {
             // Special cases when there is only a local msgtype for some event types
             when (getClearType()) {
                 EventType.STICKER -> getClearContent().toModel()?.relatesTo
-                in EventType.BEACON_LOCATION_DATA -> getClearContent().toModel()?.relatesTo
+                in EventType.BEACON_LOCATION_DATA.values -> getClearContent().toModel()?.relatesTo
                 else -> getClearContent()?.get("m.relates_to")?.toContent().toModel()
             }
         }
@@ -451,7 +451,7 @@ fun Event.getPollContent(): MessagePollContent? {
 }
 
 fun Event.supportsNotification() =
-        this.getClearType() in EventType.MESSAGE + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO
+        this.getClearType() in EventType.MESSAGE + EventType.POLL_START.values + EventType.STATE_ROOM_BEACON_INFO.values
 
 fun Event.isContentReportable() =
-        this.getClearType() in EventType.MESSAGE + EventType.STATE_ROOM_BEACON_INFO
+        this.getClearType() in EventType.MESSAGE + EventType.STATE_ROOM_BEACON_INFO.values
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt
index 3ad4f3a87f..e5c14afa90 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt
@@ -49,11 +49,10 @@ object EventType {
     const val STATE_ROOM_JOIN_RULES = "m.room.join_rules"
     const val STATE_ROOM_GUEST_ACCESS = "m.room.guest_access"
     const val STATE_ROOM_POWER_LEVELS = "m.room.power_levels"
-    val STATE_ROOM_BEACON_INFO = listOf("org.matrix.msc3672.beacon_info", "m.beacon_info")
-    val BEACON_LOCATION_DATA = listOf("org.matrix.msc3672.beacon", "m.beacon")
+    val STATE_ROOM_BEACON_INFO = StableUnstableId(stable = "m.beacon_info", unstable = "org.matrix.msc3672.beacon_info")
+    val BEACON_LOCATION_DATA = StableUnstableId(stable = "m.beacon", unstable = "org.matrix.msc3672.beacon")
 
     const val STATE_SPACE_CHILD = "m.space.child"
-
     const val STATE_SPACE_PARENT = "m.space.parent"
 
     /**
@@ -81,8 +80,7 @@ object EventType {
     const val CALL_NEGOTIATE = "m.call.negotiate"
     const val CALL_REJECT = "m.call.reject"
     const val CALL_HANGUP = "m.call.hangup"
-    const val CALL_ASSERTED_IDENTITY = "m.call.asserted_identity"
-    const val CALL_ASSERTED_IDENTITY_PREFIX = "org.matrix.call.asserted_identity"
+    val CALL_ASSERTED_IDENTITY = StableUnstableId(stable = "m.call.asserted_identity", unstable = "org.matrix.call.asserted_identity")
 
     // This type is not processed by the client, just sent to the server
     const val CALL_REPLACES = "m.call.replaces"
@@ -90,10 +88,7 @@ object EventType {
     // Key share events
     const val ROOM_KEY_REQUEST = "m.room_key_request"
     const val FORWARDED_ROOM_KEY = "m.forwarded_room_key"
-    val ROOM_KEY_WITHHELD = StableUnstableId(
-            stable = "m.room_key.withheld",
-            unstable = "org.matrix.room_key.withheld"
-    )
+    val ROOM_KEY_WITHHELD = StableUnstableId(stable = "m.room_key.withheld", unstable = "org.matrix.room_key.withheld")
 
     const val REQUEST_SECRET = "m.secret.request"
     const val SEND_SECRET = "m.secret.send"
@@ -111,9 +106,9 @@ object EventType {
     const val REACTION = "m.reaction"
 
     // Poll
-    val POLL_START = listOf("org.matrix.msc3381.poll.start", "m.poll.start")
-    val POLL_RESPONSE = listOf("org.matrix.msc3381.poll.response", "m.poll.response")
-    val POLL_END = listOf("org.matrix.msc3381.poll.end", "m.poll.end")
+    val POLL_START = StableUnstableId(stable = "m.poll.start", unstable = "org.matrix.msc3381.poll.start")
+    val POLL_RESPONSE = StableUnstableId(stable = "m.poll.response", unstable = "org.matrix.msc3381.poll.response")
+    val POLL_END = StableUnstableId(stable = "m.poll.end", unstable = "org.matrix.msc3381.poll.end")
 
     // Unwedging
     internal const val DUMMY = "m.dummy"
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt
index 8f214e0f89..634e71c43b 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt
@@ -33,5 +33,7 @@ object RoomSummaryConstants {
             EventType.ENCRYPTED,
             EventType.STICKER,
             EventType.REACTION
-    ) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO
+    ) +
+            EventType.POLL_START.values +
+            EventType.STATE_ROOM_BEACON_INFO.values
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
index 6b4a0226a0..9053425a39 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
@@ -147,9 +147,9 @@ fun TimelineEvent.getLastMessageContent(): MessageContent? {
         // Polls/Beacon are not message contents like others as there is no msgtype subtype to discriminate moshi parsing
         // so toModel won't parse them correctly
         // It's discriminated on event type instead. Maybe it shouldn't be MessageContent at all to avoid confusion?
-        in EventType.POLL_START -> (getLastEditNewContent() ?: root.getClearContent()).toModel()
-        in EventType.STATE_ROOM_BEACON_INFO -> (getLastEditNewContent() ?: root.getClearContent()).toModel()
-        in EventType.BEACON_LOCATION_DATA -> (getLastEditNewContent() ?: root.getClearContent()).toModel()
+        in EventType.POLL_START.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel()
+        in EventType.STATE_ROOM_BEACON_INFO.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel()
+        in EventType.BEACON_LOCATION_DATA.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel()
         else -> (getLastEditNewContent() ?: root.getClearContent()).toModel()
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallEventProcessor.kt
index 6a4abe9d34..b6ad7581fe 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallEventProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallEventProcessor.kt
@@ -41,9 +41,8 @@ internal class CallEventProcessor @Inject constructor(private val callSignalingH
             EventType.CALL_INVITE,
             EventType.CALL_HANGUP,
             EventType.ENCRYPTED,
-            EventType.CALL_ASSERTED_IDENTITY,
-            EventType.CALL_ASSERTED_IDENTITY_PREFIX
-    )
+    ) +
+            EventType.CALL_ASSERTED_IDENTITY.values
 
     private val eventsToPostProcess = mutableListOf()
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt
index 48a9dfd3da..d824aaa51a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt
@@ -84,8 +84,7 @@ internal class CallSignalingHandler @Inject constructor(
             EventType.CALL_NEGOTIATE -> {
                 handleCallNegotiateEvent(event)
             }
-            EventType.CALL_ASSERTED_IDENTITY,
-            EventType.CALL_ASSERTED_IDENTITY_PREFIX -> {
+            in EventType.CALL_ASSERTED_IDENTITY.values -> {
                 handleCallAssertedIdentityEvent(event)
             }
         }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushrules/ProcessEventForPushTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushrules/ProcessEventForPushTask.kt
index 09d7d50ecb..9fe93d8262 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushrules/ProcessEventForPushTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushrules/ProcessEventForPushTask.kt
@@ -56,8 +56,8 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
 
         val allEvents = (newJoinEvents + inviteEvents).filter { event ->
             when (event.type) {
-                in EventType.POLL_START,
-                in EventType.STATE_ROOM_BEACON_INFO,
+                in EventType.POLL_START.values,
+                in EventType.STATE_ROOM_BEACON_INFO.values,
                 EventType.MESSAGE,
                 EventType.REDACTION,
                 EventType.ENCRYPTED,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt
index 48e75821bd..be73309837 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt
@@ -91,7 +91,12 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
             EventType.KEY_VERIFICATION_READY,
             EventType.KEY_VERIFICATION_KEY,
             EventType.ENCRYPTED
-    ) + EventType.POLL_START + EventType.POLL_RESPONSE + EventType.POLL_END + EventType.STATE_ROOM_BEACON_INFO + EventType.BEACON_LOCATION_DATA
+    ) +
+            EventType.POLL_START.values +
+            EventType.POLL_RESPONSE.values +
+            EventType.POLL_END.values +
+            EventType.STATE_ROOM_BEACON_INFO.values +
+            EventType.BEACON_LOCATION_DATA.values
 
     override fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean {
         return allowedTypes.contains(eventType)
@@ -208,7 +213,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
                         }
                     }
                 }
-                in EventType.POLL_START -> {
+                in EventType.POLL_START.values -> {
                     val content: MessagePollContent? = event.content.toModel()
                     if (content?.relatesTo?.type == RelationType.REPLACE) {
                         Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
@@ -216,26 +221,26 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
                         handleReplace(realm, event, roomId, isLocalEcho, content.relatesTo.eventId)
                     }
                 }
-                in EventType.POLL_RESPONSE -> {
+                in EventType.POLL_RESPONSE.values -> {
                     event.content.toModel(catchError = true)?.let {
                         sessionManager.getSessionComponent(sessionId)?.session()?.let { session ->
                             pollAggregationProcessor.handlePollResponseEvent(session, realm, event)
                         }
                     }
                 }
-                in EventType.POLL_END -> {
+                in EventType.POLL_END.values -> {
                     sessionManager.getSessionComponent(sessionId)?.session()?.let { session ->
                         getPowerLevelsHelper(event.roomId)?.let {
                             pollAggregationProcessor.handlePollEndEvent(session, it, realm, event)
                         }
                     }
                 }
-                in EventType.STATE_ROOM_BEACON_INFO -> {
+                in EventType.STATE_ROOM_BEACON_INFO.values -> {
                     event.content.toModel(catchError = true)?.let {
                         liveLocationAggregationProcessor.handleBeaconInfo(realm, event, it, roomId, isLocalEcho)
                     }
                 }
-                in EventType.BEACON_LOCATION_DATA -> {
+                in EventType.BEACON_LOCATION_DATA.values -> {
                     handleBeaconLocationData(event, realm, roomId, isLocalEcho)
                 }
                 else -> Timber.v("UnHandled event ${event.eventId}")
@@ -324,7 +329,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
             }
         }
 
-        if (event.getClearType() in EventType.POLL_START) {
+        if (event.getClearType() in EventType.POLL_START.values) {
             pollAggregationProcessor.handlePollStartEvent(realm, event)
         }
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/GetActiveBeaconInfoForUserTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/GetActiveBeaconInfoForUserTask.kt
index a8d955af1d..ae7022a204 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/GetActiveBeaconInfoForUserTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/GetActiveBeaconInfoForUserTask.kt
@@ -39,7 +39,7 @@ internal class DefaultGetActiveBeaconInfoForUserTask @Inject constructor(
 ) : GetActiveBeaconInfoForUserTask {
 
     override suspend fun execute(params: GetActiveBeaconInfoForUserTask.Params): Event? {
-        return EventType.STATE_ROOM_BEACON_INFO
+        return EventType.STATE_ROOM_BEACON_INFO.values
                 .mapNotNull {
                     stateEventDataSource.getStateEvent(
                             roomId = params.roomId,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessor.kt
index 9041ef2677..dbdc5dc228 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessor.kt
@@ -48,7 +48,7 @@ internal class LiveLocationShareRedactionEventProcessor @Inject constructor() :
         val redactedEvent = EventEntity.where(realm, eventId = event.redacts).findFirst()
                 ?: return
 
-        if (redactedEvent.type in EventType.STATE_ROOM_BEACON_INFO) {
+        if (redactedEvent.type in EventType.STATE_ROOM_BEACON_INFO.values) {
             val liveSummary = LiveLocationShareAggregatedSummaryEntity.get(realm, eventId = redactedEvent.eventId)
 
             if (liveSummary != null) {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt
index 781def1abe..13753115ac 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt
@@ -46,7 +46,7 @@ internal class DefaultStartLiveLocationShareTask @Inject constructor(
                 isLive = true,
                 unstableTimestampMillis = clock.epochMillis()
         ).toContent()
-        val eventType = EventType.STATE_ROOM_BEACON_INFO.first()
+        val eventType = EventType.STATE_ROOM_BEACON_INFO.stable
         val sendStateTaskParams = SendStateTask.Params(
                 roomId = params.roomId,
                 stateKey = userId,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StopLiveLocationShareTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StopLiveLocationShareTask.kt
index da5fd76940..40f7aa2dd2 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StopLiveLocationShareTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StopLiveLocationShareTask.kt
@@ -45,7 +45,7 @@ internal class DefaultStopLiveLocationShareTask @Inject constructor(
         val sendStateTaskParams = SendStateTask.Params(
                 roomId = params.roomId,
                 stateKey = stateKey,
-                eventType = EventType.STATE_ROOM_BEACON_INFO.first(),
+                eventType = EventType.STATE_ROOM_BEACON_INFO.stable,
                 body = updatedContent
         )
         return try {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt
index 7968eabd30..3d1ac71bb4 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt
@@ -74,9 +74,9 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr
             when (typeToPrune) {
                 EventType.ENCRYPTED,
                 EventType.MESSAGE,
-                in EventType.STATE_ROOM_BEACON_INFO,
-                in EventType.BEACON_LOCATION_DATA,
-                in EventType.POLL_START -> {
+                in EventType.STATE_ROOM_BEACON_INFO.values,
+                in EventType.BEACON_LOCATION_DATA.values,
+                in EventType.POLL_START.values -> {
                     Timber.d("REDACTION for message ${eventToPrune.eventId}")
                     val unsignedData = EventMapper.map(eventToPrune).unsignedData
                             ?: UnsignedData(null, null)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
index 55ba78c2a5..2f8be69473 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
@@ -181,7 +181,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 originServerTs = dummyOriginServerTs(),
                 senderId = userId,
                 eventId = localId,
-                type = EventType.POLL_START.first(),
+                type = EventType.POLL_START.stable,
                 content = newContent.toContent().plus(additionalContent.orEmpty())
         )
     }
@@ -206,7 +206,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 originServerTs = dummyOriginServerTs(),
                 senderId = userId,
                 eventId = localId,
-                type = EventType.POLL_RESPONSE.first(),
+                type = EventType.POLL_RESPONSE.stable,
                 content = content.toContent().plus(additionalContent.orEmpty()),
                 unsignedData = UnsignedData(age = null, transactionId = localId)
         )
@@ -226,7 +226,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 originServerTs = dummyOriginServerTs(),
                 senderId = userId,
                 eventId = localId,
-                type = EventType.POLL_START.first(),
+                type = EventType.POLL_START.stable,
                 content = content.toContent().plus(additionalContent.orEmpty()),
                 unsignedData = UnsignedData(age = null, transactionId = localId)
         )
@@ -249,7 +249,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 originServerTs = dummyOriginServerTs(),
                 senderId = userId,
                 eventId = localId,
-                type = EventType.POLL_END.first(),
+                type = EventType.POLL_END.stable,
                 content = content.toContent().plus(additionalContent.orEmpty()),
                 unsignedData = UnsignedData(age = null, transactionId = localId)
         )
@@ -300,7 +300,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 originServerTs = dummyOriginServerTs(),
                 senderId = userId,
                 eventId = localId,
-                type = EventType.BEACON_LOCATION_DATA.first(),
+                type = EventType.BEACON_LOCATION_DATA.stable,
                 content = content.toContent().plus(additionalContent.orEmpty()),
                 unsignedData = UnsignedData(age = null, transactionId = localId)
         )
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollEventsTestData.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollEventsTestData.kt
index 129d49633e..bdd1fd9b0d 100644
--- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollEventsTestData.kt
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollEventsTestData.kt
@@ -87,7 +87,7 @@ object PollEventsTestData {
     )
 
     internal val A_POLL_START_EVENT = Event(
-            type = EventType.POLL_START.first(),
+            type = EventType.POLL_START.stable,
             eventId = AN_EVENT_ID,
             originServerTs = 1652435922563,
             senderId = A_USER_ID_1,
@@ -96,7 +96,7 @@ object PollEventsTestData {
     )
 
     internal val A_POLL_RESPONSE_EVENT = Event(
-            type = EventType.POLL_RESPONSE.first(),
+            type = EventType.POLL_RESPONSE.stable,
             eventId = AN_EVENT_ID,
             originServerTs = 1652435922563,
             senderId = A_USER_ID_1,
@@ -105,7 +105,7 @@ object PollEventsTestData {
     )
 
     internal val A_POLL_END_EVENT = Event(
-            type = EventType.POLL_END.first(),
+            type = EventType.POLL_END.stable,
             eventId = AN_EVENT_ID,
             originServerTs = 1652435922563,
             senderId = A_USER_ID_1,
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultGetActiveBeaconInfoForUserTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultGetActiveBeaconInfoForUserTaskTest.kt
index d51ed77399..4a10795647 100644
--- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultGetActiveBeaconInfoForUserTaskTest.kt
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultGetActiveBeaconInfoForUserTaskTest.kt
@@ -69,7 +69,7 @@ class DefaultGetActiveBeaconInfoForUserTaskTest {
         result shouldBeEqualTo currentStateEvent
         fakeStateEventDataSource.verifyGetStateEvent(
                 roomId = params.roomId,
-                eventType = EventType.STATE_ROOM_BEACON_INFO.first(),
+                eventType = EventType.STATE_ROOM_BEACON_INFO.stable,
                 stateKey = QueryStringValue.Equals(A_USER_ID)
         )
     }
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStartLiveLocationShareTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStartLiveLocationShareTaskTest.kt
index fdc411cc9d..a5c126cf72 100644
--- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStartLiveLocationShareTaskTest.kt
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStartLiveLocationShareTaskTest.kt
@@ -75,7 +75,7 @@ internal class DefaultStartLiveLocationShareTaskTest {
         val expectedParams = SendStateTask.Params(
                 roomId = params.roomId,
                 stateKey = A_USER_ID,
-                eventType = EventType.STATE_ROOM_BEACON_INFO.first(),
+                eventType = EventType.STATE_ROOM_BEACON_INFO.stable,
                 body = expectedBeaconContent
         )
         fakeSendStateTask.verifyExecuteRetry(
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStopLiveLocationShareTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStopLiveLocationShareTaskTest.kt
index 1abf179ccf..a7adadfc63 100644
--- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStopLiveLocationShareTaskTest.kt
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStopLiveLocationShareTaskTest.kt
@@ -79,7 +79,7 @@ class DefaultStopLiveLocationShareTaskTest {
         val expectedSendParams = SendStateTask.Params(
                 roomId = params.roomId,
                 stateKey = A_USER_ID,
-                eventType = EventType.STATE_ROOM_BEACON_INFO.first(),
+                eventType = EventType.STATE_ROOM_BEACON_INFO.stable,
                 body = expectedBeaconContent
         )
         fakeSendStateTask.verifyExecuteRetry(
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessorTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessorTest.kt
index 24d9c30039..d6edb69d93 100644
--- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessorTest.kt
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessorTest.kt
@@ -79,7 +79,7 @@ class LiveLocationShareRedactionEventProcessorTest {
     @Test
     fun `given a redacted live location share event when processing it then related summaries are deleted from database`() = runTest {
         val event = Event(eventId = AN_EVENT_ID, redacts = A_REDACTED_EVENT_ID)
-        val redactedEventEntity = EventEntity(eventId = A_REDACTED_EVENT_ID, type = EventType.STATE_ROOM_BEACON_INFO.first())
+        val redactedEventEntity = EventEntity(eventId = A_REDACTED_EVENT_ID, type = EventType.STATE_ROOM_BEACON_INFO.stable)
         fakeRealm.givenWhere()
                 .givenEqualTo(EventEntityFields.EVENT_ID, A_REDACTED_EVENT_ID)
                 .givenFindFirst(redactedEventEntity)
diff --git a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt
index d907f39ee3..63144ca1b3 100644
--- a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt
+++ b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt
@@ -28,7 +28,7 @@ import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
 
 fun TimelineEvent.canReact(): Boolean {
     // Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment
-    return root.getClearType() in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START &&
+    return root.getClearType() in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START.values &&
             root.sendState == SendState.SYNCED &&
             !root.isRedacted()
 }
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
index 9bed0aae04..34d7e45028 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
@@ -1774,7 +1774,7 @@ class TimelineFragment :
                 timelineViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add))
             }
             is EventSharedAction.Edit -> {
-                if (action.eventType in EventType.POLL_START) {
+                if (action.eventType in EventType.POLL_START.values) {
                     navigator.openCreatePoll(requireContext(), timelineArgs.roomId, action.eventId, PollMode.EDIT)
                 } else if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) {
                     messageComposerViewModel.handle(MessageComposerAction.EnterEditMode(action.eventId))
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCase.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCase.kt
index 8cb82691d9..dedd4a53c8 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCase.kt
@@ -33,8 +33,8 @@ class CheckIfCanRedactEventUseCase @Inject constructor(
                 EventType.STICKER,
                 VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
         ) +
-                EventType.POLL_START +
-                EventType.STATE_ROOM_BEACON_INFO
+                EventType.POLL_START.values +
+                EventType.STATE_ROOM_BEACON_INFO.values
 
         return event.root.getClearType() in canRedactEventTypes &&
                 // Message sent by the current user can always be redacted, else check permission for messages sent by other users
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCase.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCase.kt
index c312ef31b7..a9df059cc1 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCase.kt
@@ -26,7 +26,7 @@ class CheckIfCanReplyEventUseCase @Inject constructor() {
 
     fun execute(event: TimelineEvent, messageContent: MessageContent?, actionPermissions: ActionPermissions): Boolean {
         // Only EventType.MESSAGE, EventType.POLL_START and EventType.STATE_ROOM_BEACON_INFO event types are supported for the moment
-        if (event.root.getClearType() !in EventType.STATE_ROOM_BEACON_INFO + EventType.POLL_START + EventType.MESSAGE) return false
+        if (event.root.getClearType() !in EventType.STATE_ROOM_BEACON_INFO.values + EventType.POLL_START.values + EventType.MESSAGE) return false
         if (!actionPermissions.canSendMessage) return false
         return when (messageContent?.msgType) {
             MessageType.MSGTYPE_TEXT,
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
index 0c44ee386d..a6d7e8386f 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
@@ -215,7 +215,7 @@ class MessageActionsViewModel @AssistedInject constructor(
                     EventType.CALL_ANSWER -> {
                         noticeEventFormatter.format(timelineEvent, room?.roomSummary()?.isDirect.orFalse())
                     }
-                    in EventType.POLL_START -> {
+                    in EventType.POLL_START.values -> {
                         timelineEvent.root.getClearContent().toModel(catchError = true)
                                 ?.getBestPollCreationInfo()?.question?.getBestQuestion() ?: ""
                     }
@@ -383,7 +383,7 @@ class MessageActionsViewModel @AssistedInject constructor(
             }
 
             if (canRedact(timelineEvent, actionPermissions)) {
-                if (timelineEvent.root.getClearType() in EventType.POLL_START) {
+                if (timelineEvent.root.getClearType() in EventType.POLL_START.values) {
                     add(
                             EventSharedAction.Redact(
                                     eventId,
@@ -530,13 +530,13 @@ class MessageActionsViewModel @AssistedInject constructor(
 
     private fun canViewReactions(event: TimelineEvent): Boolean {
         // Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment
-        if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START) return false
+        if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START.values) return false
         return event.annotations?.reactionsSummary?.isNotEmpty() ?: false
     }
 
     private fun canEdit(event: TimelineEvent, myUserId: String, actionPermissions: ActionPermissions): Boolean {
         // Only event of type EventType.MESSAGE and EventType.POLL_START are supported for the moment
-        if (event.root.getClearType() !in listOf(EventType.MESSAGE) + EventType.POLL_START) return false
+        if (event.root.getClearType() !in listOf(EventType.MESSAGE) + EventType.POLL_START.values) return false
         if (!actionPermissions.canSendMessage) return false
         // TODO if user is admin or moderator
         val messageContent = event.root.getClearContent().toModel()
@@ -582,14 +582,14 @@ class MessageActionsViewModel @AssistedInject constructor(
     }
 
     private fun canEndPoll(event: TimelineEvent, actionPermissions: ActionPermissions): Boolean {
-        return event.root.getClearType() in EventType.POLL_START &&
+        return event.root.getClearType() in EventType.POLL_START.values &&
                 canRedact(event, actionPermissions) &&
                 event.annotations?.pollResponseSummary?.closedTime == null
     }
 
     private fun canEditPoll(event: TimelineEvent): Boolean {
-        return event.root.getClearType() in EventType.POLL_START &&
+        return event.root.getClearType() in EventType.POLL_START.values &&
                 event.annotations?.pollResponseSummary?.closedTime == null &&
-                event.annotations?.pollResponseSummary?.aggregatedContent?.totalVotes ?: 0 == 0
+                (event.annotations?.pollResponseSummary?.aggregatedContent?.totalVotes ?: 0) == 0
     }
 }
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt
index 31ff257214..ae3ea143a7 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt
@@ -88,7 +88,7 @@ class TimelineItemFactory @Inject constructor(
                     EventType.STATE_ROOM_ENCRYPTION -> encryptionItemFactory.create(params)
                     // State room create
                     EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(params)
-                    in EventType.STATE_ROOM_BEACON_INFO -> messageItemFactory.create(params)
+                    in EventType.STATE_ROOM_BEACON_INFO.values -> messageItemFactory.create(params)
                     VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> messageItemFactory.create(params)
                     // Unhandled state event types
                     else -> {
@@ -101,7 +101,7 @@ class TimelineItemFactory @Inject constructor(
                 when (event.root.getClearType()) {
                     // Message itemsX
                     EventType.STICKER,
-                    in EventType.POLL_START,
+                    in EventType.POLL_START.values,
                     EventType.MESSAGE -> messageItemFactory.create(params)
                     EventType.REDACTION,
                     EventType.KEY_VERIFICATION_ACCEPT,
@@ -114,9 +114,9 @@ class TimelineItemFactory @Inject constructor(
                     EventType.CALL_SELECT_ANSWER,
                     EventType.CALL_NEGOTIATE,
                     EventType.REACTION,
-                    in EventType.POLL_RESPONSE,
-                    in EventType.POLL_END -> noticeItemFactory.create(params)
-                    in EventType.BEACON_LOCATION_DATA -> {
+                    in EventType.POLL_RESPONSE.values,
+                    in EventType.POLL_END.values -> noticeItemFactory.create(params)
+                    in EventType.BEACON_LOCATION_DATA.values -> {
                         if (event.root.isRedacted()) {
                             messageItemFactory.create(params)
                         } else {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt
index eb531b6f1b..aaa0fc10c9 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt
@@ -122,17 +122,17 @@ class DisplayableEventFormatter @Inject constructor(
             EventType.CALL_CANDIDATES -> {
                 span { }
             }
-            in EventType.POLL_START -> {
+            in EventType.POLL_START.values -> {
                 timelineEvent.root.getClearContent().toModel(catchError = true)?.getBestPollCreationInfo()?.question?.getBestQuestion()
                         ?: stringProvider.getString(R.string.sent_a_poll)
             }
-            in EventType.POLL_RESPONSE -> {
+            in EventType.POLL_RESPONSE.values -> {
                 stringProvider.getString(R.string.poll_response_room_list_preview)
             }
-            in EventType.POLL_END -> {
+            in EventType.POLL_END.values -> {
                 stringProvider.getString(R.string.poll_end_room_list_preview)
             }
-            in EventType.STATE_ROOM_BEACON_INFO -> {
+            in EventType.STATE_ROOM_BEACON_INFO.values -> {
                 simpleFormat(senderName, stringProvider.getString(R.string.sent_live_location), appendAuthor)
             }
             else -> {
@@ -220,17 +220,17 @@ class DisplayableEventFormatter @Inject constructor(
                     emojiSpanify.spanify(stringProvider.getString(R.string.sent_a_reaction, it.key))
                 } ?: span { }
             }
-            in EventType.POLL_START -> {
+            in EventType.POLL_START.values -> {
                 event.getClearContent().toModel(catchError = true)?.pollCreationInfo?.question?.question
                         ?: stringProvider.getString(R.string.sent_a_poll)
             }
-            in EventType.POLL_RESPONSE -> {
+            in EventType.POLL_RESPONSE.values -> {
                 stringProvider.getString(R.string.poll_response_room_list_preview)
             }
-            in EventType.POLL_END -> {
+            in EventType.POLL_END.values -> {
                 stringProvider.getString(R.string.poll_end_room_list_preview)
             }
-            in EventType.STATE_ROOM_BEACON_INFO -> {
+            in EventType.STATE_ROOM_BEACON_INFO.values -> {
                 stringProvider.getString(R.string.sent_live_location)
             }
             else -> {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt
index def3fb1a44..3f702ed72d 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt
@@ -107,9 +107,9 @@ class NoticeEventFormatter @Inject constructor(
             EventType.STATE_SPACE_PARENT,
             EventType.REDACTION,
             EventType.STICKER,
-            in EventType.POLL_RESPONSE,
-            in EventType.POLL_END,
-            in EventType.BEACON_LOCATION_DATA,
+            in EventType.POLL_RESPONSE.values,
+            in EventType.POLL_END.values,
+            in EventType.BEACON_LOCATION_DATA.values,
             VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> formatDebug(timelineEvent.root)
             else -> {
                 Timber.v("Type $type not handled by this formatter")
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt
index 2411cb3877..51e961f247 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt
@@ -54,9 +54,9 @@ object TimelineDisplayableEvents {
             EventType.KEY_VERIFICATION_CANCEL,
             VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
     ) +
-            EventType.POLL_START +
-            EventType.STATE_ROOM_BEACON_INFO +
-            EventType.BEACON_LOCATION_DATA
+            EventType.POLL_START.values +
+            EventType.STATE_ROOM_BEACON_INFO.values +
+            EventType.BEACON_LOCATION_DATA.values
 }
 
 fun TimelineEvent.isRoomConfiguration(roomCreatorUserId: String?): Boolean {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt
index cce20d102c..1360151074 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt
@@ -248,7 +248,7 @@ class TimelineEventVisibilityHelper @Inject constructor(
             } else root.eventId != rootThreadEventId
         }
 
-        if (root.getClearType() in EventType.BEACON_LOCATION_DATA) {
+        if (root.getClearType() in EventType.BEACON_LOCATION_DATA.values) {
             return !root.isRedacted()
         }
 
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt
index 379e5b3b91..c207a5f67e 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt
@@ -47,8 +47,10 @@ class TimelineMessageLayoutFactory @Inject constructor(
         private val EVENT_TYPES_WITH_BUBBLE_LAYOUT = setOf(
                 EventType.MESSAGE,
                 EventType.ENCRYPTED,
-                EventType.STICKER
-        ) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO
+                EventType.STICKER,
+        ) +
+                EventType.POLL_START.values +
+                EventType.STATE_ROOM_BEACON_INFO.values
 
         // Can't be rendered in bubbles, so get back to default layout
         private val MSG_TYPES_WITHOUT_BUBBLE_LAYOUT = setOf(
diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt
index 4c7abd99b8..cdef7d3302 100644
--- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt
@@ -83,7 +83,7 @@ class LocationSharingViewModel @AssistedInject constructor(
                 .distinctUntilChanged()
                 .setOnEach {
                     val powerLevelsHelper = PowerLevelsHelper(it)
-                    val canShareLiveLocation = EventType.STATE_ROOM_BEACON_INFO
+                    val canShareLiveLocation = EventType.STATE_ROOM_BEACON_INFO.values
                             .all { beaconInfoType ->
                                 powerLevelsHelper.isUserAllowedToSend(session.myUserId, true, beaconInfoType)
                             }
diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt
index ba1d5c7f6f..d28ab22684 100644
--- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt
+++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt
@@ -66,7 +66,7 @@ class NotifiableEventResolver @Inject constructor(
 ) {
 
     private val nonEncryptedNotifiableEventTypes: List =
-            listOf(EventType.MESSAGE) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO
+            listOf(EventType.MESSAGE) + EventType.POLL_START.values + EventType.STATE_ROOM_BEACON_INFO.values
 
     suspend fun resolveEvent(event: Event, session: Session, isNoisy: Boolean): NotifiableEvent? {
         val roomID = event.roomId ?: return null
diff --git a/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCaseTest.kt
index e2157c3af0..bedb289a39 100644
--- a/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCaseTest.kt
+++ b/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCaseTest.kt
@@ -35,8 +35,13 @@ class CheckIfCanRedactEventUseCaseTest {
 
     @Test
     fun `given an event which can be redacted and owned by user when use case executes then the result is true`() {
-        val canRedactEventTypes = listOf(EventType.MESSAGE, EventType.STICKER, VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO) +
-                EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO
+        val canRedactEventTypes = listOf(
+                EventType.MESSAGE,
+                EventType.STICKER,
+                VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO
+        ) +
+                EventType.POLL_START.values +
+                EventType.STATE_ROOM_BEACON_INFO.values
 
         canRedactEventTypes.forEach { eventType ->
             val event = givenAnEvent(
diff --git a/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCaseTest.kt
index 83d23681fc..51082e0e06 100644
--- a/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCaseTest.kt
+++ b/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCaseTest.kt
@@ -43,7 +43,7 @@ class CheckIfCanReplyEventUseCaseTest {
 
     @Test
     fun `given reply is allowed for the event type when use case is executed then result is true`() {
-        val eventTypes = EventType.STATE_ROOM_BEACON_INFO + EventType.POLL_START + EventType.MESSAGE
+        val eventTypes = EventType.STATE_ROOM_BEACON_INFO.values + EventType.POLL_START.values + EventType.MESSAGE
 
         eventTypes.forEach { eventType ->
             val event = givenAnEvent(eventType)
diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeCreatePollViewStates.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeCreatePollViewStates.kt
index 04f3526602..42a500671b 100644
--- a/vector/src/test/java/im/vector/app/test/fakes/FakeCreatePollViewStates.kt
+++ b/vector/src/test/java/im/vector/app/test/fakes/FakeCreatePollViewStates.kt
@@ -63,7 +63,7 @@ object FakeCreatePollViewStates {
     )
 
     private val A_POLL_START_EVENT = Event(
-            type = EventType.POLL_START.first(),
+            type = EventType.POLL_START.stable,
             eventId = A_FAKE_EVENT_ID,
             originServerTs = 1652435922563,
             senderId = A_FAKE_USER_ID,
@@ -80,8 +80,8 @@ object FakeCreatePollViewStates {
     )
 
     val initialCreatePollViewState = CreatePollViewState(createPollArgs).copy(
-        canCreatePoll = false,
-        canAddMoreOptions = true
+            canCreatePoll = false,
+            canAddMoreOptions = true
     )
 
     val pollViewStateWithOnlyQuestion = initialCreatePollViewState.copy(

From 0209cc4969a34b48b651784ec193fb2e4db56fb4 Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Thu, 17 Nov 2022 11:54:00 +0100
Subject: [PATCH 124/173] Prune redacted events which are not explicitly
 restricted

---
 .../room/prune/RedactionEventProcessor.kt     | 74 ++++++++++++-------
 1 file changed, 46 insertions(+), 28 deletions(-)

diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt
index 3d1ac71bb4..9de55968f1 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt
@@ -61,45 +61,33 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr
         val isLocalEcho = LocalEcho.isLocalEchoId(redactionEvent.eventId ?: "")
         Timber.v("Redact event for ${redactionEvent.redacts} localEcho=$isLocalEcho")
 
-        val eventToPrune = EventEntity.where(realm, eventId = redactionEvent.redacts).findFirst()
-                ?: return
+        val eventToPrune = EventEntity.where(realm, eventId = redactionEvent.redacts).findFirst() ?: return
 
         val typeToPrune = eventToPrune.type
         val stateKey = eventToPrune.stateKey
         val allowedKeys = computeAllowedKeys(typeToPrune)
-        if (allowedKeys.isNotEmpty()) {
-            val prunedContent = ContentMapper.map(eventToPrune.content)?.filterKeys { key -> allowedKeys.contains(key) }
-            eventToPrune.content = ContentMapper.map(prunedContent)
-        } else {
-            when (typeToPrune) {
-                EventType.ENCRYPTED,
-                EventType.MESSAGE,
-                in EventType.STATE_ROOM_BEACON_INFO.values,
-                in EventType.BEACON_LOCATION_DATA.values,
-                in EventType.POLL_START.values -> {
-                    Timber.d("REDACTION for message ${eventToPrune.eventId}")
-                    val unsignedData = EventMapper.map(eventToPrune).unsignedData
-                            ?: UnsignedData(null, null)
+        when {
+            allowedKeys.isNotEmpty() -> {
+                val prunedContent = ContentMapper.map(eventToPrune.content)?.filterKeys { key -> allowedKeys.contains(key) }
+                eventToPrune.content = ContentMapper.map(prunedContent)
+            }
+            canPruneEventType(typeToPrune) -> {
+                Timber.d("REDACTION for message ${eventToPrune.eventId}")
+                val unsignedData = EventMapper.map(eventToPrune).unsignedData ?: UnsignedData(null, null)
 
-                    // was this event a m.replace
+                // was this event a m.replace
 //                    val contentModel = ContentMapper.map(eventToPrune.content)?.toModel()
 //                    if (RelationType.REPLACE == contentModel?.relatesTo?.type && contentModel.relatesTo?.eventId != null) {
 //                        eventRelationsAggregationUpdater.handleRedactionOfReplace(eventToPrune, contentModel.relatesTo!!.eventId!!, realm)
 //                    }
 
-                    val modified = unsignedData.copy(redactedEvent = redactionEvent)
-                    // Deleting the content of a thread message will result to delete the thread relation, however threads are now dynamic
-                    // so there is not much of a problem
-                    eventToPrune.content = ContentMapper.map(emptyMap())
-                    eventToPrune.unsignedData = MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).toJson(modified)
-                    eventToPrune.decryptionResultJson = null
-                    eventToPrune.decryptionErrorCode = null
+                val modified = unsignedData.copy(redactedEvent = redactionEvent)
+                eventToPrune.content = ContentMapper.map(emptyMap())
+                eventToPrune.unsignedData = MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).toJson(modified)
+                eventToPrune.decryptionResultJson = null
+                eventToPrune.decryptionErrorCode = null
 
-                    handleTimelineThreadSummaryIfNeeded(realm, eventToPrune, isLocalEcho)
-                }
-//                EventType.REACTION -> {
-//                    eventRelationsAggregationUpdater.handleReactionRedact(eventToPrune, realm, userId)
-//                }
+                handleTimelineThreadSummaryIfNeeded(realm, eventToPrune, isLocalEcho)
             }
         }
         if (typeToPrune == EventType.STATE_ROOM_MEMBER && stateKey != null) {
@@ -167,4 +155,34 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr
             else -> emptyList()
         }
     }
+
+    private fun canPruneEventType(eventType: String): Boolean {
+        return when {
+            EventType.isCallEvent(eventType) -> false
+            EventType.isVerificationEvent(eventType) -> false
+            eventType == EventType.ROOM_KEY ||
+                    eventType == EventType.STATE_ROOM_WIDGET_LEGACY ||
+                    eventType == EventType.STATE_ROOM_WIDGET ||
+                    eventType == EventType.STATE_ROOM_NAME ||
+                    eventType == EventType.STATE_ROOM_TOPIC ||
+                    eventType == EventType.STATE_ROOM_AVATAR ||
+                    eventType == EventType.STATE_ROOM_THIRD_PARTY_INVITE ||
+                    eventType == EventType.STATE_ROOM_GUEST_ACCESS ||
+                    eventType == EventType.STATE_SPACE_CHILD ||
+                    eventType == EventType.STATE_SPACE_PARENT ||
+                    eventType == EventType.STATE_ROOM_TOMBSTONE ||
+                    eventType == EventType.STATE_ROOM_HISTORY_VISIBILITY ||
+                    eventType == EventType.STATE_ROOM_RELATED_GROUPS ||
+                    eventType == EventType.STATE_ROOM_PINNED_EVENT ||
+                    eventType == EventType.STATE_ROOM_ENCRYPTION ||
+                    eventType == EventType.STATE_ROOM_SERVER_ACL ||
+                    eventType == EventType.ROOM_KEY_REQUEST ||
+                    eventType == EventType.FORWARDED_ROOM_KEY ||
+                    eventType in EventType.ROOM_KEY_WITHHELD.values ||
+                    eventType == EventType.REQUEST_SECRET ||
+                    eventType == EventType.SEND_SECRET ||
+                    eventType == EventType.REACTION -> false
+            else -> true
+        }
+    }
 }

From 2477632e2bb8cd5e81b5386b23071049d2df26a3 Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Wed, 23 Nov 2022 13:58:31 +0100
Subject: [PATCH 125/173] Fix exception when getting models on some redacted
 event

An exception was triggered because the excepted model body was null for redacted events
---
 .../android/sdk/api/session/events/model/Event.kt    | 12 ++----------
 1 file changed, 2 insertions(+), 10 deletions(-)

diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt
index f84e9858a5..40ce6ecb5c 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt
@@ -26,10 +26,8 @@ import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult
 import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent
 import org.matrix.android.sdk.api.session.room.model.Membership
 import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
-import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent
 import org.matrix.android.sdk.api.session.room.model.message.MessageContent
 import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
-import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
 import org.matrix.android.sdk.api.session.room.model.message.MessageType
 import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
 import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
@@ -400,14 +398,8 @@ fun Event.getRelationContent(): RelationDefaultContent? {
     return if (isEncrypted()) {
         content.toModel()?.relatesTo
     } else {
-        content.toModel()?.relatesTo ?: run {
-            // Special cases when there is only a local msgtype for some event types
-            when (getClearType()) {
-                EventType.STICKER -> getClearContent().toModel()?.relatesTo
-                in EventType.BEACON_LOCATION_DATA.values -> getClearContent().toModel()?.relatesTo
-                else -> getClearContent()?.get("m.relates_to")?.toContent().toModel()
-            }
-        }
+        content.toModel()?.relatesTo
+                ?: getClearContent()?.get("m.relates_to")?.toContent().toModel() // Special cases when there is only a local msgtype for some event types
     }
 }
 

From 5a43b764888697876aa9a80645fa5cf77912b28d Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Fri, 25 Nov 2022 10:00:05 +0100
Subject: [PATCH 126/173] Log a warning if the event content is not pruned

---
 .../sdk/internal/session/room/prune/RedactionEventProcessor.kt | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt
index 9de55968f1..f9b0e893cb 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt
@@ -89,6 +89,9 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr
 
                 handleTimelineThreadSummaryIfNeeded(realm, eventToPrune, isLocalEcho)
             }
+            else -> {
+                Timber.w("Not pruning event (type $typeToPrune)")
+            }
         }
         if (typeToPrune == EventType.STATE_ROOM_MEMBER && stateKey != null) {
             TimelineEventEntity.findWithSenderMembershipEvent(realm, eventToPrune.eventId).forEach {

From a8f3bb1d4e294edb6bb11218efb69d77f9b3780a Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Fri, 25 Nov 2022 10:16:35 +0100
Subject: [PATCH 127/173] Remove to-device events from event type filtering for
 redaction

---
 .../session/room/prune/RedactionEventProcessor.kt         | 8 +-------
 1 file changed, 1 insertion(+), 7 deletions(-)

diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt
index f9b0e893cb..0cff2c5a7c 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt
@@ -163,8 +163,7 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr
         return when {
             EventType.isCallEvent(eventType) -> false
             EventType.isVerificationEvent(eventType) -> false
-            eventType == EventType.ROOM_KEY ||
-                    eventType == EventType.STATE_ROOM_WIDGET_LEGACY ||
+            eventType == EventType.STATE_ROOM_WIDGET_LEGACY ||
                     eventType == EventType.STATE_ROOM_WIDGET ||
                     eventType == EventType.STATE_ROOM_NAME ||
                     eventType == EventType.STATE_ROOM_TOPIC ||
@@ -179,11 +178,6 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr
                     eventType == EventType.STATE_ROOM_PINNED_EVENT ||
                     eventType == EventType.STATE_ROOM_ENCRYPTION ||
                     eventType == EventType.STATE_ROOM_SERVER_ACL ||
-                    eventType == EventType.ROOM_KEY_REQUEST ||
-                    eventType == EventType.FORWARDED_ROOM_KEY ||
-                    eventType in EventType.ROOM_KEY_WITHHELD.values ||
-                    eventType == EventType.REQUEST_SECRET ||
-                    eventType == EventType.SEND_SECRET ||
                     eventType == EventType.REACTION -> false
             else -> true
         }

From af59a581572afa9a6c838be30bd6e00621adff9a Mon Sep 17 00:00:00 2001
From: Platon Terekhov 
Date: Fri, 25 Nov 2022 20:09:59 +0000
Subject: [PATCH 128/173] Translated using Weblate (Ukrainian)

Currently translated at 100.0% (2556 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/uk/
---
 .../ui-strings/src/main/res/values-uk/strings.xml   | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/library/ui-strings/src/main/res/values-uk/strings.xml b/library/ui-strings/src/main/res/values-uk/strings.xml
index 27511255ee..9bf99c1e99 100644
--- a/library/ui-strings/src/main/res/values-uk/strings.xml
+++ b/library/ui-strings/src/main/res/values-uk/strings.xml
@@ -2965,4 +2965,17 @@
     
     Вийти
     Залишилося %1$s
+    відправив аудіофайл.
+    відправив файл.
+    У відповідь на
+    Сховати IP-адресу
+    створив голосування.
+    відправив наліпку.
+    відправив відео.
+    відправив зображення.
+    відправив голосове повідомлення.
+    Показати IP-адресу
+    Цитуючи
+    У відповідь на %s
+    Редагування
 
\ No newline at end of file

From 41a20bf4d08fee44d24275dd93b5d22c20a6d639 Mon Sep 17 00:00:00 2001
From: Christina Klaas 
Date: Fri, 25 Nov 2022 11:08:37 +0000
Subject: [PATCH 129/173] Translated using Weblate (German)

Currently translated at 100.0% (2556 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/de/
---
 library/ui-strings/src/main/res/values-de/strings.xml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/library/ui-strings/src/main/res/values-de/strings.xml b/library/ui-strings/src/main/res/values-de/strings.xml
index 75d77ea718..be53c15026 100644
--- a/library/ui-strings/src/main/res/values-de/strings.xml
+++ b/library/ui-strings/src/main/res/values-de/strings.xml
@@ -2333,9 +2333,9 @@
     ${app_name} konnte nicht auf deinen Standort zugreifen
     Standort
     Die Ergebnisse werden erst sichtbar, sobald du die Umfrage beendest
-    Abgeschlossene Umfrage
+    Versteckte Umfrage
     Abstimmende können die Ergebnisse nach Stimmabgabe sehen
-    Laufende Umfrage
+    Offene Umfrage
     Umfragetyp
     Umfrage bearbeiten
     Keine Stimmen abgegeben

From b7c8ae7c450644c26be39ba5f457bf7638596110 Mon Sep 17 00:00:00 2001
From: Glandos 
Date: Fri, 25 Nov 2022 11:49:50 +0000
Subject: [PATCH 130/173] Translated using Weblate (French)

Currently translated at 100.0% (2556 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/fr/
---
 .../ui-strings/src/main/res/values-fr/strings.xml   | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/library/ui-strings/src/main/res/values-fr/strings.xml b/library/ui-strings/src/main/res/values-fr/strings.xml
index ca462c592f..fae32dc52e 100644
--- a/library/ui-strings/src/main/res/values-fr/strings.xml
+++ b/library/ui-strings/src/main/res/values-fr/strings.xml
@@ -2853,4 +2853,17 @@
     
     Déconnecter
     %1$s restant
+    a créé un sondage.
+    a envoyé un autocollant.
+    a envoyé une vidéo.
+    a envoyé une image.
+    envoyer un message vocal.
+    a envoyé un fichier audio.
+    a envoyé un fichier.
+    En réponse à
+    Masquer l’adresse IP
+    Afficher l’adresse IP
+    Citation de
+    Réponse à %s
+    Modification
 
\ No newline at end of file

From e925a2c66929a16bb6497a4ae39a00a99ae4ad4c Mon Sep 17 00:00:00 2001
From: Benoit Marty 
Date: Fri, 25 Nov 2022 11:40:12 +0000
Subject: [PATCH 131/173] Translated using Weblate (French)

Currently translated at 100.0% (2556 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/fr/
---
 library/ui-strings/src/main/res/values-fr/strings.xml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/library/ui-strings/src/main/res/values-fr/strings.xml b/library/ui-strings/src/main/res/values-fr/strings.xml
index fae32dc52e..cf49733bdf 100644
--- a/library/ui-strings/src/main/res/values-fr/strings.xml
+++ b/library/ui-strings/src/main/res/values-fr/strings.xml
@@ -2846,7 +2846,7 @@
     Retour rapide de 30 secondes
     Les sessions vérifiées sont toutes celles qui utilisent ce compte après avoir saisie la phrase de sécurité ou confirmé votre identité à l’aide d’une autre session vérifiée.
 \n
-\nCela veut dire qu’elles disposent de toutes les clés nécessaires pour lire les messages chiffrés, et confirment aux autres utilisateur que vous faites confiance à cette session.
+\nCela veut dire qu’elles disposent de toutes les clés nécessaires pour lire les messages chiffrés, et confirment aux autres utilisateurs que vous faites confiance à cette session.
     
         Déconnecter %1$d session
         Déconnecter %1$d sessions

From 02deb1bf191cb08d4a4443f2071cdd18d455924a Mon Sep 17 00:00:00 2001
From: Szimszon 
Date: Fri, 25 Nov 2022 15:09:03 +0000
Subject: [PATCH 132/173] Translated using Weblate (Hungarian)

Currently translated at 100.0% (2556 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/hu/
---
 .../ui-strings/src/main/res/values-hu/strings.xml  | 14 ++++++++++++++
 1 file changed, 14 insertions(+)

diff --git a/library/ui-strings/src/main/res/values-hu/strings.xml b/library/ui-strings/src/main/res/values-hu/strings.xml
index 8f449dadaf..1dd2134b90 100644
--- a/library/ui-strings/src/main/res/values-hu/strings.xml
+++ b/library/ui-strings/src/main/res/values-hu/strings.xml
@@ -2852,4 +2852,18 @@ A Visszaállítási Kulcsot tartsd biztonságos helyen, mint pl. egy jelszókeze
     Az új hang közvetítés nem indítható el
     30 másodperccel előre
     30 másodperccel vissza
+    visszavan: %1$s
+    szavazás elkészítve.
+    matrica elküldve.
+    videót küldött.
+    kép elküldve.
+    hang üzenet elküldve.
+    hangfájl elküldve.
+    fájl elküldve.
+    Válaszolva erre
+    IP címek elrejtése
+    IP címek megjelenítése
+    Idézet
+    Válasz erre: %s
+    Szerkesztés
 
\ No newline at end of file

From 225bf09251614254e76ee357ec90ff0fb132ebed Mon Sep 17 00:00:00 2001
From: Platon Terekhov 
Date: Fri, 25 Nov 2022 20:05:18 +0000
Subject: [PATCH 133/173] Translated using Weblate (Russian)

Currently translated at 100.0% (2556 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/ru/
---
 .../ui-strings/src/main/res/values-ru/strings.xml   | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/library/ui-strings/src/main/res/values-ru/strings.xml b/library/ui-strings/src/main/res/values-ru/strings.xml
index 8cc7bc54e0..39d1c8de2b 100644
--- a/library/ui-strings/src/main/res/values-ru/strings.xml
+++ b/library/ui-strings/src/main/res/values-ru/strings.xml
@@ -2950,4 +2950,17 @@
     Дать разрешение
     Другие пользователи могут найти вас по %s
     Осталось %1$s
+    создал опрос.
+    отправил наклейку.
+    отправил видео.
+    отправил изображение.
+    отправил голосовое сообщение.
+    отправил аудиофайл.
+    отправил файл.
+    В ответ на
+    Скрыть IP-адрес
+    Показать IP-адрес
+    Цитируя
+    В ответ на %s
+    Редактирование
 
\ No newline at end of file

From 40f0f59db159cb026f3b9cc9e791ebf90d15f9e2 Mon Sep 17 00:00:00 2001
From: Ihor Hordiichuk 
Date: Fri, 25 Nov 2022 20:50:47 +0000
Subject: [PATCH 134/173] Translated using Weblate (Ukrainian)

Currently translated at 100.0% (2556 of 2556 strings)

Translation: Element Android/Element Android App
Translate-URL: https://translate.element.io/projects/element-android/element-app/uk/
---
 library/ui-strings/src/main/res/values-uk/strings.xml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/library/ui-strings/src/main/res/values-uk/strings.xml b/library/ui-strings/src/main/res/values-uk/strings.xml
index 9bf99c1e99..19889892ff 100644
--- a/library/ui-strings/src/main/res/values-uk/strings.xml
+++ b/library/ui-strings/src/main/res/values-uk/strings.xml
@@ -2965,7 +2965,7 @@
     
     Вийти
     Залишилося %1$s
-    відправив аудіофайл.
+    надсилає аудіофайл.
     відправив файл.
     У відповідь на
     Сховати IP-адресу

From 6cbd39f3d3a4642b840d8840ea952858df9808f3 Mon Sep 17 00:00:00 2001
From: Glandos 
Date: Fri, 25 Nov 2022 11:48:09 +0000
Subject: [PATCH 135/173] Translated using Weblate (French)

Currently translated at 100.0% (82 of 82 strings)

Translation: Element Android/Element Android Store
Translate-URL: https://translate.element.io/projects/element-android/element-store/fr/
---
 fastlane/metadata/android/fr-FR/changelogs/40105080.txt | 2 ++
 1 file changed, 2 insertions(+)
 create mode 100644 fastlane/metadata/android/fr-FR/changelogs/40105080.txt

diff --git a/fastlane/metadata/android/fr-FR/changelogs/40105080.txt b/fastlane/metadata/android/fr-FR/changelogs/40105080.txt
new file mode 100644
index 0000000000..d33197c270
--- /dev/null
+++ b/fastlane/metadata/android/fr-FR/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Principaux changements pour cette version : corrections de bugs et améliorations.
+Intégralité des changements : https://github.com/vector-im/element-android/releases

From e28f0d0713f2cf02ae09c837c577b0b06806d2de Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 25 Nov 2022 23:03:08 +0000
Subject: [PATCH 136/173] Bump libphonenumber from 8.13.0 to 8.13.1

Bumps [libphonenumber](https://github.com/google/libphonenumber) from 8.13.0 to 8.13.1.
- [Release notes](https://github.com/google/libphonenumber/releases)
- [Changelog](https://github.com/google/libphonenumber/blob/master/making-metadata-changes.md)
- [Commits](https://github.com/google/libphonenumber/compare/v8.13.0...v8.13.1)

---
updated-dependencies:
- dependency-name: com.googlecode.libphonenumber:libphonenumber
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] 
---
 dependencies.gradle | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/dependencies.gradle b/dependencies.gradle
index 93098952e6..31c32bb26b 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -83,7 +83,7 @@ ext.libs = [
                 'appdistributionApi'      : "com.google.firebase:firebase-appdistribution-api-ktx:$appDistribution",
                 'appdistribution'         : "com.google.firebase:firebase-appdistribution:$appDistribution",
                 // Phone number https://github.com/google/libphonenumber
-                'phonenumber'             : "com.googlecode.libphonenumber:libphonenumber:8.13.0"
+                'phonenumber'             : "com.googlecode.libphonenumber:libphonenumber:8.13.1"
         ],
         dagger      : [
                 'dagger'                  : "com.google.dagger:dagger:$dagger",

From 5aeca1f81a22abf96af0a96574e05029a31df545 Mon Sep 17 00:00:00 2001
From: Nikita Fedrunov <66663241+fedrunov@users.noreply.github.com>
Date: Mon, 28 Nov 2022 09:48:28 +0100
Subject: [PATCH 137/173] saving sync filter changed (#7627)

---
 changelog.d/7626.sdk                          |   2 +
 .../android/sdk/common/CommonTestHelper.kt    |   5 +
 .../sdk/api/session/sync/FilterService.kt     |  13 +-
 .../session/sync/filter/SyncFilterBuilder.kt  | 129 ++++++++++++++++++
 .../database/RealmSessionStoreMigration.kt    |   4 +-
 .../database/mapper/FilterParamsMapper.kt     |  61 +++++++++
 .../database/migration/MigrateSessionTo043.kt |   1 -
 .../database/migration/MigrateSessionTo045.kt |  38 ++++++
 .../database/model/SessionRealmModule.kt      |   3 +-
 .../database/model/SyncFilterParamsEntity.kt  |  36 +++++
 .../session/filter/DefaultFilterRepository.kt |  87 ++++++------
 .../session/filter/DefaultFilterService.kt    |  22 ++-
 .../internal/session/filter/FilterFactory.kt  |  39 ------
 .../internal/session/filter/FilterModule.kt   |   3 +
 .../session/filter/FilterRepository.kt        |  31 ++++-
 .../session/filter/GetCurrentFilterTask.kt    |  55 ++++++++
 .../internal/session/filter/SaveFilterTask.kt |  43 ++----
 .../timeline/FetchTokenAndPaginateTask.kt     |   2 +-
 .../room/timeline/GetContextOfEventTask.kt    |   2 +-
 .../session/room/timeline/PaginationTask.kt   |   2 +-
 .../sdk/internal/session/sync/SyncTask.kt     |  15 +-
 .../internal/sync/filter/SyncFilterParams.kt  |  25 ++++
 .../sync/DefaultGetCurrentFilterTaskTest.kt   | 100 ++++++++++++++
 .../sdk/test/fakes/FakeFilterRepository.kt    |  34 +++++
 .../FakeHomeServerCapabilitiesDataSource.kt   |  30 ++++
 .../sdk/test/fakes/FakeSaveFilterTask.kt      |  40 ++++++
 .../ConfigureAndStartSessionUseCase.kt        |   6 +-
 .../im/vector/app/features/sync/SyncUtils.kt  |  48 +++++++
 .../ConfigureAndStartSessionUseCaseTest.kt    |   8 +-
 .../app/test/fakes/FakeFilterService.kt       |  11 +-
 30 files changed, 736 insertions(+), 159 deletions(-)
 create mode 100644 changelog.d/7626.sdk
 create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/filter/SyncFilterBuilder.kt
 create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/FilterParamsMapper.kt
 create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo045.kt
 create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SyncFilterParamsEntity.kt
 create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/GetCurrentFilterTask.kt
 create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/sync/filter/SyncFilterParams.kt
 create mode 100644 matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/sync/DefaultGetCurrentFilterTaskTest.kt
 create mode 100644 matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeFilterRepository.kt
 create mode 100644 matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeHomeServerCapabilitiesDataSource.kt
 create mode 100644 matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeSaveFilterTask.kt
 create mode 100644 vector/src/main/java/im/vector/app/features/sync/SyncUtils.kt

diff --git a/changelog.d/7626.sdk b/changelog.d/7626.sdk
new file mode 100644
index 0000000000..4d9f28183a
--- /dev/null
+++ b/changelog.d/7626.sdk
@@ -0,0 +1,2 @@
+Sync Filter now taking in account homeserver capabilities to not pass unsupported parameters.
+Sync Filter is now configured by providing SyncFilterBuilder class instance, instead of Filter to identify Filter changes related to homeserver capabilities
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt
index eeb2def582..8edecb273d 100644
--- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt
@@ -50,6 +50,7 @@ import org.matrix.android.sdk.api.session.room.send.SendState
 import org.matrix.android.sdk.api.session.room.timeline.Timeline
 import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
 import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings
+import org.matrix.android.sdk.api.session.sync.filter.SyncFilterBuilder
 import timber.log.Timber
 import java.util.UUID
 import java.util.concurrent.CountDownLatch
@@ -346,6 +347,10 @@ class CommonTestHelper internal constructor(context: Context, val cryptoConfig:
         assertTrue(registrationResult is RegistrationResult.Success)
         val session = (registrationResult as RegistrationResult.Success).session
         session.open()
+        session.filterService().setSyncFilter(
+                SyncFilterBuilder()
+                        .lazyLoadMembersForStateEvents(true)
+        )
         if (sessionTestParams.withInitialSync) {
             syncSession(session, 120_000)
         }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/FilterService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/FilterService.kt
index bc592df474..7347bee165 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/FilterService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/FilterService.kt
@@ -16,19 +16,12 @@
 
 package org.matrix.android.sdk.api.session.sync
 
+import org.matrix.android.sdk.api.session.sync.filter.SyncFilterBuilder
+
 interface FilterService {
 
-    enum class FilterPreset {
-        NoFilter,
-
-        /**
-         * Filter for Element, will include only known event type.
-         */
-        ElementFilter
-    }
-
     /**
      * Configure the filter for the sync.
      */
-    fun setFilter(filterPreset: FilterPreset)
+    suspend fun setSyncFilter(filterBuilder: SyncFilterBuilder)
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/filter/SyncFilterBuilder.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/filter/SyncFilterBuilder.kt
new file mode 100644
index 0000000000..ad55b26dfd
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/filter/SyncFilterBuilder.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.api.session.sync.filter
+
+import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
+import org.matrix.android.sdk.internal.session.filter.Filter
+import org.matrix.android.sdk.internal.session.filter.RoomEventFilter
+import org.matrix.android.sdk.internal.session.filter.RoomFilter
+import org.matrix.android.sdk.internal.sync.filter.SyncFilterParams
+
+class SyncFilterBuilder {
+    private var lazyLoadMembersForStateEvents: Boolean? = null
+    private var lazyLoadMembersForMessageEvents: Boolean? = null
+    private var useThreadNotifications: Boolean? = null
+    private var listOfSupportedEventTypes: List? = null
+    private var listOfSupportedStateEventTypes: List? = null
+
+    fun lazyLoadMembersForStateEvents(lazyLoadMembersForStateEvents: Boolean) = apply { this.lazyLoadMembersForStateEvents = lazyLoadMembersForStateEvents }
+
+    fun lazyLoadMembersForMessageEvents(lazyLoadMembersForMessageEvents: Boolean) =
+            apply { this.lazyLoadMembersForMessageEvents = lazyLoadMembersForMessageEvents }
+
+    fun useThreadNotifications(useThreadNotifications: Boolean) =
+            apply { this.useThreadNotifications = useThreadNotifications }
+
+    fun listOfSupportedStateEventTypes(listOfSupportedStateEventTypes: List) =
+            apply { this.listOfSupportedStateEventTypes = listOfSupportedStateEventTypes }
+
+    fun listOfSupportedTimelineEventTypes(listOfSupportedEventTypes: List) =
+            apply { this.listOfSupportedEventTypes = listOfSupportedEventTypes }
+
+    internal fun with(currentFilterParams: SyncFilterParams?) =
+            apply {
+                currentFilterParams?.let {
+                    useThreadNotifications = currentFilterParams.useThreadNotifications
+                    lazyLoadMembersForMessageEvents = currentFilterParams.lazyLoadMembersForMessageEvents
+                    lazyLoadMembersForStateEvents = currentFilterParams.lazyLoadMembersForStateEvents
+                    listOfSupportedEventTypes = currentFilterParams.listOfSupportedEventTypes?.toList()
+                    listOfSupportedStateEventTypes = currentFilterParams.listOfSupportedStateEventTypes?.toList()
+                }
+            }
+
+    internal fun extractParams(): SyncFilterParams {
+        return SyncFilterParams(
+                useThreadNotifications = useThreadNotifications,
+                lazyLoadMembersForMessageEvents = lazyLoadMembersForMessageEvents,
+                lazyLoadMembersForStateEvents = lazyLoadMembersForStateEvents,
+                listOfSupportedEventTypes = listOfSupportedEventTypes,
+                listOfSupportedStateEventTypes = listOfSupportedStateEventTypes,
+        )
+    }
+
+    internal fun build(homeServerCapabilities: HomeServerCapabilities): Filter {
+        return Filter(
+                room = buildRoomFilter(homeServerCapabilities)
+        )
+    }
+
+    private fun buildRoomFilter(homeServerCapabilities: HomeServerCapabilities): RoomFilter {
+        return RoomFilter(
+                timeline = buildTimelineFilter(homeServerCapabilities),
+                state = buildStateFilter()
+        )
+    }
+
+    private fun buildTimelineFilter(homeServerCapabilities: HomeServerCapabilities): RoomEventFilter? {
+        val resolvedUseThreadNotifications = if (homeServerCapabilities.canUseThreadReadReceiptsAndNotifications) {
+            useThreadNotifications
+        } else {
+            null
+        }
+        return RoomEventFilter(
+                enableUnreadThreadNotifications = resolvedUseThreadNotifications,
+                lazyLoadMembers = lazyLoadMembersForMessageEvents
+        ).orNullIfEmpty()
+    }
+
+    private fun buildStateFilter(): RoomEventFilter? =
+            RoomEventFilter(
+                    lazyLoadMembers = lazyLoadMembersForStateEvents,
+                    types = listOfSupportedStateEventTypes
+            ).orNullIfEmpty()
+
+    private fun RoomEventFilter.orNullIfEmpty(): RoomEventFilter? {
+        return if (hasData()) {
+            this
+        } else {
+            null
+        }
+    }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as SyncFilterBuilder
+
+        if (lazyLoadMembersForStateEvents != other.lazyLoadMembersForStateEvents) return false
+        if (lazyLoadMembersForMessageEvents != other.lazyLoadMembersForMessageEvents) return false
+        if (useThreadNotifications != other.useThreadNotifications) return false
+        if (listOfSupportedEventTypes != other.listOfSupportedEventTypes) return false
+        if (listOfSupportedStateEventTypes != other.listOfSupportedStateEventTypes) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = lazyLoadMembersForStateEvents?.hashCode() ?: 0
+        result = 31 * result + (lazyLoadMembersForMessageEvents?.hashCode() ?: 0)
+        result = 31 * result + (useThreadNotifications?.hashCode() ?: 0)
+        result = 31 * result + (listOfSupportedEventTypes?.hashCode() ?: 0)
+        result = 31 * result + (listOfSupportedStateEventTypes?.hashCode() ?: 0)
+        return result
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
index 1529064b96..2fb87ca874 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
@@ -61,6 +61,7 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo041
 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo042
 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo043
 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo044
+import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo045
 import org.matrix.android.sdk.internal.util.Normalizer
 import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration
 import javax.inject.Inject
@@ -69,7 +70,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
         private val normalizer: Normalizer
 ) : MatrixRealmMigration(
         dbName = "Session",
-        schemaVersion = 44L,
+        schemaVersion = 45L,
 ) {
     /**
      * Forces all RealmSessionStoreMigration instances to be equal.
@@ -123,5 +124,6 @@ internal class RealmSessionStoreMigration @Inject constructor(
         if (oldVersion < 42) MigrateSessionTo042(realm).perform()
         if (oldVersion < 43) MigrateSessionTo043(realm).perform()
         if (oldVersion < 44) MigrateSessionTo044(realm).perform()
+        if (oldVersion < 45) MigrateSessionTo045(realm).perform()
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/FilterParamsMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/FilterParamsMapper.kt
new file mode 100644
index 0000000000..645cb41af5
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/FilterParamsMapper.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.internal.database.mapper
+
+import io.realm.RealmList
+import org.matrix.android.sdk.internal.database.model.SyncFilterParamsEntity
+import org.matrix.android.sdk.internal.sync.filter.SyncFilterParams
+import javax.inject.Inject
+
+internal class FilterParamsMapper @Inject constructor() {
+
+    fun map(entity: SyncFilterParamsEntity): SyncFilterParams {
+        val eventTypes = if (entity.listOfSupportedEventTypesHasBeenSet) {
+            entity.listOfSupportedEventTypes?.toList()
+        } else {
+            null
+        }
+        val stateEventTypes = if (entity.listOfSupportedStateEventTypesHasBeenSet) {
+            entity.listOfSupportedStateEventTypes?.toList()
+        } else {
+            null
+        }
+        return SyncFilterParams(
+                useThreadNotifications = entity.useThreadNotifications,
+                lazyLoadMembersForMessageEvents = entity.lazyLoadMembersForMessageEvents,
+                lazyLoadMembersForStateEvents = entity.lazyLoadMembersForStateEvents,
+                listOfSupportedEventTypes = eventTypes,
+                listOfSupportedStateEventTypes = stateEventTypes,
+        )
+    }
+
+    fun map(params: SyncFilterParams): SyncFilterParamsEntity {
+        return SyncFilterParamsEntity(
+                useThreadNotifications = params.useThreadNotifications,
+                lazyLoadMembersForMessageEvents = params.lazyLoadMembersForMessageEvents,
+                lazyLoadMembersForStateEvents = params.lazyLoadMembersForStateEvents,
+                listOfSupportedEventTypes = params.listOfSupportedEventTypes.toRealmList(),
+                listOfSupportedEventTypesHasBeenSet = params.listOfSupportedEventTypes != null,
+                listOfSupportedStateEventTypes = params.listOfSupportedStateEventTypes.toRealmList(),
+                listOfSupportedStateEventTypesHasBeenSet = params.listOfSupportedStateEventTypes != null,
+        )
+    }
+
+    private fun List?.toRealmList(): RealmList? {
+        return this?.toTypedArray()?.let { RealmList(*it) }
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt
index c7fda61671..49e9bac18c 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt
@@ -19,7 +19,6 @@ package org.matrix.android.sdk.internal.database.migration
 import io.realm.DynamicRealm
 import org.matrix.android.sdk.internal.database.model.EditionOfEventFields
 import org.matrix.android.sdk.internal.database.model.EventEntityFields
-import org.matrix.android.sdk.internal.database.query.where
 import org.matrix.android.sdk.internal.util.database.RealmMigrator
 
 internal class MigrateSessionTo043(realm: DynamicRealm) : RealmMigrator(realm, 43) {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo045.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo045.kt
new file mode 100644
index 0000000000..d2b43ded28
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo045.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.internal.database.migration
+
+import io.realm.DynamicRealm
+import org.matrix.android.sdk.internal.database.model.SyncFilterParamsEntityFields
+import org.matrix.android.sdk.internal.util.database.RealmMigrator
+
+internal class MigrateSessionTo045(realm: DynamicRealm) : RealmMigrator(realm, 45) {
+
+    override fun doMigrate(realm: DynamicRealm) {
+        realm.schema.create("SyncFilterParamsEntity")
+                .addField(SyncFilterParamsEntityFields.LAZY_LOAD_MEMBERS_FOR_STATE_EVENTS, Boolean::class.java)
+                .setNullable(SyncFilterParamsEntityFields.LAZY_LOAD_MEMBERS_FOR_STATE_EVENTS, true)
+                .addField(SyncFilterParamsEntityFields.LAZY_LOAD_MEMBERS_FOR_MESSAGE_EVENTS, Boolean::class.java)
+                .setNullable(SyncFilterParamsEntityFields.LAZY_LOAD_MEMBERS_FOR_MESSAGE_EVENTS, true)
+                .addField(SyncFilterParamsEntityFields.LIST_OF_SUPPORTED_EVENT_TYPES_HAS_BEEN_SET, Boolean::class.java)
+                .addField(SyncFilterParamsEntityFields.LIST_OF_SUPPORTED_STATE_EVENT_TYPES_HAS_BEEN_SET, Boolean::class.java)
+                .addField(SyncFilterParamsEntityFields.USE_THREAD_NOTIFICATIONS, Boolean::class.java)
+                .setNullable(SyncFilterParamsEntityFields.USE_THREAD_NOTIFICATIONS, true)
+                .addRealmListField(SyncFilterParamsEntityFields.LIST_OF_SUPPORTED_EVENT_TYPES.`$`, String::class.java)
+                .addRealmListField(SyncFilterParamsEntityFields.LIST_OF_SUPPORTED_STATE_EVENT_TYPES.`$`, String::class.java)
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt
index b222bcb710..93ff67a911 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt
@@ -70,7 +70,8 @@ import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntit
             SpaceChildSummaryEntity::class,
             SpaceParentSummaryEntity::class,
             UserPresenceEntity::class,
-            ThreadSummaryEntity::class
+            ThreadSummaryEntity::class,
+            SyncFilterParamsEntity::class,
         ]
 )
 internal class SessionRealmModule
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SyncFilterParamsEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SyncFilterParamsEntity.kt
new file mode 100644
index 0000000000..e4b62f28e8
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SyncFilterParamsEntity.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.internal.database.model
+
+import io.realm.RealmList
+import io.realm.RealmObject
+
+/**
+ * This entity stores Sync Filter configuration data, provided by the client.
+ */
+internal open class SyncFilterParamsEntity(
+        var lazyLoadMembersForStateEvents: Boolean? = null,
+        var lazyLoadMembersForMessageEvents: Boolean? = null,
+        var useThreadNotifications: Boolean? = null,
+        var listOfSupportedEventTypes: RealmList? = null,
+        var listOfSupportedEventTypesHasBeenSet: Boolean = false,
+        var listOfSupportedStateEventTypes: RealmList? = null,
+        var listOfSupportedStateEventTypesHasBeenSet: Boolean = false,
+) : RealmObject() {
+
+    companion object
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/DefaultFilterRepository.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/DefaultFilterRepository.kt
index 1d1bb0e715..4e5b005584 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/DefaultFilterRepository.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/DefaultFilterRepository.kt
@@ -17,74 +17,71 @@
 package org.matrix.android.sdk.internal.session.filter
 
 import com.zhuinden.monarchy.Monarchy
-import io.realm.Realm
 import io.realm.kotlin.where
+import org.matrix.android.sdk.internal.database.mapper.FilterParamsMapper
 import org.matrix.android.sdk.internal.database.model.FilterEntity
-import org.matrix.android.sdk.internal.database.model.FilterEntityFields
+import org.matrix.android.sdk.internal.database.model.SyncFilterParamsEntity
 import org.matrix.android.sdk.internal.database.query.get
 import org.matrix.android.sdk.internal.database.query.getOrCreate
 import org.matrix.android.sdk.internal.di.SessionDatabase
+import org.matrix.android.sdk.internal.sync.filter.SyncFilterParams
 import org.matrix.android.sdk.internal.util.awaitTransaction
 import javax.inject.Inject
 
-internal class DefaultFilterRepository @Inject constructor(@SessionDatabase private val monarchy: Monarchy) : FilterRepository {
+internal class DefaultFilterRepository @Inject constructor(
+        @SessionDatabase private val monarchy: Monarchy,
+        private val filterParamsMapper: FilterParamsMapper
+) : FilterRepository {
 
-    override suspend fun storeFilter(filter: Filter, roomEventFilter: RoomEventFilter): Boolean {
-        return Realm.getInstance(monarchy.realmConfiguration).use { realm ->
-            val filterEntity = FilterEntity.get(realm)
-            // Filter has changed, or no filter Id yet
-            filterEntity == null ||
-                    filterEntity.filterBodyJson != filter.toJSONString() ||
-                    filterEntity.filterId.isBlank()
-        }.also { hasChanged ->
-            if (hasChanged) {
-                // Filter is new or has changed, store it and reset the filter Id.
-                // This has to be done outside of the Realm.use(), because awaitTransaction change the current thread
-                monarchy.awaitTransaction { realm ->
-                    // We manage only one filter for now
-                    val filterJson = filter.toJSONString()
-                    val roomEventFilterJson = roomEventFilter.toJSONString()
-
-                    val filterEntity = FilterEntity.getOrCreate(realm)
-
-                    filterEntity.filterBodyJson = filterJson
-                    filterEntity.roomEventFilterJson = roomEventFilterJson
-                    // Reset filterId
-                    filterEntity.filterId = ""
-                }
-            }
-        }
-    }
-
-    override suspend fun storeFilterId(filter: Filter, filterId: String) {
-        monarchy.awaitTransaction {
+    override suspend fun storeSyncFilter(filter: Filter, filterId: String, roomEventFilter: RoomEventFilter) {
+        monarchy.awaitTransaction { realm ->
             // We manage only one filter for now
             val filterJson = filter.toJSONString()
+            val roomEventFilterJson = roomEventFilter.toJSONString()
 
-            // Update the filter id, only if the filter body matches
-            it.where()
-                    .equalTo(FilterEntityFields.FILTER_BODY_JSON, filterJson)
-                    ?.findFirst()
-                    ?.filterId = filterId
+            val filterEntity = FilterEntity.getOrCreate(realm)
+
+            filterEntity.filterBodyJson = filterJson
+            filterEntity.roomEventFilterJson = roomEventFilterJson
+            filterEntity.filterId = filterId
         }
     }
 
-    override suspend fun getFilter(): String {
+    override suspend fun getStoredSyncFilterBody(): String {
         return monarchy.awaitTransaction {
-            val filter = FilterEntity.getOrCreate(it)
-            if (filter.filterId.isBlank()) {
-                // Use the Json format
-                filter.filterBodyJson
+            FilterEntity.getOrCreate(it).filterBodyJson
+        }
+    }
+
+    override suspend fun getStoredSyncFilterId(): String? {
+        return monarchy.awaitTransaction {
+            val id = FilterEntity.get(it)?.filterId
+            if (id.isNullOrBlank()) {
+                null
             } else {
-                // Use FilterId
-                filter.filterId
+                id
             }
         }
     }
 
-    override suspend fun getRoomFilter(): String {
+    override suspend fun getRoomFilterBody(): String {
         return monarchy.awaitTransaction {
             FilterEntity.getOrCreate(it).roomEventFilterJson
         }
     }
+
+    override suspend fun getStoredFilterParams(): SyncFilterParams? {
+        return monarchy.awaitTransaction { realm ->
+            realm.where().findFirst()?.let {
+                filterParamsMapper.map(it)
+            }
+        }
+    }
+
+    override suspend fun storeFilterParams(params: SyncFilterParams) {
+        return monarchy.awaitTransaction { realm ->
+            val entity = filterParamsMapper.map(params)
+            realm.insertOrUpdate(entity)
+        }
+    }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/DefaultFilterService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/DefaultFilterService.kt
index 2e68d02d8c..c54e7de07a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/DefaultFilterService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/DefaultFilterService.kt
@@ -17,19 +17,27 @@
 package org.matrix.android.sdk.internal.session.filter
 
 import org.matrix.android.sdk.api.session.sync.FilterService
-import org.matrix.android.sdk.internal.task.TaskExecutor
-import org.matrix.android.sdk.internal.task.configureWith
+import org.matrix.android.sdk.api.session.sync.filter.SyncFilterBuilder
+import org.matrix.android.sdk.internal.session.homeserver.HomeServerCapabilitiesDataSource
 import javax.inject.Inject
 
 internal class DefaultFilterService @Inject constructor(
         private val saveFilterTask: SaveFilterTask,
-        private val taskExecutor: TaskExecutor
+        private val filterRepository: FilterRepository,
+        private val homeServerCapabilitiesDataSource: HomeServerCapabilitiesDataSource,
 ) : FilterService {
 
     // TODO Pass a list of support events instead
-    override fun setFilter(filterPreset: FilterService.FilterPreset) {
-        saveFilterTask
-                .configureWith(SaveFilterTask.Params(filterPreset))
-                .executeBy(taskExecutor)
+    override suspend fun setSyncFilter(filterBuilder: SyncFilterBuilder) {
+        filterRepository.storeFilterParams(filterBuilder.extractParams())
+
+        // don't upload/store filter until homeserver capabilities are fetched
+        homeServerCapabilitiesDataSource.getHomeServerCapabilities()?.let { homeServerCapabilities ->
+            saveFilterTask.execute(
+                    SaveFilterTask.Params(
+                            filter = filterBuilder.build(homeServerCapabilities)
+                    )
+            )
+        }
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt
index e0919c52e3..1bd2e59e59 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt
@@ -45,46 +45,7 @@ internal object FilterFactory {
         return FilterUtil.enableLazyLoading(Filter(), true)
     }
 
-    fun createElementFilter(): Filter {
-        return Filter(
-                room = RoomFilter(
-                        timeline = createElementTimelineFilter(),
-                        state = createElementStateFilter()
-                )
-        )
-    }
-
     fun createDefaultRoomFilter(): RoomEventFilter {
         return RoomEventFilter(lazyLoadMembers = true)
     }
-
-    fun createElementRoomFilter(): RoomEventFilter {
-        return RoomEventFilter(
-                lazyLoadMembers = true,
-                // TODO Enable this for optimization
-                // types = (listOfSupportedEventTypes + listOfSupportedStateEventTypes).toMutableList()
-        )
-    }
-
-    private fun createElementTimelineFilter(): RoomEventFilter? {
-//        we need to check if homeserver supports thread notifications before setting this param
-//        return RoomEventFilter(enableUnreadThreadNotifications = true)
-        return null
-    }
-
-    private fun createElementStateFilter(): RoomEventFilter {
-        return RoomEventFilter(lazyLoadMembers = true)
-    }
-
-    // Get only managed types by Element
-    private val listOfSupportedEventTypes = listOf(
-            // TODO Complete the list
-            EventType.MESSAGE
-    )
-
-    // Get only managed types by Element
-    private val listOfSupportedStateEventTypes = listOf(
-            // TODO Complete the list
-            EventType.STATE_ROOM_MEMBER
-    )
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterModule.kt
index 8531bed1ff..ca9f798fd9 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterModule.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterModule.kt
@@ -44,4 +44,7 @@ internal abstract class FilterModule {
 
     @Binds
     abstract fun bindSaveFilterTask(task: DefaultSaveFilterTask): SaveFilterTask
+
+    @Binds
+    abstract fun bindGetCurrentFilterTask(task: DefaultGetCurrentFilterTask): GetCurrentFilterTask
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterRepository.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterRepository.kt
index f40231c8cf..71d7391e87 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterRepository.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterRepository.kt
@@ -16,25 +16,42 @@
 
 package org.matrix.android.sdk.internal.session.filter
 
+import org.matrix.android.sdk.internal.sync.filter.SyncFilterParams
+
+/**
+ * Repository for request filters.
+ */
 internal interface FilterRepository {
 
     /**
-     * Return true if the filterBody has changed, or need to be sent to the server.
+     * Stores sync filter and room filter.
+     * Note: It looks like we could use [Filter.room.timeline] instead of a separate [RoomEventFilter], but it's not clear if it's safe, so research is needed
+     * @return true if the filterBody has changed, or need to be sent to the server.
      */
-    suspend fun storeFilter(filter: Filter, roomEventFilter: RoomEventFilter): Boolean
+    suspend fun storeSyncFilter(filter: Filter, filterId: String, roomEventFilter: RoomEventFilter)
 
     /**
-     * Set the filterId of this filter.
+     * Returns stored sync filter's JSON body if it exists.
      */
-    suspend fun storeFilterId(filter: Filter, filterId: String)
+    suspend fun getStoredSyncFilterBody(): String?
 
     /**
-     * Return filter json or filter id.
+     * Returns stored sync filter's ID if it exists.
      */
-    suspend fun getFilter(): String
+    suspend fun getStoredSyncFilterId(): String?
 
     /**
      * Return the room filter.
      */
-    suspend fun getRoomFilter(): String
+    suspend fun getRoomFilterBody(): String
+
+    /**
+     * Returns filter params stored in local storage if it exists.
+     */
+    suspend fun getStoredFilterParams(): SyncFilterParams?
+
+    /**
+     * Stores filter params to local storage.
+     */
+    suspend fun storeFilterParams(params: SyncFilterParams)
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/GetCurrentFilterTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/GetCurrentFilterTask.kt
new file mode 100644
index 0000000000..e88f286e27
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/GetCurrentFilterTask.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.internal.session.filter
+
+import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
+import org.matrix.android.sdk.api.session.sync.filter.SyncFilterBuilder
+import org.matrix.android.sdk.internal.session.homeserver.HomeServerCapabilitiesDataSource
+import org.matrix.android.sdk.internal.task.Task
+import javax.inject.Inject
+
+internal interface GetCurrentFilterTask : Task
+
+internal class DefaultGetCurrentFilterTask @Inject constructor(
+        private val filterRepository: FilterRepository,
+        private val homeServerCapabilitiesDataSource: HomeServerCapabilitiesDataSource,
+        private val saveFilterTask: SaveFilterTask
+) : GetCurrentFilterTask {
+
+    override suspend fun execute(params: Unit): String {
+        val storedFilterId = filterRepository.getStoredSyncFilterId()
+        val storedFilterBody = filterRepository.getStoredSyncFilterBody()
+        val homeServerCapabilities = homeServerCapabilitiesDataSource.getHomeServerCapabilities() ?: HomeServerCapabilities()
+        val currentFilter = SyncFilterBuilder()
+                .with(filterRepository.getStoredFilterParams())
+                .build(homeServerCapabilities)
+
+        val currentFilterBody = currentFilter.toJSONString()
+
+        return when (storedFilterBody) {
+            currentFilterBody -> storedFilterId ?: storedFilterBody
+            else -> saveFilter(currentFilter)
+        }
+    }
+
+    private suspend fun saveFilter(filter: Filter) = saveFilterTask
+            .execute(
+                    SaveFilterTask.Params(
+                            filter = filter
+                    )
+            )
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/SaveFilterTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/SaveFilterTask.kt
index 63afa1bbbc..82d5ff4d2f 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/SaveFilterTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/SaveFilterTask.kt
@@ -16,7 +16,6 @@
 
 package org.matrix.android.sdk.internal.session.filter
 
-import org.matrix.android.sdk.api.session.sync.FilterService
 import org.matrix.android.sdk.internal.di.UserId
 import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
 import org.matrix.android.sdk.internal.network.executeRequest
@@ -26,10 +25,10 @@ import javax.inject.Inject
 /**
  * Save a filter, in db and if any changes, upload to the server.
  */
-internal interface SaveFilterTask : Task {
+internal interface SaveFilterTask : Task {
 
     data class Params(
-            val filterPreset: FilterService.FilterPreset
+            val filter: Filter
     )
 }
 
@@ -37,33 +36,21 @@ internal class DefaultSaveFilterTask @Inject constructor(
         @UserId private val userId: String,
         private val filterAPI: FilterApi,
         private val filterRepository: FilterRepository,
-        private val globalErrorReceiver: GlobalErrorReceiver
+        private val globalErrorReceiver: GlobalErrorReceiver,
 ) : SaveFilterTask {
 
-    override suspend fun execute(params: SaveFilterTask.Params) {
-        val filterBody = when (params.filterPreset) {
-            FilterService.FilterPreset.ElementFilter -> {
-                FilterFactory.createElementFilter()
-            }
-            FilterService.FilterPreset.NoFilter -> {
-                FilterFactory.createDefaultFilter()
-            }
-        }
-        val roomFilter = when (params.filterPreset) {
-            FilterService.FilterPreset.ElementFilter -> {
-                FilterFactory.createElementRoomFilter()
-            }
-            FilterService.FilterPreset.NoFilter -> {
-                FilterFactory.createDefaultRoomFilter()
-            }
-        }
-        val updated = filterRepository.storeFilter(filterBody, roomFilter)
-        if (updated) {
-            val filterResponse = executeRequest(globalErrorReceiver) {
-                // TODO auto retry
-                filterAPI.uploadFilter(userId, filterBody)
-            }
-            filterRepository.storeFilterId(filterBody, filterResponse.filterId)
+    override suspend fun execute(params: SaveFilterTask.Params): String {
+        val filter = params.filter
+        val filterResponse = executeRequest(globalErrorReceiver) {
+            // TODO auto retry
+            filterAPI.uploadFilter(userId, filter)
         }
+
+        filterRepository.storeSyncFilter(
+                filter = filter,
+                filterId = filterResponse.filterId,
+                roomEventFilter = FilterFactory.createDefaultRoomFilter()
+        )
+        return filterResponse.filterId
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/FetchTokenAndPaginateTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/FetchTokenAndPaginateTask.kt
index 96646b42ed..9d8d8ecbf1 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/FetchTokenAndPaginateTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/FetchTokenAndPaginateTask.kt
@@ -47,7 +47,7 @@ internal class DefaultFetchTokenAndPaginateTask @Inject constructor(
 ) : FetchTokenAndPaginateTask {
 
     override suspend fun execute(params: FetchTokenAndPaginateTask.Params): TokenChunkEventPersistor.Result {
-        val filter = filterRepository.getRoomFilter()
+        val filter = filterRepository.getRoomFilterBody()
         val response = executeRequest(globalErrorReceiver) {
             roomAPI.getContextOfEvent(params.roomId, params.lastKnownEventId, 0, filter)
         }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/GetContextOfEventTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/GetContextOfEventTask.kt
index 015e55f070..c3911dfa2c 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/GetContextOfEventTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/GetContextOfEventTask.kt
@@ -39,7 +39,7 @@ internal class DefaultGetContextOfEventTask @Inject constructor(
 ) : GetContextOfEventTask {
 
     override suspend fun execute(params: GetContextOfEventTask.Params): TokenChunkEventPersistor.Result {
-        val filter = filterRepository.getRoomFilter()
+        val filter = filterRepository.getRoomFilterBody()
         val response = executeRequest(globalErrorReceiver) {
             // We are limiting the response to the event with eventId to be sure we don't have any issue with potential merging process.
             roomAPI.getContextOfEvent(params.roomId, params.eventId, 0, filter)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/PaginationTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/PaginationTask.kt
index 8aeccb66c8..1a7b1cdac4 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/PaginationTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/PaginationTask.kt
@@ -41,7 +41,7 @@ internal class DefaultPaginationTask @Inject constructor(
 ) : PaginationTask {
 
     override suspend fun execute(params: PaginationTask.Params): TokenChunkEventPersistor.Result {
-        val filter = filterRepository.getRoomFilter()
+        val filter = filterRepository.getRoomFilterBody()
         val chunk = executeRequest(
                 globalErrorReceiver,
                 canRetry = true
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncTask.kt
index bc1a69769d..8a287fb0b4 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncTask.kt
@@ -36,7 +36,7 @@ import org.matrix.android.sdk.internal.network.executeRequest
 import org.matrix.android.sdk.internal.network.toFailure
 import org.matrix.android.sdk.internal.session.SessionListeners
 import org.matrix.android.sdk.internal.session.dispatchTo
-import org.matrix.android.sdk.internal.session.filter.FilterRepository
+import org.matrix.android.sdk.internal.session.filter.GetCurrentFilterTask
 import org.matrix.android.sdk.internal.session.homeserver.GetHomeServerCapabilitiesTask
 import org.matrix.android.sdk.internal.session.sync.parsing.InitialSyncResponseParser
 import org.matrix.android.sdk.internal.session.user.UserStore
@@ -64,11 +64,9 @@ internal interface SyncTask : Task {
 internal class DefaultSyncTask @Inject constructor(
         private val syncAPI: SyncAPI,
         @UserId private val userId: String,
-        private val filterRepository: FilterRepository,
         private val syncResponseHandler: SyncResponseHandler,
         private val syncRequestStateTracker: SyncRequestStateTracker,
         private val syncTokenStore: SyncTokenStore,
-        private val getHomeServerCapabilitiesTask: GetHomeServerCapabilitiesTask,
         private val userStore: UserStore,
         private val session: Session,
         private val sessionListeners: SessionListeners,
@@ -79,6 +77,8 @@ internal class DefaultSyncTask @Inject constructor(
         private val syncResponseParser: InitialSyncResponseParser,
         private val roomSyncEphemeralTemporaryStore: RoomSyncEphemeralTemporaryStore,
         private val clock: Clock,
+        private val getHomeServerCapabilitiesTask: GetHomeServerCapabilitiesTask,
+        private val getCurrentFilterTask: GetCurrentFilterTask
 ) : SyncTask {
 
     private val workingDir = File(fileDirectory, "is")
@@ -100,8 +100,13 @@ internal class DefaultSyncTask @Inject constructor(
             requestParams["since"] = token
             timeout = params.timeout
         }
+
+        // Maybe refresh the homeserver capabilities data we know
+        getHomeServerCapabilitiesTask.execute(GetHomeServerCapabilitiesTask.Params(forceRefresh = false))
+        val filter = getCurrentFilterTask.execute(Unit)
+
         requestParams["timeout"] = timeout.toString()
-        requestParams["filter"] = filterRepository.getFilter()
+        requestParams["filter"] = filter
         params.presence?.let { requestParams["set_presence"] = it.value }
 
         val isInitialSync = token == null
@@ -115,8 +120,6 @@ internal class DefaultSyncTask @Inject constructor(
             )
             syncRequestStateTracker.startRoot(InitialSyncStep.ImportingAccount, 100)
         }
-        // Maybe refresh the homeserver capabilities data we know
-        getHomeServerCapabilitiesTask.execute(GetHomeServerCapabilitiesTask.Params(forceRefresh = false))
 
         val readTimeOut = (params.timeout + TIMEOUT_MARGIN).coerceAtLeast(TimeOutInterceptor.DEFAULT_LONG_TIMEOUT)
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/sync/filter/SyncFilterParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/sync/filter/SyncFilterParams.kt
new file mode 100644
index 0000000000..a7de7f5579
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/sync/filter/SyncFilterParams.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.internal.sync.filter
+
+internal data class SyncFilterParams(
+        val lazyLoadMembersForStateEvents: Boolean? = null,
+        val lazyLoadMembersForMessageEvents: Boolean? = null,
+        val useThreadNotifications: Boolean? = null,
+        val listOfSupportedEventTypes: List? = null,
+        val listOfSupportedStateEventTypes: List? = null,
+)
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/sync/DefaultGetCurrentFilterTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/sync/DefaultGetCurrentFilterTaskTest.kt
new file mode 100644
index 0000000000..201423685c
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/sync/DefaultGetCurrentFilterTaskTest.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.internal.sync
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.amshove.kluent.shouldBeEqualTo
+import org.junit.Test
+import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
+import org.matrix.android.sdk.api.session.sync.filter.SyncFilterBuilder
+import org.matrix.android.sdk.internal.session.filter.DefaultGetCurrentFilterTask
+import org.matrix.android.sdk.internal.sync.filter.SyncFilterParams
+import org.matrix.android.sdk.test.fakes.FakeFilterRepository
+import org.matrix.android.sdk.test.fakes.FakeHomeServerCapabilitiesDataSource
+import org.matrix.android.sdk.test.fakes.FakeSaveFilterTask
+
+private const val A_FILTER_ID = "filter-id"
+private val A_HOMESERVER_CAPABILITIES = HomeServerCapabilities()
+private val A_SYNC_FILTER_PARAMS = SyncFilterParams(
+        lazyLoadMembersForMessageEvents = true,
+        lazyLoadMembersForStateEvents = true,
+        useThreadNotifications = true
+)
+
+@ExperimentalCoroutinesApi
+class DefaultGetCurrentFilterTaskTest {
+
+    private val filterRepository = FakeFilterRepository()
+    private val homeServerCapabilitiesDataSource = FakeHomeServerCapabilitiesDataSource()
+    private val saveFilterTask = FakeSaveFilterTask()
+
+    private val getCurrentFilterTask = DefaultGetCurrentFilterTask(
+            filterRepository = filterRepository,
+            homeServerCapabilitiesDataSource = homeServerCapabilitiesDataSource.instance,
+            saveFilterTask = saveFilterTask
+    )
+
+    @Test
+    fun `given no filter is stored, when execute, then executes task to save new filter`() = runTest {
+        filterRepository.givenFilterParamsAreStored(A_SYNC_FILTER_PARAMS)
+
+        homeServerCapabilitiesDataSource.givenHomeServerCapabilities(A_HOMESERVER_CAPABILITIES)
+
+        filterRepository.givenFilterStored(null, null)
+
+        getCurrentFilterTask.execute(Unit)
+
+        val filter = SyncFilterBuilder()
+                .with(A_SYNC_FILTER_PARAMS)
+                .build(A_HOMESERVER_CAPABILITIES)
+
+        saveFilterTask.verifyExecution(filter)
+    }
+
+    @Test
+    fun `given filter is stored and didn't change, when execute, then returns stored filter id`() = runTest {
+        filterRepository.givenFilterParamsAreStored(A_SYNC_FILTER_PARAMS)
+
+        homeServerCapabilitiesDataSource.givenHomeServerCapabilities(A_HOMESERVER_CAPABILITIES)
+
+        val filter = SyncFilterBuilder().with(A_SYNC_FILTER_PARAMS).build(A_HOMESERVER_CAPABILITIES)
+        filterRepository.givenFilterStored(A_FILTER_ID, filter.toJSONString())
+
+        val result = getCurrentFilterTask.execute(Unit)
+
+        result shouldBeEqualTo A_FILTER_ID
+    }
+
+    @Test
+    fun `given filter is set and home server capabilities has changed, when execute, then executes task to save new filter`() = runTest {
+        filterRepository.givenFilterParamsAreStored(A_SYNC_FILTER_PARAMS)
+
+        homeServerCapabilitiesDataSource.givenHomeServerCapabilities(A_HOMESERVER_CAPABILITIES)
+
+        val filter = SyncFilterBuilder().with(A_SYNC_FILTER_PARAMS).build(A_HOMESERVER_CAPABILITIES)
+        filterRepository.givenFilterStored(A_FILTER_ID, filter.toJSONString())
+
+        val newHomeServerCapabilities = HomeServerCapabilities(canUseThreadReadReceiptsAndNotifications = true)
+        homeServerCapabilitiesDataSource.givenHomeServerCapabilities(newHomeServerCapabilities)
+        val newFilter = SyncFilterBuilder().with(A_SYNC_FILTER_PARAMS).build(newHomeServerCapabilities)
+
+        getCurrentFilterTask.execute(Unit)
+
+        saveFilterTask.verifyExecution(newFilter)
+    }
+}
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeFilterRepository.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeFilterRepository.kt
new file mode 100644
index 0000000000..b8225f21d6
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeFilterRepository.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.test.fakes
+
+import io.mockk.coEvery
+import io.mockk.mockk
+import org.matrix.android.sdk.internal.session.filter.FilterRepository
+import org.matrix.android.sdk.internal.sync.filter.SyncFilterParams
+
+internal class FakeFilterRepository : FilterRepository by mockk() {
+
+    fun givenFilterStored(filterId: String?, filterBody: String?) {
+        coEvery { getStoredSyncFilterId() } returns filterId
+        coEvery { getStoredSyncFilterBody() } returns filterBody
+    }
+
+    fun givenFilterParamsAreStored(syncFilterParams: SyncFilterParams?) {
+        coEvery { getStoredFilterParams() } returns syncFilterParams
+    }
+}
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeHomeServerCapabilitiesDataSource.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeHomeServerCapabilitiesDataSource.kt
new file mode 100644
index 0000000000..9a56a599d1
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeHomeServerCapabilitiesDataSource.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.test.fakes
+
+import io.mockk.every
+import io.mockk.mockk
+import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
+import org.matrix.android.sdk.internal.session.homeserver.HomeServerCapabilitiesDataSource
+
+internal class FakeHomeServerCapabilitiesDataSource {
+    val instance = mockk()
+
+    fun givenHomeServerCapabilities(homeServerCapabilities: HomeServerCapabilities) {
+        every { instance.getHomeServerCapabilities() } returns homeServerCapabilities
+    }
+}
diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeSaveFilterTask.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeSaveFilterTask.kt
new file mode 100644
index 0000000000..40bee227e0
--- /dev/null
+++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeSaveFilterTask.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.matrix.android.sdk.test.fakes
+
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import io.mockk.slot
+import org.amshove.kluent.shouldBeEqualTo
+import org.matrix.android.sdk.internal.session.filter.Filter
+import org.matrix.android.sdk.internal.session.filter.SaveFilterTask
+import java.util.UUID
+
+internal class FakeSaveFilterTask : SaveFilterTask by mockk() {
+
+    init {
+        coEvery { execute(any()) } returns UUID.randomUUID().toString()
+    }
+
+    fun verifyExecution(filter: Filter) {
+        val slot = slot()
+        coVerify { execute(capture(slot)) }
+        val params = slot.captured
+        params.filter shouldBeEqualTo filter
+    }
+}
diff --git a/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt b/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt
index c47769052c..96c3f8a6ce 100644
--- a/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt
+++ b/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt
@@ -24,9 +24,9 @@ import im.vector.app.core.session.clientinfo.UpdateMatrixClientInfoUseCase
 import im.vector.app.features.call.webrtc.WebRtcCallManager
 import im.vector.app.features.session.coroutineScope
 import im.vector.app.features.settings.VectorPreferences
+import im.vector.app.features.sync.SyncUtils
 import kotlinx.coroutines.launch
 import org.matrix.android.sdk.api.session.Session
-import org.matrix.android.sdk.api.session.sync.FilterService
 import timber.log.Timber
 import javax.inject.Inject
 
@@ -41,7 +41,9 @@ class ConfigureAndStartSessionUseCase @Inject constructor(
     fun execute(session: Session, startSyncing: Boolean = true) {
         Timber.i("Configure and start session for ${session.myUserId}. startSyncing: $startSyncing")
         session.open()
-        session.filterService().setFilter(FilterService.FilterPreset.ElementFilter)
+        session.coroutineScope.launch {
+            session.filterService().setSyncFilter(SyncUtils.getSyncFilterBuilder())
+        }
         if (startSyncing) {
             session.startSyncing(context)
         }
diff --git a/vector/src/main/java/im/vector/app/features/sync/SyncUtils.kt b/vector/src/main/java/im/vector/app/features/sync/SyncUtils.kt
new file mode 100644
index 0000000000..e3408d8814
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/sync/SyncUtils.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.sync
+
+import org.matrix.android.sdk.api.session.events.model.EventType
+import org.matrix.android.sdk.api.session.sync.filter.SyncFilterBuilder
+
+object SyncUtils {
+    // Get only managed types by Element
+    private val listOfSupportedTimelineEventTypes = listOf(
+            // TODO Complete the list
+            EventType.MESSAGE
+    )
+
+    // Get only managed types by Element
+    private val listOfSupportedStateEventTypes = listOf(
+            // TODO Complete the list
+            EventType.STATE_ROOM_MEMBER
+    )
+
+    fun getSyncFilterBuilder(): SyncFilterBuilder {
+        return SyncFilterBuilder()
+                .useThreadNotifications(true)
+                .lazyLoadMembersForStateEvents(true)
+        /**
+         * Currently we don't set [lazy_load_members = true] for Filter.room.timeline even though we set it for RoomFilter which is used later to
+         * fetch messages in a room. It's not clear if it's done so by mistake or intentionally, so changing it could case side effects and need
+         * careful testing
+         * */
+//                .lazyLoadMembersForMessageEvents(true)
+//                .listOfSupportedStateEventTypes(listOfSupportedStateEventTypes)
+//                .listOfSupportedTimelineEventTypes(listOfSupportedTimelineEventTypes)
+    }
+}
diff --git a/vector/src/test/java/im/vector/app/core/session/ConfigureAndStartSessionUseCaseTest.kt b/vector/src/test/java/im/vector/app/core/session/ConfigureAndStartSessionUseCaseTest.kt
index 760879b69d..01596e796d 100644
--- a/vector/src/test/java/im/vector/app/core/session/ConfigureAndStartSessionUseCaseTest.kt
+++ b/vector/src/test/java/im/vector/app/core/session/ConfigureAndStartSessionUseCaseTest.kt
@@ -19,6 +19,7 @@ package im.vector.app.core.session
 import im.vector.app.core.extensions.startSyncing
 import im.vector.app.core.session.clientinfo.UpdateMatrixClientInfoUseCase
 import im.vector.app.features.session.coroutineScope
+import im.vector.app.features.sync.SyncUtils
 import im.vector.app.test.fakes.FakeContext
 import im.vector.app.test.fakes.FakeEnableNotificationsSettingUpdater
 import im.vector.app.test.fakes.FakeSession
@@ -38,7 +39,6 @@ import kotlinx.coroutines.test.runTest
 import org.junit.After
 import org.junit.Before
 import org.junit.Test
-import org.matrix.android.sdk.api.session.sync.FilterService
 
 class ConfigureAndStartSessionUseCaseTest {
 
@@ -83,7 +83,7 @@ class ConfigureAndStartSessionUseCaseTest {
 
         // Then
         verify { fakeSession.startSyncing(fakeContext.instance) }
-        fakeSession.fakeFilterService.verifySetFilter(FilterService.FilterPreset.ElementFilter)
+        fakeSession.fakeFilterService.verifySetSyncFilter(SyncUtils.getSyncFilterBuilder())
         fakeSession.fakePushersService.verifyRefreshPushers()
         fakeWebRtcCallManager.verifyCheckForProtocolsSupportIfNeeded()
         coVerify { fakeUpdateMatrixClientInfoUseCase.execute(fakeSession) }
@@ -105,7 +105,7 @@ class ConfigureAndStartSessionUseCaseTest {
 
         // Then
         verify { fakeSession.startSyncing(fakeContext.instance) }
-        fakeSession.fakeFilterService.verifySetFilter(FilterService.FilterPreset.ElementFilter)
+        fakeSession.fakeFilterService.verifySetSyncFilter(SyncUtils.getSyncFilterBuilder())
         fakeSession.fakePushersService.verifyRefreshPushers()
         fakeWebRtcCallManager.verifyCheckForProtocolsSupportIfNeeded()
         coVerify(inverse = true) { fakeUpdateMatrixClientInfoUseCase.execute(fakeSession) }
@@ -127,7 +127,7 @@ class ConfigureAndStartSessionUseCaseTest {
 
         // Then
         verify(inverse = true) { fakeSession.startSyncing(fakeContext.instance) }
-        fakeSession.fakeFilterService.verifySetFilter(FilterService.FilterPreset.ElementFilter)
+        fakeSession.fakeFilterService.verifySetSyncFilter(SyncUtils.getSyncFilterBuilder())
         fakeSession.fakePushersService.verifyRefreshPushers()
         fakeWebRtcCallManager.verifyCheckForProtocolsSupportIfNeeded()
         coVerify { fakeUpdateMatrixClientInfoUseCase.execute(fakeSession) }
diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeFilterService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeFilterService.kt
index 4332368127..9be59d31fd 100644
--- a/vector/src/test/java/im/vector/app/test/fakes/FakeFilterService.kt
+++ b/vector/src/test/java/im/vector/app/test/fakes/FakeFilterService.kt
@@ -16,20 +16,21 @@
 
 package im.vector.app.test.fakes
 
-import io.mockk.every
+import io.mockk.coEvery
+import io.mockk.coVerify
 import io.mockk.just
 import io.mockk.mockk
 import io.mockk.runs
-import io.mockk.verify
 import org.matrix.android.sdk.api.session.sync.FilterService
+import org.matrix.android.sdk.api.session.sync.filter.SyncFilterBuilder
 
 class FakeFilterService : FilterService by mockk() {
 
     fun givenSetFilterSucceeds() {
-        every { setFilter(any()) } just runs
+        coEvery { setSyncFilter(any()) } just runs
     }
 
-    fun verifySetFilter(filterPreset: FilterService.FilterPreset) {
-        verify { setFilter(filterPreset) }
+    fun verifySetSyncFilter(filterBuilder: SyncFilterBuilder) {
+        coVerify { setSyncFilter(filterBuilder) }
     }
 }

From dd815840764e21cd874985831ae6b6627ff00e0e Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Mon, 28 Nov 2022 12:12:49 +0100
Subject: [PATCH 138/173] Ad default value to MessageStickerContent.body in
 case of redaction

---
 .../sdk/api/session/room/model/message/MessageStickerContent.kt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageStickerContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageStickerContent.kt
index f8c1c0d798..627ce53df6 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageStickerContent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageStickerContent.kt
@@ -34,7 +34,7 @@ data class MessageStickerContent(
          * Required. A textual representation of the image. This could be the alt text of the image, the filename of the image,
          * or some kind of content description for accessibility e.g. 'image attachment'.
          */
-        @Json(name = "body") override val body: String,
+        @Json(name = "body") override val body: String = "",
 
         /**
          * Metadata about the image referred to in url.

From ee22dafbc90732003f5944c95a577bcf75ddb7fb Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Mon, 28 Nov 2022 12:14:31 +0100
Subject: [PATCH 139/173] Fix regression when getting last message content for
 Voice Broadcast state event

---
 .../main/java/im/vector/app/core/extensions/TimelineEvent.kt   | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt
index 63144ca1b3..c94f9cd921 100644
--- a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt
+++ b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt
@@ -19,7 +19,6 @@ package im.vector.app.core.extensions
 import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
 import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
 import org.matrix.android.sdk.api.session.events.model.EventType
-import org.matrix.android.sdk.api.session.events.model.toContent
 import org.matrix.android.sdk.api.session.events.model.toModel
 import org.matrix.android.sdk.api.session.room.model.message.MessageContent
 import org.matrix.android.sdk.api.session.room.send.SendState
@@ -41,7 +40,7 @@ fun TimelineEvent.getVectorLastMessageContent(): MessageContent? {
     // Iterate on event types which are not part of the matrix sdk, otherwise fallback to the sdk method
     return when (root.getClearType()) {
         VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> {
-            (annotations?.editSummary?.latestEdit?.getClearContent()?.toModel().toContent().toModel()
+            (annotations?.editSummary?.latestEdit?.getClearContent()?.toModel()
                     ?: root.getClearContent().toModel())
         }
         else -> getLastMessageContent()

From 2d60e49205b28a6d8199060afdf9089eceed72e9 Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Wed, 23 Nov 2022 12:09:41 +0100
Subject: [PATCH 140/173] Handle redaction when observing voice broadcast state
 changes

---
 .../listening/VoiceBroadcastPlayerImpl.kt     |   4 +-
 .../GetLiveVoiceBroadcastChunksUseCase.kt     |   4 +-
 ...stRecentVoiceBroadcastStateEventUseCase.kt | 163 ++++++++++++++++++
 .../usecase/GetVoiceBroadcastEventUseCase.kt  |  68 --------
 4 files changed, 167 insertions(+), 72 deletions(-)
 create mode 100644 vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt
 delete mode 100644 vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastEventUseCase.kt

diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
index 5b0e5b2b1c..79d59064e9 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
@@ -30,7 +30,7 @@ import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.Stat
 import im.vector.app.features.voicebroadcast.listening.usecase.GetLiveVoiceBroadcastChunksUseCase
 import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
 import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent
-import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastEventUseCase
+import im.vector.app.features.voicebroadcast.usecase.GetMostRecentVoiceBroadcastStateEventUseCase
 import im.vector.lib.core.utils.timer.CountUpTimer
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.launchIn
@@ -48,7 +48,7 @@ import javax.inject.Singleton
 class VoiceBroadcastPlayerImpl @Inject constructor(
         private val sessionHolder: ActiveSessionHolder,
         private val playbackTracker: AudioMessagePlaybackTracker,
-        private val getVoiceBroadcastEventUseCase: GetVoiceBroadcastEventUseCase,
+        private val getVoiceBroadcastEventUseCase: GetMostRecentVoiceBroadcastStateEventUseCase,
         private val getLiveVoiceBroadcastChunksUseCase: GetLiveVoiceBroadcastChunksUseCase
 ) : VoiceBroadcastPlayer {
 
diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt
index 16b15b9a77..03e713eeaa 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt
@@ -24,7 +24,7 @@ import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent
 import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
 import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
 import im.vector.app.features.voicebroadcast.sequence
-import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastEventUseCase
+import im.vector.app.features.voicebroadcast.usecase.GetMostRecentVoiceBroadcastStateEventUseCase
 import im.vector.app.features.voicebroadcast.voiceBroadcastId
 import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.flow.Flow
@@ -48,7 +48,7 @@ import javax.inject.Inject
  */
 class GetLiveVoiceBroadcastChunksUseCase @Inject constructor(
         private val activeSessionHolder: ActiveSessionHolder,
-        private val getVoiceBroadcastEventUseCase: GetVoiceBroadcastEventUseCase,
+        private val getVoiceBroadcastEventUseCase: GetMostRecentVoiceBroadcastStateEventUseCase,
 ) {
 
     fun execute(voiceBroadcast: VoiceBroadcast): Flow> {
diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt
new file mode 100644
index 0000000000..b882d1625b
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt
@@ -0,0 +1,163 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.voicebroadcast.usecase
+
+import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
+import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
+import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent
+import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
+import im.vector.app.features.voicebroadcast.voiceBroadcastId
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.transformWhile
+import org.matrix.android.sdk.api.extensions.orFalse
+import org.matrix.android.sdk.api.query.QueryStringValue
+import org.matrix.android.sdk.api.session.Session
+import org.matrix.android.sdk.api.session.events.model.RelationType
+import org.matrix.android.sdk.api.session.getRoom
+import org.matrix.android.sdk.api.session.room.Room
+import org.matrix.android.sdk.api.util.Optional
+import org.matrix.android.sdk.api.util.toOptional
+import org.matrix.android.sdk.flow.flow
+import org.matrix.android.sdk.flow.mapOptional
+import timber.log.Timber
+import javax.inject.Inject
+
+class GetMostRecentVoiceBroadcastStateEventUseCase @Inject constructor(
+        private val session: Session,
+) {
+
+    fun execute(voiceBroadcast: VoiceBroadcast): Flow> {
+        val room = session.getRoom(voiceBroadcast.roomId) ?: error("Unknown roomId: ${voiceBroadcast.roomId}")
+        return getMostRecentVoiceBroadcastEventFlow(room, voiceBroadcast)
+                .onEach { event ->
+                    Timber.d(
+                            "## VoiceBroadcast | " +
+                                    "voiceBroadcastId=${event.getOrNull()?.voiceBroadcastId}, " +
+                                    "state=${event.getOrNull()?.content?.voiceBroadcastState}"
+                    )
+                }
+    }
+
+    /**
+     * Get a flow of the most recent event for the given voice broadcast.
+     */
+    private fun getMostRecentVoiceBroadcastEventFlow(room: Room, voiceBroadcast: VoiceBroadcast): Flow> {
+        val startedEventFlow = room.flow().liveTimelineEvent(voiceBroadcast.voiceBroadcastId)
+        // observe started event changes
+        return startedEventFlow
+                .mapOptional { it.root.asVoiceBroadcastEvent() }
+                .flatMapLatest { startedEvent ->
+                    if (startedEvent.hasValue().not() || startedEvent.get().root.isRedacted()) {
+                        // if started event is null or redacted, send null
+                        flowOf(Optional.empty())
+                    } else {
+                        // otherwise, observe most recent event changes
+                        getMostRecentRelatedEventFlow(room, voiceBroadcast)
+                                .transformWhile { mostRecentEvent ->
+                                    emit(mostRecentEvent)
+                                    mostRecentEvent.hasValue()
+                                }
+                                .map {
+                                    if (!it.hasValue()) {
+                                        // no most recent event, fallback to started event
+                                        startedEvent
+                                    } else {
+                                        // otherwise, keep the most recent event
+                                        it
+                                    }
+                                }
+                    }
+                }
+                .distinctUntilChangedBy { it.getOrNull()?.content?.voiceBroadcastState }
+    }
+
+    /**
+     * Get a flow of the most recent related event.
+     */
+    private fun getMostRecentRelatedEventFlow(room: Room, voiceBroadcast: VoiceBroadcast): Flow> {
+        val mostRecentEvent = getMostRecentRelatedEvent(room, voiceBroadcast).toOptional()
+        return if (mostRecentEvent.hasValue()) {
+            val stateKey = mostRecentEvent.get().root.stateKey.orEmpty()
+            // observe incoming voice broadcast state events
+            room.flow()
+                    .liveStateEvent(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, QueryStringValue.Equals(stateKey))
+                    .mapOptional { it.asVoiceBroadcastEvent() }
+                    // drop first event sent by the matrix-sdk, we compute manually this first event
+                    .drop(1)
+                    // start with the computed most recent event
+                    .onStart { emit(mostRecentEvent) }
+                    // handle event if null or related to the given voice broadcast
+                    .filter { it.hasValue().not() || it.get().voiceBroadcastId == voiceBroadcast.voiceBroadcastId }
+                    // observe changes while event is not null
+                    .transformWhile { event ->
+                        emit(event)
+                        event.hasValue()
+                    }
+                    .flatMapLatest { newMostRecentEvent ->
+                        if (newMostRecentEvent.hasValue()) {
+                            // observe most recent event changes
+                            newMostRecentEvent.get().flow()
+                                    .transformWhile { event ->
+                                        // observe changes until event is null or redacted
+                                        emit(event)
+                                        event.hasValue() && event.get().root.isRedacted().not()
+                                    }
+                                    .flatMapLatest { event ->
+                                        if (event.getOrNull()?.root?.isRedacted().orFalse()) {
+                                            // event is null or redacted, switch to the latest not redacted event
+                                            getMostRecentRelatedEventFlow(room, voiceBroadcast)
+                                        } else {
+                                            // event is not redacted, send the event
+                                            flowOf(event)
+                                        }
+                                    }
+                        } else {
+                            // there is no more most recent event, just send it
+                            flowOf(newMostRecentEvent)
+                        }
+                    }
+        } else {
+            // there is no more most recent event, just send it
+            flowOf(mostRecentEvent)
+        }
+    }
+
+    /**
+     * Get the most recent event related to the given voice broadcast.
+     */
+    private fun getMostRecentRelatedEvent(room: Room, voiceBroadcast: VoiceBroadcast): VoiceBroadcastEvent? {
+        return room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, voiceBroadcast.voiceBroadcastId)
+                .mapNotNull { timelineEvent -> timelineEvent.root.asVoiceBroadcastEvent()?.takeUnless { it.root.isRedacted() } }
+                .maxByOrNull { it.root.originServerTs ?: 0 }
+    }
+
+    /**
+     * Get a flow of the given voice broadcast event changes.
+     */
+    private fun VoiceBroadcastEvent.flow(): Flow> {
+        val room = this.root.roomId?.let { session.getRoom(it) } ?: return flowOf(Optional.empty())
+        return room.flow().liveTimelineEvent(root.eventId!!).mapOptional { it.root.asVoiceBroadcastEvent() }
+    }
+}
diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastEventUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastEventUseCase.kt
deleted file mode 100644
index 94eca2b54e..0000000000
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastEventUseCase.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * Copyright (c) 2022 New Vector Ltd
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package im.vector.app.features.voicebroadcast.usecase
-
-import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
-import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
-import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent
-import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
-import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
-import im.vector.app.features.voicebroadcast.voiceBroadcastId
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.filter
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.flow.onStart
-import org.matrix.android.sdk.api.query.QueryStringValue
-import org.matrix.android.sdk.api.session.Session
-import org.matrix.android.sdk.api.session.events.model.RelationType
-import org.matrix.android.sdk.api.session.getRoom
-import org.matrix.android.sdk.api.util.Optional
-import org.matrix.android.sdk.api.util.toOptional
-import org.matrix.android.sdk.flow.flow
-import org.matrix.android.sdk.flow.mapOptional
-import timber.log.Timber
-import javax.inject.Inject
-
-class GetVoiceBroadcastEventUseCase @Inject constructor(
-        private val session: Session,
-) {
-
-    fun execute(voiceBroadcast: VoiceBroadcast): Flow> {
-        val room = session.getRoom(voiceBroadcast.roomId) ?: error("Unknown roomId: ${voiceBroadcast.roomId}")
-
-        Timber.d("## GetVoiceBroadcastUseCase: get voice broadcast $voiceBroadcast")
-
-        val initialEvent = room.timelineService().getTimelineEvent(voiceBroadcast.voiceBroadcastId)?.root?.asVoiceBroadcastEvent()
-        val latestEvent = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, voiceBroadcast.voiceBroadcastId)
-                .mapNotNull { it.root.asVoiceBroadcastEvent() }
-                .maxByOrNull { it.root.originServerTs ?: 0 }
-                ?: initialEvent
-
-        return when (latestEvent?.content?.voiceBroadcastState) {
-            null, VoiceBroadcastState.STOPPED -> flowOf(latestEvent.toOptional())
-            else -> {
-                room.flow()
-                        .liveStateEvent(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, QueryStringValue.Equals(latestEvent.root.stateKey.orEmpty()))
-                        .onStart { emit(latestEvent.root.toOptional()) }
-                        .distinctUntilChanged()
-                        .filter { !it.hasValue() || it.getOrNull()?.asVoiceBroadcastEvent()?.voiceBroadcastId == voiceBroadcast.voiceBroadcastId }
-                        .mapOptional { it.asVoiceBroadcastEvent() }
-            }
-        }
-    }
-}

From f436de12300b766fadfaf2c93300ac8bf9e01614 Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Wed, 23 Nov 2022 14:10:44 +0100
Subject: [PATCH 141/173] Handle voice broadcast deletion on listener side

---
 .../listening/VoiceBroadcastPlayerImpl.kt      | 18 ++++++++++++------
 1 file changed, 12 insertions(+), 6 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
index 79d59064e9..f04b85859b 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
@@ -145,19 +145,25 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
 
         playingState = State.BUFFERING
 
-        observeVoiceBroadcastLiveState(voiceBroadcast)
+        observeVoiceBroadcastStateEvent(voiceBroadcast)
         fetchPlaylistAndStartPlayback(voiceBroadcast)
     }
 
-    private fun observeVoiceBroadcastLiveState(voiceBroadcast: VoiceBroadcast) {
+    private fun observeVoiceBroadcastStateEvent(voiceBroadcast: VoiceBroadcast) {
         voiceBroadcastStateObserver = getVoiceBroadcastEventUseCase.execute(voiceBroadcast)
-                .onEach {
-                    currentVoiceBroadcastEvent = it.getOrNull()
-                    updateLiveListeningMode()
-                }
+                .onEach { onVoiceBroadcastStateEventUpdated(it.getOrNull()) }
                 .launchIn(sessionScope)
     }
 
+    private fun onVoiceBroadcastStateEventUpdated(event: VoiceBroadcastEvent?) {
+        if (event == null) {
+            stop()
+        } else {
+            currentVoiceBroadcastEvent = event
+            updateLiveListeningMode()
+        }
+    }
+
     private fun fetchPlaylistAndStartPlayback(voiceBroadcast: VoiceBroadcast) {
         fetchPlaylistTask = getLiveVoiceBroadcastChunksUseCase.execute(voiceBroadcast)
                 .onEach {

From 763b60ee6b5dae8b8aa7c7b5e7ccb9654b38bdd5 Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Wed, 23 Nov 2022 17:31:29 +0100
Subject: [PATCH 142/173] Update voice broadcast recorder according to the most
 recent voice broadcast state event

---
 .../java/im/vector/app/core/di/VoiceModule.kt | 13 ++++-
 .../recording/VoiceBroadcastRecorder.kt       |  3 +-
 .../recording/VoiceBroadcastRecorderQ.kt      | 58 ++++++++++++++++---
 .../usecase/PauseVoiceBroadcastUseCase.kt     |  6 --
 .../usecase/ResumeVoiceBroadcastUseCase.kt    | 10 +---
 .../usecase/StartVoiceBroadcastUseCase.kt     | 19 ++++--
 .../ResumeVoiceBroadcastUseCaseTest.kt        |  5 +-
 7 files changed, 78 insertions(+), 36 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/core/di/VoiceModule.kt b/vector/src/main/java/im/vector/app/core/di/VoiceModule.kt
index 30a8565771..6437326294 100644
--- a/vector/src/main/java/im/vector/app/core/di/VoiceModule.kt
+++ b/vector/src/main/java/im/vector/app/core/di/VoiceModule.kt
@@ -27,6 +27,7 @@ import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer
 import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayerImpl
 import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder
 import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorderQ
+import im.vector.app.features.voicebroadcast.usecase.GetMostRecentVoiceBroadcastStateEventUseCase
 import javax.inject.Singleton
 
 @InstallIn(SingletonComponent::class)
@@ -36,9 +37,17 @@ abstract class VoiceModule {
     companion object {
         @Provides
         @Singleton
-        fun providesVoiceBroadcastRecorder(context: Context): VoiceBroadcastRecorder? {
+        fun providesVoiceBroadcastRecorder(
+                context: Context,
+                sessionHolder: ActiveSessionHolder,
+                getMostRecentVoiceBroadcastStateEventUseCase: GetMostRecentVoiceBroadcastStateEventUseCase,
+        ): VoiceBroadcastRecorder? {
             return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
-                VoiceBroadcastRecorderQ(context)
+                VoiceBroadcastRecorderQ(
+                        context = context,
+                        sessionHolder = sessionHolder,
+                        getVoiceBroadcastEventUseCase = getMostRecentVoiceBroadcastStateEventUseCase
+                )
             } else {
                 null
             }
diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt
index bc13d1fea8..00e4bb17dd 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt
@@ -18,6 +18,7 @@ package im.vector.app.features.voicebroadcast.recording
 
 import androidx.annotation.IntRange
 import im.vector.app.features.voice.VoiceRecorder
+import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
 import java.io.File
 
 interface VoiceBroadcastRecorder : VoiceRecorder {
@@ -31,7 +32,7 @@ interface VoiceBroadcastRecorder : VoiceRecorder {
     /** Current remaining time of recording, in seconds, if any. */
     val currentRemainingTime: Long?
 
-    fun startRecord(roomId: String, chunkLength: Int, maxLength: Int)
+    fun startRecordVoiceBroadcast(voiceBroadcast: VoiceBroadcast, chunkLength: Int, maxLength: Int)
     fun addListener(listener: Listener)
     fun removeListener(listener: Listener)
 
diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt
index c5408b768b..483b88f57c 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt
@@ -20,8 +20,17 @@ import android.content.Context
 import android.media.MediaRecorder
 import android.os.Build
 import androidx.annotation.RequiresApi
+import im.vector.app.core.di.ActiveSessionHolder
+import im.vector.app.features.session.coroutineScope
 import im.vector.app.features.voice.AbstractVoiceRecorderQ
+import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
+import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent
+import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
+import im.vector.app.features.voicebroadcast.usecase.GetMostRecentVoiceBroadcastStateEventUseCase
 import im.vector.lib.core.utils.timer.CountUpTimer
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
 import org.matrix.android.sdk.api.extensions.tryOrNull
 import org.matrix.android.sdk.api.session.content.ContentAttachmentData
 import java.util.concurrent.CopyOnWriteArrayList
@@ -30,10 +39,17 @@ import java.util.concurrent.TimeUnit
 @RequiresApi(Build.VERSION_CODES.Q)
 class VoiceBroadcastRecorderQ(
         context: Context,
+        private val sessionHolder: ActiveSessionHolder,
+        private val getVoiceBroadcastEventUseCase: GetMostRecentVoiceBroadcastStateEventUseCase
 ) : AbstractVoiceRecorderQ(context), VoiceBroadcastRecorder {
 
+    private val session get() = sessionHolder.getActiveSession()
+    private val sessionScope get() = session.coroutineScope
+
+    private var voiceBroadcastStateObserver: Job? = null
+
     private var maxFileSize = 0L // zero or negative for no limit
-    private var currentRoomId: String? = null
+    private var currentVoiceBroadcast: VoiceBroadcast? = null
     private var currentMaxLength: Int = 0
 
     override var currentSequence = 0
@@ -68,14 +84,16 @@ class VoiceBroadcastRecorderQ(
         }
     }
 
-    override fun startRecord(roomId: String, chunkLength: Int, maxLength: Int) {
-        currentRoomId = roomId
+    override fun startRecordVoiceBroadcast(voiceBroadcast: VoiceBroadcast, chunkLength: Int, maxLength: Int) {
+        // Stop recording previous voice broadcast if any
+        if (recordingState != VoiceBroadcastRecorder.State.Idle) stopRecord()
+
+        currentVoiceBroadcast = voiceBroadcast
         maxFileSize = (chunkLength * audioEncodingBitRate / 8).toLong()
         currentMaxLength = maxLength
         currentSequence = 1
-        startRecord(roomId)
-        recordingState = VoiceBroadcastRecorder.State.Recording
-        recordingTicker.start()
+
+        observeVoiceBroadcastStateEvent(voiceBroadcast)
     }
 
     override fun pauseRecord() {
@@ -88,7 +106,7 @@ class VoiceBroadcastRecorderQ(
 
     override fun resumeRecord() {
         currentSequence++
-        currentRoomId?.let { startRecord(it) }
+        currentVoiceBroadcast?.let { startRecord(it.roomId) }
         recordingState = VoiceBroadcastRecorder.State.Recording
         recordingTicker.resume()
     }
@@ -104,11 +122,15 @@ class VoiceBroadcastRecorderQ(
         // Remove listeners
         listeners.clear()
 
+        // Do not observe anymore voice broadcast changes
+        voiceBroadcastStateObserver?.cancel()
+        voiceBroadcastStateObserver = null
+
         // Reset data
         currentSequence = 0
         currentMaxLength = 0
         currentRemainingTime = null
-        currentRoomId = null
+        currentVoiceBroadcast = null
     }
 
     override fun release() {
@@ -126,6 +148,26 @@ class VoiceBroadcastRecorderQ(
         listeners.remove(listener)
     }
 
+    private fun observeVoiceBroadcastStateEvent(voiceBroadcast: VoiceBroadcast) {
+        voiceBroadcastStateObserver = getVoiceBroadcastEventUseCase.execute(voiceBroadcast)
+                .onEach { onVoiceBroadcastStateEventUpdated(voiceBroadcast, it.getOrNull()) }
+                .launchIn(sessionScope)
+    }
+
+    private fun onVoiceBroadcastStateEventUpdated(voiceBroadcast: VoiceBroadcast, event: VoiceBroadcastEvent?) {
+        when (event?.content?.voiceBroadcastState) {
+            VoiceBroadcastState.STARTED -> {
+                startRecord(voiceBroadcast.roomId)
+                recordingState = VoiceBroadcastRecorder.State.Recording
+                recordingTicker.start()
+            }
+            VoiceBroadcastState.PAUSED -> pauseRecord()
+            VoiceBroadcastState.RESUMED -> resumeRecord()
+            VoiceBroadcastState.STOPPED,
+            null -> stopRecord()
+        }
+    }
+
     private fun onMaxFileSizeApproaching(roomId: String) {
         setNextOutputFile(roomId)
     }
diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt
index 58e1f26f44..3ce6e4a533 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt
@@ -62,11 +62,5 @@ class PauseVoiceBroadcastUseCase @Inject constructor(
                         lastChunkSequence = voiceBroadcastRecorder?.currentSequence,
                 ).toContent(),
         )
-
-        pauseRecording()
-    }
-
-    private fun pauseRecording() {
-        voiceBroadcastRecorder?.pauseRecord()
     }
 }
diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt
index 524b64e095..5ad5b0704d 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt
@@ -20,7 +20,6 @@ import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
 import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
 import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
 import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
-import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder
 import org.matrix.android.sdk.api.query.QueryStringValue
 import org.matrix.android.sdk.api.session.Session
 import org.matrix.android.sdk.api.session.events.model.toContent
@@ -31,8 +30,7 @@ import timber.log.Timber
 import javax.inject.Inject
 
 class ResumeVoiceBroadcastUseCase @Inject constructor(
-        private val session: Session,
-        private val voiceBroadcastRecorder: VoiceBroadcastRecorder?,
+        private val session: Session
 ) {
 
     suspend fun execute(roomId: String): Result = runCatching {
@@ -66,11 +64,5 @@ class ResumeVoiceBroadcastUseCase @Inject constructor(
                         voiceBroadcastStateStr = VoiceBroadcastState.RESUMED.value,
                 ).toContent(),
         )
-
-        resumeRecording()
-    }
-
-    private fun resumeRecording() {
-        voiceBroadcastRecorder?.resumeRecord()
     }
 }
diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt
index 45f622ad92..20f0615863 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt
@@ -24,11 +24,13 @@ import im.vector.app.features.session.coroutineScope
 import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
 import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure
 import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
+import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
 import im.vector.app.features.voicebroadcast.model.VoiceBroadcastChunk
 import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
 import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder
 import im.vector.app.features.voicebroadcast.usecase.GetOngoingVoiceBroadcastsUseCase
 import im.vector.lib.multipicker.utils.toMultiPickerAudioType
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.launch
 import org.jetbrains.annotations.VisibleForTesting
 import org.matrix.android.sdk.api.query.QueryStringValue
@@ -43,6 +45,8 @@ import org.matrix.android.sdk.api.session.room.getStateEvent
 import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent
 import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
 import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper
+import org.matrix.android.sdk.flow.flow
+import org.matrix.android.sdk.flow.unwrap
 import timber.log.Timber
 import java.io.File
 import javax.inject.Inject
@@ -63,6 +67,7 @@ class StartVoiceBroadcastUseCase @Inject constructor(
 
         assertCanStartVoiceBroadcast(room)
         startVoiceBroadcast(room)
+        return Result.success(Unit)
     }
 
     private suspend fun startVoiceBroadcast(room: Room) {
@@ -79,13 +84,15 @@ class StartVoiceBroadcastUseCase @Inject constructor(
                 ).toContent()
         )
 
-        startRecording(room, eventId, chunkLength, maxLength)
+        val voiceBroadcast = VoiceBroadcast(roomId = room.roomId, voiceBroadcastId = eventId)
+        room.flow().liveTimelineEvent(eventId).unwrap().first() // wait for the event come back from the sync
+        startRecording(room, voiceBroadcast, chunkLength, maxLength)
     }
 
-    private fun startRecording(room: Room, eventId: String, chunkLength: Int, maxLength: Int) {
+    private fun startRecording(room: Room, voiceBroadcast: VoiceBroadcast, chunkLength: Int, maxLength: Int) {
         voiceBroadcastRecorder?.addListener(object : VoiceBroadcastRecorder.Listener {
             override fun onVoiceMessageCreated(file: File, sequence: Int) {
-                sendVoiceFile(room, file, eventId, sequence)
+                sendVoiceFile(room, file, voiceBroadcast, sequence)
             }
 
             override fun onRemainingTimeUpdated(remainingTime: Long?) {
@@ -94,10 +101,10 @@ class StartVoiceBroadcastUseCase @Inject constructor(
                 }
             }
         })
-        voiceBroadcastRecorder?.startRecord(room.roomId, chunkLength, maxLength)
+        voiceBroadcastRecorder?.startRecordVoiceBroadcast(voiceBroadcast, chunkLength, maxLength)
     }
 
-    private fun sendVoiceFile(room: Room, voiceMessageFile: File, referenceEventId: String, sequence: Int) {
+    private fun sendVoiceFile(room: Room, voiceMessageFile: File, voiceBroadcast: VoiceBroadcast, sequence: Int) {
         val outputFileUri = FileProvider.getUriForFile(
                 context,
                 buildMeta.applicationId + ".fileProvider",
@@ -109,7 +116,7 @@ class StartVoiceBroadcastUseCase @Inject constructor(
                 attachment = audioType.toContentAttachmentData(isVoiceMessage = true),
                 compressBeforeSending = false,
                 roomIds = emptySet(),
-                relatesTo = RelationDefaultContent(RelationType.REFERENCE, referenceEventId),
+                relatesTo = RelationDefaultContent(RelationType.REFERENCE, voiceBroadcast.voiceBroadcastId),
                 additionalContent = mapOf(
                         VoiceBroadcastConstants.VOICE_BROADCAST_CHUNK_KEY to VoiceBroadcastChunk(sequence = sequence).toContent()
                 )
diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt
index 8b66d45dd4..7fe74052a9 100644
--- a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt
+++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt
@@ -19,7 +19,6 @@ package im.vector.app.features.voicebroadcast.usecase
 import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
 import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
 import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
-import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder
 import im.vector.app.features.voicebroadcast.recording.usecase.ResumeVoiceBroadcastUseCase
 import im.vector.app.test.fakes.FakeRoom
 import im.vector.app.test.fakes.FakeRoomService
@@ -27,7 +26,6 @@ import im.vector.app.test.fakes.FakeSession
 import io.mockk.clearAllMocks
 import io.mockk.coEvery
 import io.mockk.coVerify
-import io.mockk.mockk
 import io.mockk.slot
 import kotlinx.coroutines.test.runTest
 import org.amshove.kluent.shouldBe
@@ -47,8 +45,7 @@ class ResumeVoiceBroadcastUseCaseTest {
 
     private val fakeRoom = FakeRoom()
     private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom))
-    private val fakeVoiceBroadcastRecorder = mockk(relaxed = true)
-    private val resumeVoiceBroadcastUseCase = ResumeVoiceBroadcastUseCase(fakeSession, fakeVoiceBroadcastRecorder)
+    private val resumeVoiceBroadcastUseCase = ResumeVoiceBroadcastUseCase(fakeSession)
 
     @Test
     fun `given a room id with a potential existing voice broadcast state when calling execute then the voice broadcast is resumed or not`() = runTest {

From 3ebcd8c1f4274f86c2d55052e61941be80978b3e Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Wed, 23 Nov 2022 17:36:59 +0100
Subject: [PATCH 143/173] changelog

---
 changelog.d/7629.wip | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 changelog.d/7629.wip

diff --git a/changelog.d/7629.wip b/changelog.d/7629.wip
new file mode 100644
index 0000000000..ecc4449b6f
--- /dev/null
+++ b/changelog.d/7629.wip
@@ -0,0 +1 @@
+Voice Broadcast - Handle redaction of the state events on the listener and recorder sides

From 023326a20db38885bad98449b3c4e345528b968f Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Thu, 24 Nov 2022 10:18:58 +0100
Subject: [PATCH 144/173] Do not wait for state event feedback for pause/stop
 actions on the recorder

---
 .../voicebroadcast/recording/VoiceBroadcastRecorderQ.kt   | 2 ++
 .../recording/usecase/PauseVoiceBroadcastUseCase.kt       | 8 ++++++++
 .../recording/usecase/StopVoiceBroadcastUseCase.kt        | 6 ++++--
 3 files changed, 14 insertions(+), 2 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt
index 483b88f57c..b751417ca6 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt
@@ -97,6 +97,7 @@ class VoiceBroadcastRecorderQ(
     }
 
     override fun pauseRecord() {
+        if (recordingState != VoiceBroadcastRecorder.State.Recording) return
         tryOrNull { mediaRecorder?.stop() }
         mediaRecorder?.reset()
         recordingState = VoiceBroadcastRecorder.State.Paused
@@ -105,6 +106,7 @@ class VoiceBroadcastRecorderQ(
     }
 
     override fun resumeRecord() {
+        if (recordingState != VoiceBroadcastRecorder.State.Paused) return
         currentSequence++
         currentVoiceBroadcast?.let { startRecord(it.roomId) }
         recordingState = VoiceBroadcastRecorder.State.Recording
diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt
index 3ce6e4a533..817c1a72e4 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt
@@ -53,6 +53,10 @@ class PauseVoiceBroadcastUseCase @Inject constructor(
 
     private suspend fun pauseVoiceBroadcast(room: Room, reference: RelationDefaultContent?) {
         Timber.d("## PauseVoiceBroadcastUseCase: Send new voice broadcast info state event")
+
+        // immediately pause the recording
+        pauseRecording()
+
         room.stateService().sendStateEvent(
                 eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
                 stateKey = session.myUserId,
@@ -63,4 +67,8 @@ class PauseVoiceBroadcastUseCase @Inject constructor(
                 ).toContent(),
         )
     }
+
+    private fun pauseRecording() {
+        voiceBroadcastRecorder?.pauseRecord()
+    }
 }
diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt
index da13100609..cd70671e76 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt
@@ -54,6 +54,10 @@ class StopVoiceBroadcastUseCase @Inject constructor(
 
     private suspend fun stopVoiceBroadcast(room: Room, reference: RelationDefaultContent?) {
         Timber.d("## StopVoiceBroadcastUseCase: Send new voice broadcast info state event")
+
+        // Immediately stop the recording
+        stopRecording()
+
         room.stateService().sendStateEvent(
                 eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO,
                 stateKey = session.myUserId,
@@ -63,8 +67,6 @@ class StopVoiceBroadcastUseCase @Inject constructor(
                         lastChunkSequence = voiceBroadcastRecorder?.currentSequence,
                 ).toContent(),
         )
-
-        stopRecording()
     }
 
     private fun stopRecording() {

From a2dee2193afa8d451cff9b012ae33ad40bd0f2cc Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Thu, 24 Nov 2022 16:36:35 +0100
Subject: [PATCH 145/173] Fix bad condition

---
 .../usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt   | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt
index b882d1625b..a401e8c157 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt
@@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.flow.transformWhile
-import org.matrix.android.sdk.api.extensions.orFalse
+import org.matrix.android.sdk.api.extensions.orTrue
 import org.matrix.android.sdk.api.query.QueryStringValue
 import org.matrix.android.sdk.api.session.Session
 import org.matrix.android.sdk.api.session.events.model.RelationType
@@ -125,7 +125,7 @@ class GetMostRecentVoiceBroadcastStateEventUseCase @Inject constructor(
                                         event.hasValue() && event.get().root.isRedacted().not()
                                     }
                                     .flatMapLatest { event ->
-                                        if (event.getOrNull()?.root?.isRedacted().orFalse()) {
+                                        if (event.getOrNull()?.root?.isRedacted().orTrue()) {
                                             // event is null or redacted, switch to the latest not redacted event
                                             getMostRecentRelatedEventFlow(room, voiceBroadcast)
                                         } else {

From d092c837745b48d588d38036fe4ff1d12368d2fa Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Thu, 24 Nov 2022 17:49:16 +0100
Subject: [PATCH 146/173] Fix wrong sequence number in stopped state event
 content

---
 .../recording/usecase/PauseVoiceBroadcastUseCase.kt          | 5 +++--
 .../recording/usecase/StopVoiceBroadcastUseCase.kt           | 5 +++--
 2 files changed, 6 insertions(+), 4 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt
index 817c1a72e4..0b22d7adf5 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt
@@ -54,7 +54,8 @@ class PauseVoiceBroadcastUseCase @Inject constructor(
     private suspend fun pauseVoiceBroadcast(room: Room, reference: RelationDefaultContent?) {
         Timber.d("## PauseVoiceBroadcastUseCase: Send new voice broadcast info state event")
 
-        // immediately pause the recording
+        // save the last sequence number and immediately pause the recording
+        val lastSequence = voiceBroadcastRecorder?.currentSequence
         pauseRecording()
 
         room.stateService().sendStateEvent(
@@ -63,7 +64,7 @@ class PauseVoiceBroadcastUseCase @Inject constructor(
                 body = MessageVoiceBroadcastInfoContent(
                         relatesTo = reference,
                         voiceBroadcastStateStr = VoiceBroadcastState.PAUSED.value,
-                        lastChunkSequence = voiceBroadcastRecorder?.currentSequence,
+                        lastChunkSequence = lastSequence,
                 ).toContent(),
         )
     }
diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt
index cd70671e76..b93bd346db 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt
@@ -55,7 +55,8 @@ class StopVoiceBroadcastUseCase @Inject constructor(
     private suspend fun stopVoiceBroadcast(room: Room, reference: RelationDefaultContent?) {
         Timber.d("## StopVoiceBroadcastUseCase: Send new voice broadcast info state event")
 
-        // Immediately stop the recording
+        // save the last sequence number and immediately stop the recording
+        val lastSequence = voiceBroadcastRecorder?.currentSequence
         stopRecording()
 
         room.stateService().sendStateEvent(
@@ -64,7 +65,7 @@ class StopVoiceBroadcastUseCase @Inject constructor(
                 body = MessageVoiceBroadcastInfoContent(
                         relatesTo = reference,
                         voiceBroadcastStateStr = VoiceBroadcastState.STOPPED.value,
-                        lastChunkSequence = voiceBroadcastRecorder?.currentSequence,
+                        lastChunkSequence = lastSequence,
                 ).toContent(),
         )
     }

From 9dba6d7c8c4e3497b3ef146d67a1edbae2c64788 Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Thu, 24 Nov 2022 17:24:36 +0100
Subject: [PATCH 147/173] Fix issue on live playback detection

---
 .../listening/VoiceBroadcastPlayerImpl.kt      | 18 ++++++++----------
 1 file changed, 8 insertions(+), 10 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
index f04b85859b..bd541d23e4 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
@@ -66,7 +66,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
     private var nextMediaPlayer: MediaPlayer? = null
     private var isPreparingNextPlayer: Boolean = false
 
-    private var currentVoiceBroadcastEvent: VoiceBroadcastEvent? = null
+    private var mostRecentVoiceBroadcastEvent: VoiceBroadcastEvent? = null
 
     override var currentVoiceBroadcast: VoiceBroadcast? = null
     override var isLiveListening: Boolean = false
@@ -121,7 +121,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
         // Clear playlist
         playlist.reset()
 
-        currentVoiceBroadcastEvent = null
+        mostRecentVoiceBroadcastEvent = null
         currentVoiceBroadcast = null
     }
 
@@ -159,7 +159,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
         if (event == null) {
             stop()
         } else {
-            currentVoiceBroadcastEvent = event
+            mostRecentVoiceBroadcastEvent = event
             updateLiveListeningMode()
         }
     }
@@ -204,7 +204,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
 
         val playlistItem = when {
             position != null -> playlist.findByPosition(position)
-            currentVoiceBroadcastEvent?.isLive.orFalse() -> playlist.lastOrNull()
+            mostRecentVoiceBroadcastEvent?.isLive.orFalse() -> playlist.lastOrNull()
             else -> playlist.firstOrNull()
         }
         val content = playlistItem?.audioEvent?.content ?: run { Timber.w("## VoiceBroadcastPlayer: No content to play"); return }
@@ -346,7 +346,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
     private fun updateLiveListeningMode(seekPosition: Int? = null) {
         isLiveListening = when {
             // the current voice broadcast is not live (ended)
-            currentVoiceBroadcastEvent?.isLive?.not().orFalse() -> false
+            mostRecentVoiceBroadcastEvent?.isLive != true -> false
             // the player is stopped or paused
             playingState == State.IDLE || playingState == State.PAUSED -> false
             seekPosition != null -> {
@@ -412,13 +412,11 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
         override fun onCompletion(mp: MediaPlayer) {
             if (nextMediaPlayer != null) return
 
-            val content = currentVoiceBroadcastEvent?.content
-            val isLive = content?.isLive.orFalse()
-            if (!isLive && content?.lastChunkSequence == playlist.currentSequence) {
+            if (isLiveListening || mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence == playlist.currentSequence) {
+                playingState = State.BUFFERING
+            } else {
                 // We'll not receive new chunks anymore so we can stop the live listening
                 stop()
-            } else {
-                playingState = State.BUFFERING
             }
         }
 

From 4427156f0b734649a9d253f7f5d37d81cc250e9d Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Fri, 25 Nov 2022 15:27:46 +0100
Subject: [PATCH 148/173] Restore trailing comma

---
 .../recording/usecase/ResumeVoiceBroadcastUseCase.kt            | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt
index 5ad5b0704d..5be726c03e 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt
@@ -30,7 +30,7 @@ import timber.log.Timber
 import javax.inject.Inject
 
 class ResumeVoiceBroadcastUseCase @Inject constructor(
-        private val session: Session
+        private val session: Session,
 ) {
 
     suspend fun execute(roomId: String): Result = runCatching {

From aa53105f1713b237b06c059329175cc400784d5e Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Fri, 25 Nov 2022 15:32:29 +0100
Subject: [PATCH 149/173] improve flow stream

---
 ...MostRecentVoiceBroadcastStateEventUseCase.kt | 17 +++++++----------
 1 file changed, 7 insertions(+), 10 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt
index a401e8c157..1b3ba6ba22 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt
@@ -27,7 +27,6 @@ import kotlinx.coroutines.flow.drop
 import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.flow.transformWhile
@@ -76,17 +75,15 @@ class GetMostRecentVoiceBroadcastStateEventUseCase @Inject constructor(
                         // otherwise, observe most recent event changes
                         getMostRecentRelatedEventFlow(room, voiceBroadcast)
                                 .transformWhile { mostRecentEvent ->
-                                    emit(mostRecentEvent)
-                                    mostRecentEvent.hasValue()
-                                }
-                                .map {
-                                    if (!it.hasValue()) {
-                                        // no most recent event, fallback to started event
-                                        startedEvent
+                                    val hasValue = mostRecentEvent.hasValue()
+                                    if (hasValue) {
+                                        // keep the most recent event
+                                        emit(mostRecentEvent)
                                     } else {
-                                        // otherwise, keep the most recent event
-                                        it
+                                        // no most recent event, fallback to started event
+                                        emit(startedEvent)
                                     }
+                                    hasValue
                                 }
                     }
                 }

From 620bebc3a3f92f0f2bb0eadcd73c5468222a7f80 Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Fri, 25 Nov 2022 15:35:52 +0100
Subject: [PATCH 150/173] Rewrite condition for better clarity

---
 .../usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt   | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt
index 1b3ba6ba22..e0179e403f 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt
@@ -30,7 +30,6 @@ import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.flow.transformWhile
-import org.matrix.android.sdk.api.extensions.orTrue
 import org.matrix.android.sdk.api.query.QueryStringValue
 import org.matrix.android.sdk.api.session.Session
 import org.matrix.android.sdk.api.session.events.model.RelationType
@@ -122,7 +121,8 @@ class GetMostRecentVoiceBroadcastStateEventUseCase @Inject constructor(
                                         event.hasValue() && event.get().root.isRedacted().not()
                                     }
                                     .flatMapLatest { event ->
-                                        if (event.getOrNull()?.root?.isRedacted().orTrue()) {
+                                        val isRedactedOrNull = !event.hasValue() || event.get().root.isRedacted()
+                                        if (isRedactedOrNull) {
                                             // event is null or redacted, switch to the latest not redacted event
                                             getMostRecentRelatedEventFlow(room, voiceBroadcast)
                                         } else {

From 9840731778fe9947f8b0d7f8612e07448bd621b1 Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Mon, 28 Nov 2022 16:15:07 +0100
Subject: [PATCH 151/173] Add todo for missing unit test

---
 .../recording/usecase/StartVoiceBroadcastUseCase.kt            | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt
index 20f0615863..e3814608ea 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt
@@ -85,7 +85,10 @@ class StartVoiceBroadcastUseCase @Inject constructor(
         )
 
         val voiceBroadcast = VoiceBroadcast(roomId = room.roomId, voiceBroadcastId = eventId)
+
+        // TODO Update unit test to cover the following line
         room.flow().liveTimelineEvent(eventId).unwrap().first() // wait for the event come back from the sync
+
         startRecording(room, voiceBroadcast, chunkLength, maxLength)
     }
 

From 4be954eeeb5cbc45b886347c0fb4e5e62768f618 Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Thu, 24 Nov 2022 17:04:29 +0100
Subject: [PATCH 152/173] Voice Broadcast - Fix (live) playback stuck in
 buffering after receiving new chunk

---
 .../listening/VoiceBroadcastPlayerImpl.kt     | 90 +++++++++++--------
 1 file changed, 51 insertions(+), 39 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
index bd541d23e4..f68e546809 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
@@ -36,7 +36,6 @@ import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.launch
-import org.matrix.android.sdk.api.extensions.orFalse
 import org.matrix.android.sdk.api.extensions.tryOrNull
 import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
 import timber.log.Timber
@@ -73,7 +72,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
         @MainThread
         set(value) {
             if (field != value) {
-                Timber.w("isLiveListening: $field -> $value")
+                Timber.w("## Voice Broadcast | isLiveListening: $field -> $value")
                 field = value
                 onLiveListeningChanged(value)
             }
@@ -83,7 +82,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
         @MainThread
         set(value) {
             if (field != value) {
-                Timber.w("playingState: $field -> $value")
+                Timber.w("## Voice Broadcast | playingState: $field -> $value")
                 field = value
                 onPlayingStateChanged(value)
             }
@@ -175,41 +174,35 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
 
     private fun onPlaylistUpdated() {
         when (playingState) {
-            State.PLAYING -> {
-                if (nextMediaPlayer == null && !isPreparingNextPlayer) {
-                    prepareNextMediaPlayer()
-                }
-            }
+            State.PLAYING,
             State.PAUSED -> {
                 if (nextMediaPlayer == null && !isPreparingNextPlayer) {
                     prepareNextMediaPlayer()
                 }
             }
             State.BUFFERING -> {
-                val nextItem = playlist.getNextItem()
+                val nextItem = if (isLiveListening && playlist.currentSequence == null) {
+                    // live listening, jump to the last item if playback has not started
+                    playlist.lastOrNull()
+                } else {
+                    // not live or playback already started, request next item
+                    playlist.getNextItem()
+                }
                 if (nextItem != null) {
-                    val savedPosition = currentVoiceBroadcast?.let { playbackTracker.getPlaybackTime(it.voiceBroadcastId) }
-                    startPlayback(savedPosition?.takeIf { it > 0 })
+                    startPlayback(nextItem.startTime)
                 }
             }
-            State.IDLE -> {
-                val savedPosition = currentVoiceBroadcast?.let { playbackTracker.getPlaybackTime(it.voiceBroadcastId) }
-                startPlayback(savedPosition?.takeIf { it > 0 })
-            }
+            State.IDLE -> Unit // Should not happen
         }
     }
 
-    private fun startPlayback(position: Int? = null) {
+    private fun startPlayback(position: Int) {
         stopPlayer()
 
-        val playlistItem = when {
-            position != null -> playlist.findByPosition(position)
-            mostRecentVoiceBroadcastEvent?.isLive.orFalse() -> playlist.lastOrNull()
-            else -> playlist.firstOrNull()
-        }
-        val content = playlistItem?.audioEvent?.content ?: run { Timber.w("## VoiceBroadcastPlayer: No content to play"); return }
-        val sequence = playlistItem.sequence ?: run { Timber.w("## VoiceBroadcastPlayer: playlist item has no sequence"); return }
-        val sequencePosition = position?.let { it - playlistItem.startTime } ?: 0
+        val playlistItem = playlist.findByPosition(position)
+        val content = playlistItem?.audioEvent?.content ?: run { Timber.w("## Voice Broadcast | No content to play at position $position"); return }
+        val sequence = playlistItem.sequence ?: run { Timber.w("## Voice Broadcast | Playlist item has no sequence"); return }
+        val sequencePosition = position - playlistItem.startTime
         sessionScope.launch {
             try {
                 prepareMediaPlayer(content) { mp ->
@@ -223,7 +216,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
                     prepareNextMediaPlayer()
                 }
             } catch (failure: Throwable) {
-                Timber.e(failure, "Unable to start playback")
+                Timber.e(failure, "## Voice Broadcast | Unable to start playback: $failure")
                 throw VoiceFailure.UnableToPlay(failure)
             }
         }
@@ -248,8 +241,8 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
             currentMediaPlayer?.start()
             playingState = State.PLAYING
         } else {
-            val position = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) }
-            startPlayback(position)
+            val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } ?: 0
+            startPlayback(savedPosition)
         }
     }
 
@@ -274,9 +267,19 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
             isPreparingNextPlayer = true
             sessionScope.launch {
                 prepareMediaPlayer(nextItem.audioEvent.content) { mp ->
-                    nextMediaPlayer = mp
-                    currentMediaPlayer?.setNextMediaPlayer(mp)
                     isPreparingNextPlayer = false
+                    nextMediaPlayer = mp
+                    when (playingState) {
+                        State.PLAYING,
+                        State.PAUSED -> {
+                            currentMediaPlayer?.setNextMediaPlayer(mp)
+                        }
+                        State.BUFFERING -> {
+                            mp.start()
+                            onNextMediaPlayerStarted(mp)
+                        }
+                        State.IDLE -> stopPlayer()
+                    }
                 }
             }
         }
@@ -287,7 +290,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
         val audioFile = try {
             session.fileService().downloadFile(messageAudioContent)
         } catch (failure: Throwable) {
-            Timber.e(failure, "Unable to start playback")
+            Timber.e(failure, "Voice Broadcast | Download has failed: $failure")
             throw VoiceFailure.UnableToPlay(failure)
         }
 
@@ -375,6 +378,14 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
         }
     }
 
+    private fun onNextMediaPlayerStarted(mp: MediaPlayer) {
+        playingState = State.PLAYING
+        playlist.currentSequence = playlist.currentSequence?.inc()
+        currentMediaPlayer = mp
+        nextMediaPlayer = null
+        prepareNextMediaPlayer()
+    }
+
     private fun getCurrentPlaybackPosition(): Int? {
         val playlistPosition = playlist.currentItem?.startTime
         val computedPosition = currentMediaPlayer?.currentPosition?.let { playlistPosition?.plus(it) } ?: playlistPosition
@@ -398,23 +409,24 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
 
         override fun onInfo(mp: MediaPlayer, what: Int, extra: Int): Boolean {
             when (what) {
-                MediaPlayer.MEDIA_INFO_STARTED_AS_NEXT -> {
-                    playlist.currentSequence = playlist.currentSequence?.inc()
-                    currentMediaPlayer = mp
-                    nextMediaPlayer = null
-                    playingState = State.PLAYING
-                    prepareNextMediaPlayer()
-                }
+                MediaPlayer.MEDIA_INFO_STARTED_AS_NEXT -> onNextMediaPlayerStarted(mp)
             }
             return false
         }
 
         override fun onCompletion(mp: MediaPlayer) {
+            // Next media player is already attached to this player and will start playing automatically
             if (nextMediaPlayer != null) return
 
-            if (isLiveListening || mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence == playlist.currentSequence) {
+            // Next media player is preparing but not attached yet, reset the currentMediaPlayer and let the new player take over
+            if (isPreparingNextPlayer) {
+                currentMediaPlayer?.release()
+                currentMediaPlayer = null
                 playingState = State.BUFFERING
-            } else {
+                return
+            }
+
+            if (!isLiveListening && mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence == playlist.currentSequence) {
                 // We'll not receive new chunks anymore so we can stop the live listening
                 stop()
             }

From c2d5908542d9a25f3b542d768f5defc8bf18f4ec Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Thu, 24 Nov 2022 18:10:36 +0100
Subject: [PATCH 153/173] Stop playback if live broadcast has ended and there
 is no more chunk to listen

---
 .../voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt     | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
index f68e546809..addaaeec30 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
@@ -376,6 +376,11 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
             // Notify live mode change to all the listeners attached to the current voice broadcast id
             listeners[voiceBroadcastId]?.forEach { listener -> listener.onLiveModeChanged(isLiveListening) }
         }
+
+        // Live has ended and last chunk has been reached, we can stop the playback
+        if (!isLiveListening && playingState == State.BUFFERING && playlist.currentSequence == mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence) {
+            stop()
+        }
     }
 
     private fun onNextMediaPlayerStarted(mp: MediaPlayer) {

From a4255525e009e99f844304ee64a68072fd654ce1 Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Fri, 25 Nov 2022 15:57:33 +0100
Subject: [PATCH 154/173] Changelog

---
 changelog.d/7646.bugfix | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 changelog.d/7646.bugfix

diff --git a/changelog.d/7646.bugfix b/changelog.d/7646.bugfix
new file mode 100644
index 0000000000..7f771bc6f7
--- /dev/null
+++ b/changelog.d/7646.bugfix
@@ -0,0 +1 @@
+Voice Broadcast - Fix playback stuck in buffering mode

From 46fc0ac5637cdde1e7931dc3ecdfc513fb0aadbc Mon Sep 17 00:00:00 2001
From: Nikita Fedrunov <66663241+fedrunov@users.noreply.github.com>
Date: Mon, 28 Nov 2022 17:29:30 +0100
Subject: [PATCH 155/173] ignore push for a thread if it's currently visible to
 user (#7641)

---
 changelog.d/7634.bugfix                       |  1 +
 .../app/core/pushers/VectorPushHandler.kt     | 13 +++--
 .../home/room/detail/TimelineFragment.kt      |  2 +
 .../notifications/NotifiableEventProcessor.kt | 10 +---
 .../notifications/NotifiableEventResolver.kt  |  5 +-
 .../notifications/NotifiableMessageEvent.kt   |  8 +++
 .../NotificationBroadcastReceiver.kt          |  1 +
 .../NotificationDrawerManager.kt              | 23 +++++++-
 .../notifications/NotificationEventQueue.kt   |  9 ++-
 .../NotifiableEventProcessorTest.kt           | 56 +++++++++++++++----
 .../test/fixtures/NotifiableEventFixture.kt   |  2 +
 11 files changed, 102 insertions(+), 28 deletions(-)
 create mode 100644 changelog.d/7634.bugfix

diff --git a/changelog.d/7634.bugfix b/changelog.d/7634.bugfix
new file mode 100644
index 0000000000..a3c829840a
--- /dev/null
+++ b/changelog.d/7634.bugfix
@@ -0,0 +1 @@
+Push notification for thread message is now shown correctly when user observes rooms main timeline
diff --git a/vector/src/main/java/im/vector/app/core/pushers/VectorPushHandler.kt b/vector/src/main/java/im/vector/app/core/pushers/VectorPushHandler.kt
index b74028d579..0d2cd56995 100644
--- a/vector/src/main/java/im/vector/app/core/pushers/VectorPushHandler.kt
+++ b/vector/src/main/java/im/vector/app/core/pushers/VectorPushHandler.kt
@@ -28,6 +28,7 @@ import im.vector.app.core.network.WifiDetector
 import im.vector.app.core.pushers.model.PushData
 import im.vector.app.core.resources.BuildMeta
 import im.vector.app.features.notifications.NotifiableEventResolver
+import im.vector.app.features.notifications.NotifiableMessageEvent
 import im.vector.app.features.notifications.NotificationActionIds
 import im.vector.app.features.notifications.NotificationDrawerManager
 import im.vector.app.features.settings.VectorDataStore
@@ -142,11 +143,6 @@ class VectorPushHandler @Inject constructor(
         pushData.roomId ?: return
         pushData.eventId ?: return
 
-        // If the room is currently displayed, we will not show a notification, so no need to get the Event faster
-        if (notificationDrawerManager.shouldIgnoreMessageEventInRoom(pushData.roomId)) {
-            return
-        }
-
         if (wifiDetector.isConnectedToWifi().not()) {
             Timber.tag(loggerTag.value).d("No WiFi network, do not get Event")
             return
@@ -157,6 +153,13 @@ class VectorPushHandler @Inject constructor(
 
         val resolvedEvent = notifiableEventResolver.resolveInMemoryEvent(session, event, canBeReplaced = true)
 
+        if (resolvedEvent is NotifiableMessageEvent) {
+            // If the room is currently displayed, we will not show a notification, so no need to get the Event faster
+            if (notificationDrawerManager.shouldIgnoreMessageEventInRoom(resolvedEvent)) {
+                return
+            }
+        }
+
         resolvedEvent
                 ?.also { Timber.tag(loggerTag.value).d("Fast lane: notify drawer") }
                 ?.let {
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
index 34d7e45028..b73d443832 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt
@@ -972,6 +972,7 @@ class TimelineFragment :
     override fun onResume() {
         super.onResume()
         notificationDrawerManager.setCurrentRoom(timelineArgs.roomId)
+        notificationDrawerManager.setCurrentThread(timelineArgs.threadTimelineArgs?.rootThreadEventId)
         roomDetailPendingActionStore.data?.let { handlePendingAction(it) }
         roomDetailPendingActionStore.data = null
     }
@@ -991,6 +992,7 @@ class TimelineFragment :
     override fun onPause() {
         super.onPause()
         notificationDrawerManager.setCurrentRoom(null)
+        notificationDrawerManager.setCurrentThread(null)
     }
 
     private val emojiActivityResultLauncher = registerStartForActivityResult { activityResult ->
diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt
index 6bdd2ab511..81b9844e36 100644
--- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt
+++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt
@@ -30,13 +30,13 @@ class NotifiableEventProcessor @Inject constructor(
         private val autoAcceptInvites: AutoAcceptInvites
 ) {
 
-    fun process(queuedEvents: List, currentRoomId: String?, renderedEvents: ProcessedEvents): ProcessedEvents {
+    fun process(queuedEvents: List, currentRoomId: String?, currentThreadId: String?, renderedEvents: ProcessedEvents): ProcessedEvents {
         val processedEvents = queuedEvents.map {
             val type = when (it) {
                 is InviteNotifiableEvent -> if (autoAcceptInvites.hideInvites) REMOVE else KEEP
                 is NotifiableMessageEvent -> when {
-                    shouldIgnoreMessageEventInRoom(currentRoomId, it.roomId) -> REMOVE
-                            .also { Timber.d("notification message removed due to currently viewing the same room") }
+                    it.shouldIgnoreMessageEventInRoom(currentRoomId, currentThreadId) -> REMOVE
+                            .also { Timber.d("notification message removed due to currently viewing the same room or thread") }
                     outdatedDetector.isMessageOutdated(it) -> REMOVE
                             .also { Timber.d("notification message removed due to being read") }
                     else -> KEEP
@@ -55,8 +55,4 @@ class NotifiableEventProcessor @Inject constructor(
 
         return removedEventsDiff + processedEvents
     }
-
-    private fun shouldIgnoreMessageEventInRoom(currentRoomId: String?, roomId: String?): Boolean {
-        return currentRoomId != null && roomId == currentRoomId
-    }
 }
diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt
index d28ab22684..988ab01ef8 100644
--- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt
+++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt
@@ -32,6 +32,7 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError
 import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult
 import org.matrix.android.sdk.api.session.events.model.Event
 import org.matrix.android.sdk.api.session.events.model.EventType
+import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId
 import org.matrix.android.sdk.api.session.events.model.isEdition
 import org.matrix.android.sdk.api.session.events.model.isImageMessage
 import org.matrix.android.sdk.api.session.events.model.supportsNotification
@@ -133,7 +134,7 @@ class NotifiableEventResolver @Inject constructor(
         }
     }
 
-    private suspend fun resolveMessageEvent(event: TimelineEvent, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableEvent? {
+    private suspend fun resolveMessageEvent(event: TimelineEvent, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableMessageEvent? {
         // The event only contains an eventId, and roomId (type is m.room.*) , we need to get the displayable content (names, avatar, text, etc...)
         val room = session.getRoom(event.root.roomId!! /*roomID cannot be null*/)
 
@@ -155,6 +156,7 @@ class NotifiableEventResolver @Inject constructor(
                     body = body.toString(),
                     imageUriString = event.fetchImageIfPresent(session)?.toString(),
                     roomId = event.root.roomId!!,
+                    threadId = event.root.getRootThreadEventId(),
                     roomName = roomName,
                     matrixID = session.myUserId
             )
@@ -178,6 +180,7 @@ class NotifiableEventResolver @Inject constructor(
                             body = body,
                             imageUriString = event.fetchImageIfPresent(session)?.toString(),
                             roomId = event.root.roomId!!,
+                            threadId = event.root.getRootThreadEventId(),
                             roomName = roomName,
                             roomIsDirect = room.roomSummary()?.isDirect ?: false,
                             roomAvatarPath = session.contentUrlResolver()
diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableMessageEvent.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableMessageEvent.kt
index 68268739a0..bbd8c6638c 100644
--- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableMessageEvent.kt
+++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableMessageEvent.kt
@@ -31,6 +31,7 @@ data class NotifiableMessageEvent(
         // NotSerializableException when persisting this to storage
         val imageUriString: String?,
         val roomId: String,
+        val threadId: String?,
         val roomName: String?,
         val roomIsDirect: Boolean = false,
         val roomAvatarPath: String? = null,
@@ -51,3 +52,10 @@ data class NotifiableMessageEvent(
     val imageUri: Uri?
         get() = imageUriString?.let { Uri.parse(it) }
 }
+
+fun NotifiableMessageEvent.shouldIgnoreMessageEventInRoom(currentRoomId: String?, currentThreadId: String?): Boolean {
+    return when (currentRoomId) {
+        null -> false
+        else -> roomId == currentRoomId && threadId == currentThreadId
+    }
+}
diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt
index 180351f806..455f4778e8 100644
--- a/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt
+++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt
@@ -148,6 +148,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
                 body = message,
                 imageUriString = null,
                 roomId = room.roomId,
+                threadId = null, // needs to be changed: https://github.com/vector-im/element-android/issues/7475
                 roomName = room.roomSummary()?.displayName ?: room.roomId,
                 roomIsDirect = room.roomSummary()?.isDirect == true,
                 outGoingMessage = true,
diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt
index 2623045cf3..4f05e83bd4 100644
--- a/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt
+++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt
@@ -63,6 +63,7 @@ class NotificationDrawerManager @Inject constructor(
     private val notificationState by lazy { createInitialNotificationState() }
     private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size)
     private var currentRoomId: String? = null
+    private var currentThreadId: String? = null
     private val firstThrottler = FirstThrottler(200)
 
     private var useCompleteNotificationFormat = vectorPreferences.useCompleteNotificationFormat()
@@ -123,6 +124,22 @@ class NotificationDrawerManager @Inject constructor(
         }
     }
 
+    /**
+     * Should be called when the application is currently opened and showing timeline for the given threadId.
+     * Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room.
+     */
+    fun setCurrentThread(threadId: String?) {
+        updateEvents {
+            val hasChanged = threadId != currentThreadId
+            currentThreadId = threadId
+            currentRoomId?.let { roomId ->
+                if (hasChanged && threadId != null) {
+                    it.clearMessagesForThread(roomId, threadId)
+                }
+            }
+        }
+    }
+
     fun notificationStyleChanged() {
         updateEvents {
             val newSettings = vectorPreferences.useCompleteNotificationFormat()
@@ -164,7 +181,7 @@ class NotificationDrawerManager @Inject constructor(
     private fun refreshNotificationDrawerBg() {
         Timber.v("refreshNotificationDrawerBg()")
         val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents ->
-            notifiableEventProcessor.process(queuedEvents.rawEvents(), currentRoomId, renderedEvents).also {
+            notifiableEventProcessor.process(queuedEvents.rawEvents(), currentRoomId, currentThreadId, renderedEvents).also {
                 queuedEvents.clearAndAdd(it.onlyKeptEvents())
             }
         }
@@ -198,8 +215,8 @@ class NotificationDrawerManager @Inject constructor(
         notificationRenderer.render(session.myUserId, myUserDisplayName, myUserAvatarUrl, useCompleteNotificationFormat, eventsToRender)
     }
 
-    fun shouldIgnoreMessageEventInRoom(roomId: String?): Boolean {
-        return currentRoomId != null && roomId == currentRoomId
+    fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean {
+        return resolvedEvent.shouldIgnoreMessageEventInRoom(currentRoomId, currentThreadId)
     }
 
     companion object {
diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationEventQueue.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationEventQueue.kt
index f02424803a..8aff9c3bf2 100644
--- a/vector/src/main/java/im/vector/app/features/notifications/NotificationEventQueue.kt
+++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationEventQueue.kt
@@ -122,15 +122,20 @@ data class NotificationEventQueue(
     }
 
     fun clearMemberShipNotificationForRoom(roomId: String) {
-        Timber.v("clearMemberShipOfRoom $roomId")
+        Timber.d("clearMemberShipOfRoom $roomId")
         queue.removeAll { it is InviteNotifiableEvent && it.roomId == roomId }
     }
 
     fun clearMessagesForRoom(roomId: String) {
-        Timber.v("clearMessageEventOfRoom $roomId")
+        Timber.d("clearMessageEventOfRoom $roomId")
         queue.removeAll { it is NotifiableMessageEvent && it.roomId == roomId }
     }
 
+    fun clearMessagesForThread(roomId: String, threadId: String) {
+        Timber.d("clearMessageEventOfThread $roomId, $threadId")
+        queue.removeAll { it is NotifiableMessageEvent && it.roomId == roomId && it.threadId == threadId }
+    }
+
     fun rawEvents(): List = queue
 }
 
diff --git a/vector/src/test/java/im/vector/app/features/notifications/NotifiableEventProcessorTest.kt b/vector/src/test/java/im/vector/app/features/notifications/NotifiableEventProcessorTest.kt
index 131a423316..59e42a9568 100644
--- a/vector/src/test/java/im/vector/app/features/notifications/NotifiableEventProcessorTest.kt
+++ b/vector/src/test/java/im/vector/app/features/notifications/NotifiableEventProcessorTest.kt
@@ -27,6 +27,7 @@ import org.junit.Test
 import org.matrix.android.sdk.api.session.events.model.EventType
 
 private val NOT_VIEWING_A_ROOM: String? = null
+private val NOT_VIEWING_A_THREAD: String? = null
 
 class NotifiableEventProcessorTest {
 
@@ -42,7 +43,7 @@ class NotifiableEventProcessorTest {
                 aSimpleNotifiableEvent(eventId = "event-2")
         )
 
-        val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
+        val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, currentThreadId = NOT_VIEWING_A_THREAD, renderedEvents = emptyList())
 
         result shouldBeEqualTo listOfProcessedEvents(
                 Type.KEEP to events[0],
@@ -54,7 +55,7 @@ class NotifiableEventProcessorTest {
     fun `given redacted simple event when processing then remove redaction event`() {
         val events = listOf(aSimpleNotifiableEvent(eventId = "event-1", type = EventType.REDACTION))
 
-        val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
+        val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, currentThreadId = NOT_VIEWING_A_THREAD, renderedEvents = emptyList())
 
         result shouldBeEqualTo listOfProcessedEvents(
                 Type.REMOVE to events[0]
@@ -69,7 +70,7 @@ class NotifiableEventProcessorTest {
                 anInviteNotifiableEvent(roomId = "room-2")
         )
 
-        val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
+        val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, currentThreadId = NOT_VIEWING_A_THREAD, renderedEvents = emptyList())
 
         result shouldBeEqualTo listOfProcessedEvents(
                 Type.REMOVE to events[0],
@@ -85,7 +86,7 @@ class NotifiableEventProcessorTest {
                 anInviteNotifiableEvent(roomId = "room-2")
         )
 
-        val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
+        val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, currentThreadId = NOT_VIEWING_A_THREAD, renderedEvents = emptyList())
 
         result shouldBeEqualTo listOfProcessedEvents(
                 Type.KEEP to events[0],
@@ -98,7 +99,7 @@ class NotifiableEventProcessorTest {
         val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1"))
         outdatedDetector.givenEventIsOutOfDate(events[0])
 
-        val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
+        val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, currentThreadId = NOT_VIEWING_A_THREAD, renderedEvents = emptyList())
 
         result shouldBeEqualTo listOfProcessedEvents(
                 Type.REMOVE to events[0],
@@ -110,7 +111,7 @@ class NotifiableEventProcessorTest {
         val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1"))
         outdatedDetector.givenEventIsInDate(events[0])
 
-        val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())
+        val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, currentThreadId = NOT_VIEWING_A_THREAD, renderedEvents = emptyList())
 
         result shouldBeEqualTo listOfProcessedEvents(
                 Type.KEEP to events[0],
@@ -118,16 +119,51 @@ class NotifiableEventProcessorTest {
     }
 
     @Test
-    fun `given viewing the same room as message event when processing then removes message`() {
-        val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1"))
+    fun `given viewing the same room main timeline when processing main timeline message event then removes message`() {
+        val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1", threadId = null))
 
-        val result = eventProcessor.process(events, currentRoomId = "room-1", renderedEvents = emptyList())
+        val result = eventProcessor.process(events, currentRoomId = "room-1", currentThreadId = null, renderedEvents = emptyList())
 
         result shouldBeEqualTo listOfProcessedEvents(
                 Type.REMOVE to events[0],
         )
     }
 
+    @Test
+    fun `given viewing the same thread timeline when processing thread message event then removes message`() {
+        val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1", threadId = "thread-1"))
+
+        val result = eventProcessor.process(events, currentRoomId = "room-1", currentThreadId = "thread-1", renderedEvents = emptyList())
+
+        result shouldBeEqualTo listOfProcessedEvents(
+                Type.REMOVE to events[0],
+        )
+    }
+
+    @Test
+    fun `given viewing main timeline of the same room when processing thread timeline message event then keep message`() {
+        val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1", threadId = "thread-1"))
+        outdatedDetector.givenEventIsInDate(events[0])
+
+        val result = eventProcessor.process(events, currentRoomId = "room-1", currentThreadId = null, renderedEvents = emptyList())
+
+        result shouldBeEqualTo listOfProcessedEvents(
+                Type.KEEP to events[0],
+        )
+    }
+
+    @Test
+    fun `given viewing thread timeline of the same room when processing main timeline message event then keep message`() {
+        val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1"))
+        outdatedDetector.givenEventIsInDate(events[0])
+
+        val result = eventProcessor.process(events, currentRoomId = "room-1", currentThreadId = "thread-1", renderedEvents = emptyList())
+
+        result shouldBeEqualTo listOfProcessedEvents(
+                Type.KEEP to events[0],
+        )
+    }
+
     @Test
     fun `given events are different to rendered events when processing then removes difference`() {
         val events = listOf(aSimpleNotifiableEvent(eventId = "event-1"))
@@ -136,7 +172,7 @@ class NotifiableEventProcessorTest {
                 ProcessedEvent(Type.KEEP, anInviteNotifiableEvent(roomId = "event-2"))
         )
 
-        val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = renderedEvents)
+        val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, currentThreadId = NOT_VIEWING_A_THREAD, renderedEvents = renderedEvents)
 
         result shouldBeEqualTo listOfProcessedEvents(
                 Type.REMOVE to renderedEvents[1].event,
diff --git a/vector/src/test/java/im/vector/app/test/fixtures/NotifiableEventFixture.kt b/vector/src/test/java/im/vector/app/test/fixtures/NotifiableEventFixture.kt
index 397ca80f84..a6d21a46c9 100644
--- a/vector/src/test/java/im/vector/app/test/fixtures/NotifiableEventFixture.kt
+++ b/vector/src/test/java/im/vector/app/test/fixtures/NotifiableEventFixture.kt
@@ -63,6 +63,7 @@ fun anInviteNotifiableEvent(
 fun aNotifiableMessageEvent(
         eventId: String = "a-message-event-id",
         roomId: String = "a-message-room-id",
+        threadId: String? = null,
         isRedacted: Boolean = false
 ) = NotifiableMessageEvent(
         eventId = eventId,
@@ -73,6 +74,7 @@ fun aNotifiableMessageEvent(
         senderId = "sending-id",
         body = "message-body",
         roomId = roomId,
+        threadId = threadId,
         roomName = "room-name",
         roomIsDirect = false,
         canBeReplaced = false,

From fe0bca75f8d40040206971f7f432571a2d95825b Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Tue, 29 Nov 2022 00:59:45 +0100
Subject: [PATCH 156/173] Change log level

---
 .../voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt      | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
index addaaeec30..724be600a3 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
@@ -72,7 +72,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
         @MainThread
         set(value) {
             if (field != value) {
-                Timber.w("## Voice Broadcast | isLiveListening: $field -> $value")
+                Timber.d("## Voice Broadcast | isLiveListening: $field -> $value")
                 field = value
                 onLiveListeningChanged(value)
             }
@@ -82,7 +82,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
         @MainThread
         set(value) {
             if (field != value) {
-                Timber.w("## Voice Broadcast | playingState: $field -> $value")
+                Timber.d("## Voice Broadcast | playingState: $field -> $value")
                 field = value
                 onPlayingStateChanged(value)
             }

From 912de8286ff3958302cbb4e976b3284d5812cc5b Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Tue, 29 Nov 2022 00:17:38 +0100
Subject: [PATCH 157/173] Move buffering view in tile header

---
 .../src/main/res/values/strings.xml           |  3 +-
 .../MessageVoiceBroadcastListeningItem.kt     | 15 ++++----
 .../views/VoiceBroadcastBufferingView.kt      | 37 +++++++++++++++++++
 ...e_event_voice_broadcast_listening_stub.xml | 19 ++++------
 .../layout/view_voice_broadcast_buffering.xml | 26 +++++++++++++
 5 files changed, 79 insertions(+), 21 deletions(-)
 create mode 100644 vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastBufferingView.kt
 create mode 100644 vector/src/main/res/layout/view_voice_broadcast_buffering.xml

diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml
index f1d5bfbcad..616acfb343 100644
--- a/library/ui-strings/src/main/res/values/strings.xml
+++ b/library/ui-strings/src/main/res/values/strings.xml
@@ -3092,12 +3092,13 @@
     (%1$s)
 
     Live
+    
+    Buffering…
     Resume voice broadcast record
     Pause voice broadcast record
     Stop voice broadcast record
     Play or resume voice broadcast
     Pause voice broadcast
-    Buffering
     Fast backward 30 seconds
     Fast forward 30 seconds
     Can’t start a new voice broadcast
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt
index e5cb677763..8d32875f0c 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt
@@ -17,7 +17,6 @@
 package im.vector.app.features.home.room.detail.timeline.item
 
 import android.text.format.DateUtils
-import android.view.View
 import android.widget.ImageButton
 import android.widget.SeekBar
 import android.widget.TextView
@@ -30,6 +29,7 @@ import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAc
 import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker.Listener.State
 import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer
 import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
+import im.vector.app.features.voicebroadcast.views.VoiceBroadcastBufferingView
 import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
 
 @EpoxyModelClass
@@ -63,10 +63,10 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
             playPauseButton.setOnClickListener {
                 if (player.currentVoiceBroadcast == voiceBroadcast) {
                     when (player.playingState) {
-                        VoiceBroadcastPlayer.State.PLAYING -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause)
+                        VoiceBroadcastPlayer.State.PLAYING,
+                        VoiceBroadcastPlayer.State.BUFFERING -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause)
                         VoiceBroadcastPlayer.State.PAUSED,
                         VoiceBroadcastPlayer.State.IDLE -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast))
-                        VoiceBroadcastPlayer.State.BUFFERING -> Unit
                     }
                 } else {
                     callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast))
@@ -86,7 +86,6 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
     override fun renderMetadata(holder: Holder) {
         with(holder) {
             broadcasterNameMetadata.value = recorderName
-            voiceBroadcastMetadata.isVisible = true
             listenersCountMetadata.isVisible = false
         }
     }
@@ -102,10 +101,11 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
     private fun renderPlayingState(holder: Holder, state: VoiceBroadcastPlayer.State) {
         with(holder) {
             bufferingView.isVisible = state == VoiceBroadcastPlayer.State.BUFFERING
-            playPauseButton.isVisible = state != VoiceBroadcastPlayer.State.BUFFERING
+            voiceBroadcastMetadata.isVisible = state != VoiceBroadcastPlayer.State.BUFFERING
 
             when (state) {
-                VoiceBroadcastPlayer.State.PLAYING -> {
+                VoiceBroadcastPlayer.State.PLAYING,
+                VoiceBroadcastPlayer.State.BUFFERING -> {
                     playPauseButton.setImageResource(R.drawable.ic_play_pause_pause)
                     playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast)
                 }
@@ -114,7 +114,6 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
                     playPauseButton.setImageResource(R.drawable.ic_play_pause_play)
                     playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast)
                 }
-                VoiceBroadcastPlayer.State.BUFFERING -> Unit
             }
 
             renderLiveIndicator(holder)
@@ -174,7 +173,7 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
 
     class Holder : AbsMessageVoiceBroadcastItem.Holder(STUB_ID) {
         val playPauseButton by bind(R.id.playPauseButton)
-        val bufferingView by bind(R.id.bufferingView)
+        val bufferingView by bind(R.id.bufferingMetadata)
         val fastBackwardButton by bind(R.id.fastBackwardButton)
         val fastForwardButton by bind(R.id.fastForwardButton)
         val seekBar by bind(R.id.seekBar)
diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastBufferingView.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastBufferingView.kt
new file mode 100644
index 0000000000..eabefa323e
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastBufferingView.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.features.voicebroadcast.views
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.widget.LinearLayout
+import im.vector.app.databinding.ViewVoiceBroadcastBufferingBinding
+
+class VoiceBroadcastBufferingView @JvmOverloads constructor(
+        context: Context,
+        attrs: AttributeSet? = null,
+        defStyleAttr: Int = 0
+) : LinearLayout(context, attrs, defStyleAttr) {
+
+    init {
+        ViewVoiceBroadcastBufferingBinding.inflate(
+                LayoutInflater.from(context),
+                this
+        )
+    }
+}
diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml
index 1d31afba99..f872db3d00 100644
--- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml
+++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml
@@ -51,7 +51,7 @@
         android:layout_height="wrap_content"
         android:layout_marginTop="4dp"
         android:orientation="vertical"
-        app:constraint_referenced_ids="broadcasterNameMetadata,voiceBroadcastMetadata,listenersCountMetadata"
+        app:constraint_referenced_ids="broadcasterNameMetadata,bufferingMetadata,voiceBroadcastMetadata,listenersCountMetadata"
         app:flow_horizontalAlign="start"
         app:flow_verticalGap="4dp"
         app:layout_constraintStart_toEndOf="@id/avatarRightBarrier"
@@ -64,6 +64,11 @@
         app:metadataIcon="@drawable/ic_voice_broadcast_mic"
         tools:metadataValue="@sample/users.json/data/displayName" />
 
+    
+
     
 
@@ -117,16 +122,6 @@
         android:src="@drawable/ic_play_pause_play"
         app:tint="?vctr_content_secondary" />
 
-    
-
     
+
+
+    
+
+    
+

From 12a86e7d29e4e1a9efc12e1daf438f972b1c6c8c Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Tue, 29 Nov 2022 00:21:38 +0100
Subject: [PATCH 158/173] Reduce tiles padding

---
 .../item_timeline_event_voice_broadcast_listening_stub.xml      | 2 +-
 .../item_timeline_event_voice_broadcast_recording_stub.xml      | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml
index f872db3d00..f26a6bf7ab 100644
--- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml
+++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml
@@ -7,7 +7,7 @@
     android:layout_height="wrap_content"
     android:background="@drawable/rounded_rect_shape_8"
     android:backgroundTint="?vctr_content_quinary"
-    android:padding="@dimen/layout_vertical_margin">
+    android:padding="12dp">
 
     
+    android:padding="12dp">
 
     
Date: Tue, 29 Nov 2022 00:24:29 +0100
Subject: [PATCH 159/173] Remove seekBar padding

---
 .../item_timeline_event_voice_broadcast_listening_stub.xml     | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml
index f26a6bf7ab..d3c0dc4dc5 100644
--- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml
+++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml
@@ -138,6 +138,9 @@
         android:layout_width="0dp"
         android:layout_height="wrap_content"
         android:layout_marginTop="24dp"
+        android:layout_marginEnd="6dp"
+        android:paddingStart="0dp"
+        android:paddingEnd="0dp"
         android:progressDrawable="@drawable/bg_seek_bar"
         android:thumbTint="?vctr_content_tertiary"
         app:layout_constraintBottom_toBottomOf="parent"

From 9458276a4e62af6331492dc69570cb6cf16fcaa3 Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Tue, 29 Nov 2022 00:36:02 +0100
Subject: [PATCH 160/173] Change seekBar and duration colors to secondary

---
 vector/src/main/res/drawable/bg_seek_bar.xml                | 4 ++--
 .../src/main/res/layout/item_timeline_event_audio_stub.xml  | 6 +++---
 .../item_timeline_event_voice_broadcast_listening_stub.xml  | 2 +-
 3 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/vector/src/main/res/drawable/bg_seek_bar.xml b/vector/src/main/res/drawable/bg_seek_bar.xml
index 0a33522dfd..eff461091e 100644
--- a/vector/src/main/res/drawable/bg_seek_bar.xml
+++ b/vector/src/main/res/drawable/bg_seek_bar.xml
@@ -13,9 +13,9 @@
             
                 
             
         
     
-
\ No newline at end of file
+
diff --git a/vector/src/main/res/layout/item_timeline_event_audio_stub.xml b/vector/src/main/res/layout/item_timeline_event_audio_stub.xml
index 2a6fbf5a9e..4c4286af9b 100644
--- a/vector/src/main/res/layout/item_timeline_event_audio_stub.xml
+++ b/vector/src/main/res/layout/item_timeline_event_audio_stub.xml
@@ -73,7 +73,7 @@
             android:layout_marginTop="12dp"
             android:layout_marginBottom="10dp"
             android:progressDrawable="@drawable/bg_seek_bar"
-            android:thumbTint="?vctr_content_tertiary"
+            android:thumbTint="?vctr_content_secondary"
             app:layout_constraintStart_toStartOf="parent"
             app:layout_constraintEnd_toStartOf="@id/audioPlaybackTime"
             app:layout_constraintTop_toBottomOf="@id/audioPlaybackControlButton"
@@ -85,7 +85,7 @@
             style="@style/Widget.Vector.TextView.Caption"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
-            android:textColor="?vctr_content_tertiary"
+            android:textColor="?vctr_content_secondary"
             android:layout_marginEnd="4dp"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintTop_toTopOf="@id/audioSeekBar"
@@ -104,4 +104,4 @@
         android:visibility="gone"
         tools:visibility="visible" />
 
-
\ No newline at end of file
+
diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml
index d3c0dc4dc5..e002102e6c 100644
--- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml
+++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml
@@ -142,7 +142,7 @@
         android:paddingStart="0dp"
         android:paddingEnd="0dp"
         android:progressDrawable="@drawable/bg_seek_bar"
-        android:thumbTint="?vctr_content_tertiary"
+        android:thumbTint="?vctr_content_secondary"
         app:layout_constraintBottom_toBottomOf="parent"
         app:layout_constraintEnd_toStartOf="@id/playbackDuration"
         app:layout_constraintStart_toStartOf="parent"

From 7b4c1650332cdd2650f5f064320b65a2b1a8a0b8 Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Tue, 29 Nov 2022 01:22:31 +0100
Subject: [PATCH 161/173] Changelog

---
 changelog.d/7655.wip | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 changelog.d/7655.wip

diff --git a/changelog.d/7655.wip b/changelog.d/7655.wip
new file mode 100644
index 0000000000..24358007a9
--- /dev/null
+++ b/changelog.d/7655.wip
@@ -0,0 +1 @@
+Voice Broadcast - Update the buffering display in the timeline

From 471bf853c81bf4877ec6274dd6a6e2e1bdce2106 Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Tue, 29 Nov 2022 01:41:31 +0100
Subject: [PATCH 162/173] Remove voice broadcast chunks from the room
 attachments list

---
 .../detail/timeline/helper/TimelineEventVisibilityHelper.kt   | 3 +--
 .../app/features/roomprofile/uploads/RoomUploadsViewModel.kt  | 4 ++++
 2 files changed, 5 insertions(+), 2 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt
index 1360151074..382f1c2301 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt
@@ -22,7 +22,6 @@ import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
 import im.vector.app.features.voicebroadcast.isVoiceBroadcast
 import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
 import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
-import org.matrix.android.sdk.api.extensions.orFalse
 import org.matrix.android.sdk.api.session.events.model.Event
 import org.matrix.android.sdk.api.session.events.model.EventType
 import org.matrix.android.sdk.api.session.events.model.RelationType
@@ -257,7 +256,7 @@ class TimelineEventVisibilityHelper @Inject constructor(
             return true
         }
 
-        if (root.asMessageAudioEvent()?.isVoiceBroadcast().orFalse()) {
+        if (root.asMessageAudioEvent().isVoiceBroadcast()) {
             return true
         }
 
diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsViewModel.kt b/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsViewModel.kt
index 87dff2f00b..a71490f4a7 100644
--- a/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsViewModel.kt
+++ b/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsViewModel.kt
@@ -26,10 +26,12 @@ import dagger.assisted.AssistedInject
 import im.vector.app.core.di.MavericksAssistedViewModelFactory
 import im.vector.app.core.di.hiltMavericksViewModelFactory
 import im.vector.app.core.platform.VectorViewModel
+import im.vector.app.features.voicebroadcast.isVoiceBroadcast
 import kotlinx.coroutines.launch
 import org.matrix.android.sdk.api.session.Session
 import org.matrix.android.sdk.api.session.getRoom
 import org.matrix.android.sdk.api.session.room.model.message.MessageType
+import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
 import org.matrix.android.sdk.flow.flow
 import org.matrix.android.sdk.flow.unwrap
 
@@ -78,6 +80,8 @@ class RoomUploadsViewModel @AssistedInject constructor(
                 token = result.nextToken
 
                 val groupedUploadEvents = result.uploadEvents
+                        // Remove voice broadcast chunks from the attachments
+                        .filterNot { it.root.asMessageAudioEvent().isVoiceBroadcast() }
                         .groupBy {
                             it.contentWithAttachmentContent.msgType == MessageType.MSGTYPE_IMAGE ||
                                     it.contentWithAttachmentContent.msgType == MessageType.MSGTYPE_VIDEO

From f8881638f9a18cd0a8d3622eb6313e7cab70ceeb Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Tue, 29 Nov 2022 01:49:50 +0100
Subject: [PATCH 163/173] Changelog

---
 changelog.d/7656.wip | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 changelog.d/7656.wip

diff --git a/changelog.d/7656.wip b/changelog.d/7656.wip
new file mode 100644
index 0000000000..ab0e47289f
--- /dev/null
+++ b/changelog.d/7656.wip
@@ -0,0 +1 @@
+Voice Broadcast - Remove voice messages related to a VB from the room attachments

From 9ab2d1afb0e31d8b5f1a1612bc75deec8b9395b2 Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Tue, 29 Nov 2022 10:50:31 +0100
Subject: [PATCH 164/173] Fix thumb cropped

---
 .../item_timeline_event_voice_broadcast_listening_stub.xml   | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml
index e002102e6c..3c59d49418 100644
--- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml
+++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml
@@ -7,6 +7,8 @@
     android:layout_height="wrap_content"
     android:background="@drawable/rounded_rect_shape_8"
     android:backgroundTint="?vctr_content_quinary"
+    android:clipChildren="false"
+    android:clipToPadding="false"
     android:padding="12dp">
 
     
+        tools:progress="0" />
 
     
Date: Tue, 29 Nov 2022 10:54:31 +0100
Subject: [PATCH 165/173] Fix playback not in buffering if waiting for new
 chunks

---
 .../voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt        | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
index 724be600a3..95a4ddcf2e 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
@@ -434,6 +434,8 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
             if (!isLiveListening && mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence == playlist.currentSequence) {
                 // We'll not receive new chunks anymore so we can stop the live listening
                 stop()
+            } else {
+                playingState = State.BUFFERING
             }
         }
 

From 42b3ecc0b69985cdca753ac16a50d31a8240c76e Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Tue, 29 Nov 2022 14:12:39 +0100
Subject: [PATCH 166/173] Fix pause/resume playback not working correctly

---
 .../im/vector/app/core/extensions/Flow.kt     | 35 ++++++++++++++
 .../detail/composer/AudioMessageHelper.kt     |  2 +-
 .../helper/AudioMessagePlaybackTracker.kt     | 25 +++++-----
 .../MessageVoiceBroadcastListeningItem.kt     |  4 +-
 .../listening/VoiceBroadcastPlayerImpl.kt     | 47 +++++++++----------
 5 files changed, 74 insertions(+), 39 deletions(-)
 create mode 100644 vector/src/main/java/im/vector/app/core/extensions/Flow.kt

diff --git a/vector/src/main/java/im/vector/app/core/extensions/Flow.kt b/vector/src/main/java/im/vector/app/core/extensions/Flow.kt
new file mode 100644
index 0000000000..82e6e5f9a6
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/core/extensions/Flow.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2022 New Vector Ltd
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package im.vector.app.core.extensions
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+
+/**
+ * Returns a flow that invokes the given action after the first value of the upstream flow is emitted downstream.
+ */
+fun  Flow.onFirst(action: (T) -> Unit): Flow = flow {
+    var emitted = false
+    collect { value ->
+        emit(value) // always emit value
+
+        if (!emitted) {
+            action(value) // execute the action after the first emission
+            emitted = true
+        }
+    }
+}
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt
index b5ea528bd7..900de041d0 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt
@@ -149,7 +149,7 @@ class AudioMessageHelper @Inject constructor(
     }
 
     private fun startPlayback(id: String, file: File) {
-        val currentPlaybackTime = playbackTracker.getPlaybackTime(id)
+        val currentPlaybackTime = playbackTracker.getPlaybackTime(id) ?: 0
 
         try {
             FileInputStream(file).use { fis ->
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt
index 90fd66f9ab..c34cbbc74a 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt
@@ -67,8 +67,8 @@ class AudioMessagePlaybackTracker @Inject constructor() {
     }
 
     fun startPlayback(id: String) {
-        val currentPlaybackTime = getPlaybackTime(id)
-        val currentPercentage = getPercentage(id)
+        val currentPlaybackTime = getPlaybackTime(id) ?: 0
+        val currentPercentage = getPercentage(id) ?: 0f
         val currentState = Listener.State.Playing(currentPlaybackTime, currentPercentage)
         setState(id, currentState)
         // Pause any active playback
@@ -85,9 +85,10 @@ class AudioMessagePlaybackTracker @Inject constructor() {
     }
 
     fun pausePlayback(id: String) {
-        if (getPlaybackState(id) is Listener.State.Playing) {
-            val currentPlaybackTime = getPlaybackTime(id)
-            val currentPercentage = getPercentage(id)
+        val state = getPlaybackState(id)
+        if (state is Listener.State.Playing) {
+            val currentPlaybackTime = state.playbackTime
+            val currentPercentage = state.percentage
             setState(id, Listener.State.Paused(currentPlaybackTime, currentPercentage))
         }
     }
@@ -110,21 +111,23 @@ class AudioMessagePlaybackTracker @Inject constructor() {
 
     fun getPlaybackState(id: String) = states[id]
 
-    fun getPlaybackTime(id: String): Int {
+    fun getPlaybackTime(id: String): Int? {
         return when (val state = states[id]) {
             is Listener.State.Playing -> state.playbackTime
             is Listener.State.Paused -> state.playbackTime
-            /* Listener.State.Idle, */
-            else -> 0
+            is Listener.State.Recording,
+            Listener.State.Idle,
+            null -> null
         }
     }
 
-    fun getPercentage(id: String): Float {
+    fun getPercentage(id: String): Float? {
         return when (val state = states[id]) {
             is Listener.State.Playing -> state.percentage
             is Listener.State.Paused -> state.percentage
-            /* Listener.State.Idle, */
-            else -> 0f
+            is Listener.State.Recording,
+            Listener.State.Idle,
+            null -> null
         }
     }
 
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt
index 8d32875f0c..38fe1e8f17 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt
@@ -141,14 +141,14 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
             renderBackwardForwardButtons(holder, playbackState)
             renderLiveIndicator(holder)
             if (!isUserSeeking) {
-                holder.seekBar.progress = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId)
+                holder.seekBar.progress = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) ?: 0
             }
         }
     }
 
     private fun renderBackwardForwardButtons(holder: Holder, playbackState: State) {
         val isPlayingOrPaused = playbackState is State.Playing || playbackState is State.Paused
-        val playbackTime = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId)
+        val playbackTime = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) ?: 0
         val canBackward = isPlayingOrPaused && playbackTime > 0
         val canForward = isPlayingOrPaused && playbackTime < duration
         holder.fastBackwardButton.isInvisible = !canBackward
diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
index 95a4ddcf2e..f8025d078e 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt
@@ -21,6 +21,7 @@ import android.media.MediaPlayer
 import android.media.MediaPlayer.OnPreparedListener
 import androidx.annotation.MainThread
 import im.vector.app.core.di.ActiveSessionHolder
+import im.vector.app.core.extensions.onFirst
 import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
 import im.vector.app.features.session.coroutineScope
 import im.vector.app.features.voice.VoiceFailure
@@ -145,11 +146,11 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
         playingState = State.BUFFERING
 
         observeVoiceBroadcastStateEvent(voiceBroadcast)
-        fetchPlaylistAndStartPlayback(voiceBroadcast)
     }
 
     private fun observeVoiceBroadcastStateEvent(voiceBroadcast: VoiceBroadcast) {
         voiceBroadcastStateObserver = getVoiceBroadcastEventUseCase.execute(voiceBroadcast)
+                .onFirst { fetchPlaylistAndStartPlayback(voiceBroadcast) }
                 .onEach { onVoiceBroadcastStateEventUpdated(it.getOrNull()) }
                 .launchIn(sessionScope)
     }
@@ -222,24 +223,19 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
         }
     }
 
-    private fun pausePlayback(positionMillis: Int? = null) {
-        if (positionMillis == null) {
+    private fun pausePlayback() {
+        playingState = State.PAUSED // This will trigger a playing state update and save the current position
+        if (currentMediaPlayer != null) {
             currentMediaPlayer?.pause()
         } else {
             stopPlayer()
-            val voiceBroadcastId = currentVoiceBroadcast?.voiceBroadcastId
-            val duration = playlist.duration.takeIf { it > 0 }
-            if (voiceBroadcastId != null && duration != null) {
-                playbackTracker.updatePausedAtPlaybackTime(voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration)
-            }
         }
-        playingState = State.PAUSED
     }
 
     private fun resumePlayback() {
         if (currentMediaPlayer != null) {
-            currentMediaPlayer?.start()
             playingState = State.PLAYING
+            currentMediaPlayer?.start()
         } else {
             val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } ?: 0
             startPlayback(savedPosition)
@@ -256,7 +252,8 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
                 startPlayback(positionMillis)
             }
             playingState == State.IDLE || playingState == State.PAUSED -> {
-                pausePlayback(positionMillis)
+                stopPlayer()
+                playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration)
             }
         }
     }
@@ -366,8 +363,12 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
                     isLiveListening && newSequence == playlist.currentSequence
                 }
             }
-            // otherwise, stay in live or go in live if we reached the latest sequence
-            else -> isLiveListening || playlist.currentSequence == playlist.lastOrNull()?.sequence
+            // if there is no saved position, go in live
+            getCurrentPlaybackPosition() == null -> true
+            // if we reached the latest sequence, go in live
+            playlist.currentSequence == playlist.lastOrNull()?.sequence -> true
+            // otherwise, do not change
+            else -> isLiveListening
         }
     }
 
@@ -392,9 +393,9 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
     }
 
     private fun getCurrentPlaybackPosition(): Int? {
-        val playlistPosition = playlist.currentItem?.startTime
-        val computedPosition = currentMediaPlayer?.currentPosition?.let { playlistPosition?.plus(it) } ?: playlistPosition
-        val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) }
+        val voiceBroadcastId = currentVoiceBroadcast?.voiceBroadcastId ?: return null
+        val computedPosition = currentMediaPlayer?.currentPosition?.let { playlist.currentItem?.startTime?.plus(it) }
+        val savedPosition = playbackTracker.getPlaybackTime(voiceBroadcastId)
         return computedPosition ?: savedPosition
     }
 
@@ -423,19 +424,15 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
             // Next media player is already attached to this player and will start playing automatically
             if (nextMediaPlayer != null) return
 
-            // Next media player is preparing but not attached yet, reset the currentMediaPlayer and let the new player take over
-            if (isPreparingNextPlayer) {
-                currentMediaPlayer?.release()
-                currentMediaPlayer = null
-                playingState = State.BUFFERING
-                return
-            }
-
-            if (!isLiveListening && mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence == playlist.currentSequence) {
+            val hasEnded = !isLiveListening && mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence == playlist.currentSequence
+            if (hasEnded) {
                 // We'll not receive new chunks anymore so we can stop the live listening
                 stop()
             } else {
+                // Enter in buffering mode and release current media player
                 playingState = State.BUFFERING
+                currentMediaPlayer?.release()
+                currentMediaPlayer = null
             }
         }
 

From 1415504f84b2296865e536de9fdf0509e8e95bd2 Mon Sep 17 00:00:00 2001
From: Florian Renaud 
Date: Tue, 29 Nov 2022 14:32:05 +0100
Subject: [PATCH 167/173] Rename view ids

---
 .../voicebroadcast/views/VoiceBroadcastMetadataView.kt      | 6 +++---
 .../src/main/res/layout/view_voice_broadcast_buffering.xml  | 5 ++---
 .../src/main/res/layout/view_voice_broadcast_metadata.xml   | 2 +-
 3 files changed, 6 insertions(+), 7 deletions(-)

diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt
index e142cb15ce..c743d8a542 100644
--- a/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt
@@ -37,9 +37,9 @@ class VoiceBroadcastMetadataView @JvmOverloads constructor(
     )
 
     var value: String
-        get() = views.metadataValue.text.toString()
+        get() = views.metadataText.text.toString()
         set(newValue) {
-            views.metadataValue.text = newValue
+            views.metadataText.text = newValue
         }
 
     init {
@@ -61,6 +61,6 @@ class VoiceBroadcastMetadataView @JvmOverloads constructor(
 
     private fun setValue(typedArray: TypedArray) {
         val value = typedArray.getString(R.styleable.VoiceBroadcastMetadataView_metadataValue)
-        views.metadataValue.text = value
+        views.metadataText.text = value
     }
 }
diff --git a/vector/src/main/res/layout/view_voice_broadcast_buffering.xml b/vector/src/main/res/layout/view_voice_broadcast_buffering.xml
index c58858f033..e292169537 100644
--- a/vector/src/main/res/layout/view_voice_broadcast_buffering.xml
+++ b/vector/src/main/res/layout/view_voice_broadcast_buffering.xml
@@ -8,17 +8,16 @@
     tools:parentTag="android.widget.LinearLayout">
 
     
 
     
 
     
Date: Wed, 30 Nov 2022 09:00:37 +0000
Subject: [PATCH 168/173] [Rich text editor] Fix design and spacing of rich
 text editor (#7658)

Improve design and spacing of the rich text editor.

Minor changes to
 - position of input field relative to buttons
 - spacing around attachments button
 - spacing around send button
 - selectable backgrounds
---
 7658.bugfix                                   |  1 +
 .../detail/composer/RichTextComposerLayout.kt |  4 ++--
 .../res/layout/composer_rich_text_layout.xml  | 22 +++++++++----------
 3 files changed, 14 insertions(+), 13 deletions(-)
 create mode 100644 7658.bugfix

diff --git a/7658.bugfix b/7658.bugfix
new file mode 100644
index 0000000000..a5ab85b191
--- /dev/null
+++ b/7658.bugfix
@@ -0,0 +1 @@
+[Rich text editor] Fix design and spacing of rich text editor
diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt
index 4664c4213c..a058d4fdaa 100644
--- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt
@@ -274,8 +274,8 @@ class RichTextComposerLayout @JvmOverloads constructor(
                 connect(R.id.composerEditTextOuterBorder, ConstraintSet.START, R.id.composerLayoutContent, ConstraintSet.START, dpToPx(12))
                 connect(R.id.composerEditTextOuterBorder, ConstraintSet.END, R.id.composerLayoutContent, ConstraintSet.END, dpToPx(12))
             } else {
-                connect(R.id.composerEditTextOuterBorder, ConstraintSet.TOP, R.id.composerLayoutContent, ConstraintSet.TOP, dpToPx(10))
-                connect(R.id.composerEditTextOuterBorder, ConstraintSet.BOTTOM, R.id.composerLayoutContent, ConstraintSet.BOTTOM, dpToPx(10))
+                connect(R.id.composerEditTextOuterBorder, ConstraintSet.TOP, R.id.composerLayoutContent, ConstraintSet.TOP, dpToPx(8))
+                connect(R.id.composerEditTextOuterBorder, ConstraintSet.BOTTOM, R.id.composerLayoutContent, ConstraintSet.BOTTOM, dpToPx(8))
                 connect(R.id.composerEditTextOuterBorder, ConstraintSet.START, R.id.attachmentButton, ConstraintSet.END, 0)
                 connect(R.id.composerEditTextOuterBorder, ConstraintSet.END, R.id.sendButton, ConstraintSet.START, 0)
             }
diff --git a/vector/src/main/res/layout/composer_rich_text_layout.xml b/vector/src/main/res/layout/composer_rich_text_layout.xml
index 88f96c528e..3484616c72 100644
--- a/vector/src/main/res/layout/composer_rich_text_layout.xml
+++ b/vector/src/main/res/layout/composer_rich_text_layout.xml
@@ -32,26 +32,26 @@
 
         
 
+        
         
 
         
Date: Wed, 30 Nov 2022 09:54:16 +0100
Subject: [PATCH 169/173] Release script: Fix script error

---
 tools/release/releaseScript.sh | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/tools/release/releaseScript.sh b/tools/release/releaseScript.sh
index 943e9771f2..e1579b00fc 100755
--- a/tools/release/releaseScript.sh
+++ b/tools/release/releaseScript.sh
@@ -77,7 +77,7 @@ fi
 cp ./vector-app/build.gradle ./vector-app/build.gradle.bak
 sed "s/ext.versionMajor = .*/ext.versionMajor = ${versionMajor}/" ./vector-app/build.gradle.bak > ./vector-app/build.gradle
 sed "s/ext.versionMinor = .*/ext.versionMinor = ${versionMinor}/" ./vector-app/build.gradle     > ./vector-app/build.gradle.bak
-sed "s/ext.versionPatch = .*/ext.versionPatch = ${patchVersion}/" ./vector-app/build.gradle.bak > ./vector-app/build.gradle
+sed "s/ext.versionPatch = .*/ext.versionPatch = ${versionPatch}/" ./vector-app/build.gradle.bak > ./vector-app/build.gradle
 rm ./vector-app/build.gradle.bak
 cp ./matrix-sdk-android/build.gradle ./matrix-sdk-android/build.gradle.bak
 sed "s/\"SDK_VERSION\", .*$/\"SDK_VERSION\", \"\\\\\"${version}\\\\\"\"/" ./matrix-sdk-android/build.gradle.bak > ./matrix-sdk-android/build.gradle
@@ -184,7 +184,6 @@ git checkout develop
 # Set next version
 printf "\n================================================================================\n"
 printf "Setting next version on file './vector-app/build.gradle'...\n"
-nextPatchVersion=$((versionPatch + 2))
 cp ./vector-app/build.gradle ./vector-app/build.gradle.bak
 sed "s/ext.versionPatch = .*/ext.versionPatch = ${nextPatchVersion}/" ./vector-app/build.gradle.bak > ./vector-app/build.gradle
 rm ./vector-app/build.gradle.bak

From b0029f2dd3409a568cb7d56f9c1fddfcb19bef1c Mon Sep 17 00:00:00 2001
From: Benoit Marty 
Date: Wed, 30 Nov 2022 10:00:12 +0100
Subject: [PATCH 170/173] Release script: Check if git flow is enabled

---
 tools/release/releaseScript.sh | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/tools/release/releaseScript.sh b/tools/release/releaseScript.sh
index e1579b00fc..de97f2e7bb 100755
--- a/tools/release/releaseScript.sh
+++ b/tools/release/releaseScript.sh
@@ -38,6 +38,17 @@ if [[ ! -f ${releaseScriptFullPath} ]]; then
   exit 1
 fi
 
+# Check if git flow is enabled
+git flow config >/dev/null 2>&1
+if [[ $? == 0 ]]
+then
+    printf "Git flow is initialized"
+else
+    printf "Git flow is not initialized. Initializing...\n"
+    # All default value, just set 'v' for tag prefix
+    git flow init -d -t 'v'
+fi
+
 # Guessing version to propose a default version
 versionMajorCandidate=`grep "ext.versionMajor" ./vector-app/build.gradle | cut  -d " " -f3`
 versionMinorCandidate=`grep "ext.versionMinor" ./vector-app/build.gradle | cut  -d " " -f3`

From eb7154d42c03c6358558af33f864d3119b041775 Mon Sep 17 00:00:00 2001
From: Benoit Marty 
Date: Wed, 30 Nov 2022 10:25:21 +0100
Subject: [PATCH 171/173] Remove the obsolete description of the attribute.

---
 .github/ISSUE_TEMPLATE/release.yml | 1 -
 1 file changed, 1 deletion(-)

diff --git a/.github/ISSUE_TEMPLATE/release.yml b/.github/ISSUE_TEMPLATE/release.yml
index 0c3542997a..4ab77af5a0 100644
--- a/.github/ISSUE_TEMPLATE/release.yml
+++ b/.github/ISSUE_TEMPLATE/release.yml
@@ -10,7 +10,6 @@ body:
     id: checklist
     attributes:
       label: Release checklist
-      description: For the template example, we are releasing the version 1.2.3. Replace 1.2.3 with the version in the issue body.
       placeholder: |
         If you are reading this, you have deleted the content of the release template: undo the deletion or start again.
       value: |

From d447d809f733e5a7154d1a922196afe548c83959 Mon Sep 17 00:00:00 2001
From: Benoit Marty 
Date: Wed, 30 Nov 2022 10:46:58 +0100
Subject: [PATCH 172/173] Changelog for version 1.5.10

---
 CHANGES.md               | 36 ++++++++++++++++++++++++++++++++++++
 changelog.d/2725.feature |  1 -
 changelog.d/5679.bugfix  |  1 -
 changelog.d/6996.sdk     |  1 -
 changelog.d/7546.feature |  1 -
 changelog.d/7555.bugfix  |  1 -
 changelog.d/7577.feature |  1 -
 changelog.d/7583.misc    |  1 -
 changelog.d/7594.misc    |  1 -
 changelog.d/7604.bugfix  |  1 -
 changelog.d/7620.bugfix  |  1 -
 changelog.d/7626.sdk     |  2 --
 changelog.d/7629.wip     |  1 -
 changelog.d/7634.bugfix  |  1 -
 changelog.d/7646.bugfix  |  1 -
 changelog.d/7655.wip     |  1 -
 changelog.d/7656.wip     |  1 -
 17 files changed, 36 insertions(+), 17 deletions(-)
 delete mode 100644 changelog.d/2725.feature
 delete mode 100644 changelog.d/5679.bugfix
 delete mode 100644 changelog.d/6996.sdk
 delete mode 100644 changelog.d/7546.feature
 delete mode 100644 changelog.d/7555.bugfix
 delete mode 100644 changelog.d/7577.feature
 delete mode 100644 changelog.d/7583.misc
 delete mode 100644 changelog.d/7594.misc
 delete mode 100644 changelog.d/7604.bugfix
 delete mode 100644 changelog.d/7620.bugfix
 delete mode 100644 changelog.d/7626.sdk
 delete mode 100644 changelog.d/7629.wip
 delete mode 100644 changelog.d/7634.bugfix
 delete mode 100644 changelog.d/7646.bugfix
 delete mode 100644 changelog.d/7655.wip
 delete mode 100644 changelog.d/7656.wip

diff --git a/CHANGES.md b/CHANGES.md
index 442d3641dd..022591f5a1 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,39 @@
+Changes in Element v1.5.10 (2022-11-30)
+=======================================
+
+Features ✨
+----------
+ - Add setting to allow disabling direct share ([#2725](https://github.com/vector-im/element-android/issues/2725))
+ - [Device Manager] Toggle IP address visibility ([#7546](https://github.com/vector-im/element-android/issues/7546))
+ - New implementation of the full screen mode for the Rich Text Editor. ([#7577](https://github.com/vector-im/element-android/issues/7577))
+
+Bugfixes 🐛
+----------
+ - Fix italic text is truncated when bubble mode and markdown is enabled ([#5679](https://github.com/vector-im/element-android/issues/5679))
+ - Missing translations on "replyTo" messages ([#7555](https://github.com/vector-im/element-android/issues/7555))
+ - ANR on session start when sending client info is enabled ([#7604](https://github.com/vector-im/element-android/issues/7604))
+ - Make the plain text mode layout of the RTE more compact. ([#7620](https://github.com/vector-im/element-android/issues/7620))
+ - Push notification for thread message is now shown correctly when user observes rooms main timeline ([#7634](https://github.com/vector-im/element-android/issues/7634))
+ - Voice Broadcast - Fix playback stuck in buffering mode ([#7646](https://github.com/vector-im/element-android/issues/7646))
+
+In development 🚧
+----------------
+ - Voice Broadcast - Handle redaction of the state events on the listener and recorder sides ([#7629](https://github.com/vector-im/element-android/issues/7629))
+ - Voice Broadcast - Update the buffering display in the timeline ([#7655](https://github.com/vector-im/element-android/issues/7655))
+ - Voice Broadcast - Remove voice messages related to a VB from the room attachments ([#7656](https://github.com/vector-im/element-android/issues/7656))
+
+SDK API changes ⚠️
+------------------
+ - Added support for read receipts in threads. Now user in a room can have multiple read receipts (one per thread + one in main thread + one without threadId) ([#6996](https://github.com/vector-im/element-android/issues/6996))
+ - Sync Filter now taking in account homeserver capabilities to not pass unsupported parameters.
+  Sync Filter is now configured by providing SyncFilterBuilder class instance, instead of Filter to identify Filter changes related to homeserver capabilities ([#7626](https://github.com/vector-im/element-android/issues/7626))
+
+Other changes
+-------------
+ - Remove usage of Buildkite. ([#7583](https://github.com/vector-im/element-android/issues/7583))
+ - Better validation of edits ([#7594](https://github.com/vector-im/element-android/issues/7594))
+
+
 Changes in Element v1.5.8 (2022-11-17)
 ======================================
 
diff --git a/changelog.d/2725.feature b/changelog.d/2725.feature
deleted file mode 100644
index eb3fcaed57..0000000000
--- a/changelog.d/2725.feature
+++ /dev/null
@@ -1 +0,0 @@
-Add setting to allow disabling direct share
diff --git a/changelog.d/5679.bugfix b/changelog.d/5679.bugfix
deleted file mode 100644
index 0394bc3e5d..0000000000
--- a/changelog.d/5679.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Fix italic text is truncated when bubble mode and markdown is enabled
diff --git a/changelog.d/6996.sdk b/changelog.d/6996.sdk
deleted file mode 100644
index 588ec160d7..0000000000
--- a/changelog.d/6996.sdk
+++ /dev/null
@@ -1 +0,0 @@
-Added support for read receipts in threads. Now user in a room can have multiple read receipts (one per thread + one in main thread + one without threadId)
diff --git a/changelog.d/7546.feature b/changelog.d/7546.feature
deleted file mode 100644
index 94450082c9..0000000000
--- a/changelog.d/7546.feature
+++ /dev/null
@@ -1 +0,0 @@
-[Device Manager] Toggle IP address visibility
diff --git a/changelog.d/7555.bugfix b/changelog.d/7555.bugfix
deleted file mode 100644
index 064b21a9e5..0000000000
--- a/changelog.d/7555.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Missing translations on "replyTo" messages
diff --git a/changelog.d/7577.feature b/changelog.d/7577.feature
deleted file mode 100644
index e21ccb13c0..0000000000
--- a/changelog.d/7577.feature
+++ /dev/null
@@ -1 +0,0 @@
-New implementation of the full screen mode for the Rich Text Editor.
diff --git a/changelog.d/7583.misc b/changelog.d/7583.misc
deleted file mode 100644
index 3c63aeaadf..0000000000
--- a/changelog.d/7583.misc
+++ /dev/null
@@ -1 +0,0 @@
-Remove usage of Buildkite.
diff --git a/changelog.d/7594.misc b/changelog.d/7594.misc
deleted file mode 100644
index 5c5771d8d0..0000000000
--- a/changelog.d/7594.misc
+++ /dev/null
@@ -1 +0,0 @@
-Better validation of edits
diff --git a/changelog.d/7604.bugfix b/changelog.d/7604.bugfix
deleted file mode 100644
index 0fbee55bce..0000000000
--- a/changelog.d/7604.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-ANR on session start when sending client info is enabled
diff --git a/changelog.d/7620.bugfix b/changelog.d/7620.bugfix
deleted file mode 100644
index 55c0e423ad..0000000000
--- a/changelog.d/7620.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Make the plain text mode layout of the RTE more compact.
diff --git a/changelog.d/7626.sdk b/changelog.d/7626.sdk
deleted file mode 100644
index 4d9f28183a..0000000000
--- a/changelog.d/7626.sdk
+++ /dev/null
@@ -1,2 +0,0 @@
-Sync Filter now taking in account homeserver capabilities to not pass unsupported parameters.
-Sync Filter is now configured by providing SyncFilterBuilder class instance, instead of Filter to identify Filter changes related to homeserver capabilities
diff --git a/changelog.d/7629.wip b/changelog.d/7629.wip
deleted file mode 100644
index ecc4449b6f..0000000000
--- a/changelog.d/7629.wip
+++ /dev/null
@@ -1 +0,0 @@
-Voice Broadcast - Handle redaction of the state events on the listener and recorder sides
diff --git a/changelog.d/7634.bugfix b/changelog.d/7634.bugfix
deleted file mode 100644
index a3c829840a..0000000000
--- a/changelog.d/7634.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Push notification for thread message is now shown correctly when user observes rooms main timeline
diff --git a/changelog.d/7646.bugfix b/changelog.d/7646.bugfix
deleted file mode 100644
index 7f771bc6f7..0000000000
--- a/changelog.d/7646.bugfix
+++ /dev/null
@@ -1 +0,0 @@
-Voice Broadcast - Fix playback stuck in buffering mode
diff --git a/changelog.d/7655.wip b/changelog.d/7655.wip
deleted file mode 100644
index 24358007a9..0000000000
--- a/changelog.d/7655.wip
+++ /dev/null
@@ -1 +0,0 @@
-Voice Broadcast - Update the buffering display in the timeline
diff --git a/changelog.d/7656.wip b/changelog.d/7656.wip
deleted file mode 100644
index ab0e47289f..0000000000
--- a/changelog.d/7656.wip
+++ /dev/null
@@ -1 +0,0 @@
-Voice Broadcast - Remove voice messages related to a VB from the room attachments

From a656b003296ac0bf72485e7a37d4345153586f23 Mon Sep 17 00:00:00 2001
From: Benoit Marty 
Date: Wed, 30 Nov 2022 10:50:06 +0100
Subject: [PATCH 173/173] Adding fastlane file for version 1.5.10

---
 fastlane/metadata/android/en-US/changelogs/40105100.txt | 2 ++
 tools/release/releaseScript.sh                          | 1 +
 2 files changed, 3 insertions(+)
 create mode 100644 fastlane/metadata/android/en-US/changelogs/40105100.txt

diff --git a/fastlane/metadata/android/en-US/changelogs/40105100.txt b/fastlane/metadata/android/en-US/changelogs/40105100.txt
new file mode 100644
index 0000000000..c9e5ba5fa9
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40105100.txt
@@ -0,0 +1,2 @@
+Main changes in this version: New implementation of the full screen mode for the Rich Text Editor and bugfixes.
+Full changelog: https://github.com/vector-im/element-android/releases
diff --git a/tools/release/releaseScript.sh b/tools/release/releaseScript.sh
index de97f2e7bb..d8980b9da7 100755
--- a/tools/release/releaseScript.sh
+++ b/tools/release/releaseScript.sh
@@ -166,6 +166,7 @@ fastlanePathFile="./fastlane/metadata/android/en-US/changelogs/${fastlaneFile}"
 printf "Main changes in this version: TODO.\nFull changelog: https://github.com/vector-im/element-android/releases" > ${fastlanePathFile}
 
 read -p "I have created the file ${fastlanePathFile}, please edit it and press enter when it's done."
+git add ${fastlanePathFile}
 git commit -a -m "Adding fastlane file for version ${version}"
 
 printf "\n================================================================================\n"