From b70370b21704e5908a9c18438be08cd8eb337930 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Dec 2022 23:02:57 +0000 Subject: [PATCH 01/85] Bump soloader from 0.10.4 to 0.10.5 Bumps [soloader](https://github.com/facebook/soloader) from 0.10.4 to 0.10.5. - [Release notes](https://github.com/facebook/soloader/releases) - [Commits](https://github.com/facebook/soloader/commits) --- updated-dependencies: - dependency-name: com.facebook.soloader:soloader dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- vector-app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vector-app/build.gradle b/vector-app/build.gradle index 68e20996ad..1096c7ee63 100644 --- a/vector-app/build.gradle +++ b/vector-app/build.gradle @@ -359,7 +359,7 @@ dependencies { debugImplementation(libs.flipper.flipperNetworkPlugin) { exclude group: 'com.facebook.fbjni', module: 'fbjni' } - debugImplementation 'com.facebook.soloader:soloader:0.10.4' + debugImplementation 'com.facebook.soloader:soloader:0.10.5' debugImplementation "com.kgurgul.flipper:flipper-realm-android:2.2.0" gplayImplementation "com.google.android.gms:play-services-location:21.0.1" From b8ab1b5620e071974d3600479b915e91ba72780c Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Fri, 2 Dec 2022 17:35:13 +0100 Subject: [PATCH 02/85] Adding changelog entry --- changelog.d/7697.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7697.feature diff --git a/changelog.d/7697.feature b/changelog.d/7697.feature new file mode 100644 index 0000000000..6d71a84a40 --- /dev/null +++ b/changelog.d/7697.feature @@ -0,0 +1 @@ +[Session manager] Add actions to rename and signout current session From bbc756136cfa4b5d8dbc3baea00f88932b818b1f Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Mon, 5 Dec 2022 10:26:07 +0100 Subject: [PATCH 03/85] Adding the rename and signout actions in the menu --- .../v2/VectorSettingsDevicesFragment.kt | 54 ++++++++++++------- .../res/menu/menu_current_session_header.xml | 10 ++++ 2 files changed, 44 insertions(+), 20 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 d748600416..64744f6eeb 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 @@ -101,6 +101,7 @@ class VectorSettingsDevicesFragment : initWaitingView() initCurrentSessionHeaderView() + initCurrentSessionListView() initOtherSessionsHeaderView() initOtherSessionsView() initSecurityRecommendationsView() @@ -153,6 +154,12 @@ class VectorSettingsDevicesFragment : } } + private fun initCurrentSessionListView() { + views.deviceListCurrentSession.viewVerifyButton.debouncedClicks { + viewModel.handle(DevicesAction.VerifyCurrentSession) + } + } + private fun initOtherSessionsHeaderView() { views.deviceListHeaderOtherSessions.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { @@ -351,31 +358,38 @@ class VectorSettingsDevicesFragment : private fun renderCurrentSessionView(currentDeviceInfo: DeviceFullInfo?, hasOtherDevices: Boolean) { currentDeviceInfo?.let { - views.deviceListHeaderCurrentSession.isVisible = true - val colorDestructive = colorProvider.getColorFromAttribute(R.attr.colorError) - val signoutOtherSessionsItem = views.deviceListHeaderCurrentSession.menu.findItem(R.id.currentSessionHeaderSignoutOtherSessions) - signoutOtherSessionsItem.setTextColor(colorDestructive) - signoutOtherSessionsItem.isVisible = hasOtherDevices - views.deviceListCurrentSession.isVisible = true - val viewState = SessionInfoViewState( - isCurrentSession = true, - deviceFullInfo = it - ) - views.deviceListCurrentSession.render(viewState, dateFormatter, drawableProvider, colorProvider, stringProvider) - views.deviceListCurrentSession.debouncedClicks { - currentDeviceInfo.deviceInfo.deviceId?.let { deviceId -> navigateToSessionOverview(deviceId) } - } - views.deviceListCurrentSession.viewDetailsButton.debouncedClicks { - currentDeviceInfo.deviceInfo.deviceId?.let { deviceId -> navigateToSessionOverview(deviceId) } - } - views.deviceListCurrentSession.viewVerifyButton.debouncedClicks { - viewModel.handle(DevicesAction.VerifyCurrentSession) - } + renderCurrentSessionHeaderView(hasOtherDevices) + renderCurrentSessionListView(it) } ?: run { hideCurrentSessionView() } } + private fun renderCurrentSessionHeaderView(hasOtherDevices: Boolean) { + views.deviceListHeaderCurrentSession.isVisible = true + val colorDestructive = colorProvider.getColorFromAttribute(R.attr.colorError) + val signoutSessionItem = views.deviceListHeaderCurrentSession.menu.findItem(R.id.currentSessionHeaderSignout) + signoutSessionItem.setTextColor(colorDestructive) + val signoutOtherSessionsItem = views.deviceListHeaderCurrentSession.menu.findItem(R.id.currentSessionHeaderSignoutOtherSessions) + signoutOtherSessionsItem.setTextColor(colorDestructive) + signoutOtherSessionsItem.isVisible = hasOtherDevices + } + + private fun renderCurrentSessionListView(currentDeviceInfo: DeviceFullInfo) { + views.deviceListCurrentSession.isVisible = true + val viewState = SessionInfoViewState( + isCurrentSession = true, + deviceFullInfo = currentDeviceInfo + ) + views.deviceListCurrentSession.render(viewState, dateFormatter, drawableProvider, colorProvider, stringProvider) + views.deviceListCurrentSession.debouncedClicks { + currentDeviceInfo.deviceInfo.deviceId?.let { deviceId -> navigateToSessionOverview(deviceId) } + } + views.deviceListCurrentSession.viewDetailsButton.debouncedClicks { + currentDeviceInfo.deviceInfo.deviceId?.let { deviceId -> navigateToSessionOverview(deviceId) } + } + } + private fun navigateToSessionOverview(deviceId: String) { viewNavigator.navigateToSessionOverview( context = requireActivity(), diff --git a/vector/src/main/res/menu/menu_current_session_header.xml b/vector/src/main/res/menu/menu_current_session_header.xml index 3b00423488..993bee6178 100644 --- a/vector/src/main/res/menu/menu_current_session_header.xml +++ b/vector/src/main/res/menu/menu_current_session_header.xml @@ -4,6 +4,16 @@ xmlns:tools="http://schemas.android.com/tools" tools:ignore="AlwaysShowAction"> + + + + Date: Mon, 5 Dec 2022 10:43:56 +0100 Subject: [PATCH 04/85] Navigate to rename session screen from current session menu --- .../v2/VectorSettingsDevicesFragment.kt | 14 ++++++++ .../v2/VectorSettingsDevicesViewNavigator.kt | 5 +++ .../VectorSettingsDevicesViewNavigatorTest.kt | 35 +++++++++++++++---- .../OtherSessionsViewNavigatorTest.kt | 8 ++--- .../SessionOverviewViewNavigatorTest.kt | 8 ++--- .../im/vector/app/test/fakes/FakeContext.kt | 10 ++++-- 6 files changed, 61 insertions(+), 19 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 64744f6eeb..c29655a0c7 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 @@ -145,6 +145,10 @@ class VectorSettingsDevicesFragment : private fun initCurrentSessionHeaderView() { views.deviceListHeaderCurrentSession.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { + R.id.currentSessionHeaderRename -> { + navigateToRenameCurrentSession() + true + } R.id.currentSessionHeaderSignoutOtherSessions -> { confirmMultiSignoutOtherSessions() true @@ -154,6 +158,16 @@ class VectorSettingsDevicesFragment : } } + private fun navigateToRenameCurrentSession() = withState(viewModel) { state -> + val currentDeviceId = state.currentSessionCrossSigningInfo.deviceId + if (currentDeviceId.isNotEmpty()) { + viewNavigator.navigateToRenameSession( + context = requireActivity(), + deviceId = currentDeviceId, + ) + } + } + private fun initCurrentSessionListView() { views.deviceListCurrentSession.viewVerifyButton.debouncedClicks { viewModel.handle(DevicesAction.VerifyCurrentSession) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesViewNavigator.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesViewNavigator.kt index 47e697822b..d4b3345fea 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesViewNavigator.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesViewNavigator.kt @@ -20,6 +20,7 @@ import android.content.Context import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType import im.vector.app.features.settings.devices.v2.othersessions.OtherSessionsActivity import im.vector.app.features.settings.devices.v2.overview.SessionOverviewActivity +import im.vector.app.features.settings.devices.v2.rename.RenameSessionActivity import javax.inject.Inject class VectorSettingsDevicesViewNavigator @Inject constructor() { @@ -38,4 +39,8 @@ class VectorSettingsDevicesViewNavigator @Inject constructor() { OtherSessionsActivity.newIntent(context, titleResourceId, defaultFilter, excludeCurrentDevice) ) } + + fun navigateToRenameSession(context: Context, deviceId: String) { + context.startActivity(RenameSessionActivity.newIntent(context, deviceId)) + } } diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesViewNavigatorTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesViewNavigatorTest.kt index ec8019384a..37823f7d53 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesViewNavigatorTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesViewNavigatorTest.kt @@ -20,6 +20,7 @@ import android.content.Intent import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType import im.vector.app.features.settings.devices.v2.othersessions.OtherSessionsActivity import im.vector.app.features.settings.devices.v2.overview.SessionOverviewActivity +import im.vector.app.features.settings.devices.v2.rename.RenameSessionActivity import im.vector.app.test.fakes.FakeContext import io.mockk.every import io.mockk.mockk @@ -43,6 +44,7 @@ class VectorSettingsDevicesViewNavigatorTest { fun setUp() { mockkObject(SessionOverviewActivity.Companion) mockkObject(OtherSessionsActivity.Companion) + mockkObject(RenameSessionActivity.Companion) } @After @@ -52,26 +54,41 @@ class VectorSettingsDevicesViewNavigatorTest { @Test fun `given a session id when navigating to overview then it starts the correct activity`() { + // Given val intent = givenIntentForSessionOverview(A_SESSION_ID) context.givenStartActivity(intent) + // When vectorSettingsDevicesViewNavigator.navigateToSessionOverview(context.instance, A_SESSION_ID) - verify { - context.instance.startActivity(intent) - } + // Then + context.verifyStartActivity(intent) } @Test fun `given an intent when navigating to other sessions list then it starts the correct activity`() { + // Given val intent = givenIntentForOtherSessions(A_TITLE_RESOURCE_ID, A_DEFAULT_FILTER, true) context.givenStartActivity(intent) + // When vectorSettingsDevicesViewNavigator.navigateToOtherSessions(context.instance, A_TITLE_RESOURCE_ID, A_DEFAULT_FILTER, true) - verify { - context.instance.startActivity(intent) - } + // Then + context.verifyStartActivity(intent) + } + + @Test + fun `given an intent when navigating to rename session screen then it starts the correct activity`() { + // Given + val intent = givenIntentForRenameSession(A_SESSION_ID) + context.givenStartActivity(intent) + + // When + vectorSettingsDevicesViewNavigator.navigateToRenameSession(context.instance, A_SESSION_ID) + + // Then + context.verifyStartActivity(intent) } private fun givenIntentForSessionOverview(sessionId: String): Intent { @@ -85,4 +102,10 @@ class VectorSettingsDevicesViewNavigatorTest { every { OtherSessionsActivity.newIntent(context.instance, titleResourceId, defaultFilter, excludeCurrentDevice) } returns intent return intent } + + private fun givenIntentForRenameSession(sessionId: String): Intent { + val intent = mockk() + every { RenameSessionActivity.newIntent(context.instance, sessionId) } returns intent + return intent + } } diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewNavigatorTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewNavigatorTest.kt index 3123572521..23fc1cbdce 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewNavigatorTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewNavigatorTest.kt @@ -23,7 +23,6 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkAll -import io.mockk.verify import org.junit.After import org.junit.Before import org.junit.Test @@ -47,14 +46,15 @@ class OtherSessionsViewNavigatorTest { @Test fun `given a device id when navigating to overview then it starts the correct activity`() { + // Given val intent = givenIntentForDeviceOverview(A_DEVICE_ID) context.givenStartActivity(intent) + // When otherSessionsViewNavigator.navigateToSessionOverview(context.instance, A_DEVICE_ID) - verify { - context.instance.startActivity(intent) - } + // Then + context.verifyStartActivity(intent) } private fun givenIntentForDeviceOverview(deviceId: String): Intent { diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewNavigatorTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewNavigatorTest.kt index e309c05042..99f3013697 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewNavigatorTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewNavigatorTest.kt @@ -60,9 +60,7 @@ class SessionOverviewViewNavigatorTest { sessionOverviewViewNavigator.goToSessionDetails(context.instance, A_SESSION_ID) // Then - verify { - context.instance.startActivity(intent) - } + context.verifyStartActivity(intent) } @Test @@ -75,9 +73,7 @@ class SessionOverviewViewNavigatorTest { sessionOverviewViewNavigator.goToRenameSession(context.instance, A_SESSION_ID) // Then - verify { - context.instance.startActivity(intent) - } + context.verifyStartActivity(intent) } @Test diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt index 9a94313fec..22e191e29c 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt @@ -24,9 +24,9 @@ import android.net.ConnectivityManager import android.net.Uri import android.os.ParcelFileDescriptor import io.mockk.every -import io.mockk.just +import io.mockk.justRun import io.mockk.mockk -import io.mockk.runs +import io.mockk.verify import java.io.OutputStream class FakeContext( @@ -73,7 +73,11 @@ class FakeContext( } fun givenStartActivity(intent: Intent) { - every { instance.startActivity(intent) } just runs + justRun { instance.startActivity(intent) } + } + + fun verifyStartActivity(intent: Intent) { + verify { instance.startActivity(intent) } } fun givenClipboardManager(): FakeClipboardManager { From 57554c5d3611642023669279d65d4c8c909b2b51 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Mon, 5 Dec 2022 14:10:56 +0100 Subject: [PATCH 05/85] Handling signout current session action --- .../settings/devices/v2/VectorSettingsDevicesFragment.kt | 9 +++++++++ 1 file changed, 9 insertions(+) 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 c29655a0c7..a21c7accb7 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 @@ -52,6 +52,7 @@ import im.vector.app.features.settings.devices.v2.list.SecurityRecommendationVie import im.vector.app.features.settings.devices.v2.list.SecurityRecommendationViewState import im.vector.app.features.settings.devices.v2.list.SessionInfoViewState import im.vector.app.features.settings.devices.v2.signout.BuildConfirmSignoutDialogUseCase +import im.vector.app.features.workers.signout.SignOutUiWorker import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel @@ -149,6 +150,10 @@ class VectorSettingsDevicesFragment : navigateToRenameCurrentSession() true } + R.id.currentSessionHeaderSignout -> { + confirmSignoutCurrentSession() + true + } R.id.currentSessionHeaderSignoutOtherSessions -> { confirmMultiSignoutOtherSessions() true @@ -168,6 +173,10 @@ class VectorSettingsDevicesFragment : } } + private fun confirmSignoutCurrentSession() { + activity?.let { SignOutUiWorker(it).perform() } + } + private fun initCurrentSessionListView() { views.deviceListCurrentSession.viewVerifyButton.debouncedClicks { viewModel.handle(DevicesAction.VerifyCurrentSession) From a00508e08588568c8a85d14aa0f591ca31393b60 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Mon, 5 Dec 2022 14:12:00 +0100 Subject: [PATCH 06/85] Removing unused import --- .../devices/v2/VectorSettingsDevicesViewNavigatorTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesViewNavigatorTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesViewNavigatorTest.kt index 37823f7d53..24582c75d8 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesViewNavigatorTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesViewNavigatorTest.kt @@ -26,7 +26,6 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkAll -import io.mockk.verify import org.junit.After import org.junit.Before import org.junit.Test From f2952f2deee61693de49e6b703b913ba0d5d8d6d Mon Sep 17 00:00:00 2001 From: valere Date: Mon, 5 Dec 2022 18:15:30 +0100 Subject: [PATCH 07/85] add to device tracing id --- changelog.d/7708.misc | 1 + .../internal/crypto/tasks/SendToDeviceTask.kt | 44 ++++- .../session/sync/handler/CryptoSyncHandler.kt | 5 +- .../crypto/DefaultSendToDeviceTaskTest.kt | 161 ++++++++++++++++++ 4 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 changelog.d/7708.misc create mode 100644 matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt diff --git a/changelog.d/7708.misc b/changelog.d/7708.misc new file mode 100644 index 0000000000..6273330395 --- /dev/null +++ b/changelog.d/7708.misc @@ -0,0 +1 @@ +Add tracing Id for to device messages diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendToDeviceTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendToDeviceTask.kt index fc4d422360..1e6ceeb138 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendToDeviceTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendToDeviceTask.kt @@ -17,14 +17,21 @@ package org.matrix.android.sdk.internal.crypto.tasks import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.internal.crypto.api.CryptoApi import org.matrix.android.sdk.internal.crypto.model.rest.SendToDeviceBody import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.task.Task +import timber.log.Timber import java.util.UUID import javax.inject.Inject +const val TO_DEVICE_TRACING_ID_KEY = "org.matrix.msgid" + +fun Event.toDeviceTracingId(): String? = content?.get(TO_DEVICE_TRACING_ID_KEY) as? String + internal interface SendToDeviceTask : Task { data class Params( // the type of event to send @@ -42,15 +49,17 @@ internal class DefaultSendToDeviceTask @Inject constructor( ) : SendToDeviceTask { override suspend fun execute(params: SendToDeviceTask.Params) { - val sendToDeviceBody = SendToDeviceBody( - messages = params.contentMap.map - ) - // If params.transactionId is not provided, we create a unique txnId. // It's important to do that outside the requestBlock parameter of executeRequest() // to use the same value if the request is retried val txnId = params.transactionId ?: createUniqueTxnId() + // add id tracing to debug + val decorated = decorateWithToDeviceTracingIds(params) + val sendToDeviceBody = SendToDeviceBody( + messages = decorated.first + ) + return executeRequest( globalErrorReceiver, canRetry = true, @@ -61,8 +70,35 @@ internal class DefaultSendToDeviceTask @Inject constructor( transactionId = txnId, body = sendToDeviceBody ) + Timber.i("Sent to device type=${params.eventType} txnid=$txnId [${decorated.second.joinToString(",")}]") } } + + /** + * To make it easier to track down where to-device messages are getting lost, + * add a custom property to each one, and that will be logged after sent and on reception. Synapse will also log + * this property. + * @return A pair, first is the decorated content, and second info to log out after sending + */ + private fun decorateWithToDeviceTracingIds(params: SendToDeviceTask.Params): Pair>, List> { + val tracingInfo = mutableListOf() + val decoratedContent = params.contentMap.map.map { userToDeviceMap -> + val userId = userToDeviceMap.key + userId to userToDeviceMap.value.map { + val deviceId = it.key + deviceId to it.value.toContent().toMutableMap().apply { + put( + TO_DEVICE_TRACING_ID_KEY, + UUID.randomUUID().toString().also { + tracingInfo.add("$userId/$deviceId (msgid $it)") + } + ) + } + }.toMap() + }.toMap() + + return decoratedContent to tracingInfo + } } internal fun createUniqueTxnId() = UUID.randomUUID().toString() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt index b2fe12ebc3..291e785aa5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt @@ -29,6 +29,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.sync.model.SyncResponse import org.matrix.android.sdk.api.session.sync.model.ToDeviceSyncResponse import org.matrix.android.sdk.internal.crypto.DefaultCryptoService +import org.matrix.android.sdk.internal.crypto.tasks.toDeviceTracingId import org.matrix.android.sdk.internal.crypto.verification.DefaultVerificationService import org.matrix.android.sdk.internal.session.sync.ProgressReporter import timber.log.Timber @@ -48,12 +49,14 @@ internal class CryptoSyncHandler @Inject constructor( ?.forEachIndexed { index, event -> progressReporter?.reportProgress(index * 100F / total) // Decrypt event if necessary - Timber.tag(loggerTag.value).i("To device event from ${event.senderId} of type:${event.type}") + Timber.tag(loggerTag.value).d("To device event tracingId:${event.toDeviceTracingId()}") decryptToDeviceEvent(event, null) + if (event.getClearType() == EventType.MESSAGE && event.getClearContent()?.toModel()?.msgType == "m.bad.encrypted") { Timber.tag(loggerTag.value).e("handleToDeviceEvent() : Warning: Unable to decrypt to-device event : ${event.content}") } else { + Timber.tag(loggerTag.value).d("received to-device ${event.getClearType()} from:${event.senderId} id:${event.toDeviceTracingId()}") verificationService.onToDeviceEvent(event) cryptoService.onToDeviceEvent(event) } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt new file mode 100644 index 0000000000..72d166c1f4 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt @@ -0,0 +1,161 @@ +/* + * 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.crypto + +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.amshove.kluent.internal.assertEquals +import org.junit.Assert +import org.junit.Test +import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo +import org.matrix.android.sdk.api.session.crypto.model.DevicesListResponse +import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.internal.crypto.api.CryptoApi +import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDeviceParams +import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDevicesParams +import org.matrix.android.sdk.internal.crypto.model.rest.KeyChangesResponse +import org.matrix.android.sdk.internal.crypto.model.rest.KeysClaimBody +import org.matrix.android.sdk.internal.crypto.model.rest.KeysClaimResponse +import org.matrix.android.sdk.internal.crypto.model.rest.KeysQueryBody +import org.matrix.android.sdk.internal.crypto.model.rest.KeysQueryResponse +import org.matrix.android.sdk.internal.crypto.model.rest.KeysUploadBody +import org.matrix.android.sdk.internal.crypto.model.rest.KeysUploadResponse +import org.matrix.android.sdk.internal.crypto.model.rest.SendToDeviceBody +import org.matrix.android.sdk.internal.crypto.model.rest.SignatureUploadResponse +import org.matrix.android.sdk.internal.crypto.model.rest.UpdateDeviceInfoBody +import org.matrix.android.sdk.internal.crypto.model.rest.UploadSigningKeysBody +import org.matrix.android.sdk.internal.crypto.tasks.DefaultSendToDeviceTask +import org.matrix.android.sdk.internal.crypto.tasks.SendToDeviceTask +import org.matrix.android.sdk.internal.network.GlobalErrorReceiver + +class DefaultSendToDeviceTaskTest { + + val users = listOf( + "@alice:example.com" to listOf("D0", "D1"), + "bob@example.com" to listOf("D2", "D3") + ) + + val fakeEncryptedContent = mapOf( + "algorithm" to "m.olm.v1.curve25519-aes-sha2", + "sender_key" to "gMObR+/4dqL5T4DisRRRYBJpn+OjzFnkyCFOktP6Eyw", + "ciphertext" to mapOf( + "tdwXf7006FDgzmufMCVI4rDdVPO51ecRTTT6HkRxUwE" to mapOf( + "type" to 0, + "body" to "AwogCA1ULEc0abGIFxMDIC9iv7ul3jqJSnapTHQ+8JJx" + ) + ) + ) + + @Test + fun `tracing id should be added to all to_device contents`() { + val fakeCryptoAPi = FakeCryptoApi() + + val sendToDeviceTask = DefaultSendToDeviceTask( + cryptoApi = fakeCryptoAPi, + globalErrorReceiver = mockk(relaxed = true) + ) + + val contentMap = MXUsersDevicesMap() + + users.forEach { + val userId = it.first + it.second.forEach { + contentMap.setObject(userId, it, fakeEncryptedContent) + } + } + + val params = SendToDeviceTask.Params( + eventType = EventType.ENCRYPTED, + contentMap = contentMap + ) + + runBlocking { + sendToDeviceTask.execute(params) + } + + val generatedIds = mutableListOf() + users.forEach { + val userId = it.first + it.second.forEach { + val modifiedContent = fakeCryptoAPi.body!!.messages!![userId]!![it] as Map + Assert.assertNotNull("Tracing id should have been added", modifiedContent["org.matrix.msgid"]) + generatedIds.add(modifiedContent["org.matrix.msgid"] as String) + + assertEquals( + "The rest of the content should be the same", + fakeEncryptedContent.keys, + modifiedContent.toMutableMap().apply { remove("org.matrix.msgid") }.keys + ) + } + } + + assertEquals("Id should be unique per content", generatedIds.size, generatedIds.toSet().size) + println("modified content ${fakeCryptoAPi.body}") + } + + internal class FakeCryptoApi : CryptoApi { + override suspend fun getDevices(): DevicesListResponse { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun getDeviceInfo(deviceId: String): DeviceInfo { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun uploadKeys(body: KeysUploadBody): KeysUploadResponse { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun downloadKeysForUsers(params: KeysQueryBody): KeysQueryResponse { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun uploadSigningKeys(params: UploadSigningKeysBody): KeysQueryResponse { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun uploadSignatures(params: Map?): SignatureUploadResponse { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun claimOneTimeKeysForUsersDevices(body: KeysClaimBody): KeysClaimResponse { + throw java.lang.AssertionError("Should not be called") + } + + var body: SendToDeviceBody? = null + override suspend fun sendToDevice(eventType: String, transactionId: String, body: SendToDeviceBody) { + this.body = body + } + + override suspend fun deleteDevice(deviceId: String, params: DeleteDeviceParams) { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun deleteDevices(params: DeleteDevicesParams) { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun updateDeviceInfo(deviceId: String, params: UpdateDeviceInfoBody) { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun getKeyChanges(oldToken: String, newToken: String): KeyChangesResponse { + throw java.lang.AssertionError("Should not be called") + } + } +} From 2ed212aa11a36aed1df67722df77f8736f96ad4f Mon Sep 17 00:00:00 2001 From: valere Date: Mon, 5 Dec 2022 18:30:38 +0100 Subject: [PATCH 08/85] Fix copyright --- .../android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt index 72d166c1f4..c813991d3f 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.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. From 139eb1708c3b5e7f9fb15d87c6fb2dd3346f8639 Mon Sep 17 00:00:00 2001 From: valere Date: Tue, 6 Dec 2022 08:17:31 +0100 Subject: [PATCH 09/85] fix uncheck cast warning --- .../crypto/DefaultSendToDeviceTaskTest.kt | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt index c813991d3f..b8e870bd06 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt @@ -41,16 +41,15 @@ import org.matrix.android.sdk.internal.crypto.model.rest.UpdateDeviceInfoBody import org.matrix.android.sdk.internal.crypto.model.rest.UploadSigningKeysBody import org.matrix.android.sdk.internal.crypto.tasks.DefaultSendToDeviceTask import org.matrix.android.sdk.internal.crypto.tasks.SendToDeviceTask -import org.matrix.android.sdk.internal.network.GlobalErrorReceiver class DefaultSendToDeviceTaskTest { - val users = listOf( + private val users = listOf( "@alice:example.com" to listOf("D0", "D1"), "bob@example.com" to listOf("D2", "D3") ) - val fakeEncryptedContent = mapOf( + private val fakeEncryptedContent = mapOf( "algorithm" to "m.olm.v1.curve25519-aes-sha2", "sender_key" to "gMObR+/4dqL5T4DisRRRYBJpn+OjzFnkyCFOktP6Eyw", "ciphertext" to mapOf( @@ -67,14 +66,14 @@ class DefaultSendToDeviceTaskTest { val sendToDeviceTask = DefaultSendToDeviceTask( cryptoApi = fakeCryptoAPi, - globalErrorReceiver = mockk(relaxed = true) + globalErrorReceiver = mockk(relaxed = true) ) val contentMap = MXUsersDevicesMap() - users.forEach { - val userId = it.first - it.second.forEach { + users.forEach { pairOfUserDevices -> + val userId = pairOfUserDevices.first + pairOfUserDevices.second.forEach { contentMap.setObject(userId, it, fakeEncryptedContent) } } @@ -89,10 +88,10 @@ class DefaultSendToDeviceTaskTest { } val generatedIds = mutableListOf() - users.forEach { - val userId = it.first - it.second.forEach { - val modifiedContent = fakeCryptoAPi.body!!.messages!![userId]!![it] as Map + users.forEach { pairOfUserDevices -> + val userId = pairOfUserDevices.first + pairOfUserDevices.second.forEach { + val modifiedContent = fakeCryptoAPi.body!!.messages!![userId]!![it] as Map<*, *> Assert.assertNotNull("Tracing id should have been added", modifiedContent["org.matrix.msgid"]) generatedIds.add(modifiedContent["org.matrix.msgid"] as String) From a65e13970d9ca9a92feb913ec0e5ab71ddaac3d2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 6 Dec 2022 11:52:13 +0100 Subject: [PATCH 10/85] `appdistribution` is only for nightly builds, not necessary for gplay (prod) builds. --- vector-app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vector-app/build.gradle b/vector-app/build.gradle index fa6aa5f0fd..6ebb08600b 100644 --- a/vector-app/build.gradle +++ b/vector-app/build.gradle @@ -374,7 +374,7 @@ dependencies { // API-only library gplayImplementation libs.google.appdistributionApi // Full SDK implementation - gplayImplementation libs.google.appdistribution + nightlyImplementation libs.google.appdistribution // OSS License, gplay flavor only gplayImplementation 'com.google.android.gms:play-services-oss-licenses:17.0.0' From 0d12dbbe7e8031aba0ca64e4da514c9e79636e01 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 6 Dec 2022 12:21:07 +0100 Subject: [PATCH 11/85] Disable the Nightly popup, user registration (with `updateIfNewReleaseAvailable()`) to get upgrade does not work. Add a nightly build section in the preferences to manually try to upgrade. --- .../src/main/res/values/strings.xml | 3 +++ .../app/nightly/FirebaseNightlyProxy.kt | 21 +++++++++++++++---- .../vector/app/features/home/HomeActivity.kt | 4 +++- .../vector/app/features/home/NightlyProxy.kt | 15 ++++++++++++- .../VectorSettingsAdvancedSettingsFragment.kt | 18 ++++++++++++++++ .../xml/vector_settings_advanced_settings.xml | 12 +++++++++++ 6 files changed, 67 insertions(+), 6 deletions(-) diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 609cdac233..7f11e63469 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -2487,6 +2487,9 @@ Key Requests Export Audit + Nightly build + Get the latest build (note: you may have trouble to sign in) + Unlock encrypted messages history Refresh diff --git a/vector-app/src/gplay/java/im/vector/app/nightly/FirebaseNightlyProxy.kt b/vector-app/src/gplay/java/im/vector/app/nightly/FirebaseNightlyProxy.kt index 94a36036b6..71ffda7c36 100644 --- a/vector-app/src/gplay/java/im/vector/app/nightly/FirebaseNightlyProxy.kt +++ b/vector-app/src/gplay/java/im/vector/app/nightly/FirebaseNightlyProxy.kt @@ -34,8 +34,11 @@ class FirebaseNightlyProxy @Inject constructor( private val buildMeta: BuildMeta, ) : NightlyProxy { - override fun onHomeResumed() { - if (!canDisplayPopup()) return + override fun isNightlyBuild(): Boolean { + return buildMeta.applicationId in nightlyPackages + } + + override fun updateApplication() { val firebaseAppDistribution = FirebaseAppDistribution.getInstance() firebaseAppDistribution.updateIfNewReleaseAvailable() .addOnProgressListener { up -> @@ -46,6 +49,7 @@ class FirebaseNightlyProxy @Inject constructor( when (e.errorCode) { FirebaseAppDistributionException.Status.NOT_IMPLEMENTED -> { // SDK did nothing. This is expected when building for Play. + Timber.d("FirebaseAppDistribution NOT_IMPLEMENTED error") } else -> { // Handle other errors. @@ -56,10 +60,14 @@ class FirebaseNightlyProxy @Inject constructor( Timber.e(e, "FirebaseAppDistribution - other error") } } + .addOnSuccessListener { + Timber.d("FirebaseAppDistribution Success!") + } } - private fun canDisplayPopup(): Boolean { - if (buildMeta.applicationId != "im.vector.app.nightly") return false + override fun canDisplayPopup(): Boolean { + if (!POPUP_IS_ENABLED) return false + if (!isNightlyBuild()) return false val today = clock.epochMillis() / A_DAY_IN_MILLIS val lastDisplayPopupDay = sharedPreferences.getLong(SHARED_PREF_KEY, 0) return (today > lastDisplayPopupDay) @@ -73,7 +81,12 @@ class FirebaseNightlyProxy @Inject constructor( } companion object { + private const val POPUP_IS_ENABLED = false private const val A_DAY_IN_MILLIS = 8_600_000L private const val SHARED_PREF_KEY = "LAST_NIGHTLY_POPUP_DAY" + + private val nightlyPackages = listOf( + "im.vector.app.nightly" + ) } } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt index 8c6daae95a..2a3d8d094c 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt @@ -580,7 +580,9 @@ class HomeActivity : serverBackupStatusViewModel.refreshRemoteStateIfNeeded() // Check nightly - nightlyProxy.onHomeResumed() + if (nightlyProxy.canDisplayPopup()) { + nightlyProxy.updateApplication() + } checkNewAppLayoutFlagChange() } diff --git a/vector/src/main/java/im/vector/app/features/home/NightlyProxy.kt b/vector/src/main/java/im/vector/app/features/home/NightlyProxy.kt index b25add2ac9..42b93bf1a5 100644 --- a/vector/src/main/java/im/vector/app/features/home/NightlyProxy.kt +++ b/vector/src/main/java/im/vector/app/features/home/NightlyProxy.kt @@ -17,5 +17,18 @@ package im.vector.app.features.home interface NightlyProxy { - fun onHomeResumed() + /** + * Return true if this is a nightly build (checking the package of the app), and only once a day. + */ + fun canDisplayPopup(): Boolean + + /** + * Return true if this is a nightly build (checking the package of the app). + */ + fun isNightlyBuild(): Boolean + + /** + * Try to update the application, if update is available. Will also take care of the user sign in. + */ + fun updateApplication() } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsAdvancedSettingsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsAdvancedSettingsFragment.kt index 9c08d446f4..b6fa997f41 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsAdvancedSettingsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsAdvancedSettingsFragment.kt @@ -22,10 +22,13 @@ import androidx.preference.SeekBarPreference import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.core.preference.VectorPreference import im.vector.app.core.preference.VectorPreferenceCategory import im.vector.app.core.preference.VectorSwitchPreference import im.vector.app.features.analytics.plan.MobileScreen +import im.vector.app.features.home.NightlyProxy import im.vector.app.features.rageshake.RageShake +import javax.inject.Inject @AndroidEntryPoint class VectorSettingsAdvancedSettingsFragment : @@ -34,6 +37,8 @@ class VectorSettingsAdvancedSettingsFragment : override var titleRes = R.string.settings_advanced_settings override val preferenceXmlRes = R.xml.vector_settings_advanced_settings + @Inject lateinit var nightlyProxy: NightlyProxy + private var rageshake: RageShake? = null override fun onCreate(savedInstanceState: Bundle?) { @@ -57,6 +62,11 @@ class VectorSettingsAdvancedSettingsFragment : } override fun bindPref() { + setupRageShakeSection() + setupNightlySection() + } + + private fun setupRageShakeSection() { val isRageShakeAvailable = RageShake.isAvailable(requireContext()) if (isRageShakeAvailable) { @@ -86,4 +96,12 @@ class VectorSettingsAdvancedSettingsFragment : findPreference("SETTINGS_RAGE_SHAKE_CATEGORY_KEY")!!.isVisible = false } } + + private fun setupNightlySection() { + findPreference("SETTINGS_NIGHTLY_BUILD_PREFERENCE_KEY")?.isVisible = nightlyProxy.isNightlyBuild() + findPreference("SETTINGS_NIGHTLY_BUILD_UPDATE_PREFERENCE_KEY")?.setOnPreferenceClickListener { + nightlyProxy.updateApplication() + true + } + } } diff --git a/vector/src/main/res/xml/vector_settings_advanced_settings.xml b/vector/src/main/res/xml/vector_settings_advanced_settings.xml index 29d8051583..9260b33162 100644 --- a/vector/src/main/res/xml/vector_settings_advanced_settings.xml +++ b/vector/src/main/res/xml/vector_settings_advanced_settings.xml @@ -95,4 +95,16 @@ + + + + + + From 8646cc441d2c4c2d7e36c95f6dd3de71f5bd3dc3 Mon Sep 17 00:00:00 2001 From: valere Date: Tue, 6 Dec 2022 15:30:06 +0100 Subject: [PATCH 12/85] do not add tracing ids to verification events --- .../sdk/api/session/events/model/EventType.kt | 3 + .../internal/crypto/tasks/SendToDeviceTask.kt | 12 ++- .../crypto/DefaultSendToDeviceTaskTest.kt | 97 ++++++++++++++++++- 3 files changed, 109 insertions(+), 3 deletions(-) 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 e5c14afa90..013b452ced 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 @@ -16,6 +16,8 @@ package org.matrix.android.sdk.api.session.events.model +import org.matrix.android.sdk.api.session.room.model.message.MessageType.MSGTYPE_VERIFICATION_REQUEST + /** * Constants defining known event types from Matrix specifications. */ @@ -126,6 +128,7 @@ object EventType { fun isVerificationEvent(type: String): Boolean { return when (type) { + MSGTYPE_VERIFICATION_REQUEST, KEY_VERIFICATION_START, KEY_VERIFICATION_ACCEPT, KEY_VERIFICATION_KEY, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendToDeviceTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendToDeviceTask.kt index 1e6ceeb138..a7e93202ef 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendToDeviceTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendToDeviceTask.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.crypto.tasks import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap 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.internal.crypto.api.CryptoApi import org.matrix.android.sdk.internal.crypto.model.rest.SendToDeviceBody @@ -39,7 +40,9 @@ internal interface SendToDeviceTask : Task { // the content to send. Map from user_id to device_id to content dictionary. val contentMap: MXUsersDevicesMap, // the transactionId. If not provided, a transactionId will be created by the task - val transactionId: String? = null + val transactionId: String? = null, + // add tracing id, notice that to device events that do signature on content might be broken by it + val addTracingIds: Boolean = !EventType.isVerificationEvent(eventType), ) } @@ -55,7 +58,12 @@ internal class DefaultSendToDeviceTask @Inject constructor( val txnId = params.transactionId ?: createUniqueTxnId() // add id tracing to debug - val decorated = decorateWithToDeviceTracingIds(params) + val decorated = if (params.addTracingIds) { + decorateWithToDeviceTracingIds(params) + } else { + params.contentMap.map to emptyList() + } + val sendToDeviceBody = SendToDeviceBody( messages = decorated.first ) diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt index b8e870bd06..df6fc5f165 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt @@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo import org.matrix.android.sdk.api.session.crypto.model.DevicesListResponse import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.internal.crypto.api.CryptoApi import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDeviceParams import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDevicesParams @@ -60,8 +61,28 @@ class DefaultSendToDeviceTaskTest { ) ) + private val fakeStartVerificationContent = mapOf( + "method" to "m.sas.v1", + "from_device" to "MNQHVEISFQ", + "key_agreement_protocols" to listOf( + "curve25519-hkdf-sha256", + "curve25519" + ), + "hashes" to listOf("sha256"), + "message_authentication_codes" to listOf( + "org.matrix.msc3783.hkdf-hmac-sha256", + "hkdf-hmac-sha256", + "hmac-sha256" + ), + "short_authentication_string" to listOf( + "decimal", + "emoji" + ), + "transaction_id" to "4wNOpkHGwGZPXjkZToooCDWfb8hsf7vW" + ) + @Test - fun `tracing id should be added to all to_device contents`() { + fun `tracing id should be added to to_device contents`() { val fakeCryptoAPi = FakeCryptoApi() val sendToDeviceTask = DefaultSendToDeviceTask( @@ -107,6 +128,80 @@ class DefaultSendToDeviceTaskTest { println("modified content ${fakeCryptoAPi.body}") } + @Test + fun `tracing id should not be added to verification start to_device contents`() { + val fakeCryptoAPi = FakeCryptoApi() + + val sendToDeviceTask = DefaultSendToDeviceTask( + cryptoApi = fakeCryptoAPi, + globalErrorReceiver = mockk(relaxed = true) + ) + val contentMap = MXUsersDevicesMap() + contentMap.setObject("@alice:example.com", "MNQHVEISFQ", fakeStartVerificationContent) + + val params = SendToDeviceTask.Params( + eventType = EventType.KEY_VERIFICATION_START, + contentMap = contentMap + ) + + runBlocking { + sendToDeviceTask.execute(params) + } + + val modifiedContent = fakeCryptoAPi.body!!.messages!!["@alice:example.com"]!!["MNQHVEISFQ"] as Map<*, *> + Assert.assertNull("Tracing id should not have been added", modifiedContent["org.matrix.msgid"]) + + // try to force + runBlocking { + sendToDeviceTask.execute( + SendToDeviceTask.Params( + eventType = EventType.KEY_VERIFICATION_START, + contentMap = contentMap, + addTracingIds = true + ) + ) + } + + val modifiedContentForced = fakeCryptoAPi.body!!.messages!!["@alice:example.com"]!!["MNQHVEISFQ"] as Map<*, *> + Assert.assertNotNull("Tracing id should have been added", modifiedContentForced["org.matrix.msgid"]) + } + + @Test + fun `tracing id should not be added to all verification to_device contents`() { + val fakeCryptoAPi = FakeCryptoApi() + + val sendToDeviceTask = DefaultSendToDeviceTask( + cryptoApi = fakeCryptoAPi, + globalErrorReceiver = mockk(relaxed = true) + ) + val contentMap = MXUsersDevicesMap() + contentMap.setObject("@alice:example.com", "MNQHVEISFQ", emptyMap()) + + val verificationEvents = listOf( + MessageType.MSGTYPE_VERIFICATION_REQUEST, + EventType.KEY_VERIFICATION_START, + EventType.KEY_VERIFICATION_ACCEPT, + EventType.KEY_VERIFICATION_KEY, + EventType.KEY_VERIFICATION_MAC, + EventType.KEY_VERIFICATION_CANCEL, + EventType.KEY_VERIFICATION_DONE, + EventType.KEY_VERIFICATION_READY + ) + + for (type in verificationEvents) { + val params = SendToDeviceTask.Params( + eventType = type, + contentMap = contentMap + ) + runBlocking { + sendToDeviceTask.execute(params) + } + + val modifiedContent = fakeCryptoAPi.body!!.messages!!["@alice:example.com"]!!["MNQHVEISFQ"] as Map<*, *> + Assert.assertNull("Tracing id should not have been added", modifiedContent["org.matrix.msgid"]) + } + } + internal class FakeCryptoApi : CryptoApi { override suspend fun getDevices(): DevicesListResponse { throw java.lang.AssertionError("Should not be called") From 63d2886415820d979c1e6592dd582123b1bb09ec Mon Sep 17 00:00:00 2001 From: valere Date: Tue, 6 Dec 2022 16:07:24 +0100 Subject: [PATCH 13/85] use msgid in logs for consistency --- .../sdk/internal/session/sync/handler/CryptoSyncHandler.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt index 291e785aa5..551db52dbd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt @@ -49,14 +49,14 @@ internal class CryptoSyncHandler @Inject constructor( ?.forEachIndexed { index, event -> progressReporter?.reportProgress(index * 100F / total) // Decrypt event if necessary - Timber.tag(loggerTag.value).d("To device event tracingId:${event.toDeviceTracingId()}") + Timber.tag(loggerTag.value).d("To device event msgid:${event.toDeviceTracingId()}") decryptToDeviceEvent(event, null) if (event.getClearType() == EventType.MESSAGE && event.getClearContent()?.toModel()?.msgType == "m.bad.encrypted") { Timber.tag(loggerTag.value).e("handleToDeviceEvent() : Warning: Unable to decrypt to-device event : ${event.content}") } else { - Timber.tag(loggerTag.value).d("received to-device ${event.getClearType()} from:${event.senderId} id:${event.toDeviceTracingId()}") + Timber.tag(loggerTag.value).d("received to-device ${event.getClearType()} from:${event.senderId} msgid:${event.toDeviceTracingId()}") verificationService.onToDeviceEvent(event) cryptoService.onToDeviceEvent(event) } From 988afa4ebe5354849972c5a8faa2a4a4a331c846 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 6 Dec 2022 18:21:07 +0100 Subject: [PATCH 14/85] Fix FDroid build --- vector-app/src/fdroid/java/im/vector/app/di/FlavorModule.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vector-app/src/fdroid/java/im/vector/app/di/FlavorModule.kt b/vector-app/src/fdroid/java/im/vector/app/di/FlavorModule.kt index 63b4c2a3cd..e7d9598b65 100644 --- a/vector-app/src/fdroid/java/im/vector/app/di/FlavorModule.kt +++ b/vector-app/src/fdroid/java/im/vector/app/di/FlavorModule.kt @@ -46,9 +46,9 @@ abstract class FlavorModule { @Provides fun provideNightlyProxy() = object : NightlyProxy { - override fun onHomeResumed() { - // no op - } + override fun canDisplayPopup() = false + override fun isNightlyBuild() = false + override fun updateApplication() = Unit } @Provides From 9bbecbeed33eba37af120ea004cd7c49fd539f21 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Dec 2022 23:02:30 +0000 Subject: [PATCH 15/85] Bump wysiwyg from 0.8.0 to 0.9.0 Bumps [wysiwyg](https://github.com/matrix-org/matrix-wysiwyg) from 0.8.0 to 0.9.0. - [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/compare/0.8.0...0.9.0) --- updated-dependencies: - dependency-name: io.element.android:wysiwyg dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- dependencies.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dependencies.gradle b/dependencies.gradle index fd630eba6d..b408ee01eb 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.8.0" + 'wysiwyg' : "io.element.android:wysiwyg:0.9.0" ], squareup : [ 'moshi' : "com.squareup.moshi:moshi:$moshi", From 041fcef1db267de5f8dfcf954fb4f043d38238c0 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Wed, 7 Dec 2022 10:30:47 +0100 Subject: [PATCH 16/85] Adding changelog entry --- changelog.d/7733.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7733.bugfix diff --git a/changelog.d/7733.bugfix b/changelog.d/7733.bugfix new file mode 100644 index 0000000000..9de3759f1a --- /dev/null +++ b/changelog.d/7733.bugfix @@ -0,0 +1 @@ +[Session manager] Sessions without encryption support should not prompt to verify From c9c5483d227e80d3761596fecba6502ea000321c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 7 Dec 2022 14:09:59 +0100 Subject: [PATCH 17/85] Changelog --- changelog.d/7723.misc | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7723.misc diff --git a/changelog.d/7723.misc b/changelog.d/7723.misc new file mode 100644 index 0000000000..36869d1efb --- /dev/null +++ b/changelog.d/7723.misc @@ -0,0 +1 @@ +Disable nightly popup and add an entry point in the advanced settings instead. From f014866d063a6c7d1df769c4c74cbd6aa7d916c9 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Wed, 7 Dec 2022 14:34:45 +0100 Subject: [PATCH 18/85] Handling the case where device has no CryptoDeviceInfo --- .../src/main/res/values/strings.xml | 2 + .../app/core/ui/views/ShieldImageView.kt | 28 ++++++++------ .../settings/devices/DevicesViewModel.kt | 2 +- .../settings/devices/v2/DeviceFullInfo.kt | 2 +- .../devices/v2/list/SessionInfoView.kt | 11 +++++- .../v2/overview/SessionOverviewFragment.kt | 37 +++++++++++-------- ...GetEncryptionTrustLevelForDeviceUseCase.kt | 10 +++-- ...ncryptionTrustLevelForDeviceUseCaseTest.kt | 15 ++++++++ 8 files changed, 75 insertions(+), 32 deletions(-) diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 609cdac233..b10d11d048 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3305,6 +3305,7 @@ Verify your current session for enhanced secure messaging. Verify or sign out from this session for best security and reliability. Verify your current session to reveal this session\'s verification status. + This session doesn\'t support encryption and thus can\'t be verified. Verify Session View Details View All (%1$d) @@ -3397,6 +3398,7 @@ Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.\n\nThis means they hold encryption keys for your previous messages, and confirm to other users you are communicating with that these sessions are really you. Verified sessions are anywhere you are using this account after entering your passphrase or confirming your identity with another verified session.\n\nThis means that you have all the keys needed to unlock your encrypted messages and confirm to other users that you trust this session. + This session doesn\'t support encryption, so it can\'t be verified.\n\nYou won\'t be able to participate in rooms where encryption is enabled when using this session.\n\nFor best security and privacy, it is recommended to use Matrix clients that support encryption. Renaming sessions Other users in direct messages and rooms that you join are able to view a full list of your sessions.\n\nThis provides them with confidence that they are really speaking to you, but it also means they can see the session name you enter here. Enable new session manager diff --git a/vector/src/main/java/im/vector/app/core/ui/views/ShieldImageView.kt b/vector/src/main/java/im/vector/app/core/ui/views/ShieldImageView.kt index 0570bbe4d7..34714d97d0 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/ShieldImageView.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/ShieldImageView.kt @@ -40,20 +40,26 @@ class ShieldImageView @JvmOverloads constructor( /** * Renders device shield with the support of unknown shields instead of black shields which is used for rooms. - * @param roomEncryptionTrustLevel trust level that is usally calculated with [im.vector.app.features.settings.devices.TrustUtils.shieldForTrust] + * @param roomEncryptionTrustLevel trust level that is usually calculated with [im.vector.app.features.settings.devices.TrustUtils.shieldForTrust] * @param borderLess if true then the shield icon with border around is used */ fun renderDeviceShield(roomEncryptionTrustLevel: RoomEncryptionTrustLevel?, borderLess: Boolean = false) { - isVisible = roomEncryptionTrustLevel != null - - if (roomEncryptionTrustLevel == RoomEncryptionTrustLevel.Default) { - contentDescription = context.getString(R.string.a11y_trust_level_default) - setImageResource( - if (borderLess) R.drawable.ic_shield_unknown_no_border - else R.drawable.ic_shield_unknown - ) - } else { - render(roomEncryptionTrustLevel, borderLess) + when (roomEncryptionTrustLevel) { + null -> { + contentDescription = context.getString(R.string.a11y_trust_level_warning) + setImageResource( + if (borderLess) R.drawable.ic_shield_warning_no_border + else R.drawable.ic_shield_warning + ) + } + RoomEncryptionTrustLevel.Default -> { + contentDescription = context.getString(R.string.a11y_trust_level_default) + setImageResource( + if (borderLess) R.drawable.ic_shield_unknown_no_border + else R.drawable.ic_shield_unknown + ) + } + else -> render(roomEncryptionTrustLevel, borderLess) } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewModel.kt index 67b41ea5aa..e779948b41 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewModel.kt @@ -88,7 +88,7 @@ data class DevicesViewState( data class DeviceFullInfo( val deviceInfo: DeviceInfo, val cryptoDeviceInfo: CryptoDeviceInfo?, - val trustLevelForShield: RoomEncryptionTrustLevel, + val trustLevelForShield: RoomEncryptionTrustLevel?, val isInactive: Boolean, ) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceFullInfo.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceFullInfo.kt index 4864c41394..186a6ebe69 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceFullInfo.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceFullInfo.kt @@ -25,7 +25,7 @@ import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel data class DeviceFullInfo( val deviceInfo: DeviceInfo, val cryptoDeviceInfo: CryptoDeviceInfo?, - val roomEncryptionTrustLevel: RoomEncryptionTrustLevel, + val roomEncryptionTrustLevel: RoomEncryptionTrustLevel?, val isInactive: Boolean, val isCurrentDevice: Boolean, val deviceExtendedInfo: DeviceExtendedInfo, 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 7727cee4fa..eecec72b0a 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 @@ -85,13 +85,14 @@ class SessionInfoView @JvmOverloads constructor( } private fun renderVerificationStatus( - encryptionTrustLevel: RoomEncryptionTrustLevel, + encryptionTrustLevel: RoomEncryptionTrustLevel?, isCurrentSession: Boolean, hasLearnMoreLink: Boolean, isVerifyButtonVisible: Boolean, ) { views.sessionInfoVerificationStatusImageView.renderDeviceShield(encryptionTrustLevel) when { + encryptionTrustLevel == null -> renderCrossSigningEncryptionNotSupported() encryptionTrustLevel == RoomEncryptionTrustLevel.Trusted -> renderCrossSigningVerified(isCurrentSession) encryptionTrustLevel == RoomEncryptionTrustLevel.Default && !isCurrentSession -> renderCrossSigningUnknown() else -> renderCrossSigningUnverified(isCurrentSession, isVerifyButtonVisible) @@ -149,6 +150,14 @@ class SessionInfoView @JvmOverloads constructor( views.sessionInfoVerifySessionButton.isVisible = false } + private fun renderCrossSigningEncryptionNotSupported() { + views.sessionInfoVerificationStatusTextView.text = context.getString(R.string.device_manager_verification_status_unverified) + views.sessionInfoVerificationStatusTextView.setTextColor(ThemeUtils.getColor(context, R.attr.colorError)) + views.sessionInfoVerificationStatusDetailTextView.text = + context.getString(R.string.device_manager_verification_status_detail_session_encryption_not_supported) + views.sessionInfoVerifySessionButton.isVisible = false + } + private fun renderDeviceInfo(sessionName: String, deviceType: DeviceType, stringProvider: StringProvider) { setDeviceTypeIconUseCase.execute(deviceType, views.sessionInfoDeviceTypeImageView, stringProvider) views.sessionInfoNameTextView.text = sessionName 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 be60b3b805..6fbd2f1fef 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 @@ -229,7 +229,7 @@ class SessionOverviewFragment : ) views.sessionOverviewInfo.render(infoViewState, dateFormatter, drawableProvider, colorProvider, stringProvider) views.sessionOverviewInfo.onLearnMoreClickListener = { - showLearnMoreInfoVerificationStatus(deviceInfo.roomEncryptionTrustLevel == RoomEncryptionTrustLevel.Trusted) + showLearnMoreInfoVerificationStatus(deviceInfo.roomEncryptionTrustLevel) } } else { views.sessionOverviewInfo.isVisible = false @@ -293,21 +293,28 @@ class SessionOverviewFragment : } } - private fun showLearnMoreInfoVerificationStatus(isVerified: Boolean) { - val titleResId = if (isVerified) { - R.string.device_manager_verification_status_verified - } else { - R.string.device_manager_verification_status_unverified + private fun showLearnMoreInfoVerificationStatus(roomEncryptionTrustLevel: RoomEncryptionTrustLevel?) { + val args = when(roomEncryptionTrustLevel) { + null -> { + // encryption not supported + SessionLearnMoreBottomSheet.Args( + title = getString(R.string.device_manager_verification_status_unverified), + description = getString(R.string.device_manager_learn_more_sessions_encryption_not_supported), + ) + } + RoomEncryptionTrustLevel.Trusted -> { + SessionLearnMoreBottomSheet.Args( + title = getString(R.string.device_manager_verification_status_verified), + description = getString(R.string.device_manager_learn_more_sessions_verified_description), + ) + } + else -> { + SessionLearnMoreBottomSheet.Args( + title = getString(R.string.device_manager_verification_status_unverified), + description = getString(R.string.device_manager_learn_more_sessions_unverified), + ) + } } - val descriptionResId = if (isVerified) { - R.string.device_manager_learn_more_sessions_verified_description - } else { - R.string.device_manager_learn_more_sessions_unverified - } - val args = SessionLearnMoreBottomSheet.Args( - title = getString(titleResId), - description = getString(descriptionResId), - ) SessionLearnMoreBottomSheet.show(childFragmentManager, args) } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/verification/GetEncryptionTrustLevelForDeviceUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/verification/GetEncryptionTrustLevelForDeviceUseCase.kt index ba9a380ade..31a7e93d04 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/verification/GetEncryptionTrustLevelForDeviceUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/verification/GetEncryptionTrustLevelForDeviceUseCase.kt @@ -25,11 +25,15 @@ class GetEncryptionTrustLevelForDeviceUseCase @Inject constructor( private val getEncryptionTrustLevelForOtherDeviceUseCase: GetEncryptionTrustLevelForOtherDeviceUseCase, ) { - fun execute(currentSessionCrossSigningInfo: CurrentSessionCrossSigningInfo, cryptoDeviceInfo: CryptoDeviceInfo?): RoomEncryptionTrustLevel { + fun execute(currentSessionCrossSigningInfo: CurrentSessionCrossSigningInfo, cryptoDeviceInfo: CryptoDeviceInfo?): RoomEncryptionTrustLevel? { + if(cryptoDeviceInfo == null) { + return null + } + val legacyMode = !currentSessionCrossSigningInfo.isCrossSigningInitialized val trustMSK = currentSessionCrossSigningInfo.isCrossSigningVerified - val isCurrentDevice = !cryptoDeviceInfo?.deviceId.isNullOrEmpty() && cryptoDeviceInfo?.deviceId == currentSessionCrossSigningInfo.deviceId - val deviceTrustLevel = cryptoDeviceInfo?.trustLevel + val isCurrentDevice = !cryptoDeviceInfo.deviceId.isNullOrEmpty() && cryptoDeviceInfo.deviceId == currentSessionCrossSigningInfo.deviceId + val deviceTrustLevel = cryptoDeviceInfo.trustLevel return when { isCurrentDevice -> getEncryptionTrustLevelForCurrentDeviceUseCase.execute(trustMSK, legacyMode) diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/verification/GetEncryptionTrustLevelForDeviceUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/verification/GetEncryptionTrustLevelForDeviceUseCaseTest.kt index 1b39fe5f73..fd10ee1083 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/verification/GetEncryptionTrustLevelForDeviceUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/verification/GetEncryptionTrustLevelForDeviceUseCaseTest.kt @@ -19,6 +19,7 @@ package im.vector.app.features.settings.devices.v2.verification import io.mockk.every import io.mockk.mockk import io.mockk.verify +import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBeEqualTo import org.junit.Test import org.matrix.android.sdk.api.session.crypto.crosssigning.DeviceTrustLevel @@ -89,6 +90,20 @@ class GetEncryptionTrustLevelForDeviceUseCaseTest { } } + @Test + fun `given no crypto device info when computing trust level then result is null`() { + val currentSessionCrossSigningInfo = givenCurrentSessionCrossSigningInfo( + deviceId = A_DEVICE_ID, + isCrossSigningInitialized = true, + isCrossSigningVerified = false + ) + val cryptoDeviceInfo = null + + val result = getEncryptionTrustLevelForDeviceUseCase.execute(currentSessionCrossSigningInfo, cryptoDeviceInfo) + + result shouldBe null + } + private fun givenCurrentSessionCrossSigningInfo( deviceId: String, isCrossSigningInitialized: Boolean, From a44c8dfca302bab2e50f349acdbf0ed4a419a325 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Wed, 7 Dec 2022 15:10:21 +0100 Subject: [PATCH 19/85] Renaming a method to avoid confusion --- .../settings/devices/v2/VectorSettingsDevicesFragment.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 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 a21c7accb7..c21b044f1f 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 @@ -102,7 +102,7 @@ class VectorSettingsDevicesFragment : initWaitingView() initCurrentSessionHeaderView() - initCurrentSessionListView() + initCurrentSessionView() initOtherSessionsHeaderView() initOtherSessionsView() initSecurityRecommendationsView() @@ -177,7 +177,7 @@ class VectorSettingsDevicesFragment : activity?.let { SignOutUiWorker(it).perform() } } - private fun initCurrentSessionListView() { + private fun initCurrentSessionView() { views.deviceListCurrentSession.viewVerifyButton.debouncedClicks { viewModel.handle(DevicesAction.VerifyCurrentSession) } From 6c94f1cd52a6e871281a299ef92614dfeddb5cf2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 7 Dec 2022 15:50:26 +0100 Subject: [PATCH 20/85] Quick tweak on the release script. --- tools/release/releaseScript.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/release/releaseScript.sh b/tools/release/releaseScript.sh index d76cd98061..f91e11584c 100755 --- a/tools/release/releaseScript.sh +++ b/tools/release/releaseScript.sh @@ -345,7 +345,8 @@ ${buildToolsPath}/aapt dump badging ${targetPath}/vector-gplay-x86-release-signe printf "File vector-gplay-x86_64-release-signed.apk:\n" ${buildToolsPath}/aapt dump badging ${targetPath}/vector-gplay-x86_64-release-signed.apk | grep package -read -p "\nDoes it look correct? Press enter when it's done." +printf "\n" +read -p "Does it look correct? Press enter when it's done." printf "\n================================================================================\n" read -p "Installing apk on a real device, press enter when a real device is connected. " @@ -356,7 +357,7 @@ read -p "Please run the APK on your phone to check that the upgrade went well (n # 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 "Add the 4 signed APKs to the GitHub release. They are located at ${targetPath}. Press enter when it's done." printf "\n================================================================================\n" printf "Message for the Android internal room:\n\n" From 88f743988064b51c1562b8c9291032f2e83639a8 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Wed, 7 Dec 2022 15:52:56 +0100 Subject: [PATCH 21/85] Updating comment to clarify intention --- .../app/features/home/UnknownDeviceDetectorSharedViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt b/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt index 21c7bd6ea1..347c16653d 100644 --- a/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt @@ -104,7 +104,7 @@ class UnknownDeviceDetectorSharedViewModel @AssistedInject constructor( // Timber.v("## Detector trigger canCrossSign ${pInfo.get().selfSigned != null}") infoList .filter { info -> - // filter verified session, by checking the crypto device info + // filter out verified sessions or those which do not support encryption (i.e. without crypto info) cryptoList.firstOrNull { info.deviceId == it.deviceId }?.isVerified?.not().orFalse() } // filter out ignored devices From d244f7324c40c59b82c315491ad459591f006d81 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Wed, 7 Dec 2022 18:12:25 +0300 Subject: [PATCH 22/85] Add api functions to delete account data. --- .../android/sdk/internal/session/room/RoomAPI.kt | 13 +++++++++++++ .../session/user/accountdata/AccountDataAPI.kt | 13 +++++++++++++ 2 files changed, 26 insertions(+) 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 31bed90b62..3cf5526a47 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 @@ -427,6 +427,19 @@ internal interface RoomAPI { @Body content: JsonDict ) + /** + * Remove an account_data event from the room. + * @param userId the user id + * @param roomId the room id + * @param type the type + */ + @DELETE(NetworkConstants.URI_API_PREFIX_PATH_MEDIA_PROXY_UNSTABLE + "org.matrix.msc3391/user/{userId}/rooms/{roomId}/account_data/{type}") + suspend fun deleteRoomAccountData( + @Path("userId") userId: String, + @Path("roomId") roomId: String, + @Path("type") type: String + ) + /** * Upgrades the given room to a particular room version. * Errors: diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataAPI.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataAPI.kt index b283d51845..fd813f1fed 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataAPI.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataAPI.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.user.accountdata import org.matrix.android.sdk.internal.network.NetworkConstants import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.PUT import retrofit2.http.Path @@ -36,4 +37,16 @@ internal interface AccountDataAPI { @Path("type") type: String, @Body params: Any ) + + /** + * Remove an account_data for the client. + * + * @param userId the user id + * @param type the type + */ + @DELETE(NetworkConstants.URI_API_PREFIX_PATH_UNSTABLE + "org.matrix.msc3391/user/{userId}/account_data/{type}") + suspend fun deleteAccountData( + @Path("userId") userId: String, + @Path("type") type: String + ) } From 765202e05a841c257b771bb0d446e61f4f368b3f Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Wed, 7 Dec 2022 18:17:43 +0300 Subject: [PATCH 23/85] Add helper functions to delete user and room account data. --- .../database/model/RoomAccountDataEntity.kt | 5 ++- .../query/RoomAccountDataEntityQueries.kt | 33 +++++++++++++++++++ .../query/UserAccountDataEntityQueries.kt | 33 +++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomAccountDataEntityQueries.kt create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/UserAccountDataEntityQueries.kt diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomAccountDataEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomAccountDataEntity.kt index 40040b5738..2eb5a63784 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomAccountDataEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomAccountDataEntity.kt @@ -24,4 +24,7 @@ import io.realm.annotations.RealmClass internal open class RoomAccountDataEntity( @Index var type: String? = null, var contentStr: String? = null -) : RealmObject() +) : RealmObject() { + + companion object +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomAccountDataEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomAccountDataEntityQueries.kt new file mode 100644 index 0000000000..5ae4c5da72 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomAccountDataEntityQueries.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.internal.database.query + +import io.realm.Realm +import io.realm.kotlin.where +import org.matrix.android.sdk.internal.database.model.RoomAccountDataEntity +import org.matrix.android.sdk.internal.database.model.RoomAccountDataEntityFields + +/** + * Delete an account_data event. + */ +internal fun RoomAccountDataEntity.Companion.delete(realm: Realm, type: String) { + realm + .where() + .equalTo(RoomAccountDataEntityFields.TYPE, type) + .findFirst() + ?.deleteFromRealm() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/UserAccountDataEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/UserAccountDataEntityQueries.kt new file mode 100644 index 0000000000..b28965aeca --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/UserAccountDataEntityQueries.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.internal.database.query + +import io.realm.Realm +import io.realm.kotlin.where +import org.matrix.android.sdk.internal.database.model.UserAccountDataEntity +import org.matrix.android.sdk.internal.database.model.UserAccountDataEntityFields + +/** + * Delete an account_data event. + */ +internal fun UserAccountDataEntity.Companion.delete(realm: Realm, type: String) { + realm + .where() + .equalTo(UserAccountDataEntityFields.TYPE, type) + .findFirst() + ?.deleteFromRealm() +} From 23c2682f8d4e85466a90352f99bb9ad2b40630c0 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Wed, 7 Dec 2022 16:39:51 +0100 Subject: [PATCH 24/85] Fixing code style issues --- .../settings/devices/v2/overview/SessionOverviewFragment.kt | 2 +- .../v2/verification/GetEncryptionTrustLevelForDeviceUseCase.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 6fbd2f1fef..f3df0cced0 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 @@ -294,7 +294,7 @@ class SessionOverviewFragment : } private fun showLearnMoreInfoVerificationStatus(roomEncryptionTrustLevel: RoomEncryptionTrustLevel?) { - val args = when(roomEncryptionTrustLevel) { + val args = when (roomEncryptionTrustLevel) { null -> { // encryption not supported SessionLearnMoreBottomSheet.Args( diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/verification/GetEncryptionTrustLevelForDeviceUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/verification/GetEncryptionTrustLevelForDeviceUseCase.kt index 31a7e93d04..268ae86601 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/verification/GetEncryptionTrustLevelForDeviceUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/verification/GetEncryptionTrustLevelForDeviceUseCase.kt @@ -26,7 +26,7 @@ class GetEncryptionTrustLevelForDeviceUseCase @Inject constructor( ) { fun execute(currentSessionCrossSigningInfo: CurrentSessionCrossSigningInfo, cryptoDeviceInfo: CryptoDeviceInfo?): RoomEncryptionTrustLevel? { - if(cryptoDeviceInfo == null) { + if (cryptoDeviceInfo == null) { return null } From f4429d4c9c9acdc4495e9e3fde098688626fe5f0 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Wed, 7 Dec 2022 18:58:14 +0300 Subject: [PATCH 25/85] Handle sync response to delete user and room account data. --- .../query/RoomAccountDataEntityQueries.kt | 33 ------------------- .../database/query/RoomEntityQueries.kt | 9 +++++ .../handler/UserAccountDataSyncHandler.kt | 21 +++++++----- .../parsing/RoomSyncAccountDataHandler.kt | 19 +++++++---- 4 files changed, 34 insertions(+), 48 deletions(-) delete mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomAccountDataEntityQueries.kt diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomAccountDataEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomAccountDataEntityQueries.kt deleted file mode 100644 index 5ae4c5da72..0000000000 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomAccountDataEntityQueries.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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.query - -import io.realm.Realm -import io.realm.kotlin.where -import org.matrix.android.sdk.internal.database.model.RoomAccountDataEntity -import org.matrix.android.sdk.internal.database.model.RoomAccountDataEntityFields - -/** - * Delete an account_data event. - */ -internal fun RoomAccountDataEntity.Companion.delete(realm: Realm, type: String) { - realm - .where() - .equalTo(RoomAccountDataEntityFields.TYPE, type) - .findFirst() - ?.deleteFromRealm() -} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomEntityQueries.kt index 08bb9e7ff3..0489fe690f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomEntityQueries.kt @@ -21,6 +21,7 @@ import io.realm.RealmQuery import io.realm.kotlin.where import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.RoomAccountDataEntityFields import org.matrix.android.sdk.internal.database.model.RoomEntity import org.matrix.android.sdk.internal.database.model.RoomEntityFields @@ -44,3 +45,11 @@ internal fun RoomEntity.Companion.where(realm: Realm, membership: Membership? = internal fun RoomEntity.fastContains(eventId: String): Boolean { return EventEntity.where(realm, eventId = eventId).findFirst() != null } + +internal fun RoomEntity.removeAccountData(type: String) { + accountData + .where() + .equalTo(RoomAccountDataEntityFields.TYPE, type) + .findFirst() + ?.deleteFromRealm() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UserAccountDataSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UserAccountDataSyncHandler.kt index 0f296ded5d..fb2dfa10f6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UserAccountDataSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UserAccountDataSyncHandler.kt @@ -45,6 +45,7 @@ import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.model.UserAccountDataEntity import org.matrix.android.sdk.internal.database.model.UserAccountDataEntityFields import org.matrix.android.sdk.internal.database.model.deleteOnCascade +import org.matrix.android.sdk.internal.database.query.delete import org.matrix.android.sdk.internal.database.query.findAllFrom import org.matrix.android.sdk.internal.database.query.getDirectRooms import org.matrix.android.sdk.internal.database.query.getOrCreate @@ -81,20 +82,24 @@ internal class UserAccountDataSyncHandler @Inject constructor( fun handle(realm: Realm, accountData: UserAccountDataSync?) { accountData?.list?.forEach { event -> - // Generic handling, just save in base - handleGenericAccountData(realm, event.type, event.content) - when (event.type) { - UserAccountDataTypes.TYPE_DIRECT_MESSAGES -> handleDirectChatRooms(realm, event) - UserAccountDataTypes.TYPE_PUSH_RULES -> handlePushRules(realm, event) - UserAccountDataTypes.TYPE_IGNORED_USER_LIST -> handleIgnoredUsers(realm, event) - UserAccountDataTypes.TYPE_BREADCRUMBS -> handleBreadcrumbs(realm, event) + if (event.content.isEmpty()) { + UserAccountDataEntity.delete(realm, event.type) + } else { + // Generic handling, just save in base + handleGenericAccountData(realm, event.type, event.content) + when (event.type) { + UserAccountDataTypes.TYPE_DIRECT_MESSAGES -> handleDirectChatRooms(realm, event) + UserAccountDataTypes.TYPE_PUSH_RULES -> handlePushRules(realm, event) + UserAccountDataTypes.TYPE_IGNORED_USER_LIST -> handleIgnoredUsers(realm, event) + UserAccountDataTypes.TYPE_BREADCRUMBS -> handleBreadcrumbs(realm, event) + } } } } // If we get some direct chat invites, we synchronize the user account data including those. suspend fun synchronizeWithServerIfNeeded(invites: Map) { - if (invites.isNullOrEmpty()) return + if (invites.isEmpty()) return val directChats = directChatsHelper.getLocalDirectMessages().toMutable() var hasUpdate = false monarchy.doWithRealm { realm -> diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/parsing/RoomSyncAccountDataHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/parsing/RoomSyncAccountDataHandler.kt index b1b2bfef33..c5f8294077 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/parsing/RoomSyncAccountDataHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/parsing/RoomSyncAccountDataHandler.kt @@ -27,6 +27,7 @@ import org.matrix.android.sdk.internal.database.model.RoomAccountDataEntity import org.matrix.android.sdk.internal.database.model.RoomAccountDataEntityFields import org.matrix.android.sdk.internal.database.model.RoomEntity import org.matrix.android.sdk.internal.database.query.getOrCreate +import org.matrix.android.sdk.internal.database.query.removeAccountData import org.matrix.android.sdk.internal.session.room.read.FullyReadContent import org.matrix.android.sdk.internal.session.sync.handler.room.RoomFullyReadHandler import org.matrix.android.sdk.internal.session.sync.handler.room.RoomTagHandler @@ -44,13 +45,17 @@ internal class RoomSyncAccountDataHandler @Inject constructor( val roomEntity = RoomEntity.getOrCreate(realm, roomId) for (event in accountData.events) { val eventType = event.getClearType() - handleGeneric(roomEntity, event.getClearContent(), eventType) - if (eventType == RoomAccountDataTypes.EVENT_TYPE_TAG) { - val content = event.getClearContent().toModel() - roomTagHandler.handle(realm, roomId, content) - } else if (eventType == RoomAccountDataTypes.EVENT_TYPE_FULLY_READ) { - val content = event.getClearContent().toModel() - roomFullyReadHandler.handle(realm, roomId, content) + if (event.getClearContent().isNullOrEmpty()) { + roomEntity.removeAccountData(eventType) + } else { + handleGeneric(roomEntity, event.getClearContent(), eventType) + if (eventType == RoomAccountDataTypes.EVENT_TYPE_TAG) { + val content = event.getClearContent().toModel() + roomTagHandler.handle(realm, roomId, content) + } else if (eventType == RoomAccountDataTypes.EVENT_TYPE_FULLY_READ) { + val content = event.getClearContent().toModel() + roomFullyReadHandler.handle(realm, roomId, content) + } } } } From fdb8743ad36896dfef6760ae3d7402780dabe538 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Thu, 1 Dec 2022 16:14:21 +0100 Subject: [PATCH 26/85] Create provider package --- .../android/sdk/common/TestRoomDisplayNameFallbackProvider.kt | 2 +- .../main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt | 2 ++ .../api/{ => provider}/MatrixItemDisplayNameFallbackProvider.kt | 2 +- .../sdk/api/{ => provider}/RoomDisplayNameFallbackProvider.kt | 2 +- .../displayname/VectorMatrixItemDisplayNameFallbackProvider.kt | 2 +- .../app/features/room/VectorRoomDisplayNameFallbackProvider.kt | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) rename matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/{ => provider}/MatrixItemDisplayNameFallbackProvider.kt (94%) rename matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/{ => provider}/RoomDisplayNameFallbackProvider.kt (97%) diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestRoomDisplayNameFallbackProvider.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestRoomDisplayNameFallbackProvider.kt index af2d57f9ce..a74f5010c2 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestRoomDisplayNameFallbackProvider.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestRoomDisplayNameFallbackProvider.kt @@ -16,7 +16,7 @@ package org.matrix.android.sdk.common -import org.matrix.android.sdk.api.RoomDisplayNameFallbackProvider +import org.matrix.android.sdk.api.provider.RoomDisplayNameFallbackProvider class TestRoomDisplayNameFallbackProvider : RoomDisplayNameFallbackProvider { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt index 00d74ab446..d19fbe5049 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt @@ -20,6 +20,8 @@ import okhttp3.ConnectionSpec import okhttp3.Interceptor import org.matrix.android.sdk.api.crypto.MXCryptoConfig import org.matrix.android.sdk.api.metrics.MetricPlugin +import org.matrix.android.sdk.api.provider.MatrixItemDisplayNameFallbackProvider +import org.matrix.android.sdk.api.provider.RoomDisplayNameFallbackProvider import java.net.Proxy data class MatrixConfiguration( diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixItemDisplayNameFallbackProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/MatrixItemDisplayNameFallbackProvider.kt similarity index 94% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixItemDisplayNameFallbackProvider.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/MatrixItemDisplayNameFallbackProvider.kt index 82008cda8c..971845eae7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixItemDisplayNameFallbackProvider.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/MatrixItemDisplayNameFallbackProvider.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.matrix.android.sdk.api +package org.matrix.android.sdk.api.provider import org.matrix.android.sdk.api.util.MatrixItem diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/RoomDisplayNameFallbackProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/RoomDisplayNameFallbackProvider.kt similarity index 97% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/RoomDisplayNameFallbackProvider.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/RoomDisplayNameFallbackProvider.kt index 3c376b55ee..37d9b46b0b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/RoomDisplayNameFallbackProvider.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/RoomDisplayNameFallbackProvider.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.matrix.android.sdk.api +package org.matrix.android.sdk.api.provider /** * This interface exists to let the implementation provide localized room display name fallback. diff --git a/vector/src/main/java/im/vector/app/features/displayname/VectorMatrixItemDisplayNameFallbackProvider.kt b/vector/src/main/java/im/vector/app/features/displayname/VectorMatrixItemDisplayNameFallbackProvider.kt index 23b55335b8..77f08bf578 100644 --- a/vector/src/main/java/im/vector/app/features/displayname/VectorMatrixItemDisplayNameFallbackProvider.kt +++ b/vector/src/main/java/im/vector/app/features/displayname/VectorMatrixItemDisplayNameFallbackProvider.kt @@ -16,7 +16,7 @@ package im.vector.app.features.displayname -import org.matrix.android.sdk.api.MatrixItemDisplayNameFallbackProvider +import org.matrix.android.sdk.api.provider.MatrixItemDisplayNameFallbackProvider import org.matrix.android.sdk.api.util.MatrixItem // Used to provide the fallback to the MatrixSDK, in the MatrixConfiguration diff --git a/vector/src/main/java/im/vector/app/features/room/VectorRoomDisplayNameFallbackProvider.kt b/vector/src/main/java/im/vector/app/features/room/VectorRoomDisplayNameFallbackProvider.kt index 118017861c..cfbc2748ad 100644 --- a/vector/src/main/java/im/vector/app/features/room/VectorRoomDisplayNameFallbackProvider.kt +++ b/vector/src/main/java/im/vector/app/features/room/VectorRoomDisplayNameFallbackProvider.kt @@ -18,7 +18,7 @@ package im.vector.app.features.room import android.content.Context import im.vector.app.R -import org.matrix.android.sdk.api.RoomDisplayNameFallbackProvider +import org.matrix.android.sdk.api.provider.RoomDisplayNameFallbackProvider import javax.inject.Inject class VectorRoomDisplayNameFallbackProvider @Inject constructor( From 4d6c04baf9d713997b1061e6f9d94fe34d0ff00d Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Thu, 1 Dec 2022 18:08:30 +0100 Subject: [PATCH 27/85] Add provider for custom event types --- .../android/sdk/api/MatrixConfiguration.kt | 8 +++-- .../api/provider/CustomEventTypesProvider.kt | 30 +++++++++++++++++++ .../room/summary/RoomSummaryEventsHelper.kt | 10 +++++-- .../room/summary/RoomSummaryUpdater.kt | 7 +++-- 4 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/CustomEventTypesProvider.kt diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt index d19fbe5049..ccfe557ef6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt @@ -20,6 +20,7 @@ import okhttp3.ConnectionSpec import okhttp3.Interceptor import org.matrix.android.sdk.api.crypto.MXCryptoConfig import org.matrix.android.sdk.api.metrics.MetricPlugin +import org.matrix.android.sdk.api.provider.CustomEventTypesProvider import org.matrix.android.sdk.api.provider.MatrixItemDisplayNameFallbackProvider import org.matrix.android.sdk.api.provider.RoomDisplayNameFallbackProvider import java.net.Proxy @@ -77,9 +78,12 @@ data class MatrixConfiguration( * Sync configuration. */ val syncConfig: SyncConfig = SyncConfig(), - /** * Metrics plugin that can be used to capture metrics from matrix-sdk-android. */ - val metricPlugins: List = emptyList() + val metricPlugins: List = emptyList(), + /** + * CustomEventTypesProvider to provide custom event types to the sdk which should be processed with internal events. + */ + val customEventTypesProvider: CustomEventTypesProvider? = null, ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/CustomEventTypesProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/CustomEventTypesProvider.kt new file mode 100644 index 0000000000..c0f66dc1c2 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/CustomEventTypesProvider.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.api.provider + +import org.matrix.android.sdk.api.session.room.model.RoomSummary + +/** + * Provide custom event types which should be processed with the internal event types. + */ +interface CustomEventTypesProvider { + + /** + * Custom event types to include when computing [RoomSummary.latestPreviewableEvent]. + */ + val customPreviewableEventTypes: List +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryEventsHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryEventsHelper.kt index 7437a686da..a68ae620dc 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryEventsHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryEventsHelper.kt @@ -17,17 +17,23 @@ package org.matrix.android.sdk.internal.session.room.summary import io.realm.Realm +import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.api.session.room.summary.RoomSummaryConstants import org.matrix.android.sdk.api.session.room.timeline.EventTypeFilter import org.matrix.android.sdk.api.session.room.timeline.TimelineEventFilters import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.query.latestEvent +import javax.inject.Inject -internal object RoomSummaryEventsHelper { +internal class RoomSummaryEventsHelper @Inject constructor( + matrixConfiguration: MatrixConfiguration, +) { private val previewFilters = TimelineEventFilters( filterTypes = true, - allowedTypes = RoomSummaryConstants.PREVIEWABLE_TYPES.map { EventTypeFilter(eventType = it, stateKey = null) }, + allowedTypes = RoomSummaryConstants.PREVIEWABLE_TYPES + .plus(matrixConfiguration.customEventTypesProvider?.customPreviewableEventTypes.orEmpty()) + .map { EventTypeFilter(eventType = it, stateKey = null) }, filterUseless = true, filterRedacted = false, filterEdits = true 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 21a0862c65..69beb8d599 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 @@ -78,12 +78,13 @@ internal class RoomSummaryUpdater @Inject constructor( private val crossSigningService: DefaultCrossSigningService, private val roomAccountDataDataSource: RoomAccountDataDataSource, private val homeServerCapabilitiesService: HomeServerCapabilitiesService, + private val roomSummaryEventsHelper: RoomSummaryEventsHelper, ) { fun refreshLatestPreviewContent(realm: Realm, roomId: String) { val roomSummaryEntity = RoomSummaryEntity.getOrNull(realm, roomId) if (roomSummaryEntity != null) { - val latestPreviewableEvent = RoomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) + val latestPreviewableEvent = roomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) latestPreviewableEvent?.attemptToDecrypt() } } @@ -145,7 +146,7 @@ internal class RoomSummaryUpdater @Inject constructor( val encryptionEvent = CurrentStateEventEntity.getOrNull(realm, roomId, type = EventType.STATE_ROOM_ENCRYPTION, stateKey = "")?.root Timber.d("## CRYPTO: currentEncryptionEvent is $encryptionEvent") - val latestPreviewableEvent = RoomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) + val latestPreviewableEvent = roomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) val lastActivityFromEvent = latestPreviewableEvent?.root?.originServerTs if (lastActivityFromEvent != null) { @@ -231,7 +232,7 @@ internal class RoomSummaryUpdater @Inject constructor( fun updateSendingInformation(realm: Realm, roomId: String) { val roomSummaryEntity = RoomSummaryEntity.getOrCreate(realm, roomId) roomSummaryEntity.updateHasFailedSending() - roomSummaryEntity.latestPreviewableEvent = RoomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) + roomSummaryEntity.latestPreviewableEvent = roomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) } /** From 6e5461f300d4724aa853cfa4ee849f61262b80cb Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Fri, 2 Dec 2022 17:24:40 +0100 Subject: [PATCH 28/85] Stop filtering events with reference relationship when computing latest previewable event --- .../sdk/internal/database/query/TimelineEventEntityQueries.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt index 1b4b359916..37df901c7d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt @@ -115,7 +115,6 @@ internal fun RealmQuery.filterEvents(filters: TimelineEvent if (filters.filterEdits) { not().like(TimelineEventEntityFields.ROOT.CONTENT, TimelineEventFilter.Content.EDIT) not().like(TimelineEventEntityFields.ROOT.CONTENT, TimelineEventFilter.Content.RESPONSE) - not().like(TimelineEventEntityFields.ROOT.CONTENT, TimelineEventFilter.Content.REFERENCE) } if (filters.filterRedacted) { not().like(TimelineEventEntityFields.ROOT.UNSIGNED_DATA, TimelineEventFilter.Unsigned.REDACTED) From 1a3ca7b1a06b943fe9a36e53f7e5cee3d145cdff Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Fri, 2 Dec 2022 17:25:54 +0100 Subject: [PATCH 29/85] Filter event types from decrypted content --- .../query/TimelineEventEntityQueries.kt | 43 ++++++++++++++++--- .../database/query/TimelineEventFilter.kt | 1 + 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt index 37df901c7d..ab90801b7f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt @@ -22,6 +22,7 @@ import io.realm.RealmQuery import io.realm.RealmResults import io.realm.Sort import io.realm.kotlin.where +import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEventFilters import org.matrix.android.sdk.internal.database.model.ChunkEntity @@ -94,14 +95,27 @@ internal fun RealmQuery.filterEvents(filters: TimelineEvent if (filters.filterTypes && filters.allowedTypes.isNotEmpty()) { beginGroup() filters.allowedTypes.forEachIndexed { index, filter -> - if (filter.stateKey == null) { - equalTo(TimelineEventEntityFields.ROOT.TYPE, filter.eventType) + if (filter.eventType == EventType.ENCRYPTED) { + val otherTypes = filters.allowedTypes.minus(filter).map { it.eventType } + if (filter.stateKey == null) { + filterEncryptedTypes(otherTypes) + } else { + beginGroup() + filterEncryptedTypes(otherTypes) + and() + equalTo(TimelineEventEntityFields.ROOT.STATE_KEY, filter.stateKey) + endGroup() + } } else { - beginGroup() - equalTo(TimelineEventEntityFields.ROOT.TYPE, filter.eventType) - and() - equalTo(TimelineEventEntityFields.ROOT.STATE_KEY, filter.stateKey) - endGroup() + if (filter.stateKey == null) { + equalTo(TimelineEventEntityFields.ROOT.TYPE, filter.eventType) + } else { + beginGroup() + equalTo(TimelineEventEntityFields.ROOT.TYPE, filter.eventType) + and() + equalTo(TimelineEventEntityFields.ROOT.STATE_KEY, filter.stateKey) + endGroup() + } } if (index != filters.allowedTypes.size - 1) { or() @@ -123,6 +137,21 @@ internal fun RealmQuery.filterEvents(filters: TimelineEvent return this } +internal fun RealmQuery.filterEncryptedTypes(allowedTypes: List): RealmQuery { + beginGroup() + equalTo(TimelineEventEntityFields.ROOT.TYPE, EventType.ENCRYPTED) + and() + beginGroup() + isNull(TimelineEventEntityFields.ROOT.DECRYPTION_RESULT_JSON) + allowedTypes.forEach { eventType -> + or() + like(TimelineEventEntityFields.ROOT.DECRYPTION_RESULT_JSON, TimelineEventFilter.DecryptedContent.type(eventType)) + } + endGroup() + endGroup() + return this +} + internal fun RealmQuery.filterTypes(filterTypes: List): RealmQuery { return if (filterTypes.isEmpty()) { this diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventFilter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventFilter.kt index 7a65623b76..b8baeb0b33 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventFilter.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventFilter.kt @@ -34,6 +34,7 @@ internal object TimelineEventFilter { */ internal object DecryptedContent { internal const val URL = """{*"file":*"url":*}""" + fun type(type: String) = """{*"type":*"$type"*}""" } /** From 69beef464871e77cccb41ec0928da109262d5278 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Thu, 1 Dec 2022 18:15:14 +0100 Subject: [PATCH 30/85] Show voice broadcast events in the room list fix factory --- .../src/main/res/values/strings.xml | 3 ++ .../im/vector/app/core/di/SingletonModule.kt | 3 ++ .../VectorCustomEventTypesProvider.kt | 28 +++++++++++++++++++ .../format/DisplayableEventFormatter.kt | 26 +++++++++++++++++ .../home/room/list/RoomSummaryItemFactory.kt | 23 +++++++++++++-- .../GetOngoingVoiceBroadcastsUseCase.kt | 4 +-- 6 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/configuration/VectorCustomEventTypesProvider.kt diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 7f11e63469..2d289150c6 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -134,6 +134,8 @@ ** Unable to decrypt: %s ** The sender\'s device has not sent us the keys for this message. + %1$s ended a voice broadcast. + @@ -3101,6 +3103,7 @@ (%1$s) Live + Live broadcast Buffering… Resume voice broadcast record 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 28ca761ace..7a3aa7cf8b 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 @@ -48,6 +48,7 @@ import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.analytics.VectorAnalytics import im.vector.app.features.analytics.impl.DefaultVectorAnalytics import im.vector.app.features.analytics.metrics.VectorPlugins +import im.vector.app.features.configuration.VectorCustomEventTypesProvider import im.vector.app.features.invite.AutoAcceptInvites import im.vector.app.features.invite.CompileTimeAutoAcceptInvites import im.vector.app.features.navigation.DefaultNavigator @@ -141,6 +142,7 @@ import javax.inject.Singleton vectorRoomDisplayNameFallbackProvider: VectorRoomDisplayNameFallbackProvider, flipperProxy: FlipperProxy, vectorPlugins: VectorPlugins, + vectorCustomEventTypesProvider: VectorCustomEventTypesProvider, ): MatrixConfiguration { return MatrixConfiguration( applicationFlavor = BuildConfig.FLAVOR_DESCRIPTION, @@ -150,6 +152,7 @@ import javax.inject.Singleton flipperProxy.networkInterceptor(), ), metricPlugins = vectorPlugins.plugins(), + customEventTypesProvider = vectorCustomEventTypesProvider, ) } diff --git a/vector/src/main/java/im/vector/app/features/configuration/VectorCustomEventTypesProvider.kt b/vector/src/main/java/im/vector/app/features/configuration/VectorCustomEventTypesProvider.kt new file mode 100644 index 0000000000..55244685d7 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/configuration/VectorCustomEventTypesProvider.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.configuration + +import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import org.matrix.android.sdk.api.provider.CustomEventTypesProvider +import javax.inject.Inject + +class VectorCustomEventTypesProvider @Inject constructor() : CustomEventTypesProvider { + + override val customPreviewableEventTypes = listOf( + VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO + ) +} 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 aaa0fc10c9..c8af85db4f 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 @@ -21,8 +21,14 @@ import im.vector.app.EmojiSpanify import im.vector.app.R import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.resources.ColorProvider +import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.StringProvider import im.vector.app.features.html.EventHtmlRenderer +import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import im.vector.app.features.voicebroadcast.isLive +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import me.gujun.android.span.image import me.gujun.android.span.span import org.commonmark.node.Document import org.matrix.android.sdk.api.session.events.model.Event @@ -41,6 +47,7 @@ import javax.inject.Inject class DisplayableEventFormatter @Inject constructor( private val stringProvider: StringProvider, private val colorProvider: ColorProvider, + private val drawableProvider: DrawableProvider, private val emojiSpanify: EmojiSpanify, private val noticeEventFormatter: NoticeEventFormatter, private val htmlRenderer: Lazy @@ -135,6 +142,9 @@ class DisplayableEventFormatter @Inject constructor( in EventType.STATE_ROOM_BEACON_INFO.values -> { simpleFormat(senderName, stringProvider.getString(R.string.sent_live_location), appendAuthor) } + VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> { + formatVoiceBroadcastEvent(timelineEvent.root.asVoiceBroadcastEvent(), senderName) + } else -> { span { text = noticeEventFormatter.format(timelineEvent, isDm) ?: "" @@ -252,4 +262,20 @@ class DisplayableEventFormatter @Inject constructor( body } } + + private fun formatVoiceBroadcastEvent(voiceBroadcastEvent: VoiceBroadcastEvent?, senderName: String): CharSequence { + return if (voiceBroadcastEvent?.isLive == true) { + span { + drawableProvider.getDrawable(R.drawable.ic_voice_broadcast, colorProvider.getColor(R.color.palette_vermilion))?.let { + image(it) + +" " + } + span(stringProvider.getString(R.string.voice_broadcast_live_broadcast)) { + textColor = colorProvider.getColor(R.color.palette_vermilion) + } + } + } else { + stringProvider.getString(R.string.notice_voice_broadcast_ended, senderName) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt index 638e3c185d..ca80530261 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt @@ -22,6 +22,7 @@ import com.airbnb.mvrx.Loading import im.vector.app.R import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.VectorDateFormatter +import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.resources.StringProvider @@ -29,21 +30,30 @@ import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.RoomListDisplayMode import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter import im.vector.app.features.home.room.typing.TypingHelper +import im.vector.app.features.voicebroadcast.isVoiceBroadcast +import im.vector.app.features.voicebroadcast.usecase.GetOngoingVoiceBroadcastsUseCase import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence +import org.matrix.android.sdk.api.extensions.orFalse +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.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo +import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject class RoomSummaryItemFactory @Inject constructor( + private val sessionHolder: ActiveSessionHolder, private val displayableEventFormatter: DisplayableEventFormatter, private val dateFormatter: VectorDateFormatter, private val stringProvider: StringProvider, private val typingHelper: TypingHelper, private val avatarRenderer: AvatarRenderer, - private val errorFormatter: ErrorFormatter + private val errorFormatter: ErrorFormatter, + private val getOngoingVoiceBroadcastsUseCase: GetOngoingVoiceBroadcastsUseCase, ) { fun create( @@ -129,7 +139,7 @@ class RoomSummaryItemFactory @Inject constructor( val showSelected = selectedRoomIds.contains(roomSummary.roomId) var latestFormattedEvent: CharSequence = "" var latestEventTime = "" - val latestEvent = roomSummary.latestPreviewableEvent + val latestEvent = roomSummary.getVectorLatestPreviewableEvent() if (latestEvent != null) { latestFormattedEvent = displayableEventFormatter.format(latestEvent, roomSummary.isDirect, roomSummary.isDirect.not()) latestEventTime = dateFormatter.format(latestEvent.root.originServerTs, DateFormatKind.ROOM_LIST) @@ -225,4 +235,13 @@ class RoomSummaryItemFactory @Inject constructor( else -> stringProvider.getQuantityString(R.plurals.search_space_multiple_parents, size - 1, directParentNames[0], size - 1) } } + + private fun RoomSummary.getVectorLatestPreviewableEvent(): TimelineEvent? { + val room = sessionHolder.getSafeActiveSession()?.getRoom(roomId) ?: return latestPreviewableEvent + val liveVoiceBroadcastTimelineEvent = getOngoingVoiceBroadcastsUseCase.execute(roomId).lastOrNull() + ?.root?.eventId?.let { room.getTimelineEvent(it) } + return liveVoiceBroadcastTimelineEvent + ?: latestPreviewableEvent + ?.takeUnless { it.root.asMessageAudioEvent()?.isVoiceBroadcast().orFalse() } // Skip voice messages related to voice broadcast + } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt index ec50618969..9974db470f 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt @@ -18,8 +18,8 @@ package im.vector.app.features.voicebroadcast.usecase import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import im.vector.app.features.voicebroadcast.isLive 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 org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.getRoom @@ -44,6 +44,6 @@ class GetOngoingVoiceBroadcastsUseCase @Inject constructor( QueryStringValue.IsNotEmpty ) .mapNotNull { it.asVoiceBroadcastEvent() } - .filter { it.content?.voiceBroadcastState != null && it.content?.voiceBroadcastState != VoiceBroadcastState.STOPPED } + .filter { it.isLive } } } From aa5270760e3cbc0b1915a08ab411c733536fb9c7 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Mon, 5 Dec 2022 18:04:54 +0100 Subject: [PATCH 31/85] Hide typing events if there is a live voice broadcast --- .../app/features/home/room/list/RoomSummaryItemFactory.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt index ca80530261..d48448b480 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt @@ -146,6 +146,8 @@ class RoomSummaryItemFactory @Inject constructor( } val typingMessage = typingHelper.getTypingMessage(roomSummary.typingUsers) + // Skip typing while there is a live voice broadcast + .takeUnless { latestEvent?.root?.asVoiceBroadcastEvent()?.isLive.orFalse() }.orEmpty() return if (subtitle.isBlank() && displayMode == RoomListDisplayMode.FILTERED) { createCenteredRoomSummaryItem(roomSummary, displayMode, showSelected, unreadCount, onClick, onLongClick) From 7a1dfef6d59d49d485883fbdbde794cb140e12f6 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Mon, 5 Dec 2022 18:42:24 +0100 Subject: [PATCH 32/85] Display a notice in the timeline when a voice broadcast is stopped --- .../src/main/res/values/strings.xml | 1 + .../format/DisplayableEventFormatter.kt | 10 ++++----- .../timeline/format/NoticeEventFormatter.kt | 21 +++++++++++++++++-- .../helper/TimelineEventVisibilityHelper.kt | 2 +- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 2d289150c6..127d63f74c 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -135,6 +135,7 @@ The sender\'s device has not sent us the keys for this message. %1$s ended a voice broadcast. + You ended a voice broadcast. 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 c8af85db4f..5fa9576dd4 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 @@ -20,13 +20,13 @@ import dagger.Lazy import im.vector.app.EmojiSpanify import im.vector.app.R import im.vector.app.core.extensions.getVectorLastMessageContent +import im.vector.app.core.extensions.orEmpty import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.StringProvider import im.vector.app.features.html.EventHtmlRenderer import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants import im.vector.app.features.voicebroadcast.isLive -import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import me.gujun.android.span.image import me.gujun.android.span.span @@ -143,7 +143,7 @@ class DisplayableEventFormatter @Inject constructor( simpleFormat(senderName, stringProvider.getString(R.string.sent_live_location), appendAuthor) } VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> { - formatVoiceBroadcastEvent(timelineEvent.root.asVoiceBroadcastEvent(), senderName) + formatVoiceBroadcastEvent(timelineEvent.root, isDm, senderName) } else -> { span { @@ -263,8 +263,8 @@ class DisplayableEventFormatter @Inject constructor( } } - private fun formatVoiceBroadcastEvent(voiceBroadcastEvent: VoiceBroadcastEvent?, senderName: String): CharSequence { - return if (voiceBroadcastEvent?.isLive == true) { + private fun formatVoiceBroadcastEvent(event: Event, isDm: Boolean, senderName: String): CharSequence { + return if (event.asVoiceBroadcastEvent()?.isLive == true) { span { drawableProvider.getDrawable(R.drawable.ic_voice_broadcast, colorProvider.getColor(R.color.palette_vermilion))?.let { image(it) @@ -275,7 +275,7 @@ class DisplayableEventFormatter @Inject constructor( } } } else { - stringProvider.getString(R.string.notice_voice_broadcast_ended, senderName) + noticeEventFormatter.format(event, senderName, isDm).orEmpty() } } } 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 3f702ed72d..b02e515774 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 @@ -22,6 +22,8 @@ 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 im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent 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 @@ -91,6 +93,9 @@ class NoticeEventFormatter @Inject constructor( EventType.CALL_HANGUP, EventType.CALL_REJECT, EventType.CALL_ANSWER -> formatCallEvent(type, timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) + VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> { + formatVoiceBroadcastEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) + } EventType.CALL_NEGOTIATE, EventType.CALL_SELECT_ANSWER, EventType.CALL_REPLACES, @@ -109,8 +114,7 @@ class NoticeEventFormatter @Inject constructor( EventType.STICKER, 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) + in EventType.BEACON_LOCATION_DATA.values -> formatDebug(timelineEvent.root) else -> { Timber.v("Type $type not handled by this formatter") null @@ -191,6 +195,7 @@ class NoticeEventFormatter @Inject constructor( EventType.CALL_REJECT, EventType.CALL_ANSWER -> formatCallEvent(type, event, senderName) EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(event, senderName, isDm) + VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> formatVoiceBroadcastEvent(event, senderName) else -> { Timber.v("Type $type not handled by this formatter") null @@ -894,4 +899,16 @@ class NoticeEventFormatter @Inject constructor( } } } + + private fun formatVoiceBroadcastEvent(event: Event, senderName: String?): CharSequence { + return if (event.asVoiceBroadcastEvent()?.content?.voiceBroadcastState == VoiceBroadcastState.STOPPED) { + if (event.isSentByCurrentUser()) { + sp.getString(R.string.notice_voice_broadcast_ended_by_you) + } else { + sp.getString(R.string.notice_voice_broadcast_ended, senderName) + } + } else { + formatDebug(event) + } + } } 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 382f1c2301..703a5cb911 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 @@ -252,7 +252,7 @@ class TimelineEventVisibilityHelper @Inject constructor( } if (root.getClearType() == VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO && - root.asVoiceBroadcastEvent()?.content?.voiceBroadcastState != VoiceBroadcastState.STARTED) { + root.asVoiceBroadcastEvent()?.content?.voiceBroadcastState !in arrayOf(VoiceBroadcastState.STARTED, VoiceBroadcastState.STOPPED)) { return true } From 35c528405d367eca846b8f775404b2ccf570586e Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 7 Dec 2022 10:15:49 +0100 Subject: [PATCH 33/85] Code cleanup --- .../timeline/format/NoticeEventFormatter.kt | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) 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 b02e515774..a306dd6b2f 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 @@ -69,33 +69,32 @@ class NoticeEventFormatter @Inject constructor( private fun Event.isSentByCurrentUser() = senderId != null && senderId == currentUserId fun format(timelineEvent: TimelineEvent, isDm: Boolean): CharSequence? { - return when (val type = timelineEvent.root.getClearType()) { - EventType.STATE_ROOM_JOIN_RULES -> formatJoinRulesEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName, isDm) - EventType.STATE_ROOM_CREATE -> formatRoomCreateEvent(timelineEvent.root, isDm) - EventType.STATE_ROOM_NAME -> formatRoomNameEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) - EventType.STATE_ROOM_TOPIC -> formatRoomTopicEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) - EventType.STATE_ROOM_AVATAR -> formatRoomAvatarEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) - EventType.STATE_ROOM_MEMBER -> formatRoomMemberEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName, isDm) - EventType.STATE_ROOM_THIRD_PARTY_INVITE -> formatRoomThirdPartyInvite(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName, isDm) - EventType.STATE_ROOM_ALIASES -> formatRoomAliasesEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) - EventType.STATE_ROOM_CANONICAL_ALIAS -> formatRoomCanonicalAliasEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) - EventType.STATE_ROOM_HISTORY_VISIBILITY -> - formatRoomHistoryVisibilityEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName, isDm) - EventType.STATE_ROOM_SERVER_ACL -> formatRoomServerAclEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) - EventType.STATE_ROOM_GUEST_ACCESS -> formatRoomGuestAccessEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName, isDm) - EventType.STATE_ROOM_ENCRYPTION -> formatRoomEncryptionEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) + val event = timelineEvent.root + val senderName = timelineEvent.senderInfo.disambiguatedDisplayName + return when (val type = event.getClearType()) { + EventType.STATE_ROOM_JOIN_RULES -> formatJoinRulesEvent(event, senderName, isDm) + EventType.STATE_ROOM_CREATE -> formatRoomCreateEvent(event, isDm) + EventType.STATE_ROOM_NAME -> formatRoomNameEvent(event, senderName) + EventType.STATE_ROOM_TOPIC -> formatRoomTopicEvent(event, senderName) + EventType.STATE_ROOM_AVATAR -> formatRoomAvatarEvent(event, senderName) + EventType.STATE_ROOM_MEMBER -> formatRoomMemberEvent(event, senderName, isDm) + EventType.STATE_ROOM_THIRD_PARTY_INVITE -> formatRoomThirdPartyInvite(event, senderName, isDm) + EventType.STATE_ROOM_ALIASES -> formatRoomAliasesEvent(event, senderName) + EventType.STATE_ROOM_CANONICAL_ALIAS -> formatRoomCanonicalAliasEvent(event, senderName) + EventType.STATE_ROOM_HISTORY_VISIBILITY -> formatRoomHistoryVisibilityEvent(event, senderName, isDm) + EventType.STATE_ROOM_SERVER_ACL -> formatRoomServerAclEvent(event, senderName) + EventType.STATE_ROOM_GUEST_ACCESS -> formatRoomGuestAccessEvent(event, senderName, isDm) + EventType.STATE_ROOM_ENCRYPTION -> formatRoomEncryptionEvent(event, senderName) EventType.STATE_ROOM_WIDGET, - EventType.STATE_ROOM_WIDGET_LEGACY -> formatWidgetEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) - EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName, isDm) - EventType.STATE_ROOM_POWER_LEVELS -> formatRoomPowerLevels(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) + EventType.STATE_ROOM_WIDGET_LEGACY -> formatWidgetEvent(event, senderName) + EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(event, senderName, isDm) + EventType.STATE_ROOM_POWER_LEVELS -> formatRoomPowerLevels(event, senderName) EventType.CALL_INVITE, EventType.CALL_CANDIDATES, EventType.CALL_HANGUP, EventType.CALL_REJECT, - EventType.CALL_ANSWER -> formatCallEvent(type, timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) - VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> { - formatVoiceBroadcastEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) - } + EventType.CALL_ANSWER -> formatCallEvent(type, event, senderName) + VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> formatVoiceBroadcastEvent(event, senderName) EventType.CALL_NEGOTIATE, EventType.CALL_SELECT_ANSWER, EventType.CALL_REPLACES, @@ -114,7 +113,7 @@ class NoticeEventFormatter @Inject constructor( EventType.STICKER, in EventType.POLL_RESPONSE.values, in EventType.POLL_END.values, - in EventType.BEACON_LOCATION_DATA.values -> formatDebug(timelineEvent.root) + in EventType.BEACON_LOCATION_DATA.values -> formatDebug(event) else -> { Timber.v("Type $type not handled by this formatter") null From 28c59e3290898b8c3efb29ed9448db94c82842ae Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Tue, 6 Dec 2022 10:31:54 +0100 Subject: [PATCH 34/85] Changelog --- changelog.d/7719.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7719.feature diff --git a/changelog.d/7719.feature b/changelog.d/7719.feature new file mode 100644 index 0000000000..34df6ad964 --- /dev/null +++ b/changelog.d/7719.feature @@ -0,0 +1 @@ +Voice Broadcast - Update last message in the room list From bb7323a93593cf3940fc6323d83c2ee3ed6a8361 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Tue, 6 Dec 2022 17:24:52 +0100 Subject: [PATCH 35/85] Rename some use cases --- .../src/main/java/im/vector/app/core/di/VoiceModule.kt | 6 +++--- .../features/home/room/list/RoomSummaryItemFactory.kt | 8 +++++--- .../listening/VoiceBroadcastPlayerImpl.kt | 4 ++-- .../usecase/GetLiveVoiceBroadcastChunksUseCase.kt | 4 ++-- .../recording/VoiceBroadcastRecorderQ.kt | 4 ++-- .../recording/usecase/StartVoiceBroadcastUseCase.kt | 6 +++--- .../usecase/StopOngoingVoiceBroadcastUseCase.kt | 6 +++--- ...UseCase.kt => GetRoomLiveVoiceBroadcastsUseCase.kt} | 10 ++-------- ...se.kt => GetVoiceBroadcastStateEventLiveUseCase.kt} | 2 +- .../usecase/StartVoiceBroadcastUseCaseTest.kt | 6 +++--- 10 files changed, 26 insertions(+), 30 deletions(-) rename vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/{GetOngoingVoiceBroadcastsUseCase.kt => GetRoomLiveVoiceBroadcastsUseCase.kt} (84%) rename vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/{GetMostRecentVoiceBroadcastStateEventUseCase.kt => GetVoiceBroadcastStateEventLiveUseCase.kt} (99%) 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 6437326294..40fb4ecea7 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,7 +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 im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastStateEventLiveUseCase import javax.inject.Singleton @InstallIn(SingletonComponent::class) @@ -40,13 +40,13 @@ abstract class VoiceModule { fun providesVoiceBroadcastRecorder( context: Context, sessionHolder: ActiveSessionHolder, - getMostRecentVoiceBroadcastStateEventUseCase: GetMostRecentVoiceBroadcastStateEventUseCase, + getVoiceBroadcastStateEventLiveUseCase: GetVoiceBroadcastStateEventLiveUseCase, ): VoiceBroadcastRecorder? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { VoiceBroadcastRecorderQ( context = context, sessionHolder = sessionHolder, - getVoiceBroadcastEventUseCase = getMostRecentVoiceBroadcastStateEventUseCase + getVoiceBroadcastEventUseCase = getVoiceBroadcastStateEventLiveUseCase ) } else { null diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt index d48448b480..d8b7a427f0 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt @@ -30,8 +30,10 @@ import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.RoomListDisplayMode import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter import im.vector.app.features.home.room.typing.TypingHelper +import im.vector.app.features.voicebroadcast.isLive import im.vector.app.features.voicebroadcast.isVoiceBroadcast -import im.vector.app.features.voicebroadcast.usecase.GetOngoingVoiceBroadcastsUseCase +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.usecase.GetRoomLiveVoiceBroadcastsUseCase import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.getRoom @@ -53,7 +55,7 @@ class RoomSummaryItemFactory @Inject constructor( private val typingHelper: TypingHelper, private val avatarRenderer: AvatarRenderer, private val errorFormatter: ErrorFormatter, - private val getOngoingVoiceBroadcastsUseCase: GetOngoingVoiceBroadcastsUseCase, + private val getRoomLiveVoiceBroadcastsUseCase: GetRoomLiveVoiceBroadcastsUseCase, ) { fun create( @@ -240,7 +242,7 @@ class RoomSummaryItemFactory @Inject constructor( private fun RoomSummary.getVectorLatestPreviewableEvent(): TimelineEvent? { val room = sessionHolder.getSafeActiveSession()?.getRoom(roomId) ?: return latestPreviewableEvent - val liveVoiceBroadcastTimelineEvent = getOngoingVoiceBroadcastsUseCase.execute(roomId).lastOrNull() + val liveVoiceBroadcastTimelineEvent = getRoomLiveVoiceBroadcastsUseCase.execute(roomId).lastOrNull() ?.root?.eventId?.let { room.getTimelineEvent(it) } return liveVoiceBroadcastTimelineEvent ?: latestPreviewableEvent 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 f8025d078e..1bc3078c8b 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 @@ -31,7 +31,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.GetMostRecentVoiceBroadcastStateEventUseCase +import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastStateEventLiveUseCase 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: GetMostRecentVoiceBroadcastStateEventUseCase, + private val getVoiceBroadcastEventUseCase: GetVoiceBroadcastStateEventLiveUseCase, 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 03e713eeaa..b2aebd9932 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.GetMostRecentVoiceBroadcastStateEventUseCase +import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastStateEventLiveUseCase 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: GetMostRecentVoiceBroadcastStateEventUseCase, + private val getVoiceBroadcastEventUseCase: GetVoiceBroadcastStateEventLiveUseCase, ) { fun execute(voiceBroadcast: VoiceBroadcast): Flow> { 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 b751417ca6..2da807293f 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 @@ -26,7 +26,7 @@ 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.app.features.voicebroadcast.usecase.GetVoiceBroadcastStateEventLiveUseCase import im.vector.lib.core.utils.timer.CountUpTimer import kotlinx.coroutines.Job import kotlinx.coroutines.flow.launchIn @@ -40,7 +40,7 @@ import java.util.concurrent.TimeUnit class VoiceBroadcastRecorderQ( context: Context, private val sessionHolder: ActiveSessionHolder, - private val getVoiceBroadcastEventUseCase: GetMostRecentVoiceBroadcastStateEventUseCase + private val getVoiceBroadcastEventUseCase: GetVoiceBroadcastStateEventLiveUseCase ) : AbstractVoiceRecorderQ(context), VoiceBroadcastRecorder { private val session get() = sessionHolder.getActiveSession() 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 e3814608ea..87ea49cece 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 @@ -28,7 +28,7 @@ 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.app.features.voicebroadcast.usecase.GetRoomLiveVoiceBroadcastsUseCase import im.vector.lib.multipicker.utils.toMultiPickerAudioType import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -56,7 +56,7 @@ class StartVoiceBroadcastUseCase @Inject constructor( private val voiceBroadcastRecorder: VoiceBroadcastRecorder?, private val context: Context, private val buildMeta: BuildMeta, - private val getOngoingVoiceBroadcastsUseCase: GetOngoingVoiceBroadcastsUseCase, + private val getRoomLiveVoiceBroadcastsUseCase: GetRoomLiveVoiceBroadcastsUseCase, private val stopVoiceBroadcastUseCase: StopVoiceBroadcastUseCase, ) { @@ -152,7 +152,7 @@ class StartVoiceBroadcastUseCase @Inject constructor( Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: another voice broadcast") throw VoiceBroadcastFailure.RecordingError.UserAlreadyBroadcasting } - getOngoingVoiceBroadcastsUseCase.execute(room.roomId).isNotEmpty() -> { + getRoomLiveVoiceBroadcastsUseCase.execute(room.roomId).isNotEmpty() -> { Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: user already broadcasting") throw VoiceBroadcastFailure.RecordingError.BlockedBySomeoneElse } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopOngoingVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopOngoingVoiceBroadcastUseCase.kt index 791409b869..fdbf1a067d 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopOngoingVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopOngoingVoiceBroadcastUseCase.kt @@ -19,7 +19,7 @@ package im.vector.app.features.voicebroadcast.recording.usecase import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.features.voicebroadcast.VoiceBroadcastHelper import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent -import im.vector.app.features.voicebroadcast.usecase.GetOngoingVoiceBroadcastsUseCase +import im.vector.app.features.voicebroadcast.usecase.GetRoomLiveVoiceBroadcastsUseCase import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.room.model.Membership @@ -32,7 +32,7 @@ import javax.inject.Inject */ class StopOngoingVoiceBroadcastUseCase @Inject constructor( private val activeSessionHolder: ActiveSessionHolder, - private val getOngoingVoiceBroadcastsUseCase: GetOngoingVoiceBroadcastsUseCase, + private val getRoomLiveVoiceBroadcastsUseCase: GetRoomLiveVoiceBroadcastsUseCase, private val voiceBroadcastHelper: VoiceBroadcastHelper, ) { @@ -53,7 +53,7 @@ class StopOngoingVoiceBroadcastUseCase @Inject constructor( recentRooms .forEach { room -> - val ongoingVoiceBroadcasts = getOngoingVoiceBroadcastsUseCase.execute(room.roomId) + val ongoingVoiceBroadcasts = getRoomLiveVoiceBroadcastsUseCase.execute(room.roomId) val myOngoingVoiceBroadcastId = ongoingVoiceBroadcasts.find { it.root.stateKey == session.myUserId }?.reference?.eventId val initialEvent = myOngoingVoiceBroadcastId?.let { room.timelineService().getTimelineEvent(it)?.root?.asVoiceBroadcastEvent() } if (myOngoingVoiceBroadcastId != null && initialEvent?.content?.deviceId == session.sessionParams.deviceId) { diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetRoomLiveVoiceBroadcastsUseCase.kt similarity index 84% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetRoomLiveVoiceBroadcastsUseCase.kt index 9974db470f..fa5f06bfe6 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetRoomLiveVoiceBroadcastsUseCase.kt @@ -23,22 +23,16 @@ import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.getRoom -import timber.log.Timber import javax.inject.Inject -class GetOngoingVoiceBroadcastsUseCase @Inject constructor( +class GetRoomLiveVoiceBroadcastsUseCase @Inject constructor( private val activeSessionHolder: ActiveSessionHolder, ) { fun execute(roomId: String): List { - val session = activeSessionHolder.getSafeActiveSession() ?: run { - Timber.d("## GetOngoingVoiceBroadcastsUseCase: no active session") - return emptyList() - } + val session = activeSessionHolder.getSafeActiveSession() ?: return emptyList() val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") - Timber.d("## GetLastVoiceBroadcastUseCase: get last voice broadcast in $roomId") - return room.stateService().getStateEvents( setOf(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO), QueryStringValue.IsNotEmpty 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/GetVoiceBroadcastStateEventLiveUseCase.kt similarity index 99% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastStateEventLiveUseCase.kt index e0179e403f..b3bbdad635 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/GetVoiceBroadcastStateEventLiveUseCase.kt @@ -42,7 +42,7 @@ import org.matrix.android.sdk.flow.mapOptional import timber.log.Timber import javax.inject.Inject -class GetMostRecentVoiceBroadcastStateEventUseCase @Inject constructor( +class GetVoiceBroadcastStateEventLiveUseCase @Inject constructor( private val session: Session, ) { diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt index 5b4076378c..5dfdd379e0 100644 --- a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt @@ -52,14 +52,14 @@ class StartVoiceBroadcastUseCaseTest { private val fakeRoom = FakeRoom() private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom)) private val fakeVoiceBroadcastRecorder = mockk(relaxed = true) - private val fakeGetOngoingVoiceBroadcastsUseCase = mockk() + private val fakeGetRoomLiveVoiceBroadcastsUseCase = mockk() private val startVoiceBroadcastUseCase = spyk( StartVoiceBroadcastUseCase( session = fakeSession, voiceBroadcastRecorder = fakeVoiceBroadcastRecorder, context = FakeContext().instance, buildMeta = mockk(), - getOngoingVoiceBroadcastsUseCase = fakeGetOngoingVoiceBroadcastsUseCase, + getRoomLiveVoiceBroadcastsUseCase = fakeGetRoomLiveVoiceBroadcastsUseCase, stopVoiceBroadcastUseCase = mockk() ) ) @@ -140,7 +140,7 @@ class StartVoiceBroadcastUseCaseTest { } .mapNotNull { it.asVoiceBroadcastEvent() } .filter { it.content?.voiceBroadcastState != VoiceBroadcastState.STOPPED } - every { fakeGetOngoingVoiceBroadcastsUseCase.execute(any()) } returns events + every { fakeGetRoomLiveVoiceBroadcastsUseCase.execute(any()) } returns events } private data class VoiceBroadcast(val userId: String, val state: VoiceBroadcastState) From 59859ec02ee1eca0a82c41fcb1c1d600f90b0701 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 7 Dec 2022 09:56:33 +0100 Subject: [PATCH 36/85] Prioritize call events against live broadcast --- .../app/features/home/room/list/RoomSummaryItemFactory.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt index d8b7a427f0..a55900a5c4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt @@ -36,6 +36,7 @@ import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import im.vector.app.features.voicebroadcast.usecase.GetRoomLiveVoiceBroadcastsUseCase import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.events.model.EventType 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.members.ChangeMembershipState @@ -244,7 +245,8 @@ class RoomSummaryItemFactory @Inject constructor( val room = sessionHolder.getSafeActiveSession()?.getRoom(roomId) ?: return latestPreviewableEvent val liveVoiceBroadcastTimelineEvent = getRoomLiveVoiceBroadcastsUseCase.execute(roomId).lastOrNull() ?.root?.eventId?.let { room.getTimelineEvent(it) } - return liveVoiceBroadcastTimelineEvent + return latestPreviewableEvent?.takeIf { it.root.getClearType() == EventType.CALL_INVITE } + ?: liveVoiceBroadcastTimelineEvent ?: latestPreviewableEvent ?.takeUnless { it.root.asMessageAudioEvent()?.isVoiceBroadcast().orFalse() } // Skip voice messages related to voice broadcast } From 055bf6d3029d8748a9f14ff4de5a24868bb4a2a3 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Wed, 7 Dec 2022 21:41:22 +0300 Subject: [PATCH 37/85] Revert unused companion object. --- .../sdk/internal/database/model/RoomAccountDataEntity.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomAccountDataEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomAccountDataEntity.kt index 2eb5a63784..40040b5738 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomAccountDataEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomAccountDataEntity.kt @@ -24,7 +24,4 @@ import io.realm.annotations.RealmClass internal open class RoomAccountDataEntity( @Index var type: String? = null, var contentStr: String? = null -) : RealmObject() { - - companion object -} +) : RealmObject() From a5ab1b4a8bdf10bb61a0c2966c63dc6be8837283 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 8 Dec 2022 10:34:08 +0100 Subject: [PATCH 38/85] Fix crash `kotlin.UninitializedPropertyAccessException: lateinit property avatarRenderer has not been initialized`. AvatarRenderer is not used here. --- .../im/vector/app/features/userdirectory/InviteByEmailItem.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/InviteByEmailItem.kt b/vector/src/main/java/im/vector/app/features/userdirectory/InviteByEmailItem.kt index eaeec35791..da1d76e86a 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/InviteByEmailItem.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/InviteByEmailItem.kt @@ -25,12 +25,10 @@ import im.vector.app.R 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.features.home.AvatarRenderer @EpoxyModelClass abstract class InviteByEmailItem : VectorEpoxyModel(R.layout.item_invite_by_mail) { - @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer @EpoxyAttribute lateinit var foundItem: ThreePidUser @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var clickListener: ClickListener? = null @EpoxyAttribute var selected: Boolean = false From 7034d822594a5d7417444c7d613893565e5c6ae9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 8 Dec 2022 10:36:29 +0100 Subject: [PATCH 39/85] changelog --- changelog.d/7744.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7744.bugfix diff --git a/changelog.d/7744.bugfix b/changelog.d/7744.bugfix new file mode 100644 index 0000000000..7ed82a9c1c --- /dev/null +++ b/changelog.d/7744.bugfix @@ -0,0 +1 @@ +Fix crash when inviting by email. From b49045ff15044202a4ac104c39d2ebbf6eae53df Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Thu, 8 Dec 2022 10:37:00 +0100 Subject: [PATCH 40/85] Adding changelog entry --- changelog.d/7743.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7743.bugfix diff --git a/changelog.d/7743.bugfix b/changelog.d/7743.bugfix new file mode 100644 index 0000000000..867c12a3c3 --- /dev/null +++ b/changelog.d/7743.bugfix @@ -0,0 +1 @@ +Verification request is not showing when verify session popup is displayed From b25f185d63b5de9e1cb5523650e7aa7b5623cdc5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 8 Dec 2022 10:48:17 +0100 Subject: [PATCH 41/85] Try to fix issue about danger file not found. --- .github/workflows/danger.yml | 2 +- .github/workflows/quality.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index e5226d0723..8752f339bd 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -13,7 +13,7 @@ jobs: - name: Danger uses: danger/danger-js@11.2.0 with: - args: "--dangerfile tools/danger/dangerfile.js" + args: "--dangerfile ./tools/danger/dangerfile.js" env: DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }} # Fallback for forks diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 57dd5a6a45..fae8d97688 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -68,7 +68,7 @@ jobs: if: always() uses: danger/danger-js@11.2.0 with: - args: "--dangerfile tools/danger/dangerfile-lint.js" + args: "--dangerfile ./tools/danger/dangerfile-lint.js" env: DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }} # Fallback for forks From 72ecd1bbc9adcf9fa01f2ed3cfa8e7b86af349a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Dec 2022 10:51:20 +0100 Subject: [PATCH 42/85] Bump kotlin-gradle-plugin from 1.7.21 to 1.7.22 (#7664) Bumps [kotlin-gradle-plugin](https://github.com/JetBrains/kotlin) from 1.7.21 to 1.7.22. - [Release notes](https://github.com/JetBrains/kotlin/releases) - [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md) - [Commits](https://github.com/JetBrains/kotlin/compare/v1.7.21...v1.7.22) --- updated-dependencies: - dependency-name: org.jetbrains.kotlin:kotlin-gradle-plugin 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 b408ee01eb..a9aee3b681 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -8,7 +8,7 @@ ext.versions = [ def gradle = "7.3.1" // Ref: https://kotlinlang.org/releases.html -def kotlin = "1.7.21" +def kotlin = "1.7.22" def kotlinCoroutines = "1.6.4" def dagger = "2.44.2" def appDistribution = "16.0.0-beta05" From d6c20226bb97095f55007d66da6076b8d2b4072e Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Thu, 8 Dec 2022 13:46:01 +0300 Subject: [PATCH 43/85] Add changelog. --- changelog.d/7740.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7740.feature diff --git a/changelog.d/7740.feature b/changelog.d/7740.feature new file mode 100644 index 0000000000..6cd2b6c776 --- /dev/null +++ b/changelog.d/7740.feature @@ -0,0 +1 @@ +Handle account data removal From de18f37849fa599eb174ea9f5394867b86378670 Mon Sep 17 00:00:00 2001 From: jonnyandrew Date: Thu, 8 Dec 2022 11:43:19 +0000 Subject: [PATCH 44/85] [Rich text editor] Add error tracking for rich text editor (#7695) --- changelog.d/7695.bugfix | 1 + .../im/vector/app/core/di/SingletonModule.kt | 4 +++ .../app/features/analytics/VectorAnalytics.kt | 3 +- .../features/analytics/errors/ErrorTracker.kt | 21 +++++++++++++ .../analytics/impl/DefaultVectorAnalytics.kt | 14 ++++++--- .../{SentryFactory.kt => SentryAnalytics.kt} | 9 ++++-- .../composer/MessageComposerFragment.kt | 3 ++ .../detail/composer/RichTextComposerLayout.kt | 10 ++++-- .../composer/RichTextEditorException.kt | 21 +++++++++++++ .../impl/DefaultVectorAnalyticsTest.kt | 31 +++++++++++++++---- ...entryFactory.kt => FakeSentryAnalytics.kt} | 15 +++++++-- 11 files changed, 114 insertions(+), 18 deletions(-) create mode 100644 changelog.d/7695.bugfix create mode 100644 vector/src/main/java/im/vector/app/features/analytics/errors/ErrorTracker.kt rename vector/src/main/java/im/vector/app/features/analytics/impl/{SentryFactory.kt => SentryAnalytics.kt} (88%) create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextEditorException.kt rename vector/src/test/java/im/vector/app/test/fakes/{FakeSentryFactory.kt => FakeSentryAnalytics.kt} (74%) diff --git a/changelog.d/7695.bugfix b/changelog.d/7695.bugfix new file mode 100644 index 0000000000..7ec0805bce --- /dev/null +++ b/changelog.d/7695.bugfix @@ -0,0 +1 @@ +[Rich text editor] Add error tracking for rich text editor 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 28ca761ace..21a46b0757 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 @@ -46,6 +46,7 @@ import im.vector.app.core.utils.AndroidSystemSettingsProvider import im.vector.app.core.utils.SystemSettingsProvider import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.analytics.VectorAnalytics +import im.vector.app.features.analytics.errors.ErrorTracker import im.vector.app.features.analytics.impl.DefaultVectorAnalytics import im.vector.app.features.analytics.metrics.VectorPlugins import im.vector.app.features.invite.AutoAcceptInvites @@ -84,6 +85,9 @@ import javax.inject.Singleton @Binds abstract fun bindVectorAnalytics(analytics: DefaultVectorAnalytics): VectorAnalytics + @Binds + abstract fun bindErrorTracker(analytics: DefaultVectorAnalytics): ErrorTracker + @Binds abstract fun bindAnalyticsTracker(analytics: DefaultVectorAnalytics): AnalyticsTracker diff --git a/vector/src/main/java/im/vector/app/features/analytics/VectorAnalytics.kt b/vector/src/main/java/im/vector/app/features/analytics/VectorAnalytics.kt index 7d11f93883..802ba08092 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/VectorAnalytics.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/VectorAnalytics.kt @@ -16,9 +16,10 @@ package im.vector.app.features.analytics +import im.vector.app.features.analytics.errors.ErrorTracker import kotlinx.coroutines.flow.Flow -interface VectorAnalytics : AnalyticsTracker { +interface VectorAnalytics : AnalyticsTracker, ErrorTracker { /** * Return a Flow of Boolean, true if the user has given their consent. */ diff --git a/vector/src/main/java/im/vector/app/features/analytics/errors/ErrorTracker.kt b/vector/src/main/java/im/vector/app/features/analytics/errors/ErrorTracker.kt new file mode 100644 index 0000000000..8ad6bfffc0 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/analytics/errors/ErrorTracker.kt @@ -0,0 +1,21 @@ +/* + * 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.analytics.errors + +interface ErrorTracker { + fun trackError(throwable: Throwable) +} diff --git a/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt b/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt index 553d699d86..ca7608166c 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt @@ -41,7 +41,7 @@ private val IGNORED_OPTIONS: Options? = null @Singleton class DefaultVectorAnalytics @Inject constructor( postHogFactory: PostHogFactory, - private val sentryFactory: SentryFactory, + private val sentryAnalytics: SentryAnalytics, analyticsConfig: AnalyticsConfig, private val analyticsStore: AnalyticsStore, private val lateInitUserPropertiesFactory: LateInitUserPropertiesFactory, @@ -97,7 +97,7 @@ class DefaultVectorAnalytics @Inject constructor( setAnalyticsId("") // Close Sentry SDK. - sentryFactory.stopSentry() + sentryAnalytics.stopSentry() } private fun observeAnalyticsId() { @@ -135,8 +135,8 @@ class DefaultVectorAnalytics @Inject constructor( private fun initOrStopSentry() { userConsent?.let { when (it) { - true -> sentryFactory.initSentry() - false -> sentryFactory.stopSentry() + true -> sentryAnalytics.initSentry() + false -> sentryAnalytics.stopSentry() } } } @@ -180,4 +180,10 @@ class DefaultVectorAnalytics @Inject constructor( putAll(this@toPostHogUserProperties.filter { it.value != null }) } } + + override fun trackError(throwable: Throwable) { + sentryAnalytics + .takeIf { userConsent == true } + ?.trackError(throwable) + } } diff --git a/vector/src/main/java/im/vector/app/features/analytics/impl/SentryFactory.kt b/vector/src/main/java/im/vector/app/features/analytics/impl/SentryAnalytics.kt similarity index 88% rename from vector/src/main/java/im/vector/app/features/analytics/impl/SentryFactory.kt rename to vector/src/main/java/im/vector/app/features/analytics/impl/SentryAnalytics.kt index a000f2a77a..21721a31ae 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/impl/SentryFactory.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/impl/SentryAnalytics.kt @@ -18,6 +18,7 @@ package im.vector.app.features.analytics.impl import android.content.Context import im.vector.app.features.analytics.AnalyticsConfig +import im.vector.app.features.analytics.errors.ErrorTracker import im.vector.app.features.analytics.log.analyticsTag import io.sentry.Sentry import io.sentry.SentryOptions @@ -25,10 +26,10 @@ import io.sentry.android.core.SentryAndroid import timber.log.Timber import javax.inject.Inject -class SentryFactory @Inject constructor( +class SentryAnalytics @Inject constructor( private val context: Context, private val analyticsConfig: AnalyticsConfig, -) { +) : ErrorTracker { fun initSentry() { Timber.tag(analyticsTag.value).d("Initializing Sentry") @@ -47,4 +48,8 @@ class SentryFactory @Inject constructor( Timber.tag(analyticsTag.value).d("Stopping Sentry") Sentry.close() } + + override fun trackError(throwable: Throwable) { + Sentry.captureException(throwable) + } } 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 97e74785ec..bf9e0ae726 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 @@ -60,6 +60,7 @@ import im.vector.app.core.utils.onPermissionDeniedDialog import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.databinding.FragmentComposerBinding import im.vector.app.features.VectorFeatures +import im.vector.app.features.analytics.errors.ErrorTracker import im.vector.app.features.attachments.AttachmentType import im.vector.app.features.attachments.AttachmentTypeSelectorBottomSheet import im.vector.app.features.attachments.AttachmentTypeSelectorSharedAction @@ -116,6 +117,7 @@ class MessageComposerFragment : VectorBaseFragment(), A @Inject lateinit var vectorFeatures: VectorFeatures @Inject lateinit var buildMeta: BuildMeta @Inject lateinit var session: Session + @Inject lateinit var errorTracker: ErrorTracker private val roomId: String get() = withState(timelineViewModel) { it.roomId } @@ -171,6 +173,7 @@ class MessageComposerFragment : VectorBaseFragment(), A views.composerLayout.isGone = vectorPreferences.isRichTextEditorEnabled() views.richTextComposerLayout.isVisible = vectorPreferences.isRichTextEditorEnabled() + views.richTextComposerLayout.setOnErrorListener(errorTracker::trackError) messageComposerViewModel.observeViewEvents { when (it) { 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 48459b5c06..16234c3766 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 @@ -49,10 +49,11 @@ 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 io.element.android.wysiwyg.utils.RustErrorCollector import uniffi.wysiwyg_composer.ActionState import uniffi.wysiwyg_composer.ComposerAction -class RichTextComposerLayout @JvmOverloads constructor( +internal class RichTextComposerLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 @@ -248,10 +249,15 @@ class RichTextComposerLayout @JvmOverloads constructor( updateMenuStateFor(action, state) } } - updateEditTextVisibility() } + fun setOnErrorListener(onError: (e: RichTextEditorException) -> Unit) { + views.richTextComposerEditText.rustErrorCollector = RustErrorCollector { + onError(RichTextEditorException(it)) + } + } + private fun updateEditTextVisibility() { views.richTextComposerEditText.isVisible = isTextFormattingEnabled views.richTextMenu.isVisible = isTextFormattingEnabled diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextEditorException.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextEditorException.kt new file mode 100644 index 0000000000..9bdef59ae3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextEditorException.kt @@ -0,0 +1,21 @@ +/* + * 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 + +internal class RichTextEditorException( + cause: Throwable, +) : Exception(cause) diff --git a/vector/src/test/java/im/vector/app/features/analytics/impl/DefaultVectorAnalyticsTest.kt b/vector/src/test/java/im/vector/app/features/analytics/impl/DefaultVectorAnalyticsTest.kt index be53f1b908..3fd0528a19 100644 --- a/vector/src/test/java/im/vector/app/features/analytics/impl/DefaultVectorAnalyticsTest.kt +++ b/vector/src/test/java/im/vector/app/features/analytics/impl/DefaultVectorAnalyticsTest.kt @@ -23,7 +23,7 @@ import im.vector.app.test.fakes.FakeAnalyticsStore import im.vector.app.test.fakes.FakeLateInitUserPropertiesFactory import im.vector.app.test.fakes.FakePostHog import im.vector.app.test.fakes.FakePostHogFactory -import im.vector.app.test.fakes.FakeSentryFactory +import im.vector.app.test.fakes.FakeSentryAnalytics import im.vector.app.test.fixtures.AnalyticsConfigFixture.anAnalyticsConfig import im.vector.app.test.fixtures.aUserProperties import im.vector.app.test.fixtures.aVectorAnalyticsEvent @@ -46,11 +46,11 @@ class DefaultVectorAnalyticsTest { private val fakePostHog = FakePostHog() private val fakeAnalyticsStore = FakeAnalyticsStore() private val fakeLateInitUserPropertiesFactory = FakeLateInitUserPropertiesFactory() - private val fakeSentryFactory = FakeSentryFactory() + private val fakeSentryAnalytics = FakeSentryAnalytics() private val defaultVectorAnalytics = DefaultVectorAnalytics( postHogFactory = FakePostHogFactory(fakePostHog.instance).instance, - sentryFactory = fakeSentryFactory.instance, + sentryAnalytics = fakeSentryAnalytics.instance, analyticsStore = fakeAnalyticsStore.instance, globalScope = CoroutineScope(Dispatchers.Unconfined), analyticsConfig = anAnalyticsConfig(isEnabled = true), @@ -75,7 +75,7 @@ class DefaultVectorAnalyticsTest { fakePostHog.verifyOptOutStatus(optedOut = false) - fakeSentryFactory.verifySentryInit() + fakeSentryAnalytics.verifySentryInit() } @Test @@ -84,7 +84,7 @@ class DefaultVectorAnalyticsTest { fakePostHog.verifyOptOutStatus(optedOut = true) - fakeSentryFactory.verifySentryClose() + fakeSentryAnalytics.verifySentryClose() } @Test @@ -111,7 +111,7 @@ class DefaultVectorAnalyticsTest { fakePostHog.verifyReset() - fakeSentryFactory.verifySentryClose() + fakeSentryAnalytics.verifySentryClose() } @Test @@ -149,6 +149,25 @@ class DefaultVectorAnalyticsTest { fakePostHog.verifyNoEventTracking() } + + @Test + fun `given user has consented, when tracking exception, then submits to sentry`() = runTest { + fakeAnalyticsStore.givenUserContent(consent = true) + val exception = Exception("test") + + defaultVectorAnalytics.trackError(exception) + + fakeSentryAnalytics.verifySentryTrackError(exception) + } + + @Test + fun `given user has not consented, when tracking exception, then does not track to sentry`() = runTest { + fakeAnalyticsStore.givenUserContent(consent = false) + + defaultVectorAnalytics.trackError(Exception("test")) + + fakeSentryAnalytics.verifyNoErrorTracking() + } } private fun VectorAnalyticsScreen.toPostHogProperties(): Properties? { diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSentryFactory.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSentryAnalytics.kt similarity index 74% rename from vector/src/test/java/im/vector/app/test/fakes/FakeSentryFactory.kt rename to vector/src/test/java/im/vector/app/test/fakes/FakeSentryAnalytics.kt index 2628f80435..59f41543b0 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeSentryFactory.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSentryAnalytics.kt @@ -16,15 +16,15 @@ package im.vector.app.test.fakes -import im.vector.app.features.analytics.impl.SentryFactory +import im.vector.app.features.analytics.impl.SentryAnalytics import io.mockk.every import io.mockk.mockk import io.mockk.verify -class FakeSentryFactory { +class FakeSentryAnalytics { private var isSentryEnabled = false - val instance = mockk().also { + val instance = mockk(relaxUnitFun = true).also { every { it.initSentry() } answers { isSentryEnabled = true } @@ -41,4 +41,13 @@ class FakeSentryFactory { fun verifySentryClose() { verify { instance.stopSentry() } } + + fun verifySentryTrackError(error: Throwable) { + verify { instance.trackError(error) } + } + + fun verifyNoErrorTracking() = + verify(inverse = true) { + instance.trackError(any()) + } } From df55c841670ff0d13cf911bd80ce161479c1b33a Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Thu, 8 Dec 2022 14:00:35 +0100 Subject: [PATCH 45/85] Raise priority of incoming verification request alert + cancel existing verification alerts --- .../IncomingVerificationRequestHandler.kt | 26 ++++++------ .../vector/app/features/home/HomeActivity.kt | 40 ++++++++++++------- .../app/features/home/HomeDetailFragment.kt | 2 +- .../features/home/NewHomeDetailFragment.kt | 2 +- .../app/features/popup/PopupAlertManager.kt | 8 +++- .../vector/app/features/popup/VectorAlert.kt | 2 +- .../features/popup/VerificationVectorAlert.kt | 1 + 7 files changed, 50 insertions(+), 31 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt index 3a5c7e7eb8..c749e9578e 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt @@ -72,10 +72,11 @@ class IncomingVerificationRequestHandler @Inject constructor( val user = session.getUserOrDefault(tx.otherUserId).toMatrixItem() val name = user.getBestName() val alert = VerificationVectorAlert( - uid, - context.getString(R.string.sas_incoming_request_notif_title), - context.getString(R.string.sas_incoming_request_notif_content, name), - R.drawable.ic_shield_black, + uid = uid, + title = context.getString(R.string.sas_incoming_request_notif_title), + description = context.getString(R.string.sas_incoming_request_notif_content, name), + iconId = R.drawable.ic_shield_black, + priority = PopupAlertManager.INCOMING_VERIFICATION_REQUEST_PRIORITY, shouldBeDisplayedIn = { activity -> if (activity is VectorBaseActivity<*>) { // TODO a bit too ugly :/ @@ -85,7 +86,7 @@ class IncomingVerificationRequestHandler @Inject constructor( } } ?: true } else true - } + }, ) .apply { viewBinder = VerificationVectorAlert.ViewBinder(user, avatarRenderer.get()) @@ -130,8 +131,8 @@ class IncomingVerificationRequestHandler @Inject constructor( // if not this request will be underneath and not visible by the user... // it will re-appear later if (pr.otherUserId == session?.myUserId) { - // XXX this is a bit hard coded :/ - popupAlertManager.cancelAlert("review_login") + popupAlertManager.cancelAlert(PopupAlertManager.REVIEW_LOGIN_UID) + popupAlertManager.cancelAlert(PopupAlertManager.VERIFY_SESSION_UID) } val user = session.getUserOrDefault(pr.otherUserId).toMatrixItem() val name = user.getBestName() @@ -142,17 +143,18 @@ class IncomingVerificationRequestHandler @Inject constructor( } val alert = VerificationVectorAlert( - uniqueIdForVerificationRequest(pr), - context.getString(R.string.sas_incoming_request_notif_title), - description, - R.drawable.ic_shield_black, + uid = uniqueIdForVerificationRequest(pr), + title = context.getString(R.string.sas_incoming_request_notif_title), + description = description, + iconId = R.drawable.ic_shield_black, + priority = PopupAlertManager.INCOMING_VERIFICATION_REQUEST_PRIORITY, shouldBeDisplayedIn = { activity -> if (activity is RoomDetailActivity) { activity.intent?.extras?.getParcelableCompat(RoomDetailActivity.EXTRA_ROOM_DETAIL_ARGS)?.let { it.roomId != pr.roomId } ?: true } else true - } + }, ) .apply { viewBinder = VerificationVectorAlert.ViewBinder(user, avatarRenderer.get()) diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt index 2a3d8d094c..e08ed6db46 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt @@ -437,9 +437,10 @@ class HomeActivity : private fun handleAskPasswordToInitCrossSigning(events: HomeActivityViewEvents.AskPasswordToInitCrossSigning) { // We need to ask promptSecurityEvent( - events.userItem, - R.string.upgrade_security, - R.string.security_prompt_text + uid = PopupAlertManager.UPGRADE_SECURITY_UID, + userItem = events.userItem, + titleRes = R.string.upgrade_security, + descRes = R.string.security_prompt_text, ) { it.navigator.upgradeSessionSecurity(it, true) } @@ -448,9 +449,10 @@ class HomeActivity : private fun handleCrossSigningInvalidated(event: HomeActivityViewEvents.OnCrossSignedInvalidated) { // We need to ask promptSecurityEvent( - event.userItem, - R.string.crosssigning_verify_this_session, - R.string.confirm_your_identity + uid = PopupAlertManager.VERIFY_SESSION_UID, + userItem = event.userItem, + titleRes = R.string.crosssigning_verify_this_session, + descRes = R.string.confirm_your_identity, ) { it.navigator.waitSessionVerification(it) } @@ -459,9 +461,10 @@ class HomeActivity : private fun handleOnNewSession(event: HomeActivityViewEvents.CurrentSessionNotVerified) { // We need to ask promptSecurityEvent( - event.userItem, - R.string.crosssigning_verify_this_session, - R.string.confirm_your_identity + uid = PopupAlertManager.VERIFY_SESSION_UID, + userItem = event.userItem, + titleRes = R.string.crosssigning_verify_this_session, + descRes = R.string.confirm_your_identity, ) { if (event.waitForIncomingRequest) { it.navigator.waitSessionVerification(it) @@ -474,9 +477,10 @@ class HomeActivity : private fun handleCantVerify(event: HomeActivityViewEvents.CurrentSessionCannotBeVerified) { // We need to ask promptSecurityEvent( - event.userItem, - R.string.crosssigning_cannot_verify_this_session, - R.string.crosssigning_cannot_verify_this_session_desc + uid = PopupAlertManager.UPGRADE_SECURITY_UID, + userItem = event.userItem, + titleRes = R.string.crosssigning_cannot_verify_this_session, + descRes = R.string.crosssigning_cannot_verify_this_session_desc, ) { it.navigator.open4SSetup(it, SetupMode.PASSPHRASE_AND_NEEDED_SECRETS_RESET) } @@ -485,7 +489,7 @@ class HomeActivity : private fun handlePromptToEnablePush() { popupAlertManager.postVectorAlert( DefaultVectorAlert( - uid = "enablePush", + uid = PopupAlertManager.ENABLE_PUSH_UID, title = getString(R.string.alert_push_are_disabled_title), description = getString(R.string.alert_push_are_disabled_description), iconId = R.drawable.ic_room_actions_notifications_mutes, @@ -518,10 +522,16 @@ class HomeActivity : ) } - private fun promptSecurityEvent(userItem: MatrixItem.UserItem, titleRes: Int, descRes: Int, action: ((VectorBaseActivity<*>) -> Unit)) { + private fun promptSecurityEvent( + uid: String, + userItem: MatrixItem.UserItem, + titleRes: Int, + descRes: Int, + action: ((VectorBaseActivity<*>) -> Unit), + ) { popupAlertManager.postVectorAlert( VerificationVectorAlert( - uid = "upgradeSecurity", + uid = uid, title = getString(titleRes), description = getString(descRes), iconId = R.drawable.ic_shield_warning diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt index 69abeed424..d310f574dd 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt @@ -156,7 +156,7 @@ class HomeDetailFragment : unknownDeviceDetectorSharedViewModel.onEach { state -> state.unknownSessions.invoke()?.let { unknownDevices -> if (unknownDevices.firstOrNull()?.currentSessionTrust == true) { - val uid = "review_login" + val uid = PopupAlertManager.REVIEW_LOGIN_UID alertManager.cancelAlert(uid) val olderUnverified = unknownDevices.filter { !it.isNew } val newest = unknownDevices.firstOrNull { it.isNew }?.deviceInfo diff --git a/vector/src/main/java/im/vector/app/features/home/NewHomeDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/NewHomeDetailFragment.kt index ccd5a7e84b..3189c2b99e 100644 --- a/vector/src/main/java/im/vector/app/features/home/NewHomeDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/NewHomeDetailFragment.kt @@ -160,7 +160,7 @@ class NewHomeDetailFragment : unknownDeviceDetectorSharedViewModel.onEach { state -> state.unknownSessions.invoke()?.let { unknownDevices -> if (unknownDevices.firstOrNull()?.currentSessionTrust == true) { - val uid = "review_login" + val uid = PopupAlertManager.REVIEW_LOGIN_UID alertManager.cancelAlert(uid) val olderUnverified = unknownDevices.filter { !it.isNew } val newest = unknownDevices.firstOrNull { it.isNew }?.deviceInfo diff --git a/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt b/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt index b1327f0caf..e0310b340e 100644 --- a/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt +++ b/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt @@ -50,6 +50,12 @@ class PopupAlertManager @Inject constructor( companion object { const val INCOMING_CALL_PRIORITY = Int.MAX_VALUE + const val INCOMING_VERIFICATION_REQUEST_PRIORITY = 1 + const val DEFAULT_PRIORITY = 0 + const val REVIEW_LOGIN_UID = "review_login" + const val UPGRADE_SECURITY_UID = "upgrade_security" + const val VERIFY_SESSION_UID = "verify_session" + const val ENABLE_PUSH_UID = "enable_push" } private var weakCurrentActivity: WeakReference? = null @@ -145,7 +151,7 @@ class PopupAlertManager @Inject constructor( private fun displayNextIfPossible() { val currentActivity = weakCurrentActivity?.get() - if (Alerter.isShowing || currentActivity == null || currentActivity.isDestroyed) { + if (currentActivity == null || currentActivity.isDestroyed) { // will retry later return } diff --git a/vector/src/main/java/im/vector/app/features/popup/VectorAlert.kt b/vector/src/main/java/im/vector/app/features/popup/VectorAlert.kt index ffdba8e04d..1597d927d8 100644 --- a/vector/src/main/java/im/vector/app/features/popup/VectorAlert.kt +++ b/vector/src/main/java/im/vector/app/features/popup/VectorAlert.kt @@ -98,7 +98,7 @@ open class DefaultVectorAlert( override val dismissOnClick: Boolean = true - override val priority: Int = 0 + override val priority: Int = PopupAlertManager.DEFAULT_PRIORITY override val isLight: Boolean = false diff --git a/vector/src/main/java/im/vector/app/features/popup/VerificationVectorAlert.kt b/vector/src/main/java/im/vector/app/features/popup/VerificationVectorAlert.kt index c2ecbe04b3..818659872f 100644 --- a/vector/src/main/java/im/vector/app/features/popup/VerificationVectorAlert.kt +++ b/vector/src/main/java/im/vector/app/features/popup/VerificationVectorAlert.kt @@ -30,6 +30,7 @@ class VerificationVectorAlert( title: String, override val description: String, @DrawableRes override val iconId: Int?, + override val priority: Int = PopupAlertManager.DEFAULT_PRIORITY, /** * Alert are displayed by default, but let this lambda return false to prevent displaying. */ From 73fd93148ad012dc0f4ab60fd1b892c1a51e21d0 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 2 Dec 2022 18:14:58 +0000 Subject: [PATCH 46/85] Download device keys for self prior to verification checks Fixes https://github.com/vector-im/element-android/issues/7676 --- .../org/matrix/android/sdk/api/rendezvous/Rendezvous.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt index f724ac4b62..d421f8f994 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt @@ -35,7 +35,10 @@ import org.matrix.android.sdk.api.session.crypto.crosssigning.KEYBACKUP_SECRET_S import org.matrix.android.sdk.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME import org.matrix.android.sdk.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME import org.matrix.android.sdk.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME +import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo +import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap import org.matrix.android.sdk.api.util.MatrixJsonParser +import org.matrix.android.sdk.api.util.awaitCallback import timber.log.Timber /** @@ -147,6 +150,9 @@ class Rendezvous( val deviceKey = crypto.getMyDevice().fingerprint() send(Payload(PayloadType.PROGRESS, outcome = Outcome.SUCCESS, deviceId = deviceId, deviceKey = deviceKey)) + // explicitly download keys for ourself rather than wait for initial sync to complete + awaitCallback> { crypto.downloadKeys(listOf(userId), false, it) } + // await confirmation of verification val verificationResponse = receive() if (verificationResponse?.outcome == Outcome.VERIFIED) { From d0b2c0693de63ff69195c6bb3fac756725e8ac02 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 2 Dec 2022 18:19:02 +0000 Subject: [PATCH 47/85] Changelog --- changelog.d/7699.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7699.bugfix diff --git a/changelog.d/7699.bugfix b/changelog.d/7699.bugfix new file mode 100644 index 0000000000..30a4b8e9fa --- /dev/null +++ b/changelog.d/7699.bugfix @@ -0,0 +1 @@ +Fix E2EE set up failure whilst signing in using QR code From 3a2a916c2f17609fdfba82df08f3160e2685ec68 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Tue, 6 Dec 2022 11:34:25 +0000 Subject: [PATCH 48/85] Clarify comment --- .../java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt index d421f8f994..3364e900c6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt @@ -150,7 +150,7 @@ class Rendezvous( val deviceKey = crypto.getMyDevice().fingerprint() send(Payload(PayloadType.PROGRESS, outcome = Outcome.SUCCESS, deviceId = deviceId, deviceKey = deviceKey)) - // explicitly download keys for ourself rather than wait for initial sync to complete + // explicitly download keys for ourself rather than racing with initial sync which might not complete in time awaitCallback> { crypto.downloadKeys(listOf(userId), false, it) } // await confirmation of verification From 7bbd91f2a93adcd9ec73aea618912dda17876236 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 7 Dec 2022 15:09:43 +0000 Subject: [PATCH 49/85] Handle error whilst download key for self --- .../org/matrix/android/sdk/api/rendezvous/Rendezvous.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt index 3364e900c6..e5b2d6bf12 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt @@ -150,8 +150,13 @@ class Rendezvous( val deviceKey = crypto.getMyDevice().fingerprint() send(Payload(PayloadType.PROGRESS, outcome = Outcome.SUCCESS, deviceId = deviceId, deviceKey = deviceKey)) - // explicitly download keys for ourself rather than racing with initial sync which might not complete in time - awaitCallback> { crypto.downloadKeys(listOf(userId), false, it) } + try { + // explicitly download keys for ourself rather than racing with initial sync which might not complete in time + awaitCallback> { crypto.downloadKeys(listOf(userId), false, it) } + } catch (e: Throwable) { + // log as warning and continue as initial sync might still complete + Timber.tag(TAG).w(e, "Failed to download keys for self") + } // await confirmation of verification val verificationResponse = receive() From 63bde230a399bc24e6f33e1ab7254024170c7239 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Thu, 8 Dec 2022 14:40:17 +0100 Subject: [PATCH 50/85] Cancel verification alerts when adding the incoming request alert and when starting the process --- .../IncomingVerificationRequestHandler.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt index c749e9578e..0f8f5c633e 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt @@ -128,12 +128,9 @@ class IncomingVerificationRequestHandler @Inject constructor( // For incoming request we should prompt (if not in activity where this request apply) if (pr.isIncoming) { // if it's a self verification for my devices, we can discard the review login alert - // if not this request will be underneath and not visible by the user... + // if not, this request will be underneath and not visible by the user... // it will re-appear later - if (pr.otherUserId == session?.myUserId) { - popupAlertManager.cancelAlert(PopupAlertManager.REVIEW_LOGIN_UID) - popupAlertManager.cancelAlert(PopupAlertManager.VERIFY_SESSION_UID) - } + cancelAnyVerifySessionAlerts(pr) val user = session.getUserOrDefault(pr.otherUserId).toMatrixItem() val name = user.getBestName() val description = if (name == pr.otherUserId) { @@ -159,6 +156,7 @@ class IncomingVerificationRequestHandler @Inject constructor( .apply { viewBinder = VerificationVectorAlert.ViewBinder(user, avatarRenderer.get()) contentAction = Runnable { + cancelAnyVerifySessionAlerts(pr) (weakCurrentActivity?.get() as? VectorBaseActivity<*>)?.let { val roomId = pr.roomId if (roomId.isNullOrBlank()) { @@ -188,6 +186,13 @@ class IncomingVerificationRequestHandler @Inject constructor( } } + private fun cancelAnyVerifySessionAlerts(pr: PendingVerificationRequest) { + if (pr.otherUserId == session?.myUserId) { + popupAlertManager.cancelAlert(PopupAlertManager.REVIEW_LOGIN_UID) + popupAlertManager.cancelAlert(PopupAlertManager.VERIFY_SESSION_UID) + } + } + override fun verificationRequestUpdated(pr: PendingVerificationRequest) { // If an incoming request is readied (by another device?) we should discard the alert if (pr.isIncoming && (pr.isReady || pr.handledByOtherSession || pr.cancelConclusion != null)) { From b09a00efdaa6666464babb0ce96e1a8cc52ebaee Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Thu, 8 Dec 2022 17:11:09 +0300 Subject: [PATCH 51/85] Code review fixes. --- .../sdk/internal/session/room/RoomAPI.kt | 2 +- .../handler/UserAccountDataSyncHandler.kt | 28 ++++++++++--------- .../parsing/RoomSyncAccountDataHandler.kt | 26 +++++++++-------- .../user/accountdata/AccountDataAPI.kt | 4 +-- 4 files changed, 32 insertions(+), 28 deletions(-) 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 3cf5526a47..4e55b2c40a 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 @@ -433,7 +433,7 @@ internal interface RoomAPI { * @param roomId the room id * @param type the type */ - @DELETE(NetworkConstants.URI_API_PREFIX_PATH_MEDIA_PROXY_UNSTABLE + "org.matrix.msc3391/user/{userId}/rooms/{roomId}/account_data/{type}") + @DELETE(NetworkConstants.URI_API_PREFIX_PATH_UNSTABLE + "org.matrix.msc3391/user/{userId}/rooms/{roomId}/account_data/{type}") suspend fun deleteRoomAccountData( @Path("userId") userId: String, @Path("roomId") roomId: String, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UserAccountDataSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UserAccountDataSyncHandler.kt index fb2dfa10f6..6e8b260da8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UserAccountDataSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UserAccountDataSyncHandler.kt @@ -82,17 +82,13 @@ internal class UserAccountDataSyncHandler @Inject constructor( fun handle(realm: Realm, accountData: UserAccountDataSync?) { accountData?.list?.forEach { event -> - if (event.content.isEmpty()) { - UserAccountDataEntity.delete(realm, event.type) - } else { - // Generic handling, just save in base - handleGenericAccountData(realm, event.type, event.content) - when (event.type) { - UserAccountDataTypes.TYPE_DIRECT_MESSAGES -> handleDirectChatRooms(realm, event) - UserAccountDataTypes.TYPE_PUSH_RULES -> handlePushRules(realm, event) - UserAccountDataTypes.TYPE_IGNORED_USER_LIST -> handleIgnoredUsers(realm, event) - UserAccountDataTypes.TYPE_BREADCRUMBS -> handleBreadcrumbs(realm, event) - } + // Generic handling, just save in base + handleGenericAccountData(realm, event.type, event.content) + when (event.type) { + UserAccountDataTypes.TYPE_DIRECT_MESSAGES -> handleDirectChatRooms(realm, event) + UserAccountDataTypes.TYPE_PUSH_RULES -> handlePushRules(realm, event) + UserAccountDataTypes.TYPE_IGNORED_USER_LIST -> handleIgnoredUsers(realm, event) + UserAccountDataTypes.TYPE_BREADCRUMBS -> handleBreadcrumbs(realm, event) } } } @@ -261,8 +257,14 @@ internal class UserAccountDataSyncHandler @Inject constructor( .equalTo(UserAccountDataEntityFields.TYPE, type) .findFirst() if (existing != null) { - // Update current value - existing.contentStr = ContentMapper.map(content) + if (content.isNullOrEmpty()) { + // This is a response for a deleted account data according to + // https://github.com/ShadowJonathan/matrix-doc/blob/account-data-delete/proposals/3391-account-data-delete.md#sync + UserAccountDataEntity.delete(realm, type) + } else { + // Update current value + existing.contentStr = ContentMapper.map(content) + } } else { realm.createObject(UserAccountDataEntity::class.java).let { accountDataEntity -> accountDataEntity.type = type diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/parsing/RoomSyncAccountDataHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/parsing/RoomSyncAccountDataHandler.kt index c5f8294077..1128e46298 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/parsing/RoomSyncAccountDataHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/parsing/RoomSyncAccountDataHandler.kt @@ -45,17 +45,13 @@ internal class RoomSyncAccountDataHandler @Inject constructor( val roomEntity = RoomEntity.getOrCreate(realm, roomId) for (event in accountData.events) { val eventType = event.getClearType() - if (event.getClearContent().isNullOrEmpty()) { - roomEntity.removeAccountData(eventType) - } else { - handleGeneric(roomEntity, event.getClearContent(), eventType) - if (eventType == RoomAccountDataTypes.EVENT_TYPE_TAG) { - val content = event.getClearContent().toModel() - roomTagHandler.handle(realm, roomId, content) - } else if (eventType == RoomAccountDataTypes.EVENT_TYPE_FULLY_READ) { - val content = event.getClearContent().toModel() - roomFullyReadHandler.handle(realm, roomId, content) - } + handleGeneric(roomEntity, event.getClearContent(), eventType) + if (eventType == RoomAccountDataTypes.EVENT_TYPE_TAG) { + val content = event.getClearContent().toModel() + roomTagHandler.handle(realm, roomId, content) + } else if (eventType == RoomAccountDataTypes.EVENT_TYPE_FULLY_READ) { + val content = event.getClearContent().toModel() + roomFullyReadHandler.handle(realm, roomId, content) } } } @@ -63,7 +59,13 @@ internal class RoomSyncAccountDataHandler @Inject constructor( private fun handleGeneric(roomEntity: RoomEntity, content: JsonDict?, eventType: String) { val existing = roomEntity.accountData.where().equalTo(RoomAccountDataEntityFields.TYPE, eventType).findFirst() if (existing != null) { - existing.contentStr = ContentMapper.map(content) + if (content.isNullOrEmpty()) { + // This is a response for a deleted account data according to + // https://github.com/ShadowJonathan/matrix-doc/blob/account-data-delete/proposals/3391-account-data-delete.md#sync + roomEntity.removeAccountData(eventType) + } else { + existing.contentStr = ContentMapper.map(content) + } } else { val roomAccountData = RoomAccountDataEntity( type = eventType, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataAPI.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataAPI.kt index fd813f1fed..1b3d59ac66 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataAPI.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataAPI.kt @@ -25,7 +25,7 @@ import retrofit2.http.Path internal interface AccountDataAPI { /** - * Set some account_data for the client. + * Set some account_data for the user. * * @param userId the user id * @param type the type @@ -39,7 +39,7 @@ internal interface AccountDataAPI { ) /** - * Remove an account_data for the client. + * Remove an account_data for the user. * * @param userId the user id * @param type the type From 220b1d86c03bc9c68ad77bdbd9d9cba426fe9366 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Thu, 8 Dec 2022 17:41:29 +0100 Subject: [PATCH 52/85] Reverting usage of some stable fields whereas related MSCs have not landed into the specs yet --- .../room/location/StartLiveLocationShareTask.kt | 2 +- .../session/room/location/StopLiveLocationShareTask.kt | 2 +- .../session/room/send/LocalEchoEventFactory.kt | 10 +++++----- .../room/aggregation/poll/PollEventsTestData.kt | 6 +++--- .../DefaultGetActiveBeaconInfoForUserTaskTest.kt | 2 +- .../location/DefaultStartLiveLocationShareTaskTest.kt | 2 +- .../location/DefaultStopLiveLocationShareTaskTest.kt | 2 +- .../LiveLocationShareRedactionEventProcessorTest.kt | 2 +- .../vector/app/test/fakes/FakeCreatePollViewStates.kt | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) 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 13753115ac..dd409fe3a7 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.stable + val eventType = EventType.STATE_ROOM_BEACON_INFO.unstable 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 40f7aa2dd2..e1e6fa9d40 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.stable, + eventType = EventType.STATE_ROOM_BEACON_INFO.unstable, body = updatedContent ) return try { 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 2f8be69473..8be6b26249 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.stable, + type = EventType.POLL_START.unstable, 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.stable, + type = EventType.POLL_RESPONSE.unstable, 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.stable, + type = EventType.POLL_START.unstable, 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.stable, + type = EventType.POLL_END.unstable, 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.stable, + type = EventType.BEACON_LOCATION_DATA.unstable, 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 bdd1fd9b0d..e38b51132d 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.stable, + type = EventType.POLL_START.unstable, 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.stable, + type = EventType.POLL_RESPONSE.unstable, 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.stable, + type = EventType.POLL_END.unstable, 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 4a10795647..6f416a6bc1 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.stable, + eventType = EventType.STATE_ROOM_BEACON_INFO.unstable, 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 a5c126cf72..3156287774 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.stable, + eventType = EventType.STATE_ROOM_BEACON_INFO.unstable, 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 a7adadfc63..03c6f525e0 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.stable, + eventType = EventType.STATE_ROOM_BEACON_INFO.unstable, 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 d6edb69d93..8dc7a5c9bc 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.stable) + val redactedEventEntity = EventEntity(eventId = A_REDACTED_EVENT_ID, type = EventType.STATE_ROOM_BEACON_INFO.unstable) fakeRealm.givenWhere() .givenEqualTo(EventEntityFields.EVENT_ID, A_REDACTED_EVENT_ID) .givenFindFirst(redactedEventEntity) 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 42a500671b..3be1f5c643 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.stable, + type = EventType.POLL_START.unstable, eventId = A_FAKE_EVENT_ID, originServerTs = 1652435922563, senderId = A_FAKE_USER_ID, From 99942c271451f1586c599c0232b078aa90026747 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Fri, 9 Dec 2022 09:33:06 +0100 Subject: [PATCH 53/85] Adding changelog entry --- changelog.d/7751.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7751.bugfix diff --git a/changelog.d/7751.bugfix b/changelog.d/7751.bugfix new file mode 100644 index 0000000000..5d676dbc4d --- /dev/null +++ b/changelog.d/7751.bugfix @@ -0,0 +1 @@ +Revert usage of stable fields in live location sharing and polls From cf59c80100d7a4b9ec02f62a43a6350e88fe2e0a Mon Sep 17 00:00:00 2001 From: Nikita Fedrunov <66663241+fedrunov@users.noreply.github.com> Date: Fri, 9 Dec 2022 09:42:45 +0100 Subject: [PATCH 54/85] stop listening timeline collection changes when app is not resumed (#7734) --- changelog.d/7643.bugfix | 1 + .../vector/app/features/home/room/detail/TimelineFragment.kt | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 changelog.d/7643.bugfix diff --git a/changelog.d/7643.bugfix b/changelog.d/7643.bugfix new file mode 100644 index 0000000000..66e3f28d5f --- /dev/null +++ b/changelog.d/7643.bugfix @@ -0,0 +1 @@ +[Notifications] Fixed a bug when push notification was automatically dismissed while app is on background 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 b73d443832..6ab20275c2 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 @@ -975,6 +975,7 @@ class TimelineFragment : notificationDrawerManager.setCurrentThread(timelineArgs.threadTimelineArgs?.rootThreadEventId) roomDetailPendingActionStore.data?.let { handlePendingAction(it) } roomDetailPendingActionStore.data = null + views.timelineRecyclerView.adapter = timelineEventController.adapter } private fun handlePendingAction(roomDetailPendingAction: RoomDetailPendingAction) { @@ -993,6 +994,7 @@ class TimelineFragment : super.onPause() notificationDrawerManager.setCurrentRoom(null) notificationDrawerManager.setCurrentThread(null) + views.timelineRecyclerView.adapter = null } private val emojiActivityResultLauncher = registerStartForActivityResult { activityResult -> @@ -1058,7 +1060,6 @@ class TimelineFragment : it.dispatchTo(scrollOnHighlightedEventCallback) } timelineEventController.addModelBuildListener(modelBuildListener) - views.timelineRecyclerView.adapter = timelineEventController.adapter if (vectorPreferences.swipeToReplyIsEnabled()) { val quickReplyHandler = object : RoomMessageTouchHelperCallback.QuickReplayHandler { From 57cedaeb6924f7df3939ebdd4c7e74d6af0e66cb Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Fri, 9 Dec 2022 10:10:59 +0100 Subject: [PATCH 55/85] Adding changelog entry --- changelog.d/7753.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7753.bugfix diff --git a/changelog.d/7753.bugfix b/changelog.d/7753.bugfix new file mode 100644 index 0000000000..10579b6a84 --- /dev/null +++ b/changelog.d/7753.bugfix @@ -0,0 +1 @@ +[Poll] Poll end event is not recognized From 3d68233723f67d08a6c6bbd819bed5d12d4c5cca Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Fri, 9 Dec 2022 14:51:23 +0300 Subject: [PATCH 56/85] Support retrieving account data whose key starts with a string. --- .../accountdata/SessionAccountDataService.kt | 13 +++++++++++++ .../user/accountdata/UserAccountDataDataSource.kt | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/accountdata/SessionAccountDataService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/accountdata/SessionAccountDataService.kt index a22dd33774..8addb0782e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/accountdata/SessionAccountDataService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/accountdata/SessionAccountDataService.kt @@ -63,4 +63,17 @@ interface SessionAccountDataService { * Update the account data with the provided type and the provided account data content. */ suspend fun updateUserAccountData(type: String, content: Content) + + /** + * Retrieve user account data list whose type starts with the given type. + * @param type the type or the starting part of a type + * @return list of account data whose type starts with the given type + */ + fun getUserAccountDataEventsStartWith(type: String): List + + /** + * Deletes user account data of the given type. + * @param type the type to delete from user account data + */ + suspend fun deleteUserAccountData(type: String) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/UserAccountDataDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/UserAccountDataDataSource.kt index 39f155096a..2e66f4513b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/UserAccountDataDataSource.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/UserAccountDataDataSource.kt @@ -60,6 +60,16 @@ internal class UserAccountDataDataSource @Inject constructor( ) } + fun getAccountDataEventsStartWith(type: String): List { + return realmSessionProvider.withRealm { realm -> + realm + .where(UserAccountDataEntity::class.java) + .contains(UserAccountDataEntityFields.TYPE, type) + .findAll() + .map(accountDataMapper::map) + } + } + private fun accountDataEventsQuery(realm: Realm, types: Set): RealmQuery { val query = realm.where(UserAccountDataEntity::class.java) if (types.isNotEmpty()) { From 8206b534f9d132a02318436549b05b59bd3853d3 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Fri, 9 Dec 2022 14:52:27 +0300 Subject: [PATCH 57/85] Create a task to delete an event data with a given type. --- .../user/accountdata/AccountDataModule.kt | 3 ++ .../DefaultSessionAccountDataService.kt | 11 ++++- .../accountdata/DeleteUserAccountDataTask.kt | 43 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DeleteUserAccountDataTask.kt diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataModule.kt index 3173686a27..463292b9c6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataModule.kt @@ -42,4 +42,7 @@ internal abstract class AccountDataModule { @Binds abstract fun bindUpdateBreadcrumbsTask(task: DefaultUpdateBreadcrumbsTask): UpdateBreadcrumbsTask + + @Binds + abstract fun bindDeleteUserAccountDataTask(task: DefaultDeleteUserAccountDataTask): DeleteUserAccountDataTask } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultSessionAccountDataService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultSessionAccountDataService.kt index c73446cf25..304a586a79 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultSessionAccountDataService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultSessionAccountDataService.kt @@ -34,10 +34,11 @@ import javax.inject.Inject internal class DefaultSessionAccountDataService @Inject constructor( @SessionDatabase private val monarchy: Monarchy, private val updateUserAccountDataTask: UpdateUserAccountDataTask, + private val deleteUserAccountDataTask: DeleteUserAccountDataTask, private val userAccountDataSyncHandler: UserAccountDataSyncHandler, private val userAccountDataDataSource: UserAccountDataDataSource, private val roomAccountDataDataSource: RoomAccountDataDataSource, - private val taskExecutor: TaskExecutor + private val taskExecutor: TaskExecutor, ) : SessionAccountDataService { override fun getUserAccountDataEvent(type: String): UserAccountDataEvent? { @@ -78,4 +79,12 @@ internal class DefaultSessionAccountDataService @Inject constructor( userAccountDataSyncHandler.handleGenericAccountData(realm, type, content) } } + + override fun getUserAccountDataEventsStartWith(type: String): List { + return userAccountDataDataSource.getAccountDataEventsStartWith(type) + } + + override suspend fun deleteUserAccountData(type: String) { + deleteUserAccountDataTask.execute(DeleteUserAccountDataTask.Params(type)) + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DeleteUserAccountDataTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DeleteUserAccountDataTask.kt new file mode 100644 index 0000000000..8d155e32cb --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DeleteUserAccountDataTask.kt @@ -0,0 +1,43 @@ +/* + * 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.user.accountdata + +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.network.GlobalErrorReceiver +import org.matrix.android.sdk.internal.network.executeRequest +import org.matrix.android.sdk.internal.task.Task +import javax.inject.Inject + +internal interface DeleteUserAccountDataTask : Task { + + data class Params( + val type: String, + ) +} + +internal class DefaultDeleteUserAccountDataTask @Inject constructor( + private val accountDataApi: AccountDataAPI, + @UserId private val userId: String, + private val globalErrorReceiver: GlobalErrorReceiver, +) : DeleteUserAccountDataTask { + + override suspend fun execute(params: DeleteUserAccountDataTask.Params) { + return executeRequest(globalErrorReceiver) { + accountDataApi.deleteAccountData(userId, params.type) + } + } +} From 22cce30e353523b4a52085a6201a592fc5a259b2 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Fri, 9 Dec 2022 14:53:27 +0300 Subject: [PATCH 58/85] Create use case to detect and delete unnecessary account data of client information. --- .../DeleteUnusedClientInformationUseCase.kt | 39 +++++++++++++++++++ .../settings/devices/v2/DevicesViewModel.kt | 10 +++++ 2 files changed, 49 insertions(+) create mode 100644 vector/src/main/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCase.kt diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCase.kt new file mode 100644 index 0000000000..ae77cf7493 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCase.kt @@ -0,0 +1,39 @@ +/* + * 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.core.di.ActiveSessionHolder +import im.vector.app.core.session.clientinfo.MATRIX_CLIENT_INFO_KEY_PREFIX +import javax.inject.Inject + +class DeleteUnusedClientInformationUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, +) { + + suspend fun execute(deviceFullInfoList: List) { + val expectedClientInfoKeyList = deviceFullInfoList.map { MATRIX_CLIENT_INFO_KEY_PREFIX + it.deviceInfo.deviceId } + activeSessionHolder + .getSafeActiveSession() + ?.accountDataService() + ?.getUserAccountDataEventsStartWith(MATRIX_CLIENT_INFO_KEY_PREFIX) + ?.map { it.type } + ?.subtract(expectedClientInfoKeyList.toSet()) + ?.forEach { userAccountDataKeyToDelete -> + activeSessionHolder.getSafeActiveSession()?.accountDataService()?.deleteUserAccountData(userAccountDataKeyToDelete) + } + } +} 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 b7a6c5df30..d8aeefa377 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 @@ -51,6 +51,7 @@ class DevicesViewModel @AssistedInject constructor( refreshDevicesUseCase: RefreshDevicesUseCase, private val vectorPreferences: VectorPreferences, private val toggleIpAddressVisibilityUseCase: ToggleIpAddressVisibilityUseCase, + private val deleteUnusedClientInformationUseCase: DeleteUnusedClientInformationUseCase, ) : VectorSessionsListViewModel(initialState, activeSessionHolder, refreshDevicesUseCase), @@ -112,6 +113,9 @@ class DevicesViewModel @AssistedInject constructor( val deviceFullInfoList = async.invoke() val unverifiedSessionsCount = deviceFullInfoList.count { !it.cryptoDeviceInfo?.trustLevel?.isCrossSigningVerified().orFalse() } val inactiveSessionsCount = deviceFullInfoList.count { it.isInactive } + + deleteUnusedClientInformation(deviceFullInfoList) + copy( devices = async, unverifiedSessionsCount = unverifiedSessionsCount, @@ -125,6 +129,12 @@ class DevicesViewModel @AssistedInject constructor( } } + private fun deleteUnusedClientInformation(deviceFullInfoList: List) { + viewModelScope.launch { + deleteUnusedClientInformationUseCase.execute(deviceFullInfoList) + } + } + private fun refreshDevicesOnCryptoDevicesChange() { viewModelScope.launch { refreshDevicesOnCryptoDevicesChangeUseCase.execute() From 7a667b513e8f21ca1a58ed58fd6f717dfa52bd67 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Fri, 9 Dec 2022 15:47:28 +0300 Subject: [PATCH 59/85] Execute use case from a better place. --- .../home/UnknownDeviceDetectorSharedViewModel.kt | 12 ++++++++++++ .../v2/DeleteUnusedClientInformationUseCase.kt | 5 +++-- .../features/settings/devices/v2/DevicesViewModel.kt | 9 --------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt b/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt index 21c7bd6ea1..040ffa7b3f 100644 --- a/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt @@ -32,11 +32,13 @@ import im.vector.app.core.platform.EmptyViewEvents import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.core.time.Clock +import im.vector.app.features.settings.devices.v2.DeleteUnusedClientInformationUseCase import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch import org.matrix.android.sdk.api.NoOpMatrixCallback import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.Session @@ -66,6 +68,7 @@ class UnknownDeviceDetectorSharedViewModel @AssistedInject constructor( private val setUnverifiedSessionsAlertShownUseCase: SetUnverifiedSessionsAlertShownUseCase, private val isNewLoginAlertShownUseCase: IsNewLoginAlertShownUseCase, private val setNewLoginAlertShownUseCase: SetNewLoginAlertShownUseCase, + private val deleteUnusedClientInformationUseCase: DeleteUnusedClientInformationUseCase, ) : VectorViewModel(initialState) { sealed class Action : VectorViewModelAction { @@ -102,6 +105,9 @@ class UnknownDeviceDetectorSharedViewModel @AssistedInject constructor( ) { cryptoList, infoList, pInfo -> // Timber.v("## Detector trigger ${cryptoList.map { "${it.deviceId} ${it.trustLevel}" }}") // Timber.v("## Detector trigger canCrossSign ${pInfo.get().selfSigned != null}") + + deleteUnusedClientInformation(infoList) + infoList .filter { info -> // filter verified session, by checking the crypto device info @@ -143,6 +149,12 @@ class UnknownDeviceDetectorSharedViewModel @AssistedInject constructor( session.cryptoService().fetchDevicesList(NoOpMatrixCallback()) } + private fun deleteUnusedClientInformation(deviceFullInfoList: List) { + viewModelScope.launch { + deleteUnusedClientInformationUseCase.execute(deviceFullInfoList) + } + } + override fun handle(action: Action) { when (action) { is Action.IgnoreDevice -> { diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCase.kt index ae77cf7493..9cbca9664a 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCase.kt @@ -18,14 +18,15 @@ package im.vector.app.features.settings.devices.v2 import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.session.clientinfo.MATRIX_CLIENT_INFO_KEY_PREFIX +import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo import javax.inject.Inject class DeleteUnusedClientInformationUseCase @Inject constructor( private val activeSessionHolder: ActiveSessionHolder, ) { - suspend fun execute(deviceFullInfoList: List) { - val expectedClientInfoKeyList = deviceFullInfoList.map { MATRIX_CLIENT_INFO_KEY_PREFIX + it.deviceInfo.deviceId } + suspend fun execute(deviceInfoList: List) { + val expectedClientInfoKeyList = deviceInfoList.map { MATRIX_CLIENT_INFO_KEY_PREFIX + it.deviceId } activeSessionHolder .getSafeActiveSession() ?.accountDataService() 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 d8aeefa377..232fcd50f7 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 @@ -51,7 +51,6 @@ class DevicesViewModel @AssistedInject constructor( refreshDevicesUseCase: RefreshDevicesUseCase, private val vectorPreferences: VectorPreferences, private val toggleIpAddressVisibilityUseCase: ToggleIpAddressVisibilityUseCase, - private val deleteUnusedClientInformationUseCase: DeleteUnusedClientInformationUseCase, ) : VectorSessionsListViewModel(initialState, activeSessionHolder, refreshDevicesUseCase), @@ -114,8 +113,6 @@ class DevicesViewModel @AssistedInject constructor( val unverifiedSessionsCount = deviceFullInfoList.count { !it.cryptoDeviceInfo?.trustLevel?.isCrossSigningVerified().orFalse() } val inactiveSessionsCount = deviceFullInfoList.count { it.isInactive } - deleteUnusedClientInformation(deviceFullInfoList) - copy( devices = async, unverifiedSessionsCount = unverifiedSessionsCount, @@ -129,12 +126,6 @@ class DevicesViewModel @AssistedInject constructor( } } - private fun deleteUnusedClientInformation(deviceFullInfoList: List) { - viewModelScope.launch { - deleteUnusedClientInformationUseCase.execute(deviceFullInfoList) - } - } - private fun refreshDevicesOnCryptoDevicesChange() { viewModelScope.launch { refreshDevicesOnCryptoDevicesChangeUseCase.execute() From bd91db66f8897955682b4f80eed5aa1b2cee06dc Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Fri, 9 Dec 2022 14:07:06 +0100 Subject: [PATCH 60/85] Fixing retrieve of related event id in the end poll event during aggregation --- .../room/aggregation/poll/DefaultPollAggregationProcessor.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt index 90d8e02c39..10c43e3b7f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt @@ -156,7 +156,7 @@ class DefaultPollAggregationProcessor @Inject constructor() : PollAggregationPro override fun handlePollEndEvent(session: Session, powerLevelsHelper: PowerLevelsHelper, realm: Realm, event: Event): Boolean { val content = event.getClearContent()?.toModel() ?: return false val roomId = event.roomId ?: return false - val pollEventId = content.relatesTo?.eventId ?: return false + val pollEventId = (event.getRelationContent() ?: content.relatesTo)?.eventId ?: return false val pollOwnerId = getPollEvent(session, roomId, pollEventId)?.root?.senderId val isPollOwner = pollOwnerId == event.senderId From 85a6c8c6f26bdb4bd61e9ca664e3f0be09125fd5 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Fri, 9 Dec 2022 19:53:20 +0300 Subject: [PATCH 61/85] Write unit tests for the use case. --- .../DeleteUnusedClientInformationUseCase.kt | 3 + ...eleteUnusedClientInformationUseCaseTest.kt | 137 ++++++++++++++++++ .../fakes/FakeSessionAccountDataService.kt | 4 + 3 files changed, 144 insertions(+) create mode 100644 vector/src/test/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCaseTest.kt diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCase.kt index 9cbca9664a..d98e839b4f 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCase.kt @@ -26,6 +26,9 @@ class DeleteUnusedClientInformationUseCase @Inject constructor( ) { suspend fun execute(deviceInfoList: List) { + // A defensive approach against local storage reports an empty device list (although it is not a seen situation). + if (deviceInfoList.isEmpty()) return + val expectedClientInfoKeyList = deviceInfoList.map { MATRIX_CLIENT_INFO_KEY_PREFIX + it.deviceId } activeSessionHolder .getSafeActiveSession() diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCaseTest.kt new file mode 100644 index 0000000000..68c9204208 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCaseTest.kt @@ -0,0 +1,137 @@ +/* + * 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.core.session.clientinfo.MATRIX_CLIENT_INFO_KEY_PREFIX +import im.vector.app.test.fakes.FakeActiveSessionHolder +import io.mockk.coVerify +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.matrix.android.sdk.api.session.accountdata.UserAccountDataEvent +import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo + +private const val A_CURRENT_DEVICE_ID = "current-device-id" +private const val A_DEVICE_ID_1 = "a-device-id-1" +private const val A_DEVICE_ID_2 = "a-device-id-2" +private const val A_DEVICE_ID_3 = "a-device-id-3" +private const val A_DEVICE_ID_4 = "a-device-id-4" + +private val A_DEVICE_INFO_1 = DeviceInfo(deviceId = A_DEVICE_ID_1) +private val A_DEVICE_INFO_2 = DeviceInfo(deviceId = A_DEVICE_ID_2) + +class DeleteUnusedClientInformationUseCaseTest { + + private val fakeActiveSessionHolder = FakeActiveSessionHolder() + + private val deleteUnusedClientInformationUseCase = DeleteUnusedClientInformationUseCase( + activeSessionHolder = fakeActiveSessionHolder.instance, + ) + + @Before + fun setup() { + fakeActiveSessionHolder.fakeSession.givenSessionId(A_CURRENT_DEVICE_ID) + } + + @Test + fun `given a device list that account data has all of them and extra devices then use case deletes the unused ones`() = runTest { + // Given + val devices = listOf(A_DEVICE_INFO_1, A_DEVICE_INFO_2) + val userAccountDataEventList = listOf( + UserAccountDataEvent(type = MATRIX_CLIENT_INFO_KEY_PREFIX + A_DEVICE_ID_1, mapOf("key" to "value")), + UserAccountDataEvent(type = MATRIX_CLIENT_INFO_KEY_PREFIX + A_DEVICE_ID_2, mapOf("key" to "value")), + UserAccountDataEvent(type = MATRIX_CLIENT_INFO_KEY_PREFIX + A_DEVICE_ID_3, mapOf("key" to "value")), + UserAccountDataEvent(type = MATRIX_CLIENT_INFO_KEY_PREFIX + A_DEVICE_ID_4, mapOf("key" to "value")), + ) + fakeActiveSessionHolder.fakeSession.fakeSessionAccountDataService.givenGetUserAccountDataEventsStartWith( + type = MATRIX_CLIENT_INFO_KEY_PREFIX, + userAccountDataEventList = userAccountDataEventList, + ) + + // When + deleteUnusedClientInformationUseCase.execute(devices) + + // Then + coVerify { fakeActiveSessionHolder.fakeSession.fakeSessionAccountDataService.deleteUserAccountData(MATRIX_CLIENT_INFO_KEY_PREFIX + A_DEVICE_ID_3) } + coVerify { fakeActiveSessionHolder.fakeSession.fakeSessionAccountDataService.deleteUserAccountData(MATRIX_CLIENT_INFO_KEY_PREFIX + A_DEVICE_ID_4) } + } + + @Test + fun `given a device list that account data has exactly all of them then use case does nothing`() = runTest { + // Given + val devices = listOf(A_DEVICE_INFO_1, A_DEVICE_INFO_2) + val userAccountDataEventList = listOf( + UserAccountDataEvent(type = MATRIX_CLIENT_INFO_KEY_PREFIX + A_DEVICE_ID_1, mapOf("key" to "value")), + UserAccountDataEvent(type = MATRIX_CLIENT_INFO_KEY_PREFIX + A_DEVICE_ID_2, mapOf("key" to "value")), + ) + fakeActiveSessionHolder.fakeSession.fakeSessionAccountDataService.givenGetUserAccountDataEventsStartWith( + type = MATRIX_CLIENT_INFO_KEY_PREFIX, + userAccountDataEventList = userAccountDataEventList, + ) + + // When + deleteUnusedClientInformationUseCase.execute(devices) + + // Then + coVerify(exactly = 0) { + fakeActiveSessionHolder.fakeSession.fakeSessionAccountDataService.deleteUserAccountData(any()) + } + } + + @Test + fun `given a device list that account data has missing some of them then use case does nothing`() = runTest { + // Given + val devices = listOf(A_DEVICE_INFO_1, A_DEVICE_INFO_2) + val userAccountDataEventList = listOf( + UserAccountDataEvent(type = MATRIX_CLIENT_INFO_KEY_PREFIX + A_DEVICE_ID_1, mapOf("key" to "value")), + ) + fakeActiveSessionHolder.fakeSession.fakeSessionAccountDataService.givenGetUserAccountDataEventsStartWith( + type = MATRIX_CLIENT_INFO_KEY_PREFIX, + userAccountDataEventList = userAccountDataEventList, + ) + + // When + deleteUnusedClientInformationUseCase.execute(devices) + + // Then + coVerify(exactly = 0) { + fakeActiveSessionHolder.fakeSession.fakeSessionAccountDataService.deleteUserAccountData(any()) + } + } + + @Test + fun `given an empty device list that account data has some devices then use case does nothing`() = runTest { + // Given + val devices = emptyList() + val userAccountDataEventList = listOf( + UserAccountDataEvent(type = MATRIX_CLIENT_INFO_KEY_PREFIX + A_DEVICE_ID_1, mapOf("key" to "value")), + UserAccountDataEvent(type = MATRIX_CLIENT_INFO_KEY_PREFIX + A_DEVICE_ID_2, mapOf("key" to "value")), + ) + fakeActiveSessionHolder.fakeSession.fakeSessionAccountDataService.givenGetUserAccountDataEventsStartWith( + type = MATRIX_CLIENT_INFO_KEY_PREFIX, + userAccountDataEventList = userAccountDataEventList, + ) + + // When + deleteUnusedClientInformationUseCase.execute(devices) + + // Then + coVerify(exactly = 0) { + fakeActiveSessionHolder.fakeSession.fakeSessionAccountDataService.deleteUserAccountData(any()) + } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSessionAccountDataService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSessionAccountDataService.kt index f1a0ae7452..cd357ec85a 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeSessionAccountDataService.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSessionAccountDataService.kt @@ -54,4 +54,8 @@ class FakeSessionAccountDataService : SessionAccountDataService by mockk(relaxed fun verifyUpdateUserAccountDataEventSucceeds(type: String, content: Content, inverse: Boolean = false) { coVerify(inverse = inverse) { updateUserAccountData(type, content) } } + + fun givenGetUserAccountDataEventsStartWith(type: String, userAccountDataEventList: List) { + every { getUserAccountDataEventsStartWith(type) } returns userAccountDataEventList + } } From 74d7e60380caf3a625cd47cf67ef150e1664c821 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Dec 2022 09:21:24 +0100 Subject: [PATCH 62/85] Bump fragment from 1.5.4 to 1.5.5 (#7741) Bumps `fragment` from 1.5.4 to 1.5.5. Updates `fragment-ktx` from 1.5.4 to 1.5.5 Updates `fragment-testing` from 1.5.4 to 1.5.5 --- updated-dependencies: - dependency-name: androidx.fragment:fragment-ktx dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: androidx.fragment:fragment-testing 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 a9aee3b681..dbb5f5fe05 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -27,7 +27,7 @@ def jjwt = "0.11.5" // the whole commit which set version 0.16.0-SNAPSHOT def vanniktechEmoji = "0.16.0-SNAPSHOT" def sentry = "6.9.0" -def fragment = "1.5.4" +def fragment = "1.5.5" // Testing def mockk = "1.12.3" // We need to use 1.12.3 to have mocking in androidTest until a new version is released: https://github.com/mockk/mockk/issues/819 def espresso = "3.4.0" From 746fb7719a1224926d7b97eb2e5acdc164771379 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Mon, 12 Dec 2022 13:39:56 +0300 Subject: [PATCH 63/85] Code review fixes. --- .../sync/handler/UserAccountDataSyncHandler.kt | 18 ++++++++++-------- .../sync/parsing/RoomSyncAccountDataHandler.kt | 15 ++++++++------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UserAccountDataSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UserAccountDataSyncHandler.kt index 6e8b260da8..92ebb41ad9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UserAccountDataSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UserAccountDataSyncHandler.kt @@ -253,18 +253,20 @@ internal class UserAccountDataSyncHandler @Inject constructor( } fun handleGenericAccountData(realm: Realm, type: String, content: Content?) { + if (content.isNullOrEmpty()) { + // This is a response for a deleted account data according to + // https://github.com/ShadowJonathan/matrix-doc/blob/account-data-delete/proposals/3391-account-data-delete.md#sync + UserAccountDataEntity.delete(realm, type) + return + } + val existing = realm.where() .equalTo(UserAccountDataEntityFields.TYPE, type) .findFirst() + if (existing != null) { - if (content.isNullOrEmpty()) { - // This is a response for a deleted account data according to - // https://github.com/ShadowJonathan/matrix-doc/blob/account-data-delete/proposals/3391-account-data-delete.md#sync - UserAccountDataEntity.delete(realm, type) - } else { - // Update current value - existing.contentStr = ContentMapper.map(content) - } + // Update current value + existing.contentStr = ContentMapper.map(content) } else { realm.createObject(UserAccountDataEntity::class.java).let { accountDataEntity -> accountDataEntity.type = type diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/parsing/RoomSyncAccountDataHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/parsing/RoomSyncAccountDataHandler.kt index 1128e46298..9da12a2c4a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/parsing/RoomSyncAccountDataHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/parsing/RoomSyncAccountDataHandler.kt @@ -57,15 +57,16 @@ internal class RoomSyncAccountDataHandler @Inject constructor( } private fun handleGeneric(roomEntity: RoomEntity, content: JsonDict?, eventType: String) { + if (content.isNullOrEmpty()) { + // This is a response for a deleted account data according to + // https://github.com/ShadowJonathan/matrix-doc/blob/account-data-delete/proposals/3391-account-data-delete.md#sync + roomEntity.removeAccountData(eventType) + return + } + val existing = roomEntity.accountData.where().equalTo(RoomAccountDataEntityFields.TYPE, eventType).findFirst() if (existing != null) { - if (content.isNullOrEmpty()) { - // This is a response for a deleted account data according to - // https://github.com/ShadowJonathan/matrix-doc/blob/account-data-delete/proposals/3391-account-data-delete.md#sync - roomEntity.removeAccountData(eventType) - } else { - existing.contentStr = ContentMapper.map(content) - } + existing.contentStr = ContentMapper.map(content) } else { val roomAccountData = RoomAccountDataEntity( type = eventType, From a12167077f378bf0aecbe4e1e4b793457b2902b9 Mon Sep 17 00:00:00 2001 From: Ekaterina Gerasimova Date: Fri, 9 Dec 2022 12:19:43 +0000 Subject: [PATCH 64/85] Update project board IDs for automation "PN-" prefixed IDs are no longer working, update to new IDs --- .github/workflows/triage-labelled.yml | 16 ++++++++-------- .../workflows/triage-move-review-requests.yml | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml index 41cd274b93..036bc069ac 100644 --- a/.github/workflows/triage-labelled.yml +++ b/.github/workflows/triage-labelled.yml @@ -89,7 +89,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.issue.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc0sUA" + PROJECT_ID: "PVT_kwDOAM0swc0sUA" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} add_product_issues: @@ -113,7 +113,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.issue.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc4AAg6N" + PROJECT_ID: "PVT_kwDOAM0swc4AAg6N" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} delight_issues_to_board: @@ -139,7 +139,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.issue.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc1HvQ" + PROJECT_ID: "PVT_kwDOAM0swc1HvQ" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} move_voice-message_issues: @@ -164,7 +164,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.issue.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc2KCw" + PROJECT_ID: "PVT_kwDOAM0swc2KCw" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} move_message_bubbles_issues: name: A-Message-Bubbles to Message bubbles board @@ -188,7 +188,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.issue.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc3m-g" + PROJECT_ID: "PVT_kwDOAM0swc3m-g" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} move_ftue_issues: @@ -213,7 +213,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.issue.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc4AAqVx" + PROJECT_ID: "PVT_kwDOAM0swc4AAqVx" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} move_WTF_issues: @@ -238,7 +238,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.issue.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc4AArk0" + PROJECT_ID: "PVT_kwDOAM0swc4AArk0" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} move_element_x_issues: @@ -268,7 +268,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.issue.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc4ABTXY" + PROJECT_ID: "PVT_kwDOAM0swc4ABTXY" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} ps_features1: diff --git a/.github/workflows/triage-move-review-requests.yml b/.github/workflows/triage-move-review-requests.yml index 6aeba66ccc..f604b82873 100644 --- a/.github/workflows/triage-move-review-requests.yml +++ b/.github/workflows/triage-move-review-requests.yml @@ -69,7 +69,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.pull_request.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc0sUA" + PROJECT_ID: "PVT_kwDOAM0swc0sUA" TEAM: "design" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} @@ -138,6 +138,6 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.pull_request.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc4AAg6N" + PROJECT_ID: "PVT_kwDOAM0swc4AAg6N" TEAM: "product" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} From c523e144b816d313e356c2ab1a1a7db4ff99ba30 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Mon, 12 Dec 2022 13:52:17 +0100 Subject: [PATCH 65/85] Rich text editor: improve performance when changing composer mode (#7691) * Rich text editor: improve performance when changing composer mode * Add changelog * Make `MessageComposerMode.Quote` and `Reply` data classes * Re-arrange code to fix composer not being emptied when sneding a message --- changelog.d/7691.bugfix | 1 + .../composer/MessageComposerFragment.kt | 2 +- .../detail/composer/MessageComposerMode.kt | 4 +-- .../detail/composer/RichTextComposerLayout.kt | 25 ++++++++++++++----- 4 files changed, 23 insertions(+), 9 deletions(-) create mode 100644 changelog.d/7691.bugfix diff --git a/changelog.d/7691.bugfix b/changelog.d/7691.bugfix new file mode 100644 index 0000000000..0298819143 --- /dev/null +++ b/changelog.d/7691.bugfix @@ -0,0 +1 @@ +Rich Text Editor: improve performance when entering reply/edit/quote mode. 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 bf9e0ae726..d56ea8b733 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 @@ -285,7 +285,7 @@ class MessageComposerFragment : VectorBaseFragment(), A else -> return } - (composer as? RichTextComposerLayout)?.setFullScreen(setFullScreen) + (composer as? RichTextComposerLayout)?.setFullScreen(setFullScreen, true) messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(setFullScreen)) } 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 index a401f04bf5..89cb148639 100644 --- 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 @@ -23,6 +23,6 @@ sealed interface 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) + data class Quote(override val event: TimelineEvent, override val defaultContent: CharSequence) : Special(event, defaultContent) + data 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/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt index 16234c3766..d69fe8edeb 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 @@ -66,6 +66,7 @@ internal class RichTextComposerLayout @JvmOverloads constructor( // 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 + private var composerMode: MessageComposerMode? = null var isTextFormattingEnabled = true set(value) { @@ -114,9 +115,15 @@ internal class RichTextComposerLayout @JvmOverloads constructor( private val dimensionConverter = DimensionConverter(resources) - fun setFullScreen(isFullScreen: Boolean) { + fun setFullScreen(isFullScreen: Boolean, animated: Boolean) { + if (!animated && views.composerLayout.layoutParams != null) { + views.composerLayout.updateLayoutParams { + height = + if (isFullScreen) ViewGroup.LayoutParams.MATCH_PARENT else ViewGroup.LayoutParams.WRAP_CONTENT + } + } editText.updateLayoutParams { - height = if (isFullScreen) 0 else ViewGroup.LayoutParams.WRAP_CONTENT + height = if (isFullScreen) ViewGroup.LayoutParams.MATCH_PARENT else ViewGroup.LayoutParams.WRAP_CONTENT } updateTextFieldBorder(isFullScreen) @@ -371,7 +378,11 @@ internal class RichTextComposerLayout @JvmOverloads constructor( override fun renderComposerMode(mode: MessageComposerMode) { if (mode is MessageComposerMode.Special) { views.composerModeGroup.isVisible = true - replaceFormattedContent(mode.defaultContent) + if (isTextFormattingEnabled) { + replaceFormattedContent(mode.defaultContent) + } else { + views.plainTextComposerEditText.setText(mode.defaultContent) + } hasRelatedMessage = true editText.showKeyboard(andRequestFocus = true) } else { @@ -383,10 +394,14 @@ internal class RichTextComposerLayout @JvmOverloads constructor( views.plainTextComposerEditText.setText(text) } } - views.sendButton.contentDescription = resources.getString(R.string.action_send) hasRelatedMessage = false } + updateTextFieldBorder(isFullScreen) + + if (this.composerMode == mode) return + this.composerMode = mode + views.sendButton.apply { if (mode is MessageComposerMode.Edit) { contentDescription = resources.getString(R.string.action_save) @@ -397,8 +412,6 @@ internal class RichTextComposerLayout @JvmOverloads constructor( } } - updateTextFieldBorder(isFullScreen) - when (mode) { is MessageComposerMode.Edit -> { views.composerModeTitleView.setText(R.string.editing) From 8c6c2dd5c2f5ed1777f8da128c82adddf268a589 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Mon, 12 Dec 2022 16:36:40 +0300 Subject: [PATCH 66/85] Code review fixes. --- changelog.d/7754.feature | 1 + .../accountdata/UserAccountDataDataSource.kt | 2 +- .../DefaultDeleteUserAccountDataTaskTest.kt | 53 +++++++++++++++++++ .../sdk/test/fakes/FakeAccountDataApi.kt | 32 +++++++++++ .../DeleteUnusedClientInformationUseCase.kt | 7 ++- .../UnknownDeviceDetectorSharedViewModel.kt | 2 +- ...eleteUnusedClientInformationUseCaseTest.kt | 3 +- 7 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 changelog.d/7754.feature create mode 100644 matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultDeleteUserAccountDataTaskTest.kt create mode 100644 matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeAccountDataApi.kt rename vector/src/main/java/im/vector/app/{features/settings/devices/v2 => core/session/clientinfo}/DeleteUnusedClientInformationUseCase.kt (87%) rename vector/src/test/java/im/vector/app/{features/settings/devices/v2 => core/session/clientinfo}/DeleteUnusedClientInformationUseCaseTest.kt (97%) diff --git a/changelog.d/7754.feature b/changelog.d/7754.feature new file mode 100644 index 0000000000..0e1b6d0961 --- /dev/null +++ b/changelog.d/7754.feature @@ -0,0 +1 @@ +Delete unused client information from account data diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/UserAccountDataDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/UserAccountDataDataSource.kt index 2e66f4513b..01f5d9f708 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/UserAccountDataDataSource.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/UserAccountDataDataSource.kt @@ -64,7 +64,7 @@ internal class UserAccountDataDataSource @Inject constructor( return realmSessionProvider.withRealm { realm -> realm .where(UserAccountDataEntity::class.java) - .contains(UserAccountDataEntityFields.TYPE, type) + .beginsWith(UserAccountDataEntityFields.TYPE, type) .findAll() .map(accountDataMapper::map) } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultDeleteUserAccountDataTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultDeleteUserAccountDataTaskTest.kt new file mode 100644 index 0000000000..86580127dc --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultDeleteUserAccountDataTaskTest.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.internal.session.user.accountdata + +import io.mockk.coVerify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.android.sdk.test.fakes.FakeAccountDataApi +import org.matrix.android.sdk.test.fakes.FakeGlobalErrorReceiver + +private const val A_TYPE = "a-type" +private const val A_USER_ID = "a-user-id" + +@ExperimentalCoroutinesApi +class DefaultDeleteUserAccountDataTaskTest { + + private val fakeGlobalErrorReceiver = FakeGlobalErrorReceiver() + private val fakeAccountDataApi = FakeAccountDataApi() + + private val deleteUserAccountDataTask = DefaultDeleteUserAccountDataTask( + accountDataApi = fakeAccountDataApi.instance, + userId = A_USER_ID, + globalErrorReceiver = fakeGlobalErrorReceiver + ) + + @Test + fun `given parameters when executing the task then api is called`() = runTest { + // Given + val params = DeleteUserAccountDataTask.Params(type = A_TYPE) + fakeAccountDataApi.givenParamsToDeleteAccountData(A_USER_ID, A_TYPE) + + // When + deleteUserAccountDataTask.execute(params) + + // Then + coVerify { fakeAccountDataApi.instance.deleteAccountData(A_USER_ID, A_TYPE) } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeAccountDataApi.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeAccountDataApi.kt new file mode 100644 index 0000000000..f3acc02458 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeAccountDataApi.kt @@ -0,0 +1,32 @@ +/* + * 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.just +import io.mockk.mockk +import io.mockk.runs +import org.matrix.android.sdk.internal.session.user.accountdata.AccountDataAPI + +internal class FakeAccountDataApi { + + val instance: AccountDataAPI = mockk() + + fun givenParamsToDeleteAccountData(userId: String, type: String) { + coEvery { instance.deleteAccountData(userId, type) } just runs + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCase.kt b/vector/src/main/java/im/vector/app/core/session/clientinfo/DeleteUnusedClientInformationUseCase.kt similarity index 87% rename from vector/src/main/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCase.kt rename to vector/src/main/java/im/vector/app/core/session/clientinfo/DeleteUnusedClientInformationUseCase.kt index d98e839b4f..dcd5c58480 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCase.kt +++ b/vector/src/main/java/im/vector/app/core/session/clientinfo/DeleteUnusedClientInformationUseCase.kt @@ -14,10 +14,9 @@ * limitations under the License. */ -package im.vector.app.features.settings.devices.v2 +package im.vector.app.core.session.clientinfo import im.vector.app.core.di.ActiveSessionHolder -import im.vector.app.core.session.clientinfo.MATRIX_CLIENT_INFO_KEY_PREFIX import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo import javax.inject.Inject @@ -25,9 +24,9 @@ class DeleteUnusedClientInformationUseCase @Inject constructor( private val activeSessionHolder: ActiveSessionHolder, ) { - suspend fun execute(deviceInfoList: List) { + suspend fun execute(deviceInfoList: List): Result = runCatching { // A defensive approach against local storage reports an empty device list (although it is not a seen situation). - if (deviceInfoList.isEmpty()) return + if (deviceInfoList.isEmpty()) return Result.success(Unit) val expectedClientInfoKeyList = deviceInfoList.map { MATRIX_CLIENT_INFO_KEY_PREFIX + it.deviceId } activeSessionHolder diff --git a/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt b/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt index 040ffa7b3f..de9e496ee1 100644 --- a/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt @@ -31,8 +31,8 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.EmptyViewEvents import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModelAction +import im.vector.app.core.session.clientinfo.DeleteUnusedClientInformationUseCase import im.vector.app.core.time.Clock -import im.vector.app.features.settings.devices.v2.DeleteUnusedClientInformationUseCase import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCaseTest.kt b/vector/src/test/java/im/vector/app/core/session/clientinfo/DeleteUnusedClientInformationUseCaseTest.kt similarity index 97% rename from vector/src/test/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCaseTest.kt rename to vector/src/test/java/im/vector/app/core/session/clientinfo/DeleteUnusedClientInformationUseCaseTest.kt index 68c9204208..8acb7b404b 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DeleteUnusedClientInformationUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/core/session/clientinfo/DeleteUnusedClientInformationUseCaseTest.kt @@ -14,9 +14,8 @@ * limitations under the License. */ -package im.vector.app.features.settings.devices.v2 +package im.vector.app.core.session.clientinfo -import im.vector.app.core.session.clientinfo.MATRIX_CLIENT_INFO_KEY_PREFIX import im.vector.app.test.fakes.FakeActiveSessionHolder import io.mockk.coVerify import kotlinx.coroutines.test.runTest From 1930047ce18c1aafa6ea334713d6e733987cac9d Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 7 Dec 2022 15:15:18 +0000 Subject: [PATCH 67/85] Fix issue of QR not being offered where domain is entered instead of homeserver --- .../sdk/api/auth/AuthenticationService.kt | 6 ------ .../sdk/api/auth/data/LoginFlowResult.kt | 3 ++- .../auth/DefaultAuthenticationService.kt | 17 ++--------------- .../features/onboarding/OnboardingViewModel.kt | 11 ++++++----- .../features/onboarding/OnboardingViewState.kt | 1 + .../StartAuthenticationFlowUseCase.kt | 3 ++- 6 files changed, 13 insertions(+), 28 deletions(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt index 252c33a8c4..e490311b91 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt @@ -125,12 +125,6 @@ interface AuthenticationService { deviceId: String? = null ): Session - /** - * @param homeServerConnectionConfig the information about the homeserver and other configuration - * Return true if qr code login is supported by the server, false otherwise. - */ - suspend fun isQrLoginSupported(homeServerConnectionConfig: HomeServerConnectionConfig): Boolean - /** * Authenticate using m.login.token method during sign in with QR code. * @param homeServerConnectionConfig the information about the homeserver and other configuration diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt index 5b6c1897bf..5de83033e1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt @@ -22,5 +22,6 @@ data class LoginFlowResult( val isLoginAndRegistrationSupported: Boolean, val homeServerUrl: String, val isOutdatedHomeserver: Boolean, - val isLogoutDevicesSupported: Boolean + val isLogoutDevicesSupported: Boolean, + val isLoginWithQrSupported: Boolean, ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt index 5449c0a735..6556c3a9b3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt @@ -299,7 +299,8 @@ internal class DefaultAuthenticationService @Inject constructor( isLoginAndRegistrationSupported = versions.isLoginAndRegistrationSupportedBySdk(), homeServerUrl = homeServerUrl, isOutdatedHomeserver = !versions.isSupportedBySdk(), - isLogoutDevicesSupported = versions.doesServerSupportLogoutDevices() + isLogoutDevicesSupported = versions.doesServerSupportLogoutDevices(), + isLoginWithQrSupported = versions.doesServerSupportQrCodeLogin(), ) } @@ -408,20 +409,6 @@ internal class DefaultAuthenticationService @Inject constructor( ) } - override suspend fun isQrLoginSupported(homeServerConnectionConfig: HomeServerConnectionConfig): Boolean { - val authAPI = buildAuthAPI(homeServerConnectionConfig) - val versions = runCatching { - executeRequest(null) { - authAPI.versions() - } - } - return if (versions.isSuccess) { - versions.getOrNull()?.doesServerSupportQrCodeLogin().orFalse() - } else { - false - } - } - override suspend fun loginUsingQrLoginToken( homeServerConnectionConfig: HomeServerConnectionConfig, loginToken: String, diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt index 7fe73f8087..b096455611 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt @@ -118,7 +118,7 @@ class OnboardingViewModel @AssistedInject constructor( } } - private suspend fun checkQrCodeLoginCapability(config: HomeServerConnectionConfig) { + private fun checkQrCodeLoginCapability() { if (!vectorFeatures.isQrCodeLoginEnabled()) { setState { copy( @@ -133,11 +133,9 @@ class OnboardingViewModel @AssistedInject constructor( ) } } else { - // check if selected server supports MSC3882 first - val canLoginWithQrCode = authenticationService.isQrLoginSupported(config) setState { copy( - canLoginWithQrCode = canLoginWithQrCode + canLoginWithQrCode = selectedHomeserver.isLoginWithQrSupported ) } } @@ -705,7 +703,10 @@ class OnboardingViewModel @AssistedInject constructor( // This is invalid _viewEvents.post(OnboardingViewEvents.Failure(Throwable("Unable to create a HomeServerConnectionConfig"))) } else { - startAuthenticationFlow(action, homeServerConnectionConfig, serverTypeOverride, postAction) + startAuthenticationFlow(action, homeServerConnectionConfig, serverTypeOverride, suspend { + checkQrCodeLoginCapability() + postAction() + }) } } diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt index 6e7d58338e..ea0d940952 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt @@ -76,6 +76,7 @@ data class SelectedHomeserverState( val preferredLoginMode: LoginMode = LoginMode.Unknown, val supportedLoginTypes: List = emptyList(), val isLogoutDevicesSupported: Boolean = false, + val isLoginWithQrSupported: Boolean = false, ) : Parcelable @Parcelize diff --git a/vector/src/main/java/im/vector/app/features/onboarding/StartAuthenticationFlowUseCase.kt b/vector/src/main/java/im/vector/app/features/onboarding/StartAuthenticationFlowUseCase.kt index db21a53854..9b8f0a1cc4 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/StartAuthenticationFlowUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/StartAuthenticationFlowUseCase.kt @@ -47,7 +47,8 @@ class StartAuthenticationFlowUseCase @Inject constructor( upstreamUrl = authFlow.homeServerUrl, preferredLoginMode = preferredLoginMode, supportedLoginTypes = authFlow.supportedLoginTypes, - isLogoutDevicesSupported = authFlow.isLogoutDevicesSupported + isLogoutDevicesSupported = authFlow.isLogoutDevicesSupported, + isLoginWithQrSupported = authFlow.isLoginWithQrSupported, ) private fun LoginFlowResult.findPreferredLoginMode() = when { From 21cbe527400b627739e87b6efbfcf73826b6bf9d Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 7 Dec 2022 15:38:25 +0000 Subject: [PATCH 68/85] Lint --- .../android/sdk/internal/auth/DefaultAuthenticationService.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt index 6556c3a9b3..d9c2afcb40 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt @@ -30,7 +30,6 @@ import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.auth.login.LoginWizard import org.matrix.android.sdk.api.auth.registration.RegistrationWizard import org.matrix.android.sdk.api.auth.wellknown.WellknownResult -import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.MatrixIdFailure import org.matrix.android.sdk.api.session.Session From 006e2b5c0d1c0a9cc418a6657eab410f3ed8b16d Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 7 Dec 2022 15:46:47 +0000 Subject: [PATCH 69/85] Changelog --- changelog.d/7737.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7737.bugfix diff --git a/changelog.d/7737.bugfix b/changelog.d/7737.bugfix new file mode 100644 index 0000000000..1477834674 --- /dev/null +++ b/changelog.d/7737.bugfix @@ -0,0 +1 @@ +Fix issue of Scan QR code button sometimes not showing when it should be available From 1437f6d41d0ad75e58b5f31b26c6815ba1b0f655 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 9 Dec 2022 16:59:49 +0000 Subject: [PATCH 70/85] Remove unused bad function call --- .../im/vector/app/features/onboarding/OnboardingViewModel.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt index b096455611..04487c6198 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt @@ -765,8 +765,6 @@ class OnboardingViewModel @AssistedInject constructor( _viewEvents.post(OnboardingViewEvents.OutdatedHomeserver) } - checkQrCodeLoginCapability(config) - when (trigger) { is OnboardingAction.HomeServerChange.SelectHomeServer -> { onHomeServerSelected(config, serverTypeOverride, authResult) From 643b09a77c9ae9c0ef5b2bdbd4abf16e12414ad1 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 12 Dec 2022 11:12:44 +0000 Subject: [PATCH 71/85] Fix up unit tests --- .../features/onboarding/StartAuthenticationFlowUseCaseTest.kt | 3 ++- .../im/vector/app/test/fakes/FakeAuthenticationService.kt | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/vector/src/test/java/im/vector/app/features/onboarding/StartAuthenticationFlowUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/onboarding/StartAuthenticationFlowUseCaseTest.kt index d15a6cf042..9be22d7ea9 100644 --- a/vector/src/test/java/im/vector/app/features/onboarding/StartAuthenticationFlowUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/onboarding/StartAuthenticationFlowUseCaseTest.kt @@ -140,7 +140,8 @@ class StartAuthenticationFlowUseCaseTest { isLoginAndRegistrationSupported = true, homeServerUrl = A_DECLARED_HOMESERVER_URL, isOutdatedHomeserver = false, - isLogoutDevicesSupported = false + isLogoutDevicesSupported = false, + isLoginWithQrSupported = false ) private fun expectedResult( diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeAuthenticationService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeAuthenticationService.kt index 5d0e317c57..af53913169 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeAuthenticationService.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeAuthenticationService.kt @@ -58,10 +58,6 @@ class FakeAuthenticationService : AuthenticationService by mockk() { coEvery { getWellKnownData(matrixId, config) } returns result } - fun givenIsQrLoginSupported(config: HomeServerConnectionConfig, result: Boolean) { - coEvery { isQrLoginSupported(config) } returns result - } - fun givenWellKnownThrows(matrixId: String, config: HomeServerConnectionConfig?, cause: Throwable) { coEvery { getWellKnownData(matrixId, config) } throws cause } From 096e52612e94ce672e7f972f0dceca4eab05be6d Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 12 Dec 2022 11:18:20 +0000 Subject: [PATCH 72/85] More fix up of unit tests --- .../vector/app/features/onboarding/OnboardingViewModelTest.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt b/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt index 92083eb50b..f41a27ba7d 100644 --- a/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt @@ -1180,7 +1180,6 @@ class OnboardingViewModelTest { fakeStartAuthenticationFlowUseCase.givenResult(config, StartAuthenticationResult(isHomeserverOutdated = false, resultingState)) givenRegistrationResultFor(RegisterAction.StartRegistration, RegistrationActionHandler.Result.StartRegistration) fakeHomeServerHistoryService.expectUrlToBeAdded(config.homeServerUri.toString()) - fakeAuthenticationService.givenIsQrLoginSupported(config, canLoginWithQrCode) } private fun givenUpdatingHomeserverErrors(homeserverUrl: String, resultingState: SelectedHomeserverState, error: Throwable) { @@ -1188,7 +1187,6 @@ class OnboardingViewModelTest { fakeStartAuthenticationFlowUseCase.givenResult(A_HOMESERVER_CONFIG, StartAuthenticationResult(isHomeserverOutdated = false, resultingState)) givenRegistrationResultFor(RegisterAction.StartRegistration, RegistrationActionHandler.Result.Error(error)) fakeHomeServerHistoryService.expectUrlToBeAdded(A_HOMESERVER_CONFIG.homeServerUri.toString()) - fakeAuthenticationService.givenIsQrLoginSupported(A_HOMESERVER_CONFIG, false) } private fun givenUserNameIsAvailable(userName: String) { From f111a84e1745125024c4b7281ec3160874d0aef4 Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 12 Dec 2022 14:07:01 +0000 Subject: [PATCH 73/85] More unit test fix --- .../vector/app/features/onboarding/OnboardingViewModelTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt b/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt index f41a27ba7d..2649b82f72 100644 --- a/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt @@ -164,7 +164,7 @@ class OnboardingViewModelTest { fun `given combined login enabled, when handling sign in splash action, then emits OpenCombinedLogin with default homeserver qrCode supported`() = runTest { val test = viewModel.test() fakeVectorFeatures.givenCombinedLoginEnabled() - givenCanSuccessfullyUpdateHomeserver(A_DEFAULT_HOMESERVER_URL, DEFAULT_SELECTED_HOMESERVER_STATE, canLoginWithQrCode = true) + givenCanSuccessfullyUpdateHomeserver(A_DEFAULT_HOMESERVER_URL, DEFAULT_SELECTED_HOMESERVER_STATE) viewModel.handle(OnboardingAction.SplashAction.OnIAlreadyHaveAnAccount(OnboardingFlow.SignIn)) @@ -1174,7 +1174,6 @@ class OnboardingViewModelTest { resultingState: SelectedHomeserverState, config: HomeServerConnectionConfig = A_HOMESERVER_CONFIG, fingerprint: Fingerprint? = null, - canLoginWithQrCode: Boolean = false, ) { fakeHomeServerConnectionConfigFactory.givenConfigFor(homeserverUrl, fingerprint, config) fakeStartAuthenticationFlowUseCase.givenResult(config, StartAuthenticationResult(isHomeserverOutdated = false, resultingState)) From 0ffc2af679e9a1980c74cad83489d5a7d08cbcff Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 12 Dec 2022 17:32:28 +0000 Subject: [PATCH 74/85] Update test to work with new state --- .../app/features/onboarding/OnboardingViewModelTest.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt b/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt index 2649b82f72..c570a75d99 100644 --- a/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt @@ -83,6 +83,7 @@ private val A_HOMESERVER_CONFIG = HomeServerConnectionConfig(FakeUri().instance) private val SELECTED_HOMESERVER_STATE = SelectedHomeserverState(preferredLoginMode = LoginMode.Password, userFacingUrl = A_HOMESERVER_URL) private val SELECTED_HOMESERVER_STATE_SUPPORTED_LOGOUT_DEVICES = SelectedHomeserverState(isLogoutDevicesSupported = true) private val DEFAULT_SELECTED_HOMESERVER_STATE = SELECTED_HOMESERVER_STATE.copy(userFacingUrl = A_DEFAULT_HOMESERVER_URL) +private val DEFAULT_SELECTED_HOMESERVER_STATE_WITH_QR_SUPPORTED = DEFAULT_SELECTED_HOMESERVER_STATE.copy(isLoginWithQrSupported = true) private const val AN_EMAIL = "hello@example.com" private const val A_PASSWORD = "a-password" private const val A_USERNAME = "hello-world" @@ -164,7 +165,7 @@ class OnboardingViewModelTest { fun `given combined login enabled, when handling sign in splash action, then emits OpenCombinedLogin with default homeserver qrCode supported`() = runTest { val test = viewModel.test() fakeVectorFeatures.givenCombinedLoginEnabled() - givenCanSuccessfullyUpdateHomeserver(A_DEFAULT_HOMESERVER_URL, DEFAULT_SELECTED_HOMESERVER_STATE) + givenCanSuccessfullyUpdateHomeserver(A_DEFAULT_HOMESERVER_URL, DEFAULT_SELECTED_HOMESERVER_STATE_WITH_QR_SUPPORTED) viewModel.handle(OnboardingAction.SplashAction.OnIAlreadyHaveAnAccount(OnboardingFlow.SignIn)) @@ -173,9 +174,9 @@ class OnboardingViewModelTest { initialState, { copy(onboardingFlow = OnboardingFlow.SignIn) }, { copy(isLoading = true) }, - { copy(canLoginWithQrCode = true) }, - { copy(selectedHomeserver = DEFAULT_SELECTED_HOMESERVER_STATE) }, + { copy(selectedHomeserver = DEFAULT_SELECTED_HOMESERVER_STATE_WITH_QR_SUPPORTED) }, { copy(signMode = SignMode.SignIn) }, + { copy(canLoginWithQrCode = true) }, { copy(isLoading = false) } ) .assertEvents(OnboardingViewEvents.OpenCombinedLogin) From 4e0c3a97bdd45603ede10b11bd928c79f771bf58 Mon Sep 17 00:00:00 2001 From: Nikita Fedrunov <66663241+fedrunov@users.noreply.github.com> Date: Mon, 12 Dec 2022 22:35:09 +0100 Subject: [PATCH 75/85] thread message notification should navigate to thread timeline (#7771) --- changelog.d/7770.bugfix | 1 + .../home/room/threads/ThreadsActivity.kt | 12 ++++- .../notifications/NotificationUtils.kt | 52 +++++++++++++++++-- .../notifications/RoomGroupMessageCreator.kt | 9 ++-- 4 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 changelog.d/7770.bugfix diff --git a/changelog.d/7770.bugfix b/changelog.d/7770.bugfix new file mode 100644 index 0000000000..598deb6073 --- /dev/null +++ b/changelog.d/7770.bugfix @@ -0,0 +1 @@ +[Push Notifications] When push notification for threaded message is clicked, thread timeline will be opened instead of room's main timeline diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt index b3f2ef1f75..014b9f0504 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt @@ -26,6 +26,7 @@ import im.vector.app.core.extensions.addFragmentToBackstack import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivityThreadsBinding +import im.vector.app.features.MainActivity import im.vector.app.features.analytics.extensions.toAnalyticsInteraction import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.home.AvatarRenderer @@ -143,13 +144,20 @@ class ThreadsActivity : VectorBaseActivity() { context: Context, threadTimelineArgs: ThreadTimelineArgs?, threadListArgs: ThreadListArgs?, - eventIdToNavigate: String? = null + eventIdToNavigate: String? = null, + firstStartMainActivity: Boolean = false ): Intent { - return Intent(context, ThreadsActivity::class.java).apply { + val intent = Intent(context, ThreadsActivity::class.java).apply { putExtra(THREAD_TIMELINE_ARGS, threadTimelineArgs) putExtra(THREAD_EVENT_ID_TO_NAVIGATE, eventIdToNavigate) putExtra(THREAD_LIST_ARGS, threadListArgs) } + + return if (firstStartMainActivity) { + MainActivity.getIntentWithNextIntent(context, intent) + } else { + intent + } } } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt index bf1b23093d..7bf78bdb95 100755 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt @@ -60,6 +60,8 @@ import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.HomeActivity import im.vector.app.features.home.room.detail.RoomDetailActivity import im.vector.app.features.home.room.detail.arguments.TimelineArgs +import im.vector.app.features.home.room.threads.ThreadsActivity +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.troubleshoot.TestNotificationReceiver import im.vector.app.features.themes.ThemeUtils @@ -574,6 +576,7 @@ class NotificationUtils @Inject constructor( fun buildMessagesListNotification( messageStyle: NotificationCompat.MessagingStyle, roomInfo: RoomEventGroupInfo, + threadId: String?, largeIcon: Bitmap?, lastMessageTimestamp: Long, senderDisplayNameForReplyCompat: String?, @@ -581,7 +584,11 @@ class NotificationUtils @Inject constructor( ): Notification { val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) // Build the pending intent for when the notification is clicked - val openRoomIntent = buildOpenRoomIntent(roomInfo.roomId) + val openIntent = when { + threadId != null && vectorPreferences.areThreadMessagesEnabled() -> buildOpenThreadIntent(roomInfo, threadId) + else -> buildOpenRoomIntent(roomInfo.roomId) + } + val smallIcon = R.drawable.ic_notification val channelID = if (roomInfo.shouldBing) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID @@ -666,8 +673,8 @@ class NotificationUtils @Inject constructor( } } - if (openRoomIntent != null) { - setContentIntent(openRoomIntent) + if (openIntent != null) { + setContentIntent(openIntent) } if (largeIcon != null) { @@ -826,6 +833,45 @@ class NotificationUtils @Inject constructor( ) } + private fun buildOpenThreadIntent(roomInfo: RoomEventGroupInfo, threadId: String?): PendingIntent? { + val threadTimelineArgs = ThreadTimelineArgs( + startsThread = false, + roomId = roomInfo.roomId, + rootThreadEventId = threadId, + showKeyboard = false, + displayName = roomInfo.roomDisplayName, + avatarUrl = null, + roomEncryptionTrustLevel = null, + ) + val threadIntentTap = ThreadsActivity.newIntent( + context = context, + threadTimelineArgs = threadTimelineArgs, + threadListArgs = null, + firstStartMainActivity = true, + ) + threadIntentTap.action = actionIds.tapToView + // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that + threadIntentTap.data = createIgnoredUri("openThread?$threadId") + + val roomIntent = RoomDetailActivity.newIntent( + context = context, + timelineArgs = TimelineArgs( + roomId = roomInfo.roomId, + switchToParentSpace = true + ), + firstStartMainActivity = false + ) + // Recreate the back stack + return TaskStackBuilder.create(context) + .addNextIntentWithParentStack(HomeActivity.newIntent(context, firstStartMainActivity = false)) + .addNextIntentWithParentStack(roomIntent) + .addNextIntent(threadIntentTap) + .getPendingIntent( + clock.epochMillis().toInt(), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + } + private fun buildOpenHomePendingIntentForSummary(): PendingIntent { val intent = HomeActivity.newIntent(context, firstStartMainActivity = false, clearNotification = true) intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP diff --git a/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt b/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt index 7fdfa3535e..767f427f39 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt @@ -33,14 +33,14 @@ class RoomGroupMessageCreator @Inject constructor( ) { fun createRoomMessage(events: List, roomId: String, userDisplayName: String, userAvatarUrl: String?): RoomNotification.Message { - val firstKnownRoomEvent = events[0] - val roomName = firstKnownRoomEvent.roomName ?: firstKnownRoomEvent.senderName ?: "" - val roomIsGroup = !firstKnownRoomEvent.roomIsDirect + val lastKnownRoomEvent = events.last() + val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: "" + val roomIsGroup = !lastKnownRoomEvent.roomIsDirect val style = NotificationCompat.MessagingStyle( Person.Builder() .setName(userDisplayName) .setIcon(bitmapLoader.getUserIcon(userAvatarUrl)) - .setKey(firstKnownRoomEvent.matrixID) + .setKey(lastKnownRoomEvent.matrixID) .build() ).also { it.conversationTitle = roomName.takeIf { roomIsGroup } @@ -75,6 +75,7 @@ class RoomGroupMessageCreator @Inject constructor( it.customSound = events.last().soundName it.isUpdated = events.last().isUpdated }, + threadId = lastKnownRoomEvent.threadId, largeIcon = largeBitmap, lastMessageTimestamp, userDisplayName, From d05e10e10a952bfc1cdee8cdeab49585caa46ca0 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 13 Dec 2022 11:38:49 +0100 Subject: [PATCH 76/85] crypto migration tests (#7645) Crypto migration tests Co-authored-by: Benoit Marty --- .gitattributes | 1 + changelog.d/7645.misc | 1 + docs/database_migration_test.md | 55 +++++++++++++++ .../androidTest/assets/crypto_store_20.realm | 3 + .../src/androidTest/assets/session_42.realm | Bin 270336 -> 131 bytes .../database/CryptoSanityMigrationTest.kt | 65 ++++++++++++++++++ 6 files changed, 125 insertions(+) create mode 100644 changelog.d/7645.misc create mode 100644 docs/database_migration_test.md create mode 100644 matrix-sdk-android/src/androidTest/assets/crypto_store_20.realm create mode 100644 matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/CryptoSanityMigrationTest.kt diff --git a/.gitattributes b/.gitattributes index 0542767eff..b44f3fab1b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ **/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text +**/src/androidTest/assets/*.realm filter=lfs diff=lfs merge=lfs -text diff --git a/changelog.d/7645.misc b/changelog.d/7645.misc new file mode 100644 index 0000000000..a133581ac1 --- /dev/null +++ b/changelog.d/7645.misc @@ -0,0 +1 @@ +Crypto database migration tests diff --git a/docs/database_migration_test.md b/docs/database_migration_test.md new file mode 100644 index 0000000000..f7844abde8 --- /dev/null +++ b/docs/database_migration_test.md @@ -0,0 +1,55 @@ + + +* [Testing database migration](#testing-database-migration) + * [Creating a reference database](#creating-a-reference-database) + * [Testing](#testing) + + + +## Testing database migration + +### Creating a reference database + +Databases are encrypted, the key to decrypt is needed to setup the test. +A special build property must be enabled to extract it. + +Set `vector.debugPrivateData=true` in `~/.gradle/gradle.properties` (to avoid committing by mistake) + +Launch the app in your emulator, login and use the app to fill up the database. + +Save the key for the tested database +``` +RealmKeysUtils W Database key for alias `session_db_fe9f212a611ccf6dea1141777065ed0a`: 935a6dfa0b0fc5cce1414194ed190.... +RealmKeysUtils W Database key for alias `crypto_module_fe9f212a611ccf6dea1141777065ed0a`: 7b9a21a8a311e85d75b069a343..... +``` + + +Use the [Device File Explorer](https://developer.android.com/studio/debug/device-file-explorer) to extrat the database file from the emulator. + +Go to `data/data/im.vector.app.debug/files//` +Pick the database you want to test (name can be found in SessionRealmConfigurationFactory): + - crypto_store.realm for crypto + - disk_store.realm for session + - etc... + +Download the file on your disk + +### Testing + +Copy the file in `src/AndroidTest/assets` + +see `CryptoSanityMigrationTest` or `RealmSessionStoreMigration43Test` for sample tests. + +There are already some databases in the assets folder. +The existing test will properly detect schema changes, and fail with such errors if a migration is missing: + +``` +io.realm.exceptions.RealmMigrationNeededException: Migration is required due to the following errors: +- Property 'CryptoMetadataEntity.foo' has been added. +``` + +If you want to test properly more complex database migration (dynamic transforms) ensure that the database contains +the entity you want to migrate. + +You can explore the database with [realm studio](https://www.mongodb.com/docs/realm/studio/) if needed. + diff --git a/matrix-sdk-android/src/androidTest/assets/crypto_store_20.realm b/matrix-sdk-android/src/androidTest/assets/crypto_store_20.realm new file mode 100644 index 0000000000..cfdd2e6da6 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/assets/crypto_store_20.realm @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7acd69f37612bab0a1ab7f456656712d7ba19dbb679f81b97b58ef44e239f42 +size 8523776 diff --git a/matrix-sdk-android/src/androidTest/assets/session_42.realm b/matrix-sdk-android/src/androidTest/assets/session_42.realm index b92d13dab2fa6d44a38ee3443ae8cad85340db23..92681116994c0fc88108bb9b54d22b86f5b260bb 100644 GIT binary patch literal 131 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

~n*FN$6ddX2RG}!itYe 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%0DTX_=9PRTAUr6@kN4E#1dFf1 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 L00KbZ|0M7~<4-Vn diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/CryptoSanityMigrationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/CryptoSanityMigrationTest.kt new file mode 100644 index 0000000000..2643bf643a --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/CryptoSanityMigrationTest.kt @@ -0,0 +1,65 @@ +/* + * 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 org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.matrix.android.sdk.internal.crypto.store.db.RealmCryptoStoreMigration +import org.matrix.android.sdk.internal.crypto.store.db.RealmCryptoStoreModule +import org.matrix.android.sdk.internal.util.time.Clock + +class CryptoSanityMigrationTest { + @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 cryptoDatabaseShouldMigrateGracefully() { + val realmName = "crypto_store_20.realm" + val migration = RealmCryptoStoreMigration(object : Clock { + override fun epochMillis(): Long { + return 0L + } + }) + val realmConfiguration = configurationFactory.createConfiguration( + realmName, + "7b9a21a8a311e85d75b069a343c23fc952fc3fec5e0c83ecfa13f24b787479c487c3ed587db3dd1f5805d52041fc0ac246516e94b27ffa699ff928622e621aca", + RealmCryptoStoreModule(), + migration.schemaVersion, + migration + ) + configurationFactory.copyRealmFromAssets(context, realmName, realmName) + + realm = Realm.getInstance(realmConfiguration) + } +} From 4657729e3619c61d7767e0bbd78f6a12e2926410 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Dec 2022 12:41:17 +0000 Subject: [PATCH 77/85] Bump dependency-check-gradle from 7.3.0 to 7.4.1 (#7759) Bumps dependency-check-gradle from 7.3.0 to 7.4.1. --- updated-dependencies: - dependency-name: org.owasp:dependency-check-gradle 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 2abb2a9072..0f94fc418c 100644 --- a/build.gradle +++ b/build.gradle @@ -29,7 +29,7 @@ buildscript { classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.5.0.2730' classpath 'com.google.android.gms:oss-licenses-plugin:0.10.5' classpath "com.likethesalad.android:stem-plugin:2.2.3" - classpath 'org.owasp:dependency-check-gradle:7.3.0' + classpath 'org.owasp:dependency-check-gradle:7.4.1' classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.7.20" classpath "org.jetbrains.kotlinx:kotlinx-knit:0.4.0" classpath 'com.jakewharton:butterknife-gradle-plugin:10.2.3' From 3dadebe505d52a86ab1b1c71d3969ac35a77cf2e Mon Sep 17 00:00:00 2001 From: Nikita Fedrunov <66663241+fedrunov@users.noreply.github.com> Date: Tue, 13 Dec 2022 14:02:45 +0100 Subject: [PATCH 78/85] threads are enabled by default end forced to enabled for existing users (#7775) --- changelog.d/5503.misc | 1 + .../android/sdk/api/MatrixConfiguration.kt | 2 +- .../src/main/res/values/config-settings.xml | 2 +- .../features/home/HomeActivityViewModel.kt | 6 ++++++ .../features/settings/VectorPreferences.kt | 19 +++++++++++++++++++ .../labs/VectorSettingsLabsFragment.kt | 1 + 6 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 changelog.d/5503.misc diff --git a/changelog.d/5503.misc b/changelog.d/5503.misc new file mode 100644 index 0000000000..66deb33684 --- /dev/null +++ b/changelog.d/5503.misc @@ -0,0 +1 @@ +[Threads] - Threads Labs Flag is enabled by default and forced to be enabled for existing users, but sill can be disabled manually diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt index 00d74ab446..8c2296accb 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt @@ -66,7 +66,7 @@ data class MatrixConfiguration( /** * Thread messages default enable/disabled value. */ - val threadMessagesEnabledDefault: Boolean = false, + val threadMessagesEnabledDefault: Boolean = true, /** * List of network interceptors, they will be added when building an OkHttp client. */ diff --git a/vector-config/src/main/res/values/config-settings.xml b/vector-config/src/main/res/values/config-settings.xml index ad9c16c214..a8695eed44 100755 --- a/vector-config/src/main/res/values/config-settings.xml +++ b/vector-config/src/main/res/values/config-settings.xml @@ -39,7 +39,7 @@ true true - false + true true false true diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt index a54ce2cff3..8f16121a30 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt @@ -254,6 +254,12 @@ class HomeActivityViewModel @AssistedInject constructor( // } when { + !vectorPreferences.areThreadMessagesEnabled() && !vectorPreferences.wasThreadFlagChangedManually() -> { + vectorPreferences.setThreadMessagesEnabled() + lightweightSettingsStorage.setThreadMessagesEnabled(vectorPreferences.areThreadMessagesEnabled()) + // Clear Cache + _viewEvents.post(HomeActivityViewEvents.MigrateThreads(checkSession = false)) + } // Notify users vectorPreferences.shouldNotifyUserAboutThreads() && vectorPreferences.areThreadMessagesEnabled() -> { Timber.i("----> Notify users about threads") 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 d46b819cce..2d5fb351f9 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 @@ -239,6 +239,7 @@ class VectorPreferences @Inject constructor( // This key will be used to identify clients with the new thread support enabled m.thread const val SETTINGS_LABS_ENABLE_THREAD_MESSAGES = "SETTINGS_LABS_ENABLE_THREAD_MESSAGES_FINAL" + const val SETTINGS_LABS_THREAD_MESSAGES_CHANGED_BY_USER = "SETTINGS_LABS_THREAD_MESSAGES_CHANGED_BY_USER" const val SETTINGS_THREAD_MESSAGES_SYNCED = "SETTINGS_THREAD_MESSAGES_SYNCED" // This key will be used to enable user for displaying live user info or not. @@ -1129,6 +1130,24 @@ class VectorPreferences @Inject constructor( .apply() } + /** + * Indicates whether or not user changed threads flag manually. We need this to not force flag to be enabled on app start. + * Should be removed when Threads flag will be removed + */ + fun wasThreadFlagChangedManually(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_LABS_THREAD_MESSAGES_CHANGED_BY_USER, false) + } + + /** + * Sets the flag to indicate that user changed threads flag (e.g. disabled them). + */ + fun setThreadFlagChangedManually() { + defaultPrefs + .edit() + .putBoolean(SETTINGS_LABS_THREAD_MESSAGES_CHANGED_BY_USER, true) + .apply() + } + /** * Indicates whether or not the user will be notified about the new thread support. * We should notify the user only if he had old thread support enabled. diff --git a/vector/src/main/java/im/vector/app/features/settings/labs/VectorSettingsLabsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/labs/VectorSettingsLabsFragment.kt index c10411301f..189d55d990 100644 --- a/vector/src/main/java/im/vector/app/features/settings/labs/VectorSettingsLabsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/labs/VectorSettingsLabsFragment.kt @@ -141,6 +141,7 @@ class VectorSettingsLabsFragment : */ private fun onThreadsPreferenceClicked() { // We should migrate threads only if threads are disabled + vectorPreferences.setThreadFlagChangedManually() vectorPreferences.setShouldMigrateThreads(!vectorPreferences.areThreadMessagesEnabled()) lightweightSettingsStorage.setThreadMessagesEnabled(vectorPreferences.areThreadMessagesEnabled()) displayLoadingView() From 71df1e61d43782f7a181f267ab75e1f173dbbc0a Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Tue, 13 Dec 2022 15:45:46 +0100 Subject: [PATCH 79/85] Remove non necessary call when getting the targeted event id --- .../room/aggregation/poll/DefaultPollAggregationProcessor.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt index 10c43e3b7f..58583f8a91 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt @@ -78,7 +78,7 @@ class DefaultPollAggregationProcessor @Inject constructor() : PollAggregationPro val content = event.getClearContent()?.toModel() ?: return false val roomId = event.roomId ?: return false val senderId = event.senderId ?: return false - val targetEventId = (event.getRelationContent() ?: content.relatesTo)?.eventId ?: return false + val targetEventId = event.getRelationContent()?.eventId ?: return false val targetPollContent = getPollContent(session, roomId, targetEventId) ?: return false val annotationsSummaryEntity = getAnnotationsSummaryEntity(realm, roomId, targetEventId) @@ -154,9 +154,8 @@ class DefaultPollAggregationProcessor @Inject constructor() : PollAggregationPro } override fun handlePollEndEvent(session: Session, powerLevelsHelper: PowerLevelsHelper, realm: Realm, event: Event): Boolean { - val content = event.getClearContent()?.toModel() ?: return false val roomId = event.roomId ?: return false - val pollEventId = (event.getRelationContent() ?: content.relatesTo)?.eventId ?: return false + val pollEventId = event.getRelationContent()?.eventId ?: return false val pollOwnerId = getPollEvent(session, roomId, pollEventId)?.root?.senderId val isPollOwner = pollOwnerId == event.senderId From 96e29d4d10e4fd3a9749c714dedd31d17bb44054 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Tue, 13 Dec 2022 15:46:14 +0100 Subject: [PATCH 80/85] Renaming the name of the test file be consistent --- ...nProcessorTest.kt => DefaultPollAggregationProcessorTest.kt} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/{PollAggregationProcessorTest.kt => DefaultPollAggregationProcessorTest.kt} (99%) diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessorTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessorTest.kt similarity index 99% rename from matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessorTest.kt rename to matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessorTest.kt index 3044ca5d43..c1fd615e25 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessorTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessorTest.kt @@ -47,7 +47,7 @@ import org.matrix.android.sdk.test.fakes.FakeRealm import org.matrix.android.sdk.test.fakes.givenEqualTo import org.matrix.android.sdk.test.fakes.givenFindFirst -class PollAggregationProcessorTest { +class DefaultPollAggregationProcessorTest { private val pollAggregationProcessor: PollAggregationProcessor = DefaultPollAggregationProcessor() private val realm = FakeRealm() From 851276978f48811de842e36f75bf16d430dcce07 Mon Sep 17 00:00:00 2001 From: Maxime NATUREL Date: Tue, 13 Dec 2022 15:47:30 +0100 Subject: [PATCH 81/85] Remove unused import --- .../room/aggregation/poll/DefaultPollAggregationProcessor.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt index 58583f8a91..455ccabbc6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt @@ -29,7 +29,6 @@ import org.matrix.android.sdk.api.session.room.getTimelineEvent import org.matrix.android.sdk.api.session.room.model.PollSummaryContent import org.matrix.android.sdk.api.session.room.model.VoteInfo import org.matrix.android.sdk.api.session.room.model.VoteSummary -import org.matrix.android.sdk.api.session.room.model.message.MessageEndPollContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollResponseContent import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper From e0a611a16edb25a036296f4763ee3da665c15903 Mon Sep 17 00:00:00 2001 From: Nikita Fedrunov <66663241+fedrunov@users.noreply.github.com> Date: Wed, 14 Dec 2022 15:13:24 +0100 Subject: [PATCH 82/85] changed copy for threads labs flag (#7776) --- library/ui-strings/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 0ab1d85f0f..60440b0dd9 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3032,7 +3032,7 @@ Auto Report Decryption Errors. Your system will automatically send logs when an unable to decrypt error occurs - Enable Thread Messages + Enable threaded messages Note: app will be restarted Show latest user info Show the latest profile info (avatar and display name) for all the messages. From cf3abd6562f37124e4996cdccbc478dea8bfcd33 Mon Sep 17 00:00:00 2001 From: Nikita Fedrunov <66663241+fedrunov@users.noreply.github.com> Date: Wed, 14 Dec 2022 18:56:16 +0100 Subject: [PATCH 83/85] thread list loading (#7766) --- changelog.d/5819.misc | 1 + .../org/matrix/android/sdk/flow/FlowRoom.kt | 8 -- .../room/threads/FetchThreadsResult.kt | 23 ++++ .../api/session/room/threads/ThreadFilter.kt | 26 +++++ .../room/threads/ThreadLivePageResult.kt | 27 +++++ .../session/room/threads/ThreadsService.kt | 16 +-- .../database/RealmSessionStoreMigration.kt | 4 +- .../database/helper/ThreadSummaryHelper.kt | 24 ++-- .../database/migration/MigrateSessionTo046.kt | 32 ++++++ .../database/model/SessionRealmModule.kt | 2 + .../model/threads/ThreadListPageEntity.kt | 28 +++++ .../model/threads/ThreadSummaryEntity.kt | 3 + .../query/ThreadSummaryPageEntityQueries.kt | 31 +++++ .../sdk/internal/session/room/RoomAPI.kt | 9 ++ .../threads/FetchThreadSummariesTask.kt | 71 ++++++------ .../threads/ThreadSummariesResponse.kt | 27 +++++ .../room/threads/DefaultThreadsService.kt | 83 ++++++++++---- .../list/viewmodel/ThreadListController.kt | 66 +---------- .../viewmodel/ThreadListPagedController.kt | 84 ++++++++++++++ .../list/viewmodel/ThreadListViewModel.kt | 107 ++++++++++++++---- .../list/viewmodel/ThreadListViewState.kt | 2 - .../threads/list/views/ThreadListFragment.kt | 27 ++++- 22 files changed, 526 insertions(+), 175 deletions(-) create mode 100644 changelog.d/5819.misc create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/FetchThreadsResult.kt create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadFilter.kt create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadLivePageResult.kt create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo046.kt create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadListPageEntity.kt create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ThreadSummaryPageEntityQueries.kt create mode 100644 matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/ThreadSummariesResponse.kt create mode 100644 vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListPagedController.kt diff --git a/changelog.d/5819.misc b/changelog.d/5819.misc new file mode 100644 index 0000000000..5f2d05dc3c --- /dev/null +++ b/changelog.d/5819.misc @@ -0,0 +1 @@ +[Threads] - added API to fetch threads list from the server instead of building it locally from events 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 7ad342b22f..94f09e0bf5 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 @@ -31,7 +31,6 @@ import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.notification.RoomNotificationState import org.matrix.android.sdk.api.session.room.send.UserDraft -import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.toOptional @@ -119,13 +118,6 @@ class FlowRoom(private val room: Room) { return room.roomPushRuleService().getLiveRoomNotificationState().asFlow() } - fun liveThreadSummaries(): Flow> { - return room.threadsService().getAllThreadSummariesLive().asFlow() - .startWith(room.coroutineDispatchers.io) { - room.threadsService().getAllThreadSummaries() - } - } - fun liveThreadList(): Flow> { return room.threadsLocalService().getAllThreadsLive().asFlow() .startWith(room.coroutineDispatchers.io) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/FetchThreadsResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/FetchThreadsResult.kt new file mode 100644 index 0000000000..5d4d67a65e --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/FetchThreadsResult.kt @@ -0,0 +1,23 @@ +/* + * 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.room.threads + +sealed class FetchThreadsResult { + data class ShouldFetchMore(val nextBatch: String) : FetchThreadsResult() + object ReachedEnd : FetchThreadsResult() + object Failed : FetchThreadsResult() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadFilter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadFilter.kt new file mode 100644 index 0000000000..3f3576728f --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadFilter.kt @@ -0,0 +1,26 @@ +/* + * 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.room.threads + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = false) +enum class ThreadFilter { + @Json(name = "all") ALL, + @Json(name = "participated") PARTICIPATED, +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadLivePageResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadLivePageResult.kt new file mode 100644 index 0000000000..7693dc6fde --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadLivePageResult.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2021 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.room.threads + +import androidx.lifecycle.LiveData +import androidx.paging.PagedList +import org.matrix.android.sdk.api.session.room.ResultBoundaries +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary + +data class ThreadLivePageResult( + val livePagedList: LiveData>, + val liveBoundaries: LiveData +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt index 9587be68f1..bb6f6b51d3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt @@ -16,7 +16,7 @@ package org.matrix.android.sdk.api.session.room.threads -import androidx.lifecycle.LiveData +import androidx.paging.PagedList import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary /** @@ -27,15 +27,14 @@ import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary */ interface ThreadsService { - /** - * Returns a [LiveData] list of all the [ThreadSummary] that exists at the room level. - */ - fun getAllThreadSummariesLive(): LiveData> + suspend fun getPagedThreadsList(userParticipating: Boolean, pagedListConfig: PagedList.Config): ThreadLivePageResult + + suspend fun fetchThreadList(nextBatchId: String?, limit: Int, filter: ThreadFilter = ThreadFilter.ALL): FetchThreadsResult /** * Returns a list of all the [ThreadSummary] that exists at the room level. */ - fun getAllThreadSummaries(): List + suspend fun getAllThreadSummaries(): List /** * Enhance the provided ThreadSummary[List] by adding the latest @@ -51,9 +50,4 @@ interface ThreadsService { * @param limit defines the number of max results the api will respond with */ suspend fun fetchThreadTimeline(rootThreadEventId: String, from: String, limit: Int) - - /** - * Fetch all thread summaries for the current room using the enhanced /messages api. - */ - suspend fun fetchThreadSummaries() } 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 2fb87ca874..5295abffe3 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 @@ -62,6 +62,7 @@ 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.database.migration.MigrateSessionTo046 import org.matrix.android.sdk.internal.util.Normalizer import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration import javax.inject.Inject @@ -70,7 +71,7 @@ internal class RealmSessionStoreMigration @Inject constructor( private val normalizer: Normalizer ) : MatrixRealmMigration( dbName = "Session", - schemaVersion = 45L, + schemaVersion = 46L, ) { /** * Forces all RealmSessionStoreMigration instances to be equal. @@ -125,5 +126,6 @@ internal class RealmSessionStoreMigration @Inject constructor( if (oldVersion < 43) MigrateSessionTo043(realm).perform() if (oldVersion < 44) MigrateSessionTo044(realm).perform() if (oldVersion < 45) MigrateSessionTo045(realm).perform() + if (oldVersion < 46) MigrateSessionTo046(realm).perform() } } 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 0ac8dc7902..908c710df4 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 @@ -37,9 +37,11 @@ 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 import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadListPageEntity import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntityFields import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore +import org.matrix.android.sdk.internal.database.query.get import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.getOrNull import org.matrix.android.sdk.internal.database.query.where @@ -113,16 +115,16 @@ internal fun ThreadSummaryEntity.Companion.createOrUpdate( userId: String, cryptoService: CryptoService? = null, currentTimeMillis: Long, -) { +): ThreadSummaryEntity? { when (threadSummaryType) { ThreadSummaryUpdateType.REPLACE -> { - rootThreadEvent?.eventId ?: return - rootThreadEvent.senderId ?: return + rootThreadEvent?.eventId ?: return null + rootThreadEvent.senderId ?: return null - val numberOfThreads = rootThreadEvent.unsignedData?.relations?.latestThread?.count ?: return + val numberOfThreads = rootThreadEvent.unsignedData?.relations?.latestThread?.count ?: return null // Something is wrong with the server return - if (numberOfThreads <= 0) return + if (numberOfThreads <= 0) return null val threadSummary = ThreadSummaryEntity.getOrCreate(realm, roomId, rootThreadEvent.eventId).also { Timber.i("###THREADS ThreadSummaryHelper REPLACE eventId:${it.rootThreadEventId} ") @@ -153,12 +155,13 @@ internal fun ThreadSummaryEntity.Companion.createOrUpdate( ) roomEntity.addIfNecessary(threadSummary) + return threadSummary } ThreadSummaryUpdateType.ADD -> { - val rootThreadEventId = threadEventEntity?.rootThreadEventId ?: return + val rootThreadEventId = threadEventEntity?.rootThreadEventId ?: return null Timber.i("###THREADS ThreadSummaryHelper ADD for root eventId:$rootThreadEventId") - val threadSummary = ThreadSummaryEntity.getOrNull(realm, roomId, rootThreadEventId) + var threadSummary = ThreadSummaryEntity.getOrNull(realm, roomId, rootThreadEventId) if (threadSummary != null) { // ThreadSummary exists so lets add the latest event Timber.i("###THREADS ThreadSummaryHelper ADD root eventId:$rootThreadEventId exists, lets update latest thread event.") @@ -172,7 +175,7 @@ internal fun ThreadSummaryEntity.Companion.createOrUpdate( Timber.i("###THREADS ThreadSummaryHelper ADD root eventId:$rootThreadEventId do not exists, lets try to create one") threadEventEntity.findRootThreadEvent()?.let { rootThreadEventEntity -> // Root thread event entity exists so lets create a new record - ThreadSummaryEntity.getOrCreate(realm, roomId, rootThreadEventEntity.eventId).let { + threadSummary = ThreadSummaryEntity.getOrCreate(realm, roomId, rootThreadEventEntity.eventId).also { it.updateThreadSummary( rootThreadEventEntity = rootThreadEventEntity, numberOfThreads = 1, @@ -183,7 +186,12 @@ internal fun ThreadSummaryEntity.Companion.createOrUpdate( roomEntity.addIfNecessary(it) } } + + threadSummary?.let { + ThreadListPageEntity.get(realm, roomId)?.threadSummaries?.add(it) + } } + return threadSummary } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo046.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo046.kt new file mode 100644 index 0000000000..4b1d2059a4 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo046.kt @@ -0,0 +1,32 @@ +/* + * 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.threads.ThreadListPageEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +internal class MigrateSessionTo046(realm: DynamicRealm) : RealmMigrator(realm, 46) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.create("ThreadListPageEntity") + .addField(ThreadListPageEntityFields.ROOM_ID, String::class.java) + .addPrimaryKey(ThreadListPageEntityFields.ROOM_ID) + .setRequired(ThreadListPageEntityFields.ROOM_ID, true) + .addRealmListField(ThreadListPageEntityFields.THREAD_SUMMARIES.`$`, realm.schema.get("ThreadSummaryEntity")!!) + } +} 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 93ff67a911..0ab30657ed 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 @@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.database.model import io.realm.annotations.RealmModule import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity import org.matrix.android.sdk.internal.database.model.presence.UserPresenceEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadListPageEntity import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity /** @@ -72,6 +73,7 @@ import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntit UserPresenceEntity::class, ThreadSummaryEntity::class, SyncFilterParamsEntity::class, + ThreadListPageEntity::class ] ) internal class SessionRealmModule diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadListPageEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadListPageEntity.kt new file mode 100644 index 0000000000..1d64c64ddf --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadListPageEntity.kt @@ -0,0 +1,28 @@ +/* + * 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.threads + +import io.realm.RealmList +import io.realm.RealmObject +import io.realm.annotations.PrimaryKey + +internal open class ThreadListPageEntity( + @PrimaryKey var roomId: String = "", + var threadSummaries: RealmList = RealmList() +) : RealmObject() { + companion object +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt index 45f9e3aa20..487be3747a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt @@ -40,5 +40,8 @@ internal open class ThreadSummaryEntity( @LinkingObjects("threadSummaries") val room: RealmResults? = null + @LinkingObjects("threadSummaries") + val page: RealmResults? = null + companion object } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ThreadSummaryPageEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ThreadSummaryPageEntityQueries.kt new file mode 100644 index 0000000000..9525e55787 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ThreadSummaryPageEntityQueries.kt @@ -0,0 +1,31 @@ +/* + * 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.query + +import io.realm.Realm +import io.realm.kotlin.createObject +import io.realm.kotlin.where +import org.matrix.android.sdk.internal.database.model.threads.ThreadListPageEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadListPageEntityFields + +internal fun ThreadListPageEntity.Companion.get(realm: Realm, roomId: String): ThreadListPageEntity? { + return realm.where().equalTo(ThreadListPageEntityFields.ROOM_ID, roomId).findFirst() +} + +internal fun ThreadListPageEntity.Companion.getOrCreate(realm: Realm, roomId: String): ThreadListPageEntity { + return get(realm, roomId) ?: realm.createObject(roomId) +} 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 4e55b2c40a..ddb7d6a8e6 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 @@ -35,6 +35,7 @@ import org.matrix.android.sdk.internal.session.room.membership.joining.InviteBod 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.relation.threads.ThreadSummariesResponse import org.matrix.android.sdk.internal.session.room.reporting.ReportContentBody import org.matrix.android.sdk.internal.session.room.send.SendResponse import org.matrix.android.sdk.internal.session.room.tags.TagBody @@ -464,4 +465,12 @@ internal interface RoomAPI { @Path("roomIdOrAlias") roomidOrAlias: String, @Query("via") viaServers: List? ): RoomStrippedState + + @GET(NetworkConstants.URI_API_PREFIX_PATH_V1 + "rooms/{roomId}/threads") + suspend fun getThreadsList( + @Path("roomId") roomId: String, + @Query("include") include: String? = "all", + @Query("from") from: String? = null, + @Query("limit") limit: Int? = null + ): ThreadSummariesResponse } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt index 254dee4295..848b9698ee 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt @@ -16,37 +16,38 @@ package org.matrix.android.sdk.internal.session.room.relation.threads import com.zhuinden.monarchy.Monarchy +import io.realm.RealmList import org.matrix.android.sdk.api.session.room.model.RoomMemberContent +import org.matrix.android.sdk.api.session.room.threads.FetchThreadsResult +import org.matrix.android.sdk.api.session.room.threads.ThreadFilter import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummaryUpdateType import org.matrix.android.sdk.internal.crypto.DefaultCryptoService import org.matrix.android.sdk.internal.database.helper.createOrUpdate import org.matrix.android.sdk.internal.database.model.RoomEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadListPageEntity import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity +import org.matrix.android.sdk.internal.database.query.getOrCreate 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.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.executeRequest -import org.matrix.android.sdk.internal.session.filter.FilterFactory import org.matrix.android.sdk.internal.session.room.RoomAPI -import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection -import org.matrix.android.sdk.internal.session.room.timeline.PaginationResponse import org.matrix.android.sdk.internal.task.Task import org.matrix.android.sdk.internal.util.awaitTransaction import org.matrix.android.sdk.internal.util.time.Clock -import timber.log.Timber import javax.inject.Inject /*** * This class is responsible to Fetch all the thread in the current room, * To fetch all threads in a room, the /messages API is used with newly added filtering options. */ -internal interface FetchThreadSummariesTask : Task { +internal interface FetchThreadSummariesTask : Task { data class Params( val roomId: String, - val from: String = "", - val limit: Int = 500, - val isUserParticipating: Boolean = true + val from: String? = null, + val limit: Int = 5, + val filter: ThreadFilter? = null, ) } @@ -59,39 +60,43 @@ internal class DefaultFetchThreadSummariesTask @Inject constructor( private val clock: Clock, ) : FetchThreadSummariesTask { - override suspend fun execute(params: FetchThreadSummariesTask.Params): Result { - val filter = FilterFactory.createThreadsFilter( - numberOfEvents = params.limit, - userId = if (params.isUserParticipating) userId else null - ).toJSONString() - - val response = executeRequest( - globalErrorReceiver, - canRetry = true - ) { - roomAPI.getRoomMessagesFrom(params.roomId, params.from, PaginationDirection.BACKWARDS.value, params.limit, filter) + override suspend fun execute(params: FetchThreadSummariesTask.Params): FetchThreadsResult { + val response = executeRequest(globalErrorReceiver) { + roomAPI.getThreadsList( + roomId = params.roomId, + include = params.filter?.toString()?.lowercase(), + from = params.from, + limit = params.limit + ) } - Timber.i("###THREADS DefaultFetchThreadSummariesTask Fetched size:${response.events.size} nextBatch:${response.end} ") + handleResponse(response, params) - return handleResponse(response, params) + return when { + response.nextBatch != null -> FetchThreadsResult.ShouldFetchMore(response.nextBatch) + else -> FetchThreadsResult.ReachedEnd + } } private suspend fun handleResponse( - response: PaginationResponse, + response: ThreadSummariesResponse, params: FetchThreadSummariesTask.Params - ): Result { - val rootThreadList = response.events + ) { + val rootThreadList = response.chunk + + val threadSummaries = RealmList() + monarchy.awaitTransaction { realm -> val roomEntity = RoomEntity.where(realm, roomId = params.roomId).findFirst() ?: return@awaitTransaction val roomMemberContentsByUser = HashMap() + for (rootThreadEvent in rootThreadList) { if (rootThreadEvent.eventId == null || rootThreadEvent.senderId == null || rootThreadEvent.type == null) { continue } - ThreadSummaryEntity.createOrUpdate( + val threadSummary = ThreadSummaryEntity.createOrUpdate( threadSummaryType = ThreadSummaryUpdateType.REPLACE, realm = realm, roomId = params.roomId, @@ -102,14 +107,16 @@ internal class DefaultFetchThreadSummariesTask @Inject constructor( cryptoService = cryptoService, currentTimeMillis = clock.epochMillis(), ) + + threadSummaries.add(threadSummary) + } + + val page = ThreadListPageEntity.getOrCreate(realm, params.roomId) + threadSummaries.forEach { + if (!page.threadSummaries.contains(it)) { + page.threadSummaries.add(it) + } } } - return Result.SUCCESS - } - - enum class Result { - SHOULD_FETCH_MORE, - REACHED_END, - SUCCESS } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/ThreadSummariesResponse.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/ThreadSummariesResponse.kt new file mode 100644 index 0000000000..d37a058ef6 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/ThreadSummariesResponse.kt @@ -0,0 +1,27 @@ +/* + * 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.relation.threads + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.matrix.android.sdk.api.session.events.model.Event + +@JsonClass(generateAdapter = true) +internal data class ThreadSummariesResponse( + @Json(name = "chunk") val chunk: List, + @Json(name = "next_batch") val nextBatch: String?, + @Json(name = "prev_batch") val prevBatch: String? +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt index 6c6d6368d1..63756811f9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt @@ -16,32 +16,39 @@ package org.matrix.android.sdk.internal.session.room.threads -import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.paging.LivePagedListBuilder +import androidx.paging.PagedList import com.zhuinden.monarchy.Monarchy import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.realm.Realm +import io.realm.Sort +import io.realm.kotlin.where +import org.matrix.android.sdk.api.session.room.ResultBoundaries +import org.matrix.android.sdk.api.session.room.threads.FetchThreadsResult +import org.matrix.android.sdk.api.session.room.threads.ThreadFilter +import org.matrix.android.sdk.api.session.room.threads.ThreadLivePageResult import org.matrix.android.sdk.api.session.room.threads.ThreadsService import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary import org.matrix.android.sdk.internal.database.helper.enhanceWithEditions import org.matrix.android.sdk.internal.database.helper.findAllThreadsForRoomId import org.matrix.android.sdk.internal.database.mapper.ThreadSummaryMapper -import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper +import org.matrix.android.sdk.internal.database.model.threads.ThreadListPageEntity import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntityFields import org.matrix.android.sdk.internal.di.SessionDatabase -import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadSummariesTask import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask +import org.matrix.android.sdk.internal.util.awaitTransaction internal class DefaultThreadsService @AssistedInject constructor( @Assisted private val roomId: String, - @UserId private val userId: String, private val fetchThreadTimelineTask: FetchThreadTimelineTask, - private val fetchThreadSummariesTask: FetchThreadSummariesTask, @SessionDatabase private val monarchy: Monarchy, - private val timelineEventMapper: TimelineEventMapper, - private val threadSummaryMapper: ThreadSummaryMapper + private val threadSummaryMapper: ThreadSummaryMapper, + private val fetchThreadSummariesTask: FetchThreadSummariesTask, ) : ThreadsService { @AssistedFactory @@ -49,16 +56,58 @@ internal class DefaultThreadsService @AssistedInject constructor( fun create(roomId: String): DefaultThreadsService } - override fun getAllThreadSummariesLive(): LiveData> { - return monarchy.findAllMappedWithChanges( - { ThreadSummaryEntity.findAllThreadsForRoomId(it, roomId = roomId) }, - { - threadSummaryMapper.map(it) + override suspend fun getPagedThreadsList(userParticipating: Boolean, pagedListConfig: PagedList.Config): ThreadLivePageResult { + monarchy.awaitTransaction { realm -> + realm.where().findAll().deleteAllFromRealm() + } + + val realmDataSourceFactory = monarchy.createDataSourceFactory { realm -> + realm + .where().equalTo(ThreadSummaryEntityFields.PAGE.ROOM_ID, roomId) + .sort(ThreadSummaryEntityFields.LATEST_THREAD_EVENT_ENTITY.ORIGIN_SERVER_TS, Sort.DESCENDING) + } + + val dataSourceFactory = realmDataSourceFactory.map { + threadSummaryMapper.map(it) + } + + val boundaries = MutableLiveData(ResultBoundaries()) + + val builder = LivePagedListBuilder(dataSourceFactory, pagedListConfig).also { + it.setBoundaryCallback(object : PagedList.BoundaryCallback() { + override fun onItemAtEndLoaded(itemAtEnd: ThreadSummary) { + boundaries.postValue(boundaries.value?.copy(endLoaded = true)) } + + override fun onItemAtFrontLoaded(itemAtFront: ThreadSummary) { + boundaries.postValue(boundaries.value?.copy(frontLoaded = true)) + } + + override fun onZeroItemsLoaded() { + boundaries.postValue(boundaries.value?.copy(zeroItemLoaded = true)) + } + }) + } + + val livePagedList = monarchy.findAllPagedWithChanges( + realmDataSourceFactory, + builder + ) + return ThreadLivePageResult(livePagedList, boundaries) + } + + override suspend fun fetchThreadList(nextBatchId: String?, limit: Int, filter: ThreadFilter): FetchThreadsResult { + return fetchThreadSummariesTask.execute( + FetchThreadSummariesTask.Params( + roomId = roomId, + from = nextBatchId, + limit = limit, + filter = filter + ) ) } - override fun getAllThreadSummaries(): List { + override suspend fun getAllThreadSummaries(): List { return monarchy.fetchAllMappedSync( { ThreadSummaryEntity.findAllThreadsForRoomId(it, roomId = roomId) }, { threadSummaryMapper.map(it) } @@ -81,12 +130,4 @@ internal class DefaultThreadsService @AssistedInject constructor( ) ) } - - override suspend fun fetchThreadSummaries() { - fetchThreadSummariesTask.execute( - FetchThreadSummariesTask.Params( - roomId = roomId - ) - ) - } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt index 14098fd8b0..3cbe652076 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt @@ -19,24 +19,18 @@ package im.vector.app.features.home.room.threads.list.viewmodel import com.airbnb.epoxy.EpoxyController import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.VectorDateFormatter -import im.vector.app.core.resources.StringProvider import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter import im.vector.app.features.home.room.threads.list.model.threadListItem -import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.threads.ThreadNotificationState import org.matrix.android.sdk.api.util.toMatrixItem -import org.matrix.android.sdk.api.util.toMatrixItemOrNull import javax.inject.Inject class ThreadListController @Inject constructor( private val avatarRenderer: AvatarRenderer, - private val stringProvider: StringProvider, private val dateFormatter: VectorDateFormatter, private val displayableEventFormatter: DisplayableEventFormatter, - private val session: Session ) : EpoxyController() { var listener: Listener? = null @@ -48,64 +42,7 @@ class ThreadListController @Inject constructor( requestModelBuild() } - override fun buildModels() = - when (session.homeServerCapabilitiesService().getHomeServerCapabilities().canUseThreading) { - true -> buildThreadSummaries() - false -> buildThreadList() - } - - /** - * Building thread summaries when homeserver supports threading. - */ - private fun buildThreadSummaries() { - val safeViewState = viewState ?: return - val host = this - safeViewState.threadSummaryList.invoke() - ?.filter { - if (safeViewState.shouldFilterThreads) { - it.isUserParticipating - } else { - true - } - } - ?.forEach { threadSummary -> - val date = dateFormatter.format(threadSummary.latestEvent?.originServerTs, DateFormatKind.ROOM_LIST) - val lastMessageFormatted = threadSummary.let { - displayableEventFormatter.formatThreadSummary( - event = it.latestEvent, - latestEdition = it.threadEditions.latestThreadEdition - ).toString() - } - val rootMessageFormatted = threadSummary.let { - displayableEventFormatter.formatThreadSummary( - event = it.rootEvent, - latestEdition = it.threadEditions.rootThreadEdition - ).toString() - } - threadListItem { - id(threadSummary.rootEvent?.eventId) - avatarRenderer(host.avatarRenderer) - matrixItem(threadSummary.rootThreadSenderInfo.toMatrixItem()) - title(threadSummary.rootThreadSenderInfo.displayName.orEmpty()) - date(date) - rootMessageDeleted(threadSummary.rootEvent?.isRedacted() ?: false) - // TODO refactor notifications that with the new thread summary - threadNotificationState(threadSummary.rootEvent?.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE) - rootMessage(rootMessageFormatted) - lastMessage(lastMessageFormatted) - lastMessageCounter(threadSummary.numberOfThreads.toString()) - lastMessageMatrixItem(threadSummary.latestThreadSenderInfo.toMatrixItemOrNull()) - itemClickListener { - host.listener?.onThreadSummaryClicked(threadSummary) - } - } - } - } - - /** - * Building local thread list when homeserver do not support threading. - */ - private fun buildThreadList() { + override fun buildModels() { val safeViewState = viewState ?: return val host = this safeViewState.rootThreadEventList.invoke() @@ -152,7 +89,6 @@ class ThreadListController @Inject constructor( } interface Listener { - fun onThreadSummaryClicked(threadSummary: ThreadSummary) fun onThreadListClicked(timelineEvent: TimelineEvent) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListPagedController.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListPagedController.kt new file mode 100644 index 0000000000..171b690a33 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListPagedController.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2021 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.threads.list.viewmodel + +import com.airbnb.epoxy.EpoxyModel +import com.airbnb.epoxy.paging.PagedListEpoxyController +import im.vector.app.core.date.DateFormatKind +import im.vector.app.core.date.VectorDateFormatter +import im.vector.app.core.utils.createUIHandler +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter +import im.vector.app.features.home.room.threads.list.model.ThreadListItem_ +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState +import org.matrix.android.sdk.api.util.toMatrixItem +import org.matrix.android.sdk.api.util.toMatrixItemOrNull +import javax.inject.Inject + +class ThreadListPagedController @Inject constructor( + private val avatarRenderer: AvatarRenderer, + private val dateFormatter: VectorDateFormatter, + private val displayableEventFormatter: DisplayableEventFormatter, +) : PagedListEpoxyController( + // Important it must match the PageList builder notify Looper + modelBuildingHandler = createUIHandler() +) { + + var listener: Listener? = null + + override fun buildItemModel(currentPosition: Int, item: ThreadSummary?): EpoxyModel<*> { + if (item == null) { + throw java.lang.NullPointerException() + } + val host = this + val date = dateFormatter.format(item.latestEvent?.originServerTs, DateFormatKind.ROOM_LIST) + val lastMessageFormatted = item.let { + displayableEventFormatter.formatThreadSummary( + event = it.latestEvent, + latestEdition = it.threadEditions.latestThreadEdition + ).toString() + } + val rootMessageFormatted = item.let { + displayableEventFormatter.formatThreadSummary( + event = it.rootEvent, + latestEdition = it.threadEditions.rootThreadEdition + ).toString() + } + + return ThreadListItem_() + .id(item.rootEvent?.eventId) + .avatarRenderer(host.avatarRenderer) + .matrixItem(item.rootThreadSenderInfo.toMatrixItem()) + .title(item.rootThreadSenderInfo.displayName.orEmpty()) + .date(date) + .rootMessageDeleted(item.rootEvent?.isRedacted() ?: false) + // TODO refactor notifications that with the new thread summary + .threadNotificationState(item.rootEvent?.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE) + .rootMessage(rootMessageFormatted) + .lastMessage(lastMessageFormatted) + .lastMessageCounter(item.numberOfThreads.toString()) + .lastMessageMatrixItem(item.latestThreadSenderInfo.toMatrixItemOrNull()) + .itemClickListener { + host.listener?.onThreadSummaryClicked(item) + } + } + + interface Listener { + fun onThreadSummaryClicked(threadSummary: ThreadSummary) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt index 4b7af330fb..7124727bb7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt @@ -16,6 +16,11 @@ package im.vector.app.features.home.room.threads.list.viewmodel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.asFlow +import androidx.paging.PagedList import com.airbnb.mvrx.FragmentViewModelContext import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.ViewModelContext @@ -29,23 +34,47 @@ import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.analytics.extensions.toAnalyticsInteraction import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.home.room.threads.list.views.ThreadListFragment +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach 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.threads.FetchThreadsResult +import org.matrix.android.sdk.api.session.room.threads.ThreadFilter +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent import org.matrix.android.sdk.flow.flow class ThreadListViewModel @AssistedInject constructor( @Assisted val initialState: ThreadListViewState, private val analyticsTracker: AnalyticsTracker, - private val session: Session -) : - VectorViewModel(initialState) { + private val session: Session, +) : VectorViewModel(initialState) { private val room = session.getRoom(initialState.roomId) + private val defaultPagedListConfig = PagedList.Config.Builder() + .setPageSize(20) + .setInitialLoadSizeHint(40) + .setEnablePlaceholders(false) + .setPrefetchDistance(10) + .build() + + private var nextBatchId: String? = null + private var hasReachedEnd: Boolean = false + private var boundariesJob: Job? = null + + private var livePagedList: LiveData>? = null + private val _threadsLivePagedList = MutableLiveData>() + val threadsLivePagedList: LiveData> = _threadsLivePagedList + private val internalPagedListObserver = Observer> { + _threadsLivePagedList.postValue(it) + setLoading(false) + } + @AssistedFactory interface Factory { fun create(initialState: ThreadListViewState): ThreadListViewModel @@ -54,7 +83,7 @@ class ThreadListViewModel @AssistedInject constructor( companion object : MavericksViewModelFactory { @JvmStatic - override fun create(viewModelContext: ViewModelContext, state: ThreadListViewState): ThreadListViewModel? { + override fun create(viewModelContext: ViewModelContext, state: ThreadListViewState): ThreadListViewModel { val fragment: ThreadListFragment = (viewModelContext as FragmentViewModelContext).fragment() return fragment.threadListViewModelFactory.create(state) } @@ -72,7 +101,7 @@ class ThreadListViewModel @AssistedInject constructor( private fun fetchAndObserveThreads() { when (session.homeServerCapabilitiesService().getHomeServerCapabilities().canUseThreading) { true -> { - fetchThreadList() + setLoading(true) observeThreadSummaries() } false -> observeThreadsList() @@ -82,14 +111,33 @@ class ThreadListViewModel @AssistedInject constructor( /** * Observing thread summaries when homeserver support threading. */ - private fun observeThreadSummaries() { - room?.flow() - ?.liveThreadSummaries() - ?.map { room.threadsService().enhanceThreadWithEditions(it) } - ?.flowOn(room.coroutineDispatchers.io) - ?.execute { asyncThreads -> - copy(threadSummaryList = asyncThreads) - } + private fun observeThreadSummaries() = withState { state -> + viewModelScope.launch { + nextBatchId = null + hasReachedEnd = false + + livePagedList?.removeObserver(internalPagedListObserver) + + room?.threadsService() + ?.getPagedThreadsList(state.shouldFilterThreads, defaultPagedListConfig)?.let { result -> + livePagedList = result.livePagedList + + livePagedList?.observeForever(internalPagedListObserver) + + boundariesJob = result.liveBoundaries.asFlow() + .onEach { + if (it.endLoaded) { + if (!hasReachedEnd) { + fetchNextPage() + } + } + } + .launchIn(viewModelScope) + } + + setLoading(true) + fetchNextPage() + } } /** @@ -111,14 +159,6 @@ class ThreadListViewModel @AssistedInject constructor( } } - private fun fetchThreadList() { - viewModelScope.launch { - setLoading(true) - room?.threadsService()?.fetchThreadSummaries() - setLoading(false) - } - } - private fun setLoading(isLoading: Boolean) { setState { copy(isLoading = isLoading) @@ -132,5 +172,30 @@ class ThreadListViewModel @AssistedInject constructor( setState { copy(shouldFilterThreads = shouldFilterThreads) } + + fetchAndObserveThreads() + } + + private suspend fun fetchNextPage() { + val filter = when (awaitState().shouldFilterThreads) { + true -> ThreadFilter.PARTICIPATED + false -> ThreadFilter.ALL + } + room?.threadsService()?.fetchThreadList( + nextBatchId = nextBatchId, + limit = defaultPagedListConfig.pageSize, + filter = filter, + ).let { result -> + when (result) { + is FetchThreadsResult.ReachedEnd -> { + hasReachedEnd = true + } + is FetchThreadsResult.ShouldFetchMore -> { + nextBatchId = result.nextBatch + } + else -> { + } + } + } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt index 2328da0b8a..60ccfb59af 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt @@ -20,11 +20,9 @@ import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.Uninitialized import im.vector.app.features.home.room.threads.arguments.ThreadListArgs -import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent data class ThreadListViewState( - val threadSummaryList: Async> = Uninitialized, val rootThreadEventList: Async> = Uninitialized, val shouldFilterThreads: Boolean = false, val isLoading: Boolean = false, diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt index f91fe9bd91..318c250906 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt @@ -40,6 +40,7 @@ import im.vector.app.features.home.room.threads.ThreadsActivity import im.vector.app.features.home.room.threads.arguments.ThreadListArgs import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListController +import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListPagedController import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewModel import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewState import im.vector.app.features.rageshake.BugReporter @@ -52,12 +53,14 @@ import javax.inject.Inject @AndroidEntryPoint class ThreadListFragment : VectorBaseFragment(), + ThreadListPagedController.Listener, ThreadListController.Listener, VectorMenuProvider { @Inject lateinit var avatarRenderer: AvatarRenderer @Inject lateinit var bugReporter: BugReporter - @Inject lateinit var threadListController: ThreadListController + @Inject lateinit var threadListController: ThreadListPagedController + @Inject lateinit var legacyThreadListController: ThreadListController @Inject lateinit var threadListViewModelFactory: ThreadListViewModel.Factory private val threadListViewModel: ThreadListViewModel by fragmentViewModel() @@ -100,7 +103,7 @@ class ThreadListFragment : val filterBadge = filterIcon.findViewById(R.id.threadListFilterBadge) filterBadge.isVisible = state.shouldFilterThreads when (threadListViewModel.canHomeserverUseThreading()) { - true -> menu.findItem(R.id.menu_thread_list_filter).isVisible = !state.threadSummaryList.invoke().isNullOrEmpty() + true -> menu.findItem(R.id.menu_thread_list_filter).isVisible = true false -> menu.findItem(R.id.menu_thread_list_filter).isVisible = !state.rootThreadEventList.invoke().isNullOrEmpty() } } @@ -111,8 +114,18 @@ class ThreadListFragment : initToolbar() initTextConstants() initBetaFeedback() - views.threadListRecyclerView.configureWith(threadListController, TimelineItemAnimator(), hasFixedSize = false) - threadListController.listener = this + + if (threadListViewModel.canHomeserverUseThreading()) { + views.threadListRecyclerView.configureWith(threadListController, TimelineItemAnimator(), hasFixedSize = false) + threadListController.listener = this + + threadListViewModel.threadsLivePagedList.observe(viewLifecycleOwner) { threadsList -> + threadListController.submitList(threadsList) + } + } else { + views.threadListRecyclerView.configureWith(legacyThreadListController, TimelineItemAnimator(), hasFixedSize = false) + legacyThreadListController.listener = this + } } override fun onDestroyView() { @@ -144,7 +157,9 @@ class ThreadListFragment : override fun invalidate() = withState(threadListViewModel) { state -> invalidateOptionsMenu() renderEmptyStateIfNeeded(state) - threadListController.update(state) + if (!threadListViewModel.canHomeserverUseThreading()) { + legacyThreadListController.update(state) + } renderLoaderIfNeeded(state) } @@ -185,7 +200,7 @@ class ThreadListFragment : private fun renderEmptyStateIfNeeded(state: ThreadListViewState) { when (threadListViewModel.canHomeserverUseThreading()) { - true -> views.threadListEmptyConstraintLayout.isVisible = state.threadSummaryList.invoke().isNullOrEmpty() + true -> views.threadListEmptyConstraintLayout.isVisible = false false -> views.threadListEmptyConstraintLayout.isVisible = state.rootThreadEventList.invoke().isNullOrEmpty() } } From 82ad08aced2979b6c37459dd6d965260a80f23dc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 15 Dec 2022 10:15:24 +0100 Subject: [PATCH 84/85] Changelog for version 1.5.12 --- CHANGES.md | 51 ++++++++++++++++++++++++++++++++++++++++ changelog.d/5503.misc | 1 - changelog.d/5819.misc | 1 - changelog.d/7274.bugfix | 1 - changelog.d/7477.misc | 1 - changelog.d/7596.feature | 1 - changelog.d/7632.feature | 1 - changelog.d/7643.bugfix | 1 - changelog.d/7645.misc | 1 - changelog.d/7653.bugfix | 1 - changelog.d/7658.bugfix | 1 - changelog.d/7659.bugfix | 1 - changelog.d/7680.bugfix | 3 --- changelog.d/7683.bugfix | 2 -- changelog.d/7684.bugfix | 1 - changelog.d/7691.bugfix | 1 - changelog.d/7693.feature | 1 - changelog.d/7694.feature | 1 - changelog.d/7695.bugfix | 1 - changelog.d/7697.feature | 1 - changelog.d/7699.bugfix | 1 - changelog.d/7708.misc | 1 - changelog.d/7710.bugfix | 1 - changelog.d/7719.feature | 1 - changelog.d/7723.misc | 1 - changelog.d/7725.bugfix | 1 - changelog.d/7733.bugfix | 1 - changelog.d/7737.bugfix | 1 - changelog.d/7740.feature | 1 - changelog.d/7743.bugfix | 1 - changelog.d/7744.bugfix | 1 - changelog.d/7751.bugfix | 1 - changelog.d/7753.bugfix | 1 - changelog.d/7754.feature | 1 - changelog.d/7770.bugfix | 1 - 35 files changed, 51 insertions(+), 37 deletions(-) delete mode 100644 changelog.d/5503.misc delete mode 100644 changelog.d/5819.misc delete mode 100644 changelog.d/7274.bugfix delete mode 100644 changelog.d/7477.misc delete mode 100644 changelog.d/7596.feature delete mode 100644 changelog.d/7632.feature delete mode 100644 changelog.d/7643.bugfix delete mode 100644 changelog.d/7645.misc delete mode 100644 changelog.d/7653.bugfix delete mode 100644 changelog.d/7658.bugfix delete mode 100644 changelog.d/7659.bugfix delete mode 100644 changelog.d/7680.bugfix delete mode 100644 changelog.d/7683.bugfix delete mode 100644 changelog.d/7684.bugfix delete mode 100644 changelog.d/7691.bugfix delete mode 100644 changelog.d/7693.feature delete mode 100644 changelog.d/7694.feature delete mode 100644 changelog.d/7695.bugfix delete mode 100644 changelog.d/7697.feature delete mode 100644 changelog.d/7699.bugfix delete mode 100644 changelog.d/7708.misc delete mode 100644 changelog.d/7710.bugfix delete mode 100644 changelog.d/7719.feature delete mode 100644 changelog.d/7723.misc delete mode 100644 changelog.d/7725.bugfix delete mode 100644 changelog.d/7733.bugfix delete mode 100644 changelog.d/7737.bugfix delete mode 100644 changelog.d/7740.feature delete mode 100644 changelog.d/7743.bugfix delete mode 100644 changelog.d/7744.bugfix delete mode 100644 changelog.d/7751.bugfix delete mode 100644 changelog.d/7753.bugfix delete mode 100644 changelog.d/7754.feature delete mode 100644 changelog.d/7770.bugfix diff --git a/CHANGES.md b/CHANGES.md index c170c3b92b..0481ec1af6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,54 @@ +Changes in Element v1.5.12 (2022-12-15) +======================================= + +Features ✨ +---------- +- [Threads] - Threads Labs Flag is enabled by default and forced to be enabled for existing users, but sill can be disabled manually ([#5503](https://github.com/vector-im/element-android/issues/5503)) + - [Session manager] Add action to signout all the other session ([#7693](https://github.com/vector-im/element-android/issues/7693)) + - Remind unverified sessions with a banner once a week ([#7694](https://github.com/vector-im/element-android/issues/7694)) + - [Session manager] Add actions to rename and signout current session ([#7697](https://github.com/vector-im/element-android/issues/7697)) + - Voice Broadcast - Update last message in the room list ([#7719](https://github.com/vector-im/element-android/issues/7719)) + - Delete unused client information from account data ([#7754](https://github.com/vector-im/element-android/issues/7754)) + +Bugfixes 🐛 +---------- + - Fix bad pills color background. For light and dark theme the color is now 61708B (iso EleWeb) ([#7274](https://github.com/vector-im/element-android/issues/7274)) + - [Notifications] Fixed a bug when push notification was automatically dismissed while app is on background ([#7643](https://github.com/vector-im/element-android/issues/7643)) + - ANR when asking to select the notification method ([#7653](https://github.com/vector-im/element-android/issues/7653)) + - [Rich text editor] Fix design and spacing of rich text editor ([#7658](https://github.com/vector-im/element-android/issues/7658)) + - [Rich text editor] Fix keyboard closing after collapsing editor ([#7659](https://github.com/vector-im/element-android/issues/7659)) + - Rich Text Editor: fix several issues related to insets: + * Empty space displayed at the bottom when you don't have permissions to send messages into a room. + * Wrong insets being kept when you exit the room screen and the keyboard is displayed, then come back to it. ([#7680](https://github.com/vector-im/element-android/issues/7680)) + - Fix crash in message composer when room is missing ([#7683](https://github.com/vector-im/element-android/issues/7683)) + - Fix crash when invalid homeserver url is entered. ([#7684](https://github.com/vector-im/element-android/issues/7684)) + - Rich Text Editor: improve performance when entering reply/edit/quote mode. ([#7691](https://github.com/vector-im/element-android/issues/7691)) + - [Rich text editor] Add error tracking for rich text editor ([#7695](https://github.com/vector-im/element-android/issues/7695)) + - Fix E2EE set up failure whilst signing in using QR code ([#7699](https://github.com/vector-im/element-android/issues/7699)) + - Fix usage of unknown shield in room summary ([#7710](https://github.com/vector-im/element-android/issues/7710)) + - Fix crash when the network is not available. ([#7725](https://github.com/vector-im/element-android/issues/7725)) + - [Session manager] Sessions without encryption support should not prompt to verify ([#7733](https://github.com/vector-im/element-android/issues/7733)) + - Fix issue of Scan QR code button sometimes not showing when it should be available ([#7737](https://github.com/vector-im/element-android/issues/7737)) + - Verification request is not showing when verify session popup is displayed ([#7743](https://github.com/vector-im/element-android/issues/7743)) + - Fix crash when inviting by email. ([#7744](https://github.com/vector-im/element-android/issues/7744)) + - Revert usage of stable fields in live location sharing and polls ([#7751](https://github.com/vector-im/element-android/issues/7751)) + - [Poll] Poll end event is not recognized ([#7753](https://github.com/vector-im/element-android/issues/7753)) + - [Push Notifications] When push notification for threaded message is clicked, thread timeline will be opened instead of room's main timeline ([#7770](https://github.com/vector-im/element-android/issues/7770)) + +Other changes +------------- + - [Threads] - added API to fetch threads list from the server instead of building it locally from events ([#5819](https://github.com/vector-im/element-android/issues/5819)) + - Add Z-Labs label for rich text editor and migrate to new label naming. ([#7477](https://github.com/vector-im/element-android/issues/7477)) + - Crypto database migration tests ([#7645](https://github.com/vector-im/element-android/issues/7645)) + - Add tracing Id for to device messages ([#7708](https://github.com/vector-im/element-android/issues/7708)) + - Disable nightly popup and add an entry point in the advanced settings instead. ([#7723](https://github.com/vector-im/element-android/issues/7723)) +- Save m.local_notification_settings. event in account_data ([#7596](https://github.com/vector-im/element-android/issues/7596)) +- Update notifications setting when m.local_notification_settings. event changes for current device ([#7632](https://github.com/vector-im/element-android/issues/7632)) + +SDK API changes ⚠️ +------------------ +- Handle account data removal ([#7740](https://github.com/vector-im/element-android/issues/7740)) + Changes in Element 1.5.11 (2022-12-07) ====================================== diff --git a/changelog.d/5503.misc b/changelog.d/5503.misc deleted file mode 100644 index 66deb33684..0000000000 --- a/changelog.d/5503.misc +++ /dev/null @@ -1 +0,0 @@ -[Threads] - Threads Labs Flag is enabled by default and forced to be enabled for existing users, but sill can be disabled manually diff --git a/changelog.d/5819.misc b/changelog.d/5819.misc deleted file mode 100644 index 5f2d05dc3c..0000000000 --- a/changelog.d/5819.misc +++ /dev/null @@ -1 +0,0 @@ -[Threads] - added API to fetch threads list from the server instead of building it locally from events diff --git a/changelog.d/7274.bugfix b/changelog.d/7274.bugfix deleted file mode 100644 index e99daceb89..0000000000 --- a/changelog.d/7274.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix bad pills color background. For light and dark theme the color is now 61708B (iso EleWeb) diff --git a/changelog.d/7477.misc b/changelog.d/7477.misc deleted file mode 100644 index 2ea83ce81d..0000000000 --- a/changelog.d/7477.misc +++ /dev/null @@ -1 +0,0 @@ -Add Z-Labs label for rich text editor and migrate to new label naming. \ No newline at end of file diff --git a/changelog.d/7596.feature b/changelog.d/7596.feature deleted file mode 100644 index 022d86342b..0000000000 --- a/changelog.d/7596.feature +++ /dev/null @@ -1 +0,0 @@ -Save m.local_notification_settings. event in account_data diff --git a/changelog.d/7632.feature b/changelog.d/7632.feature deleted file mode 100644 index 460f987756..0000000000 --- a/changelog.d/7632.feature +++ /dev/null @@ -1 +0,0 @@ -Update notifications setting when m.local_notification_settings. event changes for current device diff --git a/changelog.d/7643.bugfix b/changelog.d/7643.bugfix deleted file mode 100644 index 66e3f28d5f..0000000000 --- a/changelog.d/7643.bugfix +++ /dev/null @@ -1 +0,0 @@ -[Notifications] Fixed a bug when push notification was automatically dismissed while app is on background diff --git a/changelog.d/7645.misc b/changelog.d/7645.misc deleted file mode 100644 index a133581ac1..0000000000 --- a/changelog.d/7645.misc +++ /dev/null @@ -1 +0,0 @@ -Crypto database migration tests diff --git a/changelog.d/7653.bugfix b/changelog.d/7653.bugfix deleted file mode 100644 index ae49c4ed4e..0000000000 --- a/changelog.d/7653.bugfix +++ /dev/null @@ -1 +0,0 @@ -ANR when asking to select the notification method diff --git a/changelog.d/7658.bugfix b/changelog.d/7658.bugfix deleted file mode 100644 index a5ab85b191..0000000000 --- a/changelog.d/7658.bugfix +++ /dev/null @@ -1 +0,0 @@ -[Rich text editor] Fix design and spacing of rich text editor diff --git a/changelog.d/7659.bugfix b/changelog.d/7659.bugfix deleted file mode 100644 index 38be1008ef..0000000000 --- a/changelog.d/7659.bugfix +++ /dev/null @@ -1 +0,0 @@ -[Rich text editor] Fix keyboard closing after collapsing editor diff --git a/changelog.d/7680.bugfix b/changelog.d/7680.bugfix deleted file mode 100644 index 2e3b4b2e48..0000000000 --- a/changelog.d/7680.bugfix +++ /dev/null @@ -1,3 +0,0 @@ -Rich Text Editor: fix several issues related to insets: -* Empty space displayed at the bottom when you don't have permissions to send messages into a room. -* Wrong insets being kept when you exit the room screen and the keyboard is displayed, then come back to it. diff --git a/changelog.d/7683.bugfix b/changelog.d/7683.bugfix deleted file mode 100644 index 3922253ba6..0000000000 --- a/changelog.d/7683.bugfix +++ /dev/null @@ -1,2 +0,0 @@ -Fix crash in message composer when room is missing - diff --git a/changelog.d/7684.bugfix b/changelog.d/7684.bugfix deleted file mode 100644 index 4a9af884a1..0000000000 --- a/changelog.d/7684.bugfix +++ /dev/null @@ -1 +0,0 @@ - Fix crash when invalid homeserver url is entered. diff --git a/changelog.d/7691.bugfix b/changelog.d/7691.bugfix deleted file mode 100644 index 0298819143..0000000000 --- a/changelog.d/7691.bugfix +++ /dev/null @@ -1 +0,0 @@ -Rich Text Editor: improve performance when entering reply/edit/quote mode. diff --git a/changelog.d/7693.feature b/changelog.d/7693.feature deleted file mode 100644 index 271964db82..0000000000 --- a/changelog.d/7693.feature +++ /dev/null @@ -1 +0,0 @@ -[Session manager] Add action to signout all the other session diff --git a/changelog.d/7694.feature b/changelog.d/7694.feature deleted file mode 100644 index 408925974e..0000000000 --- a/changelog.d/7694.feature +++ /dev/null @@ -1 +0,0 @@ -Remind unverified sessions with a banner once a week diff --git a/changelog.d/7695.bugfix b/changelog.d/7695.bugfix deleted file mode 100644 index 7ec0805bce..0000000000 --- a/changelog.d/7695.bugfix +++ /dev/null @@ -1 +0,0 @@ -[Rich text editor] Add error tracking for rich text editor diff --git a/changelog.d/7697.feature b/changelog.d/7697.feature deleted file mode 100644 index 6d71a84a40..0000000000 --- a/changelog.d/7697.feature +++ /dev/null @@ -1 +0,0 @@ -[Session manager] Add actions to rename and signout current session diff --git a/changelog.d/7699.bugfix b/changelog.d/7699.bugfix deleted file mode 100644 index 30a4b8e9fa..0000000000 --- a/changelog.d/7699.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix E2EE set up failure whilst signing in using QR code diff --git a/changelog.d/7708.misc b/changelog.d/7708.misc deleted file mode 100644 index 6273330395..0000000000 --- a/changelog.d/7708.misc +++ /dev/null @@ -1 +0,0 @@ -Add tracing Id for to device messages diff --git a/changelog.d/7710.bugfix b/changelog.d/7710.bugfix deleted file mode 100644 index 9e75a03e1b..0000000000 --- a/changelog.d/7710.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix usage of unknown shield in room summary diff --git a/changelog.d/7719.feature b/changelog.d/7719.feature deleted file mode 100644 index 34df6ad964..0000000000 --- a/changelog.d/7719.feature +++ /dev/null @@ -1 +0,0 @@ -Voice Broadcast - Update last message in the room list diff --git a/changelog.d/7723.misc b/changelog.d/7723.misc deleted file mode 100644 index 36869d1efb..0000000000 --- a/changelog.d/7723.misc +++ /dev/null @@ -1 +0,0 @@ -Disable nightly popup and add an entry point in the advanced settings instead. diff --git a/changelog.d/7725.bugfix b/changelog.d/7725.bugfix deleted file mode 100644 index b701451505..0000000000 --- a/changelog.d/7725.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix crash when the network is not available. diff --git a/changelog.d/7733.bugfix b/changelog.d/7733.bugfix deleted file mode 100644 index 9de3759f1a..0000000000 --- a/changelog.d/7733.bugfix +++ /dev/null @@ -1 +0,0 @@ -[Session manager] Sessions without encryption support should not prompt to verify diff --git a/changelog.d/7737.bugfix b/changelog.d/7737.bugfix deleted file mode 100644 index 1477834674..0000000000 --- a/changelog.d/7737.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix issue of Scan QR code button sometimes not showing when it should be available diff --git a/changelog.d/7740.feature b/changelog.d/7740.feature deleted file mode 100644 index 6cd2b6c776..0000000000 --- a/changelog.d/7740.feature +++ /dev/null @@ -1 +0,0 @@ -Handle account data removal diff --git a/changelog.d/7743.bugfix b/changelog.d/7743.bugfix deleted file mode 100644 index 867c12a3c3..0000000000 --- a/changelog.d/7743.bugfix +++ /dev/null @@ -1 +0,0 @@ -Verification request is not showing when verify session popup is displayed diff --git a/changelog.d/7744.bugfix b/changelog.d/7744.bugfix deleted file mode 100644 index 7ed82a9c1c..0000000000 --- a/changelog.d/7744.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix crash when inviting by email. diff --git a/changelog.d/7751.bugfix b/changelog.d/7751.bugfix deleted file mode 100644 index 5d676dbc4d..0000000000 --- a/changelog.d/7751.bugfix +++ /dev/null @@ -1 +0,0 @@ -Revert usage of stable fields in live location sharing and polls diff --git a/changelog.d/7753.bugfix b/changelog.d/7753.bugfix deleted file mode 100644 index 10579b6a84..0000000000 --- a/changelog.d/7753.bugfix +++ /dev/null @@ -1 +0,0 @@ -[Poll] Poll end event is not recognized diff --git a/changelog.d/7754.feature b/changelog.d/7754.feature deleted file mode 100644 index 0e1b6d0961..0000000000 --- a/changelog.d/7754.feature +++ /dev/null @@ -1 +0,0 @@ -Delete unused client information from account data diff --git a/changelog.d/7770.bugfix b/changelog.d/7770.bugfix deleted file mode 100644 index 598deb6073..0000000000 --- a/changelog.d/7770.bugfix +++ /dev/null @@ -1 +0,0 @@ -[Push Notifications] When push notification for threaded message is clicked, thread timeline will be opened instead of room's main timeline From dd88ac597e60d9011e603116d2796e5a505a2e74 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 15 Dec 2022 10:16:02 +0100 Subject: [PATCH 85/85] Adding fastlane file for version 1.5.12 --- fastlane/metadata/android/en-US/changelogs/40105120.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 fastlane/metadata/android/en-US/changelogs/40105120.txt diff --git a/fastlane/metadata/android/en-US/changelogs/40105120.txt b/fastlane/metadata/android/en-US/changelogs/40105120.txt new file mode 100644 index 0000000000..91c25cf053 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40105120.txt @@ -0,0 +1,2 @@ +Main changes in this version: Thread are now enabled by default. +Full changelog: https://github.com/vector-im/element-android/releases