diff --git a/CHANGES.md b/CHANGES.md index cf1716dea8..b66e567f5b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,14 @@ +Changes in Element v1.4.27 (2022-07-06) +======================================= + +Bugfixes 🐛 +---------- + - Fixes crash when sharing plain text, such as a url ([#6451](https://github.com/vector-im/element-android/issues/6451)) + - Fix crashes on Timeline [Thread] due to range validation ([#6461](https://github.com/vector-im/element-android/issues/6461)) + - Fix crashes when opening Thread ([#6463](https://github.com/vector-im/element-android/issues/6463)) + - Fix ConcurrentModificationException on BackgroundDetectionObserver ([#6469](https://github.com/vector-im/element-android/issues/6469)) + + Changes in Element v1.4.26 (2022-06-30) ======================================= diff --git a/build.gradle b/build.gradle index 0244080ad0..61027a0bff 100644 --- a/build.gradle +++ b/build.gradle @@ -24,7 +24,7 @@ buildscript { classpath libs.gradle.gradlePlugin classpath libs.gradle.kotlinPlugin classpath libs.gradle.hiltPlugin - classpath 'com.google.gms:google-services:4.3.10' + classpath 'com.google.gms:google-services:4.3.13' classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.4.0.2513' classpath 'com.google.android.gms:oss-licenses-plugin:0.10.5' classpath "com.likethesalad.android:stem-plugin:2.1.1" diff --git a/changelog.d/4777.bugfix b/changelog.d/4777.bugfix new file mode 100644 index 0000000000..428101f649 --- /dev/null +++ b/changelog.d/4777.bugfix @@ -0,0 +1 @@ +Fixes numbered lists always starting from 1 diff --git a/changelog.d/5398.bugfix b/changelog.d/5398.bugfix new file mode 100644 index 0000000000..24da1952b3 --- /dev/null +++ b/changelog.d/5398.bugfix @@ -0,0 +1 @@ +Adds LoginType to SessionParams to fix soft logout form not showing for SSO and Password type diff --git a/changelog.d/5853.feature b/changelog.d/5853.feature new file mode 100644 index 0000000000..2a399e76aa --- /dev/null +++ b/changelog.d/5853.feature @@ -0,0 +1 @@ +Improve user experience when he is first invited to a room. Users will be able to decrypt and view previous messages diff --git a/changelog.d/6401.feature b/changelog.d/6401.feature new file mode 100644 index 0000000000..67ded427d6 --- /dev/null +++ b/changelog.d/6401.feature @@ -0,0 +1 @@ +[Location sharing] - Reply action on a live message diff --git a/changelog.d/6423.misc b/changelog.d/6423.misc new file mode 100644 index 0000000000..26c1e84830 --- /dev/null +++ b/changelog.d/6423.misc @@ -0,0 +1 @@ +[Poll] - Add a description under undisclosed poll when not ended diff --git a/changelog.d/6430.bugfix b/changelog.d/6430.bugfix new file mode 100644 index 0000000000..99b1cb836d --- /dev/null +++ b/changelog.d/6430.bugfix @@ -0,0 +1 @@ +[Poll] Fixes visible and wrong votes in closed poll after removing 2 previous polls diff --git a/changelog.d/6434.misc b/changelog.d/6434.misc new file mode 100644 index 0000000000..faef5bc040 --- /dev/null +++ b/changelog.d/6434.misc @@ -0,0 +1 @@ +Add code check to prevent modification of frozen class diff --git a/changelog.d/6436.misc b/changelog.d/6436.misc new file mode 100644 index 0000000000..a170b624ce --- /dev/null +++ b/changelog.d/6436.misc @@ -0,0 +1 @@ +Let your Activity or Fragment implement `VectorMenuProvider` if they provide a menu. diff --git a/changelog.d/6442.bugfix b/changelog.d/6442.bugfix new file mode 100644 index 0000000000..070ff94bee --- /dev/null +++ b/changelog.d/6442.bugfix @@ -0,0 +1 @@ +Fix HTML entities being displayed in messages diff --git a/changelog.d/6450.bugfix b/changelog.d/6450.bugfix new file mode 100644 index 0000000000..212d835f20 --- /dev/null +++ b/changelog.d/6450.bugfix @@ -0,0 +1 @@ +Gallery picker can pick external images diff --git a/changelog.d/6451.bugfix b/changelog.d/6451.bugfix new file mode 100644 index 0000000000..ed4a2df7e8 --- /dev/null +++ b/changelog.d/6451.bugfix @@ -0,0 +1 @@ +Fixes crash when sharing plain text, such as a url diff --git a/changelog.d/6458.misc b/changelog.d/6458.misc new file mode 100644 index 0000000000..a9a0fcd702 --- /dev/null +++ b/changelog.d/6458.misc @@ -0,0 +1 @@ +Rename Android Service to use `AndroidService` suffix diff --git a/changelog.d/6461.bugfix b/changelog.d/6461.bugfix new file mode 100644 index 0000000000..1d3e4e14c5 --- /dev/null +++ b/changelog.d/6461.bugfix @@ -0,0 +1 @@ +Fix crashes on Timeline [Thread] due to range validation diff --git a/changelog.d/6463.bugfix b/changelog.d/6463.bugfix new file mode 100644 index 0000000000..63d66d018e --- /dev/null +++ b/changelog.d/6463.bugfix @@ -0,0 +1 @@ +Fix crashes when opening Thread diff --git a/changelog.d/6469.bugfix b/changelog.d/6469.bugfix new file mode 100644 index 0000000000..5a9cb4022e --- /dev/null +++ b/changelog.d/6469.bugfix @@ -0,0 +1 @@ +Fix ConcurrentModificationException on BackgroundDetectionObserver diff --git a/coverage.gradle b/coverage.gradle index f278a475ef..f335ed8063 100644 --- a/coverage.gradle +++ b/coverage.gradle @@ -7,6 +7,7 @@ def excludes = [ '**/*Activity*', '**/*Fragment*', '**/*Application*', +'**/*AndroidService*', // We would like to exclude android widgets as well but our naming is inconsistent diff --git a/dependencies.gradle b/dependencies.gradle index db9278b975..acd5ad892b 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -19,9 +19,9 @@ def retrofit = "2.9.0" def arrow = "0.8.2" def markwon = "4.6.2" def moshi = "1.13.0" -def lifecycle = "2.4.1" +def lifecycle = "2.5.0" def flowBinding = "1.2.0" -def flipper = "0.151.1" +def flipper = "0.153.0" def epoxy = "4.6.2" def mavericks = "2.7.0" def glide = "4.13.2" @@ -29,7 +29,7 @@ def bigImageViewer = "1.8.1" def jjwt = "0.11.5" def vanniktechEmoji = "0.15.0" -def fragment = "1.4.1" +def fragment = "1.5.0" // 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 @@ -50,7 +50,7 @@ ext.libs = [ ], androidx : [ 'annotation' : "androidx.annotation:annotation:1.4.0", - 'activity' : "androidx.activity:activity:1.4.0", + 'activity' : "androidx.activity:activity:1.5.0", 'annotations' : "androidx.annotation:annotation:1.3.0", 'appCompat' : "androidx.appcompat:appcompat:1.4.2", 'biometric' : "androidx.biometric:biometric:1.1.0", diff --git a/docs/pull_request.md b/docs/pull_request.md index eebf2814a9..7f6ac86b8a 100644 --- a/docs/pull_request.md +++ b/docs/pull_request.md @@ -191,7 +191,7 @@ Examples of prefixes: - `[Bugfix]` - etc. -Also, it's still possible to add labels to the PRs, such as `A-` or `T-` labels, even if this is not a string requirement. We prefer to spend time to add labels on issues. +Also, it's still possible to add labels to the PRs, such as `A-` or `T-` labels, even if this is not a strong requirement. We prefer to spend time to add labels on issues. ##### PR description diff --git a/fastlane/metadata/android/en-US/changelogs/40104270.txt b/fastlane/metadata/android/en-US/changelogs/40104270.txt new file mode 100644 index 0000000000..61db61727a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40104270.txt @@ -0,0 +1,2 @@ +Main changes in this version: Various bug fixes and stability improvements. +Full changelog: https://github.com/vector-im/element-android/releases diff --git a/library/multipicker/src/main/java/im/vector/lib/multipicker/MediaPicker.kt b/library/multipicker/src/main/java/im/vector/lib/multipicker/MediaPicker.kt index db74dbf9ff..82d0e358df 100644 --- a/library/multipicker/src/main/java/im/vector/lib/multipicker/MediaPicker.kt +++ b/library/multipicker/src/main/java/im/vector/lib/multipicker/MediaPicker.kt @@ -49,7 +49,7 @@ class MediaPicker : Picker() { return Intent(Intent.ACTION_GET_CONTENT).apply { addCategory(Intent.CATEGORY_OPENABLE) putExtra(Intent.EXTRA_ALLOW_MULTIPLE, !single) - type = "video/*, image/*" + type = "*/*" val mimeTypes = arrayOf("image/*", "video/*") putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) } diff --git a/library/ui-styles/build.gradle b/library/ui-styles/build.gradle index eabd0f36f6..c85f26dbc4 100644 --- a/library/ui-styles/build.gradle +++ b/library/ui-styles/build.gradle @@ -53,6 +53,7 @@ android { dependencies { implementation libs.androidx.appCompat + implementation libs.androidx.fragmentKtx implementation libs.google.material // Pref theme implementation libs.androidx.preferenceKtx diff --git a/library/ui-styles/src/debug/java/im/vector/lib/ui/styles/debug/DebugMaterialThemeActivity.kt b/library/ui-styles/src/debug/java/im/vector/lib/ui/styles/debug/DebugMaterialThemeActivity.kt index 553d495e22..412d6fdc1c 100644 --- a/library/ui-styles/src/debug/java/im/vector/lib/ui/styles/debug/DebugMaterialThemeActivity.kt +++ b/library/ui-styles/src/debug/java/im/vector/lib/ui/styles/debug/DebugMaterialThemeActivity.kt @@ -18,8 +18,12 @@ package im.vector.lib.ui.styles.debug import android.os.Bundle import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.MenuProvider +import androidx.lifecycle.Lifecycle import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import im.vector.lib.ui.styles.R @@ -31,6 +35,7 @@ abstract class DebugMaterialThemeActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setupMenu() val views = ActivityDebugMaterialThemeBinding.inflate(layoutInflater) setContentView(views.root) @@ -72,6 +77,27 @@ abstract class DebugMaterialThemeActivity : AppCompatActivity() { } } + private fun setupMenu() { + addMenuProvider( + object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.menu_debug, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + Toast.makeText( + this@DebugMaterialThemeActivity, + "Menu ${menuItem.title} clicked!", + Toast.LENGTH_SHORT + ).show() + return true + } + }, + this, + Lifecycle.State.RESUMED + ) + } + private fun showTestDialog(theme: Int) { MaterialAlertDialogBuilder(this, theme) .setTitle("Dialog title") @@ -82,9 +108,4 @@ abstract class DebugMaterialThemeActivity : AppCompatActivity() { .setNeutralButton("Neutral", null) .show() } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_debug, menu) - return true - } } diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt index 7dafe33935..a78953caac 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt @@ -18,12 +18,15 @@ package org.matrix.android.sdk.common import android.content.Context import android.net.Uri +import android.util.Log import androidx.lifecycle.Observer import androidx.test.internal.runner.junit4.statement.UiThreadStatement import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -38,7 +41,10 @@ import org.matrix.android.sdk.api.auth.registration.RegistrationResult import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.getRoomSummary import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.failure.JoinRoomFailure +import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.Timeline @@ -47,6 +53,7 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings import org.matrix.android.sdk.api.session.sync.SyncState import timber.log.Timber import java.util.UUID +import java.util.concurrent.CancellationException import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -54,7 +61,7 @@ import java.util.concurrent.TimeUnit * This class exposes methods to be used in common cases * Registration, login, Sync, Sending messages... */ -class CommonTestHelper private constructor(context: Context) { +class CommonTestHelper internal constructor(context: Context) { companion object { internal fun runSessionTest(context: Context, autoSignoutOnClose: Boolean = true, block: (CommonTestHelper) -> Unit) { @@ -241,6 +248,37 @@ class CommonTestHelper private constructor(context: Context) { return sentEvents } + fun waitForAndAcceptInviteInRoom(otherSession: Session, roomID: String) { + waitWithLatch { latch -> + retryPeriodicallyWithLatch(latch) { + val roomSummary = otherSession.getRoomSummary(roomID) + (roomSummary != null && roomSummary.membership == Membership.INVITE).also { + if (it) { + Log.v("# TEST", "${otherSession.myUserId} can see the invite") + } + } + } + } + + // not sure why it's taking so long :/ + runBlockingTest(90_000) { + Log.v("#E2E TEST", "${otherSession.myUserId} tries to join room $roomID") + try { + otherSession.roomService().joinRoom(roomID) + } catch (ex: JoinRoomFailure.JoinedWithTimeout) { + // it's ok we will wait after + } + } + + Log.v("#E2E TEST", "${otherSession.myUserId} waiting for join echo ...") + waitWithLatch { + retryPeriodicallyWithLatch(it) { + val roomSummary = otherSession.getRoomSummary(roomID) + roomSummary != null && roomSummary.membership == Membership.JOIN + } + } + } + /** * Reply in a thread * @param room the room where to send the messages @@ -285,6 +323,8 @@ class CommonTestHelper private constructor(context: Context) { ) assertNotNull(session) return session.also { + // most of the test was created pre-MSC3061 so ensure compatibility + it.cryptoService().enableShareKeyOnInvite(false) trackedSessions.add(session) } } @@ -428,16 +468,26 @@ class CommonTestHelper private constructor(context: Context) { * @param latch * @throws InterruptedException */ - fun await(latch: CountDownLatch, timeout: Long? = TestConstants.timeOutMillis) { + fun await(latch: CountDownLatch, timeout: Long? = TestConstants.timeOutMillis, job: Job? = null) { assertTrue( "Timed out after " + timeout + "ms waiting for something to happen. See stacktrace for cause.", - latch.await(timeout ?: TestConstants.timeOutMillis, TimeUnit.MILLISECONDS) + latch.await(timeout ?: TestConstants.timeOutMillis, TimeUnit.MILLISECONDS).also { + if (!it) { + // cancel job on timeout + job?.cancel("Await timeout") + } + } ) } suspend fun retryPeriodicallyWithLatch(latch: CountDownLatch, condition: (() -> Boolean)) { while (true) { - delay(1000) + try { + delay(1000) + } catch (ex: CancellationException) { + // the job was canceled, just stop + return + } if (condition()) { latch.countDown() return @@ -447,10 +497,10 @@ class CommonTestHelper private constructor(context: Context) { fun waitWithLatch(timeout: Long? = TestConstants.timeOutMillis, dispatcher: CoroutineDispatcher = Dispatchers.Main, block: suspend (CountDownLatch) -> Unit) { val latch = CountDownLatch(1) - coroutineScope.launch(dispatcher) { + val job = coroutineScope.launch(dispatcher) { block(latch) } - await(latch, timeout) + await(latch, timeout, job) } fun runBlockingTest(timeout: Long = TestConstants.timeOutMillis, block: suspend () -> T): T { diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CryptoTestHelper.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CryptoTestHelper.kt index 5fd86d4fdb..f36bfb6210 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CryptoTestHelper.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CryptoTestHelper.kt @@ -53,6 +53,7 @@ import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.room.Room import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams import org.matrix.android.sdk.api.session.room.model.message.MessageContent @@ -76,11 +77,14 @@ class CryptoTestHelper(val testHelper: CommonTestHelper) { /** * @return alice session */ - fun doE2ETestWithAliceInARoom(encryptedRoom: Boolean = true): CryptoTestData { + fun doE2ETestWithAliceInARoom(encryptedRoom: Boolean = true, roomHistoryVisibility: RoomHistoryVisibility? = null): CryptoTestData { val aliceSession = testHelper.createAccount(TestConstants.USER_ALICE, defaultSessionParams) val roomId = testHelper.runBlockingTest { - aliceSession.roomService().createRoom(CreateRoomParams().apply { name = "MyRoom" }) + aliceSession.roomService().createRoom(CreateRoomParams().apply { + historyVisibility = roomHistoryVisibility + name = "MyRoom" + }) } if (encryptedRoom) { testHelper.waitWithLatch { latch -> @@ -104,8 +108,8 @@ class CryptoTestHelper(val testHelper: CommonTestHelper) { /** * @return alice and bob sessions */ - fun doE2ETestWithAliceAndBobInARoom(encryptedRoom: Boolean = true): CryptoTestData { - val cryptoTestData = doE2ETestWithAliceInARoom(encryptedRoom) + fun doE2ETestWithAliceAndBobInARoom(encryptedRoom: Boolean = true, roomHistoryVisibility: RoomHistoryVisibility? = null): CryptoTestData { + val cryptoTestData = doE2ETestWithAliceInARoom(encryptedRoom, roomHistoryVisibility) val aliceSession = cryptoTestData.firstSession val aliceRoomId = cryptoTestData.roomId diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2EShareKeysConfigTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2EShareKeysConfigTest.kt new file mode 100644 index 0000000000..32d63a1934 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2EShareKeysConfigTest.kt @@ -0,0 +1,298 @@ +/* + * 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.crypto + +import android.util.Log +import androidx.test.filters.LargeTest +import org.amshove.kluent.internal.assertEquals +import org.junit.Assert +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import org.matrix.android.sdk.InstrumentedTest +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.crypto.keysbackup.KeysVersion +import org.matrix.android.sdk.api.session.crypto.keysbackup.KeysVersionResult +import org.matrix.android.sdk.api.session.crypto.keysbackup.MegolmBackupCreationInfo +import org.matrix.android.sdk.api.session.crypto.model.ImportRoomKeysResult +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.common.CommonTestHelper +import org.matrix.android.sdk.common.CommonTestHelper.Companion.runCryptoTest +import org.matrix.android.sdk.common.CryptoTestData +import org.matrix.android.sdk.common.SessionTestParams +import org.matrix.android.sdk.common.TestConstants +import org.matrix.android.sdk.common.TestMatrixCallback + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +@LargeTest +class E2EShareKeysConfigTest : InstrumentedTest { + + @Test + fun msc3061ShouldBeDisabledByDefault() = runCryptoTest(context()) { _, commonTestHelper -> + val aliceSession = commonTestHelper.createAccount(TestConstants.USER_ALICE, SessionTestParams(withInitialSync = false)) + Assert.assertFalse("MSC3061 is lab and should be disabled by default", aliceSession.cryptoService().isShareKeysOnInviteEnabled()) + } + + @Test + fun ensureKeysAreNotSharedIfOptionDisabled() = runCryptoTest(context()) { cryptoTestHelper, commonTestHelper -> + val aliceSession = commonTestHelper.createAccount(TestConstants.USER_ALICE, SessionTestParams(withInitialSync = true)) + aliceSession.cryptoService().enableShareKeyOnInvite(false) + val roomId = commonTestHelper.runBlockingTest { + aliceSession.roomService().createRoom(CreateRoomParams().apply { + historyVisibility = RoomHistoryVisibility.SHARED + name = "MyRoom" + enableEncryption() + }) + } + + commonTestHelper.waitWithLatch { latch -> + commonTestHelper.retryPeriodicallyWithLatch(latch) { + aliceSession.roomService().getRoomSummary(roomId)?.isEncrypted == true + } + } + val roomAlice = aliceSession.roomService().getRoom(roomId)!! + + // send some messages + val withSession1 = commonTestHelper.sendTextMessage(roomAlice, "Hello", 1) + aliceSession.cryptoService().discardOutboundSession(roomId) + val withSession2 = commonTestHelper.sendTextMessage(roomAlice, "World", 1) + + // Create bob account + val bobSession = commonTestHelper.createAccount(TestConstants.USER_BOB, SessionTestParams(withInitialSync = true)) + + // Let alice invite bob + commonTestHelper.runBlockingTest { + roomAlice.membershipService().invite(bobSession.myUserId) + } + + commonTestHelper.waitForAndAcceptInviteInRoom(bobSession, roomId) + + // Bob has join but should not be able to decrypt history + cryptoTestHelper.ensureCannotDecrypt( + withSession1.map { it.eventId } + withSession2.map { it.eventId }, + bobSession, + roomId + ) + + // We don't need bob anymore + commonTestHelper.signOutAndClose(bobSession) + + // Now let's enable history key sharing on alice side + aliceSession.cryptoService().enableShareKeyOnInvite(true) + + // let's add a new message first + val afterFlagOn = commonTestHelper.sendTextMessage(roomAlice, "After", 1) + + // Worth nothing to check that the session was rotated + Assert.assertNotEquals( + "Session should have been rotated", + withSession2.first().root.content?.get("session_id")!!, + afterFlagOn.first().root.content?.get("session_id")!! + ) + + // Invite a new user + val samSession = commonTestHelper.createAccount(TestConstants.USER_SAM, SessionTestParams(withInitialSync = true)) + + // Let alice invite sam + commonTestHelper.runBlockingTest { + roomAlice.membershipService().invite(samSession.myUserId) + } + + commonTestHelper.waitForAndAcceptInviteInRoom(samSession, roomId) + + // Sam shouldn't be able to decrypt messages with the first session, but should decrypt the one with 3rd session + cryptoTestHelper.ensureCannotDecrypt( + withSession1.map { it.eventId } + withSession2.map { it.eventId }, + samSession, + roomId + ) + + cryptoTestHelper.ensureCanDecrypt( + afterFlagOn.map { it.eventId }, + samSession, + roomId, + afterFlagOn.map { it.root.getClearContent()?.get("body") as String }) + } + + @Test + fun ifSharingDisabledOnAliceSideBobShouldNotShareAliceHistoty() = runCryptoTest(context()) { cryptoTestHelper, commonTestHelper -> + val testData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(roomHistoryVisibility = RoomHistoryVisibility.SHARED) + val aliceSession = testData.firstSession.also { + it.cryptoService().enableShareKeyOnInvite(false) + } + val bobSession = testData.secondSession!!.also { + it.cryptoService().enableShareKeyOnInvite(true) + } + + val (fromAliceNotSharable, fromBobSharable, samSession) = commonAliceAndBobSendMessages(commonTestHelper, aliceSession, testData, bobSession) + + // Bob should have shared history keys to sam. + // But has alice hasn't enabled sharing, bob shouldn't send her sessions + cryptoTestHelper.ensureCannotDecrypt( + fromAliceNotSharable.map { it.eventId }, + samSession, + testData.roomId + ) + + cryptoTestHelper.ensureCanDecrypt( + fromBobSharable.map { it.eventId }, + samSession, + testData.roomId, + fromBobSharable.map { it.root.getClearContent()?.get("body") as String }) + } + + @Test + fun ifSharingEnabledOnAliceSideBobShouldShareAliceHistoty() = runCryptoTest(context()) { cryptoTestHelper, commonTestHelper -> + val testData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(roomHistoryVisibility = RoomHistoryVisibility.SHARED) + val aliceSession = testData.firstSession.also { + it.cryptoService().enableShareKeyOnInvite(true) + } + val bobSession = testData.secondSession!!.also { + it.cryptoService().enableShareKeyOnInvite(true) + } + + val (fromAliceNotSharable, fromBobSharable, samSession) = commonAliceAndBobSendMessages(commonTestHelper, aliceSession, testData, bobSession) + + cryptoTestHelper.ensureCanDecrypt( + fromAliceNotSharable.map { it.eventId }, + samSession, + testData.roomId, + fromAliceNotSharable.map { it.root.getClearContent()?.get("body") as String }) + + cryptoTestHelper.ensureCanDecrypt( + fromBobSharable.map { it.eventId }, + samSession, + testData.roomId, + fromBobSharable.map { it.root.getClearContent()?.get("body") as String }) + } + + private fun commonAliceAndBobSendMessages(commonTestHelper: CommonTestHelper, aliceSession: Session, testData: CryptoTestData, bobSession: Session): Triple, List, Session> { + val fromAliceNotSharable = commonTestHelper.sendTextMessage(aliceSession.getRoom(testData.roomId)!!, "Hello from alice", 1) + val fromBobSharable = commonTestHelper.sendTextMessage(bobSession.getRoom(testData.roomId)!!, "Hello from bob", 1) + + // Now let bob invite Sam + // Invite a new user + val samSession = commonTestHelper.createAccount(TestConstants.USER_SAM, SessionTestParams(withInitialSync = true)) + + // Let bob invite sam + commonTestHelper.runBlockingTest { + bobSession.getRoom(testData.roomId)!!.membershipService().invite(samSession.myUserId) + } + + commonTestHelper.waitForAndAcceptInviteInRoom(samSession, testData.roomId) + return Triple(fromAliceNotSharable, fromBobSharable, samSession) + } + + // test flag on backup is correct + + @Test + fun testBackupFlagIsCorrect() = runCryptoTest(context()) { cryptoTestHelper, commonTestHelper -> + val aliceSession = commonTestHelper.createAccount(TestConstants.USER_ALICE, SessionTestParams(withInitialSync = true)) + aliceSession.cryptoService().enableShareKeyOnInvite(false) + val roomId = commonTestHelper.runBlockingTest { + aliceSession.roomService().createRoom(CreateRoomParams().apply { + historyVisibility = RoomHistoryVisibility.SHARED + name = "MyRoom" + enableEncryption() + }) + } + + commonTestHelper.waitWithLatch { latch -> + commonTestHelper.retryPeriodicallyWithLatch(latch) { + aliceSession.roomService().getRoomSummary(roomId)?.isEncrypted == true + } + } + val roomAlice = aliceSession.roomService().getRoom(roomId)!! + + // send some messages + val notSharableMessage = commonTestHelper.sendTextMessage(roomAlice, "Hello", 1) + aliceSession.cryptoService().enableShareKeyOnInvite(true) + val sharableMessage = commonTestHelper.sendTextMessage(roomAlice, "World", 1) + + Log.v("#E2E TEST", "Create and start key backup for bob ...") + val keysBackupService = aliceSession.cryptoService().keysBackupService() + val keyBackupPassword = "FooBarBaz" + val megolmBackupCreationInfo = commonTestHelper.doSync { + keysBackupService.prepareKeysBackupVersion(keyBackupPassword, null, it) + } + val version = commonTestHelper.doSync { + keysBackupService.createKeysBackupVersion(megolmBackupCreationInfo, it) + } + + commonTestHelper.waitWithLatch { latch -> + keysBackupService.backupAllGroupSessions( + null, + TestMatrixCallback(latch, true) + ) + } + + // signout + commonTestHelper.signOutAndClose(aliceSession) + + val newAliceSession = commonTestHelper.logIntoAccount(aliceSession.myUserId, SessionTestParams(true)) + newAliceSession.cryptoService().enableShareKeyOnInvite(true) + + newAliceSession.cryptoService().keysBackupService().let { kbs -> + val keyVersionResult = commonTestHelper.doSync { + kbs.getVersion(version.version, it) + } + + val importedResult = commonTestHelper.doSync { + kbs.restoreKeyBackupWithPassword( + keyVersionResult!!, + keyBackupPassword, + null, + null, + null, + it + ) + } + + assertEquals(2, importedResult.totalNumberOfKeys) + } + + // Now let's invite sam + // Invite a new user + val samSession = commonTestHelper.createAccount(TestConstants.USER_SAM, SessionTestParams(withInitialSync = true)) + + // Let alice invite sam + commonTestHelper.runBlockingTest { + newAliceSession.getRoom(roomId)!!.membershipService().invite(samSession.myUserId) + } + + commonTestHelper.waitForAndAcceptInviteInRoom(samSession, roomId) + + // Sam shouldn't be able to decrypt messages with the first session, but should decrypt the one with 3rd session + cryptoTestHelper.ensureCannotDecrypt( + notSharableMessage.map { it.eventId }, + samSession, + roomId + ) + + cryptoTestHelper.ensureCanDecrypt( + sharableMessage.map { it.eventId }, + samSession, + roomId, + sharableMessage.map { it.root.getClearContent()?.get("body") as String }) + } +} diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeSanityTests.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeSanityTests.kt index 5a61eee7fe..251c13ccbf 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeSanityTests.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeSanityTests.kt @@ -23,7 +23,6 @@ import org.amshove.kluent.fail import org.amshove.kluent.internal.assertEquals import org.junit.Assert import org.junit.FixMethodOrder -import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -49,9 +48,7 @@ import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventCon import org.matrix.android.sdk.api.session.events.model.content.WithHeldCode import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.getRoom -import org.matrix.android.sdk.api.session.getRoomSummary import org.matrix.android.sdk.api.session.room.Room -import org.matrix.android.sdk.api.session.room.failure.JoinRoomFailure import org.matrix.android.sdk.api.session.room.getTimelineEvent import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.message.MessageContent @@ -67,10 +64,10 @@ import org.matrix.android.sdk.common.TestMatrixCallback import org.matrix.android.sdk.mustFail import java.util.concurrent.CountDownLatch +// @Ignore("This test fails with an unhandled exception thrown from a coroutine which terminates the entire test run.") @RunWith(JUnit4::class) @FixMethodOrder(MethodSorters.JVM) @LargeTest -@Ignore("This test fails with an unhandled exception thrown from a coroutine which terminates the entire test run.") class E2eeSanityTests : InstrumentedTest { @get:Rule val rule = RetryTestRule(3) @@ -115,7 +112,7 @@ class E2eeSanityTests : InstrumentedTest { // All user should accept invite otherAccounts.forEach { otherSession -> - waitForAndAcceptInviteInRoom(testHelper, otherSession, e2eRoomID) + testHelper.waitForAndAcceptInviteInRoom(otherSession, e2eRoomID) Log.v("#E2E TEST", "${otherSession.myUserId} joined room $e2eRoomID") } @@ -156,7 +153,7 @@ class E2eeSanityTests : InstrumentedTest { } newAccount.forEach { - waitForAndAcceptInviteInRoom(testHelper, it, e2eRoomID) + testHelper.waitForAndAcceptInviteInRoom(it, e2eRoomID) } ensureMembersHaveJoined(testHelper, aliceSession, newAccount, e2eRoomID) @@ -740,37 +737,6 @@ class E2eeSanityTests : InstrumentedTest { } } - private fun waitForAndAcceptInviteInRoom(testHelper: CommonTestHelper, otherSession: Session, e2eRoomID: String) { - testHelper.waitWithLatch { latch -> - testHelper.retryPeriodicallyWithLatch(latch) { - val roomSummary = otherSession.getRoomSummary(e2eRoomID) - (roomSummary != null && roomSummary.membership == Membership.INVITE).also { - if (it) { - Log.v("#E2E TEST", "${otherSession.myUserId} can see the invite from alice") - } - } - } - } - - // not sure why it's taking so long :/ - testHelper.runBlockingTest(90_000) { - Log.v("#E2E TEST", "${otherSession.myUserId} tries to join room $e2eRoomID") - try { - otherSession.roomService().joinRoom(e2eRoomID) - } catch (ex: JoinRoomFailure.JoinedWithTimeout) { - // it's ok we will wait after - } - } - - Log.v("#E2E TEST", "${otherSession.myUserId} waiting for join echo ...") - testHelper.waitWithLatch { - testHelper.retryPeriodicallyWithLatch(it) { - val roomSummary = otherSession.getRoomSummary(e2eRoomID) - roomSummary != null && roomSummary.membership == Membership.JOIN - } - } - } - private fun ensureIsDecrypted(testHelper: CommonTestHelper, sentEventIds: List, session: Session, e2eRoomID: String) { testHelper.waitWithLatch { latch -> sentEventIds.forEach { sentEventId -> diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeShareKeysHistoryTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeShareKeysHistoryTest.kt new file mode 100644 index 0000000000..32a95008b1 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/E2eeShareKeysHistoryTest.kt @@ -0,0 +1,424 @@ +/* + * 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.crypto + +import android.util.Log +import androidx.test.filters.LargeTest +import org.amshove.kluent.internal.assertEquals +import org.amshove.kluent.internal.assertNotEquals +import org.junit.Assert +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import org.matrix.android.sdk.InstrumentedTest +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.failure.JoinRoomFailure +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility +import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibilityContent +import org.matrix.android.sdk.api.session.room.model.shouldShareHistory +import org.matrix.android.sdk.common.CommonTestHelper +import org.matrix.android.sdk.common.CommonTestHelper.Companion.runCryptoTest +import org.matrix.android.sdk.common.CryptoTestHelper +import org.matrix.android.sdk.common.SessionTestParams + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +@LargeTest +class E2eeShareKeysHistoryTest : InstrumentedTest { + + @Test + fun testShareMessagesHistoryWithRoomWorldReadable() { + testShareHistoryWithRoomVisibility(RoomHistoryVisibility.WORLD_READABLE) + } + + @Test + fun testShareMessagesHistoryWithRoomShared() { + testShareHistoryWithRoomVisibility(RoomHistoryVisibility.SHARED) + } + + @Test + fun testShareMessagesHistoryWithRoomJoined() { + testShareHistoryWithRoomVisibility(RoomHistoryVisibility.JOINED) + } + + @Test + fun testShareMessagesHistoryWithRoomInvited() { + testShareHistoryWithRoomVisibility(RoomHistoryVisibility.INVITED) + } + + /** + * In this test we create a room and test that new members + * can decrypt history when the room visibility is + * RoomHistoryVisibility.SHARED or RoomHistoryVisibility.WORLD_READABLE. + * We should not be able to view messages/decrypt otherwise + */ + private fun testShareHistoryWithRoomVisibility(roomHistoryVisibility: RoomHistoryVisibility? = null) = + runCryptoTest(context()) { cryptoTestHelper, testHelper -> + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true, roomHistoryVisibility) + + val e2eRoomID = cryptoTestData.roomId + + // Alice + val aliceSession = cryptoTestData.firstSession.also { + it.cryptoService().enableShareKeyOnInvite(true) + } + val aliceRoomPOV = aliceSession.roomService().getRoom(e2eRoomID)!! + + // Bob + val bobSession = cryptoTestData.secondSession!!.also { + it.cryptoService().enableShareKeyOnInvite(true) + } + val bobRoomPOV = bobSession.roomService().getRoom(e2eRoomID)!! + + assertEquals(bobRoomPOV.roomSummary()?.joinedMembersCount, 2) + Log.v("#E2E TEST", "Alice and Bob are in roomId: $e2eRoomID") + + val aliceMessageId: String? = sendMessageInRoom(aliceRoomPOV, "Hello Bob, I am Alice!", testHelper) + Assert.assertTrue("Message should be sent", aliceMessageId != null) + Log.v("#E2E TEST", "Alice sent message to roomId: $e2eRoomID") + + // Bob should be able to decrypt the message + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val timelineEvent = bobSession.roomService().getRoom(e2eRoomID)?.timelineService()?.getTimelineEvent(aliceMessageId!!) + (timelineEvent != null && + timelineEvent.isEncrypted() && + timelineEvent.root.getClearType() == EventType.MESSAGE).also { + if (it) { + Log.v("#E2E TEST", "Bob can decrypt the message: ${timelineEvent?.root?.getDecryptedTextSummary()}") + } + } + } + } + + // Create a new user + val arisSession = testHelper.createAccount("aris", SessionTestParams(true)).also { + it.cryptoService().enableShareKeyOnInvite(true) + } + Log.v("#E2E TEST", "Aris user created") + + // Alice invites new user to the room + testHelper.runBlockingTest { + Log.v("#E2E TEST", "Alice invites ${arisSession.myUserId}") + aliceRoomPOV.membershipService().invite(arisSession.myUserId) + } + + waitForAndAcceptInviteInRoom(arisSession, e2eRoomID, testHelper) + + ensureMembersHaveJoined(aliceSession, arrayListOf(arisSession), e2eRoomID, testHelper) + Log.v("#E2E TEST", "Aris has joined roomId: $e2eRoomID") + + when (roomHistoryVisibility) { + RoomHistoryVisibility.WORLD_READABLE, + RoomHistoryVisibility.SHARED, + null + -> { + // Aris should be able to decrypt the message + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val timelineEvent = arisSession.roomService().getRoom(e2eRoomID)?.timelineService()?.getTimelineEvent(aliceMessageId!!) + (timelineEvent != null && + timelineEvent.isEncrypted() && + timelineEvent.root.getClearType() == EventType.MESSAGE + ).also { + if (it) { + Log.v("#E2E TEST", "Aris can decrypt the message: ${timelineEvent?.root?.getDecryptedTextSummary()}") + } + } + } + } + } + RoomHistoryVisibility.INVITED, + RoomHistoryVisibility.JOINED -> { + // Aris should not even be able to get the message + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val timelineEvent = arisSession.roomService().getRoom(e2eRoomID) + ?.timelineService() + ?.getTimelineEvent(aliceMessageId!!) + timelineEvent == null + } + } + } + } + + testHelper.signOutAndClose(arisSession) + cryptoTestData.cleanUp(testHelper) + } + + @Test + fun testNeedsRotationFromWorldReadableToShared() { + testRotationDueToVisibilityChange(RoomHistoryVisibility.WORLD_READABLE, RoomHistoryVisibilityContent("shared")) + } + + @Test + fun testNeedsRotationFromWorldReadableToInvited() { + testRotationDueToVisibilityChange(RoomHistoryVisibility.WORLD_READABLE, RoomHistoryVisibilityContent("invited")) + } + + @Test + fun testNeedsRotationFromWorldReadableToJoined() { + testRotationDueToVisibilityChange(RoomHistoryVisibility.WORLD_READABLE, RoomHistoryVisibilityContent("joined")) + } + + @Test + fun testNeedsRotationFromSharedToWorldReadable() { + testRotationDueToVisibilityChange(RoomHistoryVisibility.SHARED, RoomHistoryVisibilityContent("world_readable")) + } + + @Test + fun testNeedsRotationFromSharedToInvited() { + testRotationDueToVisibilityChange(RoomHistoryVisibility.SHARED, RoomHistoryVisibilityContent("invited")) + } + + @Test + fun testNeedsRotationFromSharedToJoined() { + testRotationDueToVisibilityChange(RoomHistoryVisibility.SHARED, RoomHistoryVisibilityContent("joined")) + } + + @Test + fun testNeedsRotationFromInvitedToShared() { + testRotationDueToVisibilityChange(RoomHistoryVisibility.WORLD_READABLE, RoomHistoryVisibilityContent("shared")) + } + + @Test + fun testNeedsRotationFromInvitedToWorldReadable() { + testRotationDueToVisibilityChange(RoomHistoryVisibility.WORLD_READABLE, RoomHistoryVisibilityContent("world_readable")) + } + + @Test + fun testNeedsRotationFromInvitedToJoined() { + testRotationDueToVisibilityChange(RoomHistoryVisibility.WORLD_READABLE, RoomHistoryVisibilityContent("joined")) + } + + @Test + fun testNeedsRotationFromJoinedToShared() { + testRotationDueToVisibilityChange(RoomHistoryVisibility.WORLD_READABLE, RoomHistoryVisibilityContent("shared")) + } + + @Test + fun testNeedsRotationFromJoinedToInvited() { + testRotationDueToVisibilityChange(RoomHistoryVisibility.WORLD_READABLE, RoomHistoryVisibilityContent("invited")) + } + + @Test + fun testNeedsRotationFromJoinedToWorldReadable() { + testRotationDueToVisibilityChange(RoomHistoryVisibility.WORLD_READABLE, RoomHistoryVisibilityContent("world_readable")) + } + + /** + * In this test we will test that a rotation is needed when + * When the room's history visibility setting changes to world_readable or shared + * from invited or joined, or changes to invited or joined from world_readable or shared, + * senders that support this flag must rotate their megolm sessions. + */ + private fun testRotationDueToVisibilityChange( + initRoomHistoryVisibility: RoomHistoryVisibility, + nextRoomHistoryVisibility: RoomHistoryVisibilityContent + ) { + val testHelper = CommonTestHelper(context()) + val cryptoTestHelper = CryptoTestHelper(testHelper) + + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(true, initRoomHistoryVisibility) + val e2eRoomID = cryptoTestData.roomId + + // Alice + val aliceSession = cryptoTestData.firstSession.also { + it.cryptoService().enableShareKeyOnInvite(true) + } + val aliceRoomPOV = aliceSession.roomService().getRoom(e2eRoomID)!! +// val aliceCryptoStore = (aliceSession.cryptoService() as DefaultCryptoService).cryptoStoreForTesting + + // Bob + val bobSession = cryptoTestData.secondSession!! + + val bobRoomPOV = bobSession.roomService().getRoom(e2eRoomID)!! + + assertEquals(bobRoomPOV.roomSummary()?.joinedMembersCount, 2) + Log.v("#E2E TEST ROTATION", "Alice and Bob are in roomId: $e2eRoomID") + + val aliceMessageId: String? = sendMessageInRoom(aliceRoomPOV, "Hello Bob, I am Alice!", testHelper) + Assert.assertTrue("Message should be sent", aliceMessageId != null) + Log.v("#E2E TEST ROTATION", "Alice sent message to roomId: $e2eRoomID") + + // Bob should be able to decrypt the message + var firstAliceMessageMegolmSessionId: String? = null + val bobRoomPov = bobSession.roomService().getRoom(e2eRoomID) + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val timelineEvent = bobRoomPov + ?.timelineService() + ?.getTimelineEvent(aliceMessageId!!) + (timelineEvent != null && + timelineEvent.isEncrypted() && + timelineEvent.root.getClearType() == EventType.MESSAGE).also { + if (it) { + firstAliceMessageMegolmSessionId = timelineEvent?.root?.content?.get("session_id") as? String + Log.v( + "#E2E TEST", + "Bob can decrypt the message (sid:$firstAliceMessageMegolmSessionId): ${timelineEvent?.root?.getDecryptedTextSummary()}" + ) + } + } + } + } + + Assert.assertNotNull("megolm session id can't be null", firstAliceMessageMegolmSessionId) + + var secondAliceMessageSessionId: String? = null + sendMessageInRoom(aliceRoomPOV, "Other msg", testHelper)?.let { secondMessage -> + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val timelineEvent = bobRoomPov + ?.timelineService() + ?.getTimelineEvent(secondMessage) + (timelineEvent != null && + timelineEvent.isEncrypted() && + timelineEvent.root.getClearType() == EventType.MESSAGE).also { + if (it) { + secondAliceMessageSessionId = timelineEvent?.root?.content?.get("session_id") as? String + Log.v( + "#E2E TEST", + "Bob can decrypt the message (sid:$secondAliceMessageSessionId): ${timelineEvent?.root?.getDecryptedTextSummary()}" + ) + } + } + } + } + } + assertEquals("No rotation needed session should be the same", firstAliceMessageMegolmSessionId, secondAliceMessageSessionId) + Log.v("#E2E TEST ROTATION", "No rotation needed yet") + + // Let's change the room history visibility + testHelper.runBlockingTest { + aliceRoomPOV.stateService() + .sendStateEvent( + eventType = EventType.STATE_ROOM_HISTORY_VISIBILITY, + stateKey = "", + body = RoomHistoryVisibilityContent( + historyVisibilityStr = nextRoomHistoryVisibility.historyVisibilityStr + ).toContent() + ) + } + + // ensure that the state did synced down + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + aliceRoomPOV.stateService().getStateEvent(EventType.STATE_ROOM_HISTORY_VISIBILITY, QueryStringValue.IsEmpty)?.content + ?.toModel()?.historyVisibility == nextRoomHistoryVisibility.historyVisibility + } + } + + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val roomVisibility = aliceSession.getRoom(e2eRoomID)!! + .stateService() + .getStateEvent(EventType.STATE_ROOM_HISTORY_VISIBILITY, QueryStringValue.IsEmpty) + ?.content + ?.toModel() + Log.v("#E2E TEST ROTATION", "Room visibility changed from: ${initRoomHistoryVisibility.name} to: ${roomVisibility?.historyVisibility?.name}") + roomVisibility?.historyVisibility == nextRoomHistoryVisibility.historyVisibility + } + } + + var aliceThirdMessageSessionId: String? = null + sendMessageInRoom(aliceRoomPOV, "Message after visibility change", testHelper)?.let { thirdMessage -> + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val timelineEvent = bobRoomPov + ?.timelineService() + ?.getTimelineEvent(thirdMessage) + (timelineEvent != null && + timelineEvent.isEncrypted() && + timelineEvent.root.getClearType() == EventType.MESSAGE).also { + if (it) { + aliceThirdMessageSessionId = timelineEvent?.root?.content?.get("session_id") as? String + } + } + } + } + } + + when { + initRoomHistoryVisibility.shouldShareHistory() == nextRoomHistoryVisibility.historyVisibility?.shouldShareHistory() -> { + assertEquals("Session shouldn't have been rotated", secondAliceMessageSessionId, aliceThirdMessageSessionId) + Log.v("#E2E TEST ROTATION", "Rotation is not needed") + } + initRoomHistoryVisibility.shouldShareHistory() != nextRoomHistoryVisibility.historyVisibility!!.shouldShareHistory() -> { + assertNotEquals("Session should have been rotated", secondAliceMessageSessionId, aliceThirdMessageSessionId) + Log.v("#E2E TEST ROTATION", "Rotation is needed!") + } + } + + cryptoTestData.cleanUp(testHelper) + } + + private fun sendMessageInRoom(aliceRoomPOV: Room, text: String, testHelper: CommonTestHelper): String? { + return testHelper.sendTextMessage(aliceRoomPOV, text, 1).firstOrNull()?.eventId + } + + private fun ensureMembersHaveJoined(aliceSession: Session, otherAccounts: List, e2eRoomID: String, testHelper: CommonTestHelper) { + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + otherAccounts.map { + aliceSession.roomService().getRoomMember(it.myUserId, e2eRoomID)?.membership + }.all { + it == Membership.JOIN + } + } + } + } + + private fun waitForAndAcceptInviteInRoom(otherSession: Session, e2eRoomID: String, testHelper: CommonTestHelper) { + testHelper.waitWithLatch { latch -> + testHelper.retryPeriodicallyWithLatch(latch) { + val roomSummary = otherSession.roomService().getRoomSummary(e2eRoomID) + (roomSummary != null && roomSummary.membership == Membership.INVITE).also { + if (it) { + Log.v("#E2E TEST", "${otherSession.myUserId} can see the invite from alice") + } + } + } + } + + testHelper.runBlockingTest(60_000) { + Log.v("#E2E TEST", "${otherSession.myUserId} tries to join room $e2eRoomID") + try { + otherSession.roomService().joinRoom(e2eRoomID) + } catch (ex: JoinRoomFailure.JoinedWithTimeout) { + // it's ok we will wait after + } + } + + Log.v("#E2E TEST", "${otherSession.myUserId} waiting for join echo ...") + testHelper.waitWithLatch { + testHelper.retryPeriodicallyWithLatch(it) { + val roomSummary = otherSession.roomService().getRoomSummary(e2eRoomID) + roomSummary != null && roomSummary.membership == Membership.JOIN + } + } + } +} diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/PreShareKeysTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/PreShareKeysTest.kt index e37ae5be86..e8e7b1d708 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/PreShareKeysTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/PreShareKeysTest.kt @@ -72,7 +72,7 @@ class PreShareKeysTest : InstrumentedTest { assertNotNull("Bob should have received and decrypted a room key event from alice", bobInboundForAlice) assertEquals("Wrong room", e2eRoomID, bobInboundForAlice!!.roomId) - val megolmSessionId = bobInboundForAlice.olmInboundGroupSession!!.sessionIdentifier() + val megolmSessionId = bobInboundForAlice.session.sessionIdentifier() assertEquals("Wrong session", aliceOutboundSessionInRoom, megolmSessionId) diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/keysbackup/KeysBackupScenarioData.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/keysbackup/KeysBackupScenarioData.kt index 45fdb9e1e3..cf201611a0 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/keysbackup/KeysBackupScenarioData.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/keysbackup/KeysBackupScenarioData.kt @@ -19,14 +19,14 @@ package org.matrix.android.sdk.internal.crypto.keysbackup import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.common.CommonTestHelper import org.matrix.android.sdk.common.CryptoTestData -import org.matrix.android.sdk.internal.crypto.model.OlmInboundGroupSessionWrapper2 +import org.matrix.android.sdk.internal.crypto.model.MXInboundMegolmSessionWrapper /** * Data class to store result of [KeysBackupTestHelper.createKeysBackupScenarioWithPassword] */ internal data class KeysBackupScenarioData( val cryptoTestData: CryptoTestData, - val aliceKeys: List, + val aliceKeys: List, val prepareKeysBackupDataResult: PrepareKeysBackupDataResult, val aliceSession2: Session ) { diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/keysbackup/KeysBackupTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/keysbackup/KeysBackupTest.kt index fb498e0de5..e160938721 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/keysbackup/KeysBackupTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/keysbackup/KeysBackupTest.kt @@ -301,7 +301,7 @@ class KeysBackupTest : InstrumentedTest { val keyBackupCreationInfo = keysBackupTestHelper.prepareAndCreateKeysBackupData(keysBackup).megolmBackupCreationInfo // - Check encryptGroupSession() returns stg - val keyBackupData = keysBackup.encryptGroupSession(session) + val keyBackupData = testHelper.runBlockingTest { keysBackup.encryptGroupSession(session) } assertNotNull(keyBackupData) assertNotNull(keyBackupData!!.sessionData) @@ -312,7 +312,7 @@ class KeysBackupTest : InstrumentedTest { val sessionData = keysBackup .decryptKeyBackupData( keyBackupData, - session.olmInboundGroupSession!!.sessionIdentifier(), + session.safeSessionId!!, cryptoTestData.roomId, decryption!! ) diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/keysbackup/KeysBackupTestHelper.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/keysbackup/KeysBackupTestHelper.kt index 38f94c5103..2cc2b506b9 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/keysbackup/KeysBackupTestHelper.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/keysbackup/KeysBackupTestHelper.kt @@ -187,7 +187,7 @@ internal class KeysBackupTestHelper( // - Alice must have the same keys on both devices for (aliceKey1 in testData.aliceKeys) { val aliceKey2 = (testData.aliceSession2.cryptoService().keysBackupService() as DefaultKeysBackupService).store - .getInboundGroupSession(aliceKey1.olmInboundGroupSession!!.sessionIdentifier(), aliceKey1.senderKey!!) + .getInboundGroupSession(aliceKey1.safeSessionId!!, aliceKey1.senderKey!!) Assert.assertNotNull(aliceKey2) assertKeysEquals(aliceKey1.exportKeys(), aliceKey2!!.exportKeys()) } diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceCreationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceCreationTest.kt index 38136ff5ce..2cd579df24 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceCreationTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceCreationTest.kt @@ -56,19 +56,17 @@ class SpaceCreationTest : InstrumentedTest { val roomName = "My Space" val topic = "A public space for test" var spaceId: String = "" - commonTestHelper.waitWithLatch { + commonTestHelper.runBlockingTest { spaceId = session.spaceService().createSpace(roomName, topic, null, true) - // wait a bit to let the summary update it self :/ - it.countDown() } - Thread.sleep(4_000) - val syncedSpace = session.spaceService().getSpace(spaceId) commonTestHelper.waitWithLatch { commonTestHelper.retryPeriodicallyWithLatch(it) { - syncedSpace?.asRoom()?.roomSummary()?.name != null + session.spaceService().getSpace(spaceId)?.asRoom()?.roomSummary()?.name != null } } + + val syncedSpace = session.spaceService().getSpace(spaceId) assertEquals("Room name should be set", roomName, syncedSpace?.asRoom()?.roomSummary()?.name) assertEquals("Room topic should be set", topic, syncedSpace?.asRoom()?.roomSummary()?.topic) // assertEquals(topic, syncedSpace.asRoom().roomSummary()?., "Room topic should be set") diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceHierarchyTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceHierarchyTest.kt index 63ca963479..80020665f8 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceHierarchyTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/space/SpaceHierarchyTest.kt @@ -20,7 +20,6 @@ import android.util.Log import androidx.lifecycle.Observer import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.FixMethodOrder import org.junit.Ignore @@ -62,47 +61,40 @@ class SpaceHierarchyTest : InstrumentedTest { val spaceName = "My Space" val topic = "A public space for test" var spaceId = "" - commonTestHelper.waitWithLatch { + commonTestHelper.runBlockingTest { spaceId = session.spaceService().createSpace(spaceName, topic, null, true) - it.countDown() } val syncedSpace = session.spaceService().getSpace(spaceId) var roomId = "" - commonTestHelper.waitWithLatch { + commonTestHelper.runBlockingTest { roomId = session.roomService().createRoom(CreateRoomParams().apply { name = "General" }) - it.countDown() } val viaServers = listOf(session.sessionParams.homeServerHost ?: "") - commonTestHelper.waitWithLatch { + commonTestHelper.runBlockingTest { syncedSpace!!.addChildren(roomId, viaServers, null, true) - it.countDown() } - commonTestHelper.waitWithLatch { + commonTestHelper.runBlockingTest { session.spaceService().setSpaceParent(roomId, spaceId, true, viaServers) - it.countDown() } - Thread.sleep(9000) - - val parents = session.getRoom(roomId)?.roomSummary()?.spaceParents - val canonicalParents = session.getRoom(roomId)?.roomSummary()?.spaceParents?.filter { it.canonical == true } - - parents?.forEach { - Log.d("## TEST", "parent : $it") + commonTestHelper.waitWithLatch { latch -> + commonTestHelper.retryPeriodicallyWithLatch(latch) { + val parents = session.getRoom(roomId)?.roomSummary()?.spaceParents + val canonicalParents = session.getRoom(roomId)?.roomSummary()?.spaceParents?.filter { it.canonical == true } + parents?.forEach { + Log.d("## TEST", "parent : $it") + } + parents?.size == 1 && + parents.first().roomSummary?.name == spaceName && + canonicalParents?.size == 1 && + canonicalParents.first().roomSummary?.name == spaceName + } } - - assertNotNull(parents) - assertEquals(1, parents!!.size) - assertEquals(spaceName, parents.first().roomSummary?.name) - - assertNotNull(canonicalParents) - assertEquals(1, canonicalParents!!.size) - assertEquals(spaceName, canonicalParents.first().roomSummary?.name) } // @Test @@ -173,52 +165,55 @@ class SpaceHierarchyTest : InstrumentedTest { // } @Test - fun testFilteringBySpace() = CommonTestHelper.runSessionTest(context()) { commonTestHelper -> + fun testFilteringBySpace() = runSessionTest(context()) { commonTestHelper -> val session = commonTestHelper.createAccount("John", SessionTestParams(true)) val spaceAInfo = createPublicSpace( - session, "SpaceA", listOf( - Triple("A1", true /*auto-join*/, true/*canonical*/), - Triple("A2", true, true) - ) + commonTestHelper, + session, "SpaceA", + listOf( + Triple("A1", true /*auto-join*/, true/*canonical*/), + Triple("A2", true, true) + ) ) /* val spaceBInfo = */ createPublicSpace( - session, "SpaceB", listOf( - Triple("B1", true /*auto-join*/, true/*canonical*/), - Triple("B2", true, true), - Triple("B3", true, true) - ) + commonTestHelper, + session, "SpaceB", + listOf( + Triple("B1", true /*auto-join*/, true/*canonical*/), + Triple("B2", true, true), + Triple("B3", true, true) + ) ) val spaceCInfo = createPublicSpace( - session, "SpaceC", listOf( - Triple("C1", true /*auto-join*/, true/*canonical*/), - Triple("C2", true, true) - ) + commonTestHelper, + session, "SpaceC", + listOf( + Triple("C1", true /*auto-join*/, true/*canonical*/), + Triple("C2", true, true) + ) ) // add C as a subspace of A val spaceA = session.spaceService().getSpace(spaceAInfo.spaceId) val viaServers = listOf(session.sessionParams.homeServerHost ?: "") - commonTestHelper.waitWithLatch { + commonTestHelper.runBlockingTest { spaceA!!.addChildren(spaceCInfo.spaceId, viaServers, null, true) session.spaceService().setSpaceParent(spaceCInfo.spaceId, spaceAInfo.spaceId, true, viaServers) - it.countDown() } // Create orphan rooms var orphan1 = "" - commonTestHelper.waitWithLatch { + commonTestHelper.runBlockingTest { orphan1 = session.roomService().createRoom(CreateRoomParams().apply { name = "O1" }) - it.countDown() } var orphan2 = "" - commonTestHelper.waitWithLatch { + commonTestHelper.runBlockingTest { orphan2 = session.roomService().createRoom(CreateRoomParams().apply { name = "O2" }) - it.countDown() } val allRooms = session.roomService().getRoomSummaries(roomSummaryQueryParams { excludeType = listOf(RoomType.SPACE) }) @@ -240,10 +235,9 @@ class SpaceHierarchyTest : InstrumentedTest { assertTrue("A1 should be a grand child of A", aChildren.any { it.name == "C2" }) // Add a non canonical child and check that it does not appear as orphan - commonTestHelper.waitWithLatch { + commonTestHelper.runBlockingTest { val a3 = session.roomService().createRoom(CreateRoomParams().apply { name = "A3" }) spaceA!!.addChildren(a3, viaServers, null, false) - it.countDown() } Thread.sleep(6_000) @@ -255,37 +249,39 @@ class SpaceHierarchyTest : InstrumentedTest { @Test @Ignore("This test will be ignored until it is fixed") - fun testBreakCycle() = CommonTestHelper.runSessionTest(context()) { commonTestHelper -> + fun testBreakCycle() = runSessionTest(context()) { commonTestHelper -> val session = commonTestHelper.createAccount("John", SessionTestParams(true)) val spaceAInfo = createPublicSpace( - session, "SpaceA", listOf( - Triple("A1", true /*auto-join*/, true/*canonical*/), - Triple("A2", true, true) - ) + commonTestHelper, + session, "SpaceA", + listOf( + Triple("A1", true /*auto-join*/, true/*canonical*/), + Triple("A2", true, true) + ) ) val spaceCInfo = createPublicSpace( - session, "SpaceC", listOf( - Triple("C1", true /*auto-join*/, true/*canonical*/), - Triple("C2", true, true) - ) + commonTestHelper, + session, "SpaceC", + listOf( + Triple("C1", true /*auto-join*/, true/*canonical*/), + Triple("C2", true, true) + ) ) // add C as a subspace of A val spaceA = session.spaceService().getSpace(spaceAInfo.spaceId) val viaServers = listOf(session.sessionParams.homeServerHost ?: "") - commonTestHelper.waitWithLatch { + commonTestHelper.runBlockingTest { spaceA!!.addChildren(spaceCInfo.spaceId, viaServers, null, true) session.spaceService().setSpaceParent(spaceCInfo.spaceId, spaceAInfo.spaceId, true, viaServers) - it.countDown() } // add back A as subspace of C - commonTestHelper.waitWithLatch { + commonTestHelper.runBlockingTest { val spaceC = session.spaceService().getSpace(spaceCInfo.spaceId) spaceC!!.addChildren(spaceAInfo.spaceId, viaServers, null, true) - it.countDown() } // A -> C -> A @@ -300,37 +296,46 @@ class SpaceHierarchyTest : InstrumentedTest { } @Test - fun testLiveFlatChildren() = CommonTestHelper.runSessionTest(context()) { commonTestHelper -> + fun testLiveFlatChildren() = runSessionTest(context()) { commonTestHelper -> val session = commonTestHelper.createAccount("John", SessionTestParams(true)) val spaceAInfo = createPublicSpace( - session, "SpaceA", listOf( - Triple("A1", true /*auto-join*/, true/*canonical*/), - Triple("A2", true, true) - ) + commonTestHelper, + session, + "SpaceA", + listOf( + Triple("A1", true /*auto-join*/, true/*canonical*/), + Triple("A2", true, true) + ) ) val spaceBInfo = createPublicSpace( - session, "SpaceB", listOf( - Triple("B1", true /*auto-join*/, true/*canonical*/), - Triple("B2", true, true), - Triple("B3", true, true) - ) + commonTestHelper, + session, + "SpaceB", + listOf( + Triple("B1", true /*auto-join*/, true/*canonical*/), + Triple("B2", true, true), + Triple("B3", true, true) + ) ) // add B as a subspace of A val spaceA = session.spaceService().getSpace(spaceAInfo.spaceId) val viaServers = listOf(session.sessionParams.homeServerHost ?: "") - runBlocking { + commonTestHelper.runBlockingTest { spaceA!!.addChildren(spaceBInfo.spaceId, viaServers, null, true) session.spaceService().setSpaceParent(spaceBInfo.spaceId, spaceAInfo.spaceId, true, viaServers) } val spaceCInfo = createPublicSpace( - session, "SpaceC", listOf( - Triple("C1", true /*auto-join*/, true/*canonical*/), - Triple("C2", true, true) - ) + commonTestHelper, + session, + "SpaceC", + listOf( + Triple("C1", true /*auto-join*/, true/*canonical*/), + Triple("C2", true, true) + ) ) commonTestHelper.waitWithLatch { latch -> @@ -348,13 +353,13 @@ class SpaceHierarchyTest : InstrumentedTest { } } + flatAChildren.observeForever(childObserver) + // add C as subspace of B val spaceB = session.spaceService().getSpace(spaceBInfo.spaceId) spaceB!!.addChildren(spaceCInfo.spaceId, viaServers, null, true) // C1 and C2 should be in flatten child of A now - - flatAChildren.observeForever(childObserver) } // Test part one of the rooms @@ -374,10 +379,10 @@ class SpaceHierarchyTest : InstrumentedTest { } } - // part from b room - session.roomService().leaveRoom(bRoomId) // The room should have disapear from flat children flatAChildren.observeForever(childObserver) + // part from b room + session.roomService().leaveRoom(bRoomId) } commonTestHelper.signOutAndClose(session) } @@ -388,6 +393,7 @@ class SpaceHierarchyTest : InstrumentedTest { ) private fun createPublicSpace( + commonTestHelper: CommonTestHelper, session: Session, spaceName: String, childInfo: List> @@ -395,29 +401,27 @@ class SpaceHierarchyTest : InstrumentedTest { ): TestSpaceCreationResult { var spaceId = "" var roomIds: List = emptyList() - runSessionTest(context()) { commonTestHelper -> - commonTestHelper.waitWithLatch { latch -> - spaceId = session.spaceService().createSpace(spaceName, "Test Topic", null, true) - val syncedSpace = session.spaceService().getSpace(spaceId) - val viaServers = listOf(session.sessionParams.homeServerHost ?: "") + commonTestHelper.runBlockingTest { + spaceId = session.spaceService().createSpace(spaceName, "Test Topic", null, true) + val syncedSpace = session.spaceService().getSpace(spaceId) + val viaServers = listOf(session.sessionParams.homeServerHost ?: "") - roomIds = childInfo.map { entry -> - session.roomService().createRoom(CreateRoomParams().apply { name = entry.first }) + roomIds = childInfo.map { entry -> + session.roomService().createRoom(CreateRoomParams().apply { name = entry.first }) + } + roomIds.forEachIndexed { index, roomId -> + syncedSpace!!.addChildren(roomId, viaServers, null, childInfo[index].second) + val canonical = childInfo[index].third + if (canonical != null) { + session.spaceService().setSpaceParent(roomId, spaceId, canonical, viaServers) } - roomIds.forEachIndexed { index, roomId -> - syncedSpace!!.addChildren(roomId, viaServers, null, childInfo[index].second) - val canonical = childInfo[index].third - if (canonical != null) { - session.spaceService().setSpaceParent(roomId, spaceId, canonical, viaServers) - } - } - latch.countDown() } } return TestSpaceCreationResult(spaceId, roomIds) } private fun createPrivateSpace( + commonTestHelper: CommonTestHelper, session: Session, spaceName: String, childInfo: List> @@ -425,34 +429,31 @@ class SpaceHierarchyTest : InstrumentedTest { ): TestSpaceCreationResult { var spaceId = "" var roomIds: List = emptyList() - runSessionTest(context()) { commonTestHelper -> - commonTestHelper.waitWithLatch { latch -> - spaceId = session.spaceService().createSpace(spaceName, "My Private Space", null, false) - val syncedSpace = session.spaceService().getSpace(spaceId) - val viaServers = listOf(session.sessionParams.homeServerHost ?: "") - roomIds = - childInfo.map { entry -> - val homeServerCapabilities = session - .homeServerCapabilitiesService() - .getHomeServerCapabilities() - session.roomService().createRoom(CreateRoomParams().apply { - name = entry.first - this.featurePreset = RestrictedRoomPreset( - homeServerCapabilities, - listOf( - RoomJoinRulesAllowEntry.restrictedToRoom(spaceId) - ) - ) - }) - } - roomIds.forEachIndexed { index, roomId -> - syncedSpace!!.addChildren(roomId, viaServers, null, childInfo[index].second) - val canonical = childInfo[index].third - if (canonical != null) { - session.spaceService().setSpaceParent(roomId, spaceId, canonical, viaServers) + commonTestHelper.runBlockingTest { + spaceId = session.spaceService().createSpace(spaceName, "My Private Space", null, false) + val syncedSpace = session.spaceService().getSpace(spaceId) + val viaServers = listOf(session.sessionParams.homeServerHost ?: "") + roomIds = + childInfo.map { entry -> + val homeServerCapabilities = session + .homeServerCapabilitiesService() + .getHomeServerCapabilities() + session.roomService().createRoom(CreateRoomParams().apply { + name = entry.first + this.featurePreset = RestrictedRoomPreset( + homeServerCapabilities, + listOf( + RoomJoinRulesAllowEntry.restrictedToRoom(spaceId) + ) + ) + }) } + roomIds.forEachIndexed { index, roomId -> + syncedSpace!!.addChildren(roomId, viaServers, null, childInfo[index].second) + val canonical = childInfo[index].third + if (canonical != null) { + session.spaceService().setSpaceParent(roomId, spaceId, canonical, viaServers) } - latch.countDown() } } return TestSpaceCreationResult(spaceId, roomIds) @@ -463,25 +464,31 @@ class SpaceHierarchyTest : InstrumentedTest { val session = commonTestHelper.createAccount("John", SessionTestParams(true)) /* val spaceAInfo = */ createPublicSpace( - session, "SpaceA", listOf( - Triple("A1", true /*auto-join*/, true/*canonical*/), - Triple("A2", true, true) - ) + commonTestHelper, + session, "SpaceA", + listOf( + Triple("A1", true /*auto-join*/, true/*canonical*/), + Triple("A2", true, true) + ) ) val spaceBInfo = createPublicSpace( - session, "SpaceB", listOf( - Triple("B1", true /*auto-join*/, true/*canonical*/), - Triple("B2", true, true), - Triple("B3", true, true) - ) + commonTestHelper, + session, "SpaceB", + listOf( + Triple("B1", true /*auto-join*/, true/*canonical*/), + Triple("B2", true, true), + Triple("B3", true, true) + ) ) val spaceCInfo = createPublicSpace( - session, "SpaceC", listOf( - Triple("C1", true /*auto-join*/, true/*canonical*/), - Triple("C2", true, true) - ) + commonTestHelper, + session, "SpaceC", + listOf( + Triple("C1", true /*auto-join*/, true/*canonical*/), + Triple("C2", true, true) + ) ) val viaServers = listOf(session.sessionParams.homeServerHost ?: "") @@ -490,7 +497,6 @@ class SpaceHierarchyTest : InstrumentedTest { runBlocking { val spaceB = session.spaceService().getSpace(spaceBInfo.spaceId) spaceB!!.addChildren(spaceCInfo.spaceId, viaServers, null, true) - Thread.sleep(6_000) } // Thread.sleep(4_000) @@ -501,11 +507,12 @@ class SpaceHierarchyTest : InstrumentedTest { // + C // + c1, c2 - val rootSpaces = commonTestHelper.runBlockingTest { - session.spaceService().getRootSpaceSummaries() + commonTestHelper.waitWithLatch { latch -> + commonTestHelper.retryPeriodicallyWithLatch(latch) { + val rootSpaces = commonTestHelper.runBlockingTest { session.spaceService().getRootSpaceSummaries() } + rootSpaces.size == 2 + } } - - assertEquals("Unexpected number of root spaces ${rootSpaces.map { it.name }}", 2, rootSpaces.size) } @Test @@ -514,10 +521,12 @@ class SpaceHierarchyTest : InstrumentedTest { val bobSession = commonTestHelper.createAccount("Bib", SessionTestParams(true)) val spaceAInfo = createPrivateSpace( - aliceSession, "Private Space A", listOf( - Triple("General", true /*suggested*/, true/*canonical*/), - Triple("Random", true, true) - ) + commonTestHelper, + aliceSession, "Private Space A", + listOf( + Triple("General", true /*suggested*/, true/*canonical*/), + Triple("Random", true, true) + ) ) commonTestHelper.runBlockingTest { @@ -529,10 +538,9 @@ class SpaceHierarchyTest : InstrumentedTest { } var bobRoomId = "" - commonTestHelper.waitWithLatch { + commonTestHelper.runBlockingTest { bobRoomId = bobSession.roomService().createRoom(CreateRoomParams().apply { name = "A Bob Room" }) bobSession.getRoom(bobRoomId)!!.membershipService().invite(aliceSession.myUserId) - it.countDown() } commonTestHelper.runBlockingTest { @@ -545,9 +553,8 @@ class SpaceHierarchyTest : InstrumentedTest { } } - commonTestHelper.waitWithLatch { + commonTestHelper.runBlockingTest { bobSession.spaceService().setSpaceParent(bobRoomId, spaceAInfo.spaceId, false, listOf(bobSession.sessionParams.homeServerHost ?: "")) - it.countDown() } commonTestHelper.waitWithLatch { latch -> diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/LoginType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/LoginType.kt new file mode 100644 index 0000000000..627a825679 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/LoginType.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.auth + +enum class LoginType { + PASSWORD, + SSO, + UNSUPPORTED, + CUSTOM, + DIRECT, + UNKNOWN; + + companion object { + + fun fromName(name: String) = when (name) { + PASSWORD.name -> PASSWORD + SSO.name -> SSO + UNSUPPORTED.name -> UNSUPPORTED + CUSTOM.name -> CUSTOM + DIRECT.name -> DIRECT + else -> UNKNOWN + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/SessionParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/SessionParams.kt index e3815231d9..de227631ed 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/SessionParams.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/SessionParams.kt @@ -16,6 +16,8 @@ package org.matrix.android.sdk.api.auth.data +import org.matrix.android.sdk.api.auth.LoginType + /** * This data class holds necessary data to open a session. * You don't have to manually instantiate it. @@ -34,7 +36,12 @@ data class SessionParams( /** * Set to false if the current token is not valid anymore. Application should not have to use this info. */ - val isTokenValid: Boolean + val isTokenValid: Boolean, + + /** + * The authentication method that was used to create the session. + */ + val loginType: LoginType, ) { /* * Shortcuts. Usually the application should only need to use these shortcuts diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/crypto/MXCryptoConfig.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/crypto/MXCryptoConfig.kt index 9507ddda65..015cb6a1a2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/crypto/MXCryptoConfig.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/crypto/MXCryptoConfig.kt @@ -38,4 +38,5 @@ data class MXCryptoConfig constructor( * You can limit request only to your sessions by turning this setting to `true` */ val limitRoomKeyRequestsToMyDevices: Boolean = false, -) + + ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt index 638da11804..a5e05f69e0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt @@ -40,6 +40,7 @@ import org.matrix.android.sdk.api.session.crypto.verification.VerificationServic import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.content.RoomKeyWithHeldContent +import org.matrix.android.sdk.internal.crypto.model.SessionInfo interface CryptoService { @@ -84,6 +85,20 @@ interface CryptoService { fun isKeyGossipingEnabled(): Boolean + /** + * As per MSC3061. + * If true will make it possible to share part of e2ee room history + * on invite depending on the room visibility setting. + */ + fun enableShareKeyOnInvite(enable: Boolean) + + /** + * As per MSC3061. + * If true will make it possible to share part of e2ee room history + * on invite depending on the room visibility setting. + */ + fun isShareKeysOnInviteEnabled(): Boolean + fun setRoomUnBlacklistUnverifiedDevices(roomId: String) fun getDeviceTrackingStatus(userId: String): Int @@ -176,4 +191,9 @@ interface CryptoService { * send, in order to speed up sending of the message. */ fun prepareToEncrypt(roomId: String, callback: MatrixCallback) + + /** + * Share all inbound sessions of the last chunk messages to the provided userId devices. + */ + suspend fun sendSharedHistoryKeys(roomId: String, userId: String, sessionInfoSet: Set?) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/model/ForwardedRoomKeyContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/model/ForwardedRoomKeyContent.kt index 3df4ef7c9a..664cd00e94 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/model/ForwardedRoomKeyContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/model/ForwardedRoomKeyContent.kt @@ -69,5 +69,11 @@ data class ForwardedRoomKeyContent( * private part of this key unless they have done device verification. */ @Json(name = "sender_claimed_ed25519_key") - val senderClaimedEd25519Key: String? = null + val senderClaimedEd25519Key: String? = null, + + /** + * MSC3061 Identifies keys that were sent when the room's visibility setting was set to world_readable or shared. + */ + @Json(name = "org.matrix.msc3061.shared_history") + val sharedHistory: Boolean? = false, ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/content/RoomKeyContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/content/RoomKeyContent.kt index 0830a566ab..5b18d29ea0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/content/RoomKeyContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/content/RoomKeyContent.kt @@ -38,5 +38,12 @@ data class RoomKeyContent( // should be a Long but it is sometimes a double @Json(name = "chain_index") - val chainIndex: Any? = null + val chainIndex: Any? = null, + + /** + * MSC3061 Identifies keys that were sent when the room's visibility setting was set to world_readable or shared. + */ + @Json(name = "org.matrix.msc3061.shared_history") + val sharedHistory: Boolean? = false + ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt index ada3dc85d7..14095b67c0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt @@ -48,9 +48,10 @@ interface LocationSharingService { /** * Starts sharing live location in the room. * @param timeoutMillis timeout of the live in milliseconds + * @param description description of the live for text fallback * @return the result of the update of the live */ - suspend fun startLiveLocationShare(timeoutMillis: Long): UpdateLiveLocationShareResult + suspend fun startLiveLocationShare(timeoutMillis: Long, description: String): UpdateLiveLocationShareResult /** * Stops sharing live location in the room. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomHistoryVisibility.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomHistoryVisibility.kt index 06069f2646..2b0ea1d8fb 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomHistoryVisibility.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomHistoryVisibility.kt @@ -48,3 +48,9 @@ enum class RoomHistoryVisibility { */ @Json(name = "joined") JOINED } + +/** + * Room history should be shared only if room visibility is world_readable or shared. + */ +internal fun RoomHistoryVisibility.shouldShareHistory() = + this == RoomHistoryVisibility.WORLD_READABLE || this == RoomHistoryVisibility.SHARED diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt index ddb70be906..463692e574 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthModule.kt @@ -83,6 +83,9 @@ internal abstract class AuthModule { @Binds abstract fun bindSessionCreator(creator: DefaultSessionCreator): SessionCreator + @Binds + abstract fun bindSessionParamsCreator(creator: DefaultSessionParamsCreator): SessionParamsCreator + @Binds abstract fun bindDirectLoginTask(task: DefaultDirectLoginTask): DirectLoginTask 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 9d6b018a67..446f931847 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 @@ -22,6 +22,7 @@ import okhttp3.OkHttpClient import org.matrix.android.sdk.api.MatrixPatterns import org.matrix.android.sdk.api.MatrixPatterns.getServerName import org.matrix.android.sdk.api.auth.AuthenticationService +import org.matrix.android.sdk.api.auth.LoginType import org.matrix.android.sdk.api.auth.data.Credentials import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig import org.matrix.android.sdk.api.auth.data.LoginFlowResult @@ -361,7 +362,7 @@ internal class DefaultAuthenticationService @Inject constructor( homeServerConnectionConfig: HomeServerConnectionConfig, credentials: Credentials ): Session { - return sessionCreator.createSession(credentials, homeServerConnectionConfig) + return sessionCreator.createSession(credentials, homeServerConnectionConfig, LoginType.SSO) } override suspend fun getWellKnownData( diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/SessionCreator.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/SessionCreator.kt index ba01146a4a..7dbb11c7fd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/SessionCreator.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/SessionCreator.kt @@ -16,69 +16,41 @@ package org.matrix.android.sdk.internal.auth -import android.net.Uri +import org.matrix.android.sdk.api.auth.LoginType import org.matrix.android.sdk.api.auth.data.Credentials import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig -import org.matrix.android.sdk.api.auth.data.SessionParams -import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.internal.SessionManager -import timber.log.Timber import javax.inject.Inject internal interface SessionCreator { - suspend fun createSession(credentials: Credentials, homeServerConnectionConfig: HomeServerConnectionConfig): Session + + suspend fun createSession( + credentials: Credentials, + homeServerConnectionConfig: HomeServerConnectionConfig, + loginType: LoginType, + ): Session } internal class DefaultSessionCreator @Inject constructor( private val sessionParamsStore: SessionParamsStore, private val sessionManager: SessionManager, private val pendingSessionStore: PendingSessionStore, - private val isValidClientServerApiTask: IsValidClientServerApiTask + private val sessionParamsCreator: SessionParamsCreator, ) : SessionCreator { /** * Credentials can affect the homeServerConnectionConfig, override homeserver url and/or * identity server url if provided in the credentials. */ - override suspend fun createSession(credentials: Credentials, homeServerConnectionConfig: HomeServerConnectionConfig): Session { + override suspend fun createSession( + credentials: Credentials, + homeServerConnectionConfig: HomeServerConnectionConfig, + loginType: LoginType, + ): Session { // We can cleanup the pending session params pendingSessionStore.delete() - - val overriddenUrl = credentials.discoveryInformation?.homeServer?.baseURL - // remove trailing "/" - ?.trim { it == '/' } - ?.takeIf { it.isNotBlank() } - // It can be the same value, so in this case, do not check again the validity - ?.takeIf { it != homeServerConnectionConfig.homeServerUriBase.toString() } - ?.also { Timber.d("Overriding homeserver url to $it (will check if valid)") } - ?.let { Uri.parse(it) } - ?.takeIf { - // Validate the URL, if the configuration is wrong server side, do not override - tryOrNull { - isValidClientServerApiTask.execute( - IsValidClientServerApiTask.Params( - homeServerConnectionConfig.copy(homeServerUriBase = it) - ) - ) - .also { Timber.d("Overriding homeserver url: $it") } - } ?: true // In case of other error (no network, etc.), consider it is valid... - } - - val sessionParams = SessionParams( - credentials = credentials, - homeServerConnectionConfig = homeServerConnectionConfig.copy( - homeServerUriBase = overriddenUrl ?: homeServerConnectionConfig.homeServerUriBase, - identityServerUri = credentials.discoveryInformation?.identityServer?.baseURL - // remove trailing "/" - ?.trim { it == '/' } - ?.takeIf { it.isNotBlank() } - ?.also { Timber.d("Overriding identity server url to $it") } - ?.let { Uri.parse(it) } - ?: homeServerConnectionConfig.identityServerUri - ), - isTokenValid = true) - + val sessionParams = sessionParamsCreator.create(credentials, homeServerConnectionConfig, loginType) sessionParamsStore.save(sessionParams) return sessionManager.getOrCreateSession(sessionParams) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/SessionParamsCreator.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/SessionParamsCreator.kt new file mode 100644 index 0000000000..31ed9a1e85 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/SessionParamsCreator.kt @@ -0,0 +1,83 @@ +/* + * 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.auth + +import android.net.Uri +import org.matrix.android.sdk.api.auth.LoginType +import org.matrix.android.sdk.api.auth.data.Credentials +import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig +import org.matrix.android.sdk.api.auth.data.SessionParams +import org.matrix.android.sdk.api.extensions.tryOrNull +import timber.log.Timber +import javax.inject.Inject + +internal interface SessionParamsCreator { + + suspend fun create( + credentials: Credentials, + homeServerConnectionConfig: HomeServerConnectionConfig, + loginType: LoginType, + ): SessionParams +} + +internal class DefaultSessionParamsCreator @Inject constructor( + private val isValidClientServerApiTask: IsValidClientServerApiTask +) : SessionParamsCreator { + + override suspend fun create( + credentials: Credentials, + homeServerConnectionConfig: HomeServerConnectionConfig, + loginType: LoginType, + ) = SessionParams( + credentials = credentials, + homeServerConnectionConfig = homeServerConnectionConfig.overrideWithCredentials(credentials), + isTokenValid = true, + loginType = loginType, + ) + + private suspend fun HomeServerConnectionConfig.overrideWithCredentials(credentials: Credentials) = copy( + homeServerUriBase = credentials.getHomeServerUri(this) ?: homeServerUriBase, + identityServerUri = credentials.getIdentityServerUri() ?: identityServerUri + ) + + private suspend fun Credentials.getHomeServerUri(homeServerConnectionConfig: HomeServerConnectionConfig) = + discoveryInformation?.homeServer?.baseURL + ?.trim { it == '/' } + ?.takeIf { it.isNotBlank() } + // It can be the same value, so in this case, do not check again the validity + ?.takeIf { it != homeServerConnectionConfig.homeServerUriBase.toString() } + ?.also { Timber.d("Overriding homeserver url to $it (will check if valid)") } + ?.let { Uri.parse(it) } + ?.takeIf { validateUri(it, homeServerConnectionConfig) } + + private suspend fun validateUri(uri: Uri, homeServerConnectionConfig: HomeServerConnectionConfig) = + // Validate the URL, if the configuration is wrong server side, do not override + tryOrNull { + performClientServerApiValidation(uri, homeServerConnectionConfig) + } ?: true // In case of other error (no network, etc.), consider it is valid... + + private suspend fun performClientServerApiValidation(uri: Uri, homeServerConnectionConfig: HomeServerConnectionConfig) = + isValidClientServerApiTask.execute( + IsValidClientServerApiTask.Params(homeServerConnectionConfig.copy(homeServerUriBase = uri)) + ).also { Timber.d("Overriding homeserver url: $it") } + + private fun Credentials.getIdentityServerUri() = discoveryInformation?.identityServer?.baseURL + ?.trim { it == '/' } + ?.takeIf { it.isNotBlank() } + ?.also { Timber.d("Overriding identity server url to $it") } + ?.let { Uri.parse(it) } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/AuthRealmMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/AuthRealmMigration.kt index 88c6d04ee6..0bc7831f5c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/AuthRealmMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/AuthRealmMigration.kt @@ -22,6 +22,7 @@ import org.matrix.android.sdk.internal.auth.db.migration.MigrateAuthTo001 import org.matrix.android.sdk.internal.auth.db.migration.MigrateAuthTo002 import org.matrix.android.sdk.internal.auth.db.migration.MigrateAuthTo003 import org.matrix.android.sdk.internal.auth.db.migration.MigrateAuthTo004 +import org.matrix.android.sdk.internal.auth.db.migration.MigrateAuthTo005 import timber.log.Timber import javax.inject.Inject @@ -33,7 +34,7 @@ internal class AuthRealmMigration @Inject constructor() : RealmMigration { override fun equals(other: Any?) = other is AuthRealmMigration override fun hashCode() = 4000 - val schemaVersion = 4L + val schemaVersion = 5L override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { Timber.d("Migrating Auth Realm from $oldVersion to $newVersion") @@ -42,5 +43,6 @@ internal class AuthRealmMigration @Inject constructor() : RealmMigration { if (oldVersion < 2) MigrateAuthTo002(realm).perform() if (oldVersion < 3) MigrateAuthTo003(realm).perform() if (oldVersion < 4) MigrateAuthTo004(realm).perform() + if (oldVersion < 5) MigrateAuthTo005(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/SessionParamsEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/SessionParamsEntity.kt index ba1ab8147b..f6c883cac0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/SessionParamsEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/SessionParamsEntity.kt @@ -26,5 +26,6 @@ internal open class SessionParamsEntity( var homeServerConnectionConfigJson: String = "", // Set to false when the token is invalid and the user has been soft logged out // In case of hard logout, this object is deleted from DB - var isTokenValid: Boolean = true + var isTokenValid: Boolean = true, + var loginType: String = "", ) : RealmObject() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/SessionParamsMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/SessionParamsMapper.kt index 86929b1afe..23923bf267 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/SessionParamsMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/SessionParamsMapper.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.auth.db import com.squareup.moshi.Moshi +import org.matrix.android.sdk.api.auth.LoginType import org.matrix.android.sdk.api.auth.data.Credentials import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig import org.matrix.android.sdk.api.auth.data.SessionParams @@ -37,7 +38,7 @@ internal class SessionParamsMapper @Inject constructor(moshi: Moshi) { if (credentials == null || homeServerConnectionConfig == null) { return null } - return SessionParams(credentials, homeServerConnectionConfig, entity.isTokenValid) + return SessionParams(credentials, homeServerConnectionConfig, entity.isTokenValid, LoginType.fromName(entity.loginType)) } fun map(sessionParams: SessionParams?): SessionParamsEntity? { @@ -54,7 +55,8 @@ internal class SessionParamsMapper @Inject constructor(moshi: Moshi) { sessionParams.userId, credentialsJson, homeServerConnectionConfigJson, - sessionParams.isTokenValid + sessionParams.isTokenValid, + sessionParams.loginType.name, ) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo005.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo005.kt new file mode 100644 index 0000000000..2cf1b62a4c --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo005.kt @@ -0,0 +1,35 @@ +/* + * 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.auth.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.api.auth.LoginType +import org.matrix.android.sdk.internal.auth.db.SessionParamsEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import timber.log.Timber + +internal class MigrateAuthTo005(realm: DynamicRealm) : RealmMigrator(realm, 5) { + + override fun doMigrate(realm: DynamicRealm) { + Timber.d("Update SessionParamsEntity to add LoginType") + + realm.schema.get("SessionParamsEntity") + ?.addField(SessionParamsEntityFields.LOGIN_TYPE, String::class.java) + ?.setRequired(SessionParamsEntityFields.LOGIN_TYPE, true) + ?.transform { it.set(SessionParamsEntityFields.LOGIN_TYPE, LoginType.UNKNOWN.name) } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt index 656a4f671b..468e998407 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.auth.login import android.util.Patterns +import org.matrix.android.sdk.api.auth.LoginType import org.matrix.android.sdk.api.auth.login.LoginProfileInfo import org.matrix.android.sdk.api.auth.login.LoginWizard import org.matrix.android.sdk.api.auth.registration.RegisterThreePid @@ -78,7 +79,7 @@ internal class DefaultLoginWizard( authAPI.login(loginParams) } - return sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig) + return sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig, LoginType.PASSWORD) } /** @@ -92,7 +93,7 @@ internal class DefaultLoginWizard( authAPI.login(loginParams) } - return sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig) + return sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig, LoginType.SSO) } override suspend fun loginCustom(data: JsonDict): Session { @@ -100,7 +101,7 @@ internal class DefaultLoginWizard( authAPI.login(data) } - return sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig) + return sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig, LoginType.CUSTOM) } override suspend fun resetPassword(email: String) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DirectLoginTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DirectLoginTask.kt index c9311867c8..af42105756 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DirectLoginTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DirectLoginTask.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.auth.login import dagger.Lazy import okhttp3.OkHttpClient +import org.matrix.android.sdk.api.auth.LoginType import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.session.Session @@ -77,7 +78,7 @@ internal class DefaultDirectLoginTask @Inject constructor( } } - return sessionCreator.createSession(credentials, params.homeServerConnectionConfig) + return sessionCreator.createSession(credentials, params.homeServerConnectionConfig, LoginType.DIRECT) } private fun buildClient(homeServerConnectionConfig: HomeServerConnectionConfig): OkHttpClient { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/registration/DefaultRegistrationWizard.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/registration/DefaultRegistrationWizard.kt index d6ec0297b4..56425cbc74 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/registration/DefaultRegistrationWizard.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/registration/DefaultRegistrationWizard.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.auth.registration import kotlinx.coroutines.delay +import org.matrix.android.sdk.api.auth.LoginType import org.matrix.android.sdk.api.auth.data.Credentials import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.auth.registration.RegisterThreePid @@ -36,9 +37,9 @@ import org.matrix.android.sdk.internal.auth.db.PendingSessionData * This class execute the registration request and is responsible to keep the session of interactive authentication. */ internal class DefaultRegistrationWizard( - authAPI: AuthAPI, - private val sessionCreator: SessionCreator, - private val pendingSessionStore: PendingSessionStore + authAPI: AuthAPI, + private val sessionCreator: SessionCreator, + private val pendingSessionStore: PendingSessionStore ) : RegistrationWizard { private var pendingSessionData: PendingSessionData = pendingSessionStore.getPendingSessionData() ?: error("Pending session data should exist here") @@ -64,7 +65,7 @@ internal class DefaultRegistrationWizard( override suspend fun getRegistrationFlow(): RegistrationResult { val params = RegistrationParams() - return performRegistrationRequest(params) + return performRegistrationRequest(params, LoginType.PASSWORD) } override suspend fun createAccount( @@ -73,43 +74,43 @@ internal class DefaultRegistrationWizard( initialDeviceDisplayName: String? ): RegistrationResult { val params = RegistrationParams( - username = userName, - password = password, - initialDeviceDisplayName = initialDeviceDisplayName + username = userName, + password = password, + initialDeviceDisplayName = initialDeviceDisplayName ) - return performRegistrationRequest(params) - .also { - pendingSessionData = pendingSessionData.copy(isRegistrationStarted = true) - .also { pendingSessionStore.savePendingSessionData(it) } - } + return performRegistrationRequest(params, LoginType.PASSWORD) + .also { + pendingSessionData = pendingSessionData.copy(isRegistrationStarted = true) + .also { pendingSessionStore.savePendingSessionData(it) } + } } override suspend fun performReCaptcha(response: String): RegistrationResult { val safeSession = pendingSessionData.currentSession - ?: throw IllegalStateException("developer error, call createAccount() method first") + ?: throw IllegalStateException("developer error, call createAccount() method first") val params = RegistrationParams(auth = AuthParams.createForCaptcha(safeSession, response)) - return performRegistrationRequest(params) + return performRegistrationRequest(params, LoginType.PASSWORD) } override suspend fun acceptTerms(): RegistrationResult { val safeSession = pendingSessionData.currentSession - ?: throw IllegalStateException("developer error, call createAccount() method first") + ?: throw IllegalStateException("developer error, call createAccount() method first") val params = RegistrationParams(auth = AuthParams(type = LoginFlowTypes.TERMS, session = safeSession)) - return performRegistrationRequest(params) + return performRegistrationRequest(params, LoginType.PASSWORD) } override suspend fun addThreePid(threePid: RegisterThreePid): RegistrationResult { pendingSessionData = pendingSessionData.copy(currentThreePidData = null) - .also { pendingSessionStore.savePendingSessionData(it) } + .also { pendingSessionStore.savePendingSessionData(it) } return sendThreePid(threePid) } override suspend fun sendAgainThreePid(): RegistrationResult { val safeCurrentThreePid = pendingSessionData.currentThreePidData?.threePid - ?: throw IllegalStateException("developer error, call createAccount() method first") + ?: throw IllegalStateException("developer error, call createAccount() method first") return sendThreePid(safeCurrentThreePid) } @@ -125,7 +126,7 @@ internal class DefaultRegistrationWizard( ) pendingSessionData = pendingSessionData.copy(sendAttempt = pendingSessionData.sendAttempt + 1) - .also { pendingSessionStore.savePendingSessionData(it) } + .also { pendingSessionStore.savePendingSessionData(it) } val params = RegistrationParams( auth = if (threePid is RegisterThreePid.Email) { @@ -148,17 +149,17 @@ internal class DefaultRegistrationWizard( ) // Store data pendingSessionData = pendingSessionData.copy(currentThreePidData = ThreePidData.from(threePid, response, params)) - .also { pendingSessionStore.savePendingSessionData(it) } + .also { pendingSessionStore.savePendingSessionData(it) } // and send the sid a first time - return performRegistrationRequest(params) + return performRegistrationRequest(params, LoginType.PASSWORD) } override suspend fun checkIfEmailHasBeenValidated(delayMillis: Long): RegistrationResult { val safeParam = pendingSessionData.currentThreePidData?.registrationParams - ?: throw IllegalStateException("developer error, no pending three pid") + ?: throw IllegalStateException("developer error, no pending three pid") - return performRegistrationRequest(safeParam, delayMillis) + return performRegistrationRequest(safeParam, LoginType.PASSWORD, delayMillis) } override suspend fun handleValidateThreePid(code: String): RegistrationResult { @@ -167,19 +168,19 @@ internal class DefaultRegistrationWizard( private suspend fun validateThreePid(code: String): RegistrationResult { val registrationParams = pendingSessionData.currentThreePidData?.registrationParams - ?: throw IllegalStateException("developer error, no pending three pid") + ?: throw IllegalStateException("developer error, no pending three pid") val safeCurrentData = pendingSessionData.currentThreePidData ?: throw IllegalStateException("developer error, call createAccount() method first") val url = safeCurrentData.addThreePidRegistrationResponse.submitUrl ?: throw IllegalStateException("Missing url to send the code") val validationBody = ValidationCodeBody( - clientSecret = pendingSessionData.clientSecret, - sid = safeCurrentData.addThreePidRegistrationResponse.sid, - code = code + clientSecret = pendingSessionData.clientSecret, + sid = safeCurrentData.addThreePidRegistrationResponse.sid, + code = code ) val validationResponse = validateCodeTask.execute(ValidateCodeTask.Params(url, validationBody)) if (validationResponse.isSuccess()) { // The entered code is correct // Same than validate email - return performRegistrationRequest(registrationParams, 3_000) + return performRegistrationRequest(registrationParams, LoginType.PASSWORD, 3_000) } else { // The code is not correct throw Failure.SuccessError @@ -188,10 +189,10 @@ internal class DefaultRegistrationWizard( override suspend fun dummy(): RegistrationResult { val safeSession = pendingSessionData.currentSession - ?: throw IllegalStateException("developer error, call createAccount() method first") + ?: throw IllegalStateException("developer error, call createAccount() method first") val params = RegistrationParams(auth = AuthParams(type = LoginFlowTypes.DUMMY, session = safeSession)) - return performRegistrationRequest(params) + return performRegistrationRequest(params, LoginType.PASSWORD) } override suspend fun registrationCustom( @@ -204,25 +205,28 @@ internal class DefaultRegistrationWizard( mutableParams["session"] = safeSession val params = RegistrationCustomParams(auth = mutableParams) - return performRegistrationOtherRequest(params) + return performRegistrationOtherRequest(LoginType.CUSTOM, params) } private suspend fun performRegistrationRequest( registrationParams: RegistrationParams, + loginType: LoginType, delayMillis: Long = 0 ): RegistrationResult { delay(delayMillis) - return register { registerTask.execute(RegisterTask.Params(registrationParams)) } + return register(loginType) { registerTask.execute(RegisterTask.Params(registrationParams)) } } private suspend fun performRegistrationOtherRequest( - registrationCustomParams: RegistrationCustomParams + loginType: LoginType, + registrationCustomParams: RegistrationCustomParams, ): RegistrationResult { - return register { registerCustomTask.execute(RegisterCustomTask.Params(registrationCustomParams)) } + return register(loginType) { registerCustomTask.execute(RegisterCustomTask.Params(registrationCustomParams)) } } private suspend fun register( - execute: suspend () -> Credentials + loginType: LoginType, + execute: suspend () -> Credentials, ): RegistrationResult { val credentials = try { execute.invoke() @@ -237,8 +241,7 @@ internal class DefaultRegistrationWizard( } } - val session = - sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig) + val session = sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig, loginType) return RegistrationResult.Success(session) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt index e0bcde2296..850a4379ca 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt @@ -71,6 +71,7 @@ import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibilityContent import org.matrix.android.sdk.api.session.room.model.RoomMemberContent +import org.matrix.android.sdk.api.session.room.model.shouldShareHistory import org.matrix.android.sdk.api.session.sync.model.SyncResponse import org.matrix.android.sdk.internal.crypto.actions.MegolmSessionDataImporter import org.matrix.android.sdk.internal.crypto.actions.SetDeviceVerificationAction @@ -81,6 +82,7 @@ import org.matrix.android.sdk.internal.crypto.algorithms.olm.MXOlmEncryptionFact import org.matrix.android.sdk.internal.crypto.crosssigning.DefaultCrossSigningService import org.matrix.android.sdk.internal.crypto.keysbackup.DefaultKeysBackupService import org.matrix.android.sdk.internal.crypto.model.MXKey.Companion.KEY_SIGNED_CURVE_25519_TYPE +import org.matrix.android.sdk.internal.crypto.model.SessionInfo import org.matrix.android.sdk.internal.crypto.model.toRest import org.matrix.android.sdk.internal.crypto.repository.WarnOnUnknownDeviceRepository import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore @@ -963,8 +965,12 @@ internal class DefaultCryptoService @Inject constructor( private fun onRoomHistoryVisibilityEvent(roomId: String, event: Event) { if (!event.isStateEvent()) return val eventContent = event.content.toModel() - eventContent?.historyVisibility?.let { - cryptoStore.setShouldEncryptForInvitedMembers(roomId, it != RoomHistoryVisibility.JOINED) + val historyVisibility = eventContent?.historyVisibility + if (historyVisibility == null) { + cryptoStore.setShouldShareHistory(roomId, false) + } else { + cryptoStore.setShouldEncryptForInvitedMembers(roomId, historyVisibility != RoomHistoryVisibility.JOINED) + cryptoStore.setShouldShareHistory(roomId, historyVisibility.shouldShareHistory()) } } @@ -1111,6 +1117,10 @@ internal class DefaultCryptoService @Inject constructor( override fun isKeyGossipingEnabled() = cryptoStore.isKeyGossipingEnabled() + override fun isShareKeysOnInviteEnabled() = cryptoStore.isShareKeysOnInviteEnabled() + + override fun enableShareKeyOnInvite(enable: Boolean) = cryptoStore.enableShareKeyOnInvite(enable) + /** * Tells whether the client should ever send encrypted messages to unverified devices. * The default value is false. @@ -1335,6 +1345,30 @@ internal class DefaultCryptoService @Inject constructor( } } + override suspend fun sendSharedHistoryKeys(roomId: String, userId: String, sessionInfoSet: Set?) { + deviceListManager.downloadKeys(listOf(userId), false) + val userDevices = cryptoStore.getUserDeviceList(userId) + val sessionToShare = sessionInfoSet.orEmpty().mapNotNull { sessionInfo -> + // Get inbound session from sessionId and sessionKey + withContext(coroutineDispatchers.crypto) { + olmDevice.getInboundGroupSession( + sessionId = sessionInfo.sessionId, + senderKey = sessionInfo.senderKey, + roomId = roomId + ).takeIf { it.wrapper.sessionData.sharedHistory } + } + } + + userDevices?.forEach { deviceInfo -> + // Lets share the provided inbound sessions for every user device + sessionToShare.forEach { inboundGroupSession -> + val encryptor = roomEncryptorsStore.get(roomId) + encryptor?.shareHistoryKeysWithDevice(inboundGroupSession, deviceInfo) + Timber.i("## CRYPTO | Sharing inbound session") + } + } + } + /* ========================================================================================== * For test only * ========================================================================================== */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/InboundGroupSessionStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/InboundGroupSessionStore.kt index e4d322cadd..ab7cbb74b1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/InboundGroupSessionStore.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/InboundGroupSessionStore.kt @@ -23,7 +23,7 @@ import kotlinx.coroutines.sync.Mutex import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.logger.LoggerTag -import org.matrix.android.sdk.internal.crypto.model.OlmInboundGroupSessionWrapper2 +import org.matrix.android.sdk.internal.crypto.model.MXInboundMegolmSessionWrapper import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore import timber.log.Timber import java.util.Timer @@ -31,7 +31,7 @@ import java.util.TimerTask import javax.inject.Inject internal data class InboundGroupSessionHolder( - val wrapper: OlmInboundGroupSessionWrapper2, + val wrapper: MXInboundMegolmSessionWrapper, val mutex: Mutex = Mutex() ) @@ -58,7 +58,7 @@ internal class InboundGroupSessionStore @Inject constructor( cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { Timber.tag(loggerTag.value).v("## Inbound: entryRemoved ${oldValue.wrapper.roomId}-${oldValue.wrapper.senderKey}") store.storeInboundGroupSessions(listOf(oldValue).map { it.wrapper }) - oldValue.wrapper.olmInboundGroupSession?.releaseSession() + oldValue.wrapper.session.releaseSession() } } } @@ -67,7 +67,7 @@ internal class InboundGroupSessionStore @Inject constructor( private val timer = Timer() private var timerTask: TimerTask? = null - private val dirtySession = mutableListOf() + private val dirtySession = mutableListOf() @Synchronized fun clear() { @@ -90,12 +90,12 @@ internal class InboundGroupSessionStore @Inject constructor( @Synchronized fun replaceGroupSession(old: InboundGroupSessionHolder, new: InboundGroupSessionHolder, sessionId: String, senderKey: String) { Timber.tag(loggerTag.value).v("## Replacing outdated session ${old.wrapper.roomId}-${old.wrapper.senderKey}") - dirtySession.remove(old.wrapper) + dirtySession.remove(old) store.removeInboundGroupSession(sessionId, senderKey) sessionCache.remove(CacheKey(sessionId, senderKey)) // release removed session - old.wrapper.olmInboundGroupSession?.releaseSession() + old.wrapper.session.releaseSession() internalStoreGroupSession(new, sessionId, senderKey) } @@ -108,7 +108,7 @@ internal class InboundGroupSessionStore @Inject constructor( private fun internalStoreGroupSession(holder: InboundGroupSessionHolder, sessionId: String, senderKey: String) { Timber.tag(loggerTag.value).v("## Inbound: getInboundGroupSession mark as dirty ${holder.wrapper.roomId}-${holder.wrapper.senderKey}") // We want to batch this a bit for performances - dirtySession.add(holder.wrapper) + dirtySession.add(holder) if (sessionCache[CacheKey(sessionId, senderKey)] == null) { // first time seen, put it in memory cache while waiting for batch insert @@ -127,12 +127,12 @@ internal class InboundGroupSessionStore @Inject constructor( @Synchronized private fun batchSave() { - val toSave = mutableListOf().apply { addAll(dirtySession) } + val toSave = mutableListOf().apply { addAll(dirtySession) } dirtySession.clear() cryptoCoroutineScope.launch(coroutineDispatchers.crypto) { Timber.tag(loggerTag.value).v("## Inbound: getInboundGroupSession batching save of ${toSave.size}") tryOrNull { - store.storeInboundGroupSessions(toSave) + store.storeInboundGroupSessions(toSave.map { it.wrapper }) } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MXOlmDevice.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MXOlmDevice.kt index 24b6fd166f..c4a6488258 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MXOlmDevice.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MXOlmDevice.kt @@ -27,7 +27,8 @@ import org.matrix.android.sdk.api.util.JSON_DICT_PARAMETERIZED_TYPE import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.internal.crypto.algorithms.megolm.MXOutboundSessionInfo import org.matrix.android.sdk.internal.crypto.algorithms.megolm.SharedWithHelper -import org.matrix.android.sdk.internal.crypto.model.OlmInboundGroupSessionWrapper2 +import org.matrix.android.sdk.internal.crypto.model.InboundGroupSessionData +import org.matrix.android.sdk.internal.crypto.model.MXInboundMegolmSessionWrapper import org.matrix.android.sdk.internal.crypto.model.OlmSessionWrapper import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore import org.matrix.android.sdk.internal.di.MoshiProvider @@ -38,6 +39,7 @@ import org.matrix.android.sdk.internal.util.convertToUTF8 import org.matrix.android.sdk.internal.util.time.Clock import org.matrix.olm.OlmAccount import org.matrix.olm.OlmException +import org.matrix.olm.OlmInboundGroupSession import org.matrix.olm.OlmMessage import org.matrix.olm.OlmOutboundGroupSession import org.matrix.olm.OlmSession @@ -514,8 +516,9 @@ internal class MXOlmDevice @Inject constructor( return MXOutboundSessionInfo( sessionId = sessionId, sharedWithHelper = SharedWithHelper(roomId, sessionId, store), - clock, - restoredOutboundGroupSession.creationTime + clock = clock, + creationTime = restoredOutboundGroupSession.creationTime, + sharedHistory = restoredOutboundGroupSession.sharedHistory ) } return null @@ -598,40 +601,47 @@ internal class MXOlmDevice @Inject constructor( * @param forwardingCurve25519KeyChain Devices involved in forwarding this session to us. * @param keysClaimed Other keys the sender claims. * @param exportFormat true if the megolm keys are in export format + * @param sharedHistory MSC3061, this key is sharable on invite * @return true if the operation succeeds. */ - fun addInboundGroupSession( - sessionId: String, - sessionKey: String, - roomId: String, - senderKey: String, - forwardingCurve25519KeyChain: List, - keysClaimed: Map, - exportFormat: Boolean - ): AddSessionResult { - val candidateSession = OlmInboundGroupSessionWrapper2(sessionKey, exportFormat) + fun addInboundGroupSession(sessionId: String, + sessionKey: String, + roomId: String, + senderKey: String, + forwardingCurve25519KeyChain: List, + keysClaimed: Map, + exportFormat: Boolean, + sharedHistory: Boolean): AddSessionResult { + val candidateSession = tryOrNull("Failed to create inbound session in room $roomId") { + if (exportFormat) { + OlmInboundGroupSession.importSession(sessionKey) + } else { + OlmInboundGroupSession(sessionKey) + } + } + val existingSessionHolder = tryOrNull { getInboundGroupSession(sessionId, senderKey, roomId) } val existingSession = existingSessionHolder?.wrapper // If we have an existing one we should check if the new one is not better if (existingSession != null) { Timber.tag(loggerTag.value).d("## addInboundGroupSession() check if known session is better than candidate session") try { - val existingFirstKnown = existingSession.firstKnownIndex ?: return AddSessionResult.NotImported.also { + val existingFirstKnown = tryOrNull { existingSession.session.firstKnownIndex } ?: return AddSessionResult.NotImported.also { // This is quite unexpected, could throw if native was released? Timber.tag(loggerTag.value).e("## addInboundGroupSession() null firstKnownIndex on existing session") - candidateSession.olmInboundGroupSession?.releaseSession() + candidateSession?.releaseSession() // Probably should discard it? } - val newKnownFirstIndex = candidateSession.firstKnownIndex + val newKnownFirstIndex = tryOrNull("Failed to get candidate first known index") { candidateSession?.firstKnownIndex } // If our existing session is better we keep it if (newKnownFirstIndex != null && existingFirstKnown <= newKnownFirstIndex) { Timber.tag(loggerTag.value).d("## addInboundGroupSession() : ignore session our is better $senderKey/$sessionId") - candidateSession.olmInboundGroupSession?.releaseSession() + candidateSession?.releaseSession() return AddSessionResult.NotImportedHigherIndex(newKnownFirstIndex.toInt()) } } catch (failure: Throwable) { Timber.tag(loggerTag.value).e("## addInboundGroupSession() Failed to add inbound: ${failure.localizedMessage}") - candidateSession.olmInboundGroupSession?.releaseSession() + candidateSession?.releaseSession() return AddSessionResult.NotImported } } @@ -639,36 +649,42 @@ internal class MXOlmDevice @Inject constructor( Timber.tag(loggerTag.value).d("## addInboundGroupSession() : Candidate session should be added $senderKey/$sessionId") // sanity check on the new session - val candidateOlmInboundSession = candidateSession.olmInboundGroupSession - if (null == candidateOlmInboundSession) { + if (null == candidateSession) { Timber.tag(loggerTag.value).e("## addInboundGroupSession : invalid session ") return AddSessionResult.NotImported } try { - if (candidateOlmInboundSession.sessionIdentifier() != sessionId) { + if (candidateSession.sessionIdentifier() != sessionId) { Timber.tag(loggerTag.value).e("## addInboundGroupSession : ERROR: Mismatched group session ID from senderKey: $senderKey") - candidateOlmInboundSession.releaseSession() + candidateSession.releaseSession() return AddSessionResult.NotImported } } catch (e: Throwable) { - candidateOlmInboundSession.releaseSession() + candidateSession.releaseSession() Timber.tag(loggerTag.value).e(e, "## addInboundGroupSession : sessionIdentifier() failed") return AddSessionResult.NotImported } - candidateSession.senderKey = senderKey - candidateSession.roomId = roomId - candidateSession.keysClaimed = keysClaimed - candidateSession.forwardingCurve25519KeyChain = forwardingCurve25519KeyChain + val candidateSessionData = InboundGroupSessionData( + senderKey = senderKey, + roomId = roomId, + keysClaimed = keysClaimed, + forwardingCurve25519KeyChain = forwardingCurve25519KeyChain, + sharedHistory = sharedHistory, + ) + val wrapper = MXInboundMegolmSessionWrapper( + candidateSession, + candidateSessionData + ) if (existingSession != null) { - inboundGroupSessionStore.replaceGroupSession(existingSessionHolder, InboundGroupSessionHolder(candidateSession), sessionId, senderKey) + inboundGroupSessionStore.replaceGroupSession(existingSessionHolder, InboundGroupSessionHolder(wrapper), sessionId, senderKey) } else { - inboundGroupSessionStore.storeInBoundGroupSession(InboundGroupSessionHolder(candidateSession), sessionId, senderKey) + inboundGroupSessionStore.storeInBoundGroupSession(InboundGroupSessionHolder(wrapper), sessionId, senderKey) } - return AddSessionResult.Imported(candidateSession.firstKnownIndex?.toInt() ?: 0) + return AddSessionResult.Imported(candidateSession.firstKnownIndex.toInt()) } /** @@ -677,41 +693,22 @@ internal class MXOlmDevice @Inject constructor( * @param megolmSessionsData the megolm sessions data * @return the successfully imported sessions. */ - fun importInboundGroupSessions(megolmSessionsData: List): List { - val sessions = ArrayList(megolmSessionsData.size) + fun importInboundGroupSessions(megolmSessionsData: List): List { + val sessions = ArrayList(megolmSessionsData.size) for (megolmSessionData in megolmSessionsData) { val sessionId = megolmSessionData.sessionId ?: continue val senderKey = megolmSessionData.senderKey ?: continue val roomId = megolmSessionData.roomId - var candidateSessionToImport: OlmInboundGroupSessionWrapper2? = null - - try { - candidateSessionToImport = OlmInboundGroupSessionWrapper2(megolmSessionData) - } catch (e: Exception) { - Timber.tag(loggerTag.value).e(e, "## importInboundGroupSession() : Update for megolm session $senderKey/$sessionId") - } - - // sanity check - if (candidateSessionToImport?.olmInboundGroupSession == null) { - Timber.tag(loggerTag.value).e("## importInboundGroupSession : invalid session") - continue - } - - val candidateOlmInboundGroupSession = candidateSessionToImport.olmInboundGroupSession - try { - if (candidateOlmInboundGroupSession?.sessionIdentifier() != sessionId) { - Timber.tag(loggerTag.value).e("## importInboundGroupSession : ERROR: Mismatched group session ID from senderKey: $senderKey") - candidateOlmInboundGroupSession?.releaseSession() - continue - } - } catch (e: Exception) { - Timber.tag(loggerTag.value).e(e, "## importInboundGroupSession : sessionIdentifier() failed") - candidateOlmInboundGroupSession?.releaseSession() + val candidateSessionToImport = try { + MXInboundMegolmSessionWrapper.newFromMegolmData(megolmSessionData, true) + } catch (e: Throwable) { + Timber.tag(loggerTag.value).e(e, "## importInboundGroupSession() : Failed to import session $senderKey/$sessionId") continue } + val candidateOlmInboundGroupSession = candidateSessionToImport.session val existingSessionHolder = tryOrNull { getInboundGroupSession(sessionId, senderKey, roomId) } val existingSession = existingSessionHolder?.wrapper @@ -721,16 +718,16 @@ internal class MXOlmDevice @Inject constructor( sessions.add(candidateSessionToImport) } else { Timber.tag(loggerTag.value).e("## importInboundGroupSession() : Update for megolm session $senderKey/$sessionId") - val existingFirstKnown = tryOrNull { existingSession.firstKnownIndex } - val candidateFirstKnownIndex = tryOrNull { candidateSessionToImport.firstKnownIndex } + val existingFirstKnown = tryOrNull { existingSession.session.firstKnownIndex } + val candidateFirstKnownIndex = tryOrNull { candidateSessionToImport.session.firstKnownIndex } if (existingFirstKnown == null || candidateFirstKnownIndex == null) { // should not happen? - candidateSessionToImport.olmInboundGroupSession?.releaseSession() + candidateSessionToImport.session.releaseSession() Timber.tag(loggerTag.value) .w("## importInboundGroupSession() : Can't check session null index $existingFirstKnown/$candidateFirstKnownIndex") } else { - if (existingFirstKnown <= candidateSessionToImport.firstKnownIndex!!) { + if (existingFirstKnown <= candidateFirstKnownIndex) { // Ignore this, keep existing candidateOlmInboundGroupSession.releaseSession() } else { @@ -774,8 +771,7 @@ internal class MXOlmDevice @Inject constructor( ): OlmDecryptionResult { val sessionHolder = getInboundGroupSession(sessionId, senderKey, roomId) val wrapper = sessionHolder.wrapper - val inboundGroupSession = wrapper.olmInboundGroupSession - ?: throw MXCryptoError.Base(MXCryptoError.ErrorType.UNABLE_TO_DECRYPT, "Session is null") + val inboundGroupSession = wrapper.session if (roomId != wrapper.roomId) { // Check that the room id matches the original one for the session. This stops // the HS pretending a message was targeting a different room. @@ -822,9 +818,9 @@ internal class MXOlmDevice @Inject constructor( return OlmDecryptionResult( payload, - wrapper.keysClaimed, + wrapper.sessionData.keysClaimed, senderKey, - wrapper.forwardingCurve25519KeyChain + wrapper.sessionData.forwardingCurve25519KeyChain ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MegolmSessionData.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MegolmSessionData.kt index f6bc9a9148..ca0bdc8a0e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MegolmSessionData.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/MegolmSessionData.kt @@ -69,5 +69,13 @@ internal data class MegolmSessionData( * Devices which forwarded this session to us (normally empty). */ @Json(name = "forwarding_curve25519_key_chain") - val forwardingCurve25519KeyChain: List? = null + val forwardingCurve25519KeyChain: List? = null, + + /** + * Flag that indicates whether or not the current inboundSession will be shared to + * invited users to decrypt past messages. + */ + // When this feature lands in spec name = shared_history should be used + @Json(name = "org.matrix.msc3061.shared_history") + val sharedHistory: Boolean = false, ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OutgoingKeyRequestManager.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OutgoingKeyRequestManager.kt index 6b22cc09d6..810699d933 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OutgoingKeyRequestManager.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/OutgoingKeyRequestManager.kt @@ -437,7 +437,10 @@ internal class OutgoingKeyRequestManager @Inject constructor( if (perSessionBackupQueryRateLimiter.tryFromBackupIfPossible(sessionId, roomId)) { // let's see what's the index val knownIndex = tryOrNull { - inboundGroupSessionStore.getInboundGroupSession(sessionId, request.requestBody?.senderKey ?: "")?.wrapper?.firstKnownIndex + inboundGroupSessionStore.getInboundGroupSession(sessionId, request.requestBody?.senderKey ?: "") + ?.wrapper + ?.session + ?.firstKnownIndex } if (knownIndex != null && knownIndex <= request.fromIndex) { // we found the key in backup with good enough index, so we can just mark as cancelled, no need to send request diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/MegolmSessionDataImporter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/MegolmSessionDataImporter.kt index f6ab96aee6..a624b92a19 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/MegolmSessionDataImporter.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/actions/MegolmSessionDataImporter.kt @@ -84,8 +84,9 @@ internal class MegolmSessionDataImporter @Inject constructor( megolmSessionData.senderKey ?: "", tryOrNull { olmInboundGroupSessionWrappers - .firstOrNull { it.olmInboundGroupSession?.sessionIdentifier() == megolmSessionData.sessionId } - ?.firstKnownIndex?.toInt() + .firstOrNull { it.session.sessionIdentifier() == megolmSessionData.sessionId } + ?.session?.firstKnownIndex + ?.toInt() } ?: 0 ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXEncrypting.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXEncrypting.kt index 73ce5a5004..1454f5b486 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXEncrypting.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/IMXEncrypting.kt @@ -16,7 +16,9 @@ package org.matrix.android.sdk.internal.crypto.algorithms +import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo import org.matrix.android.sdk.api.session.events.model.Content +import org.matrix.android.sdk.internal.crypto.InboundGroupSessionHolder /** * An interface for encrypting data. @@ -32,4 +34,6 @@ internal interface IMXEncrypting { * @return the encrypted content */ suspend fun encryptEventContent(eventContent: Content, eventType: String, userIds: List): Content + + suspend fun shareHistoryKeysWithDevice(inboundSessionWrapper: InboundGroupSessionHolder, deviceInfo: CryptoDeviceInfo) {} } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt index 141d6f74cd..410b74e19f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryption.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.crypto.algorithms.megolm import dagger.Lazy +import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.api.logger.LoggerTag import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.crypto.NewSessionListener @@ -41,6 +42,7 @@ internal class MXMegolmDecryption( private val olmDevice: MXOlmDevice, private val outgoingKeyRequestManager: OutgoingKeyRequestManager, private val cryptoStore: IMXCryptoStore, + private val matrixConfiguration: MatrixConfiguration, private val liveEventManager: Lazy ) : IMXDecrypting { @@ -240,13 +242,14 @@ internal class MXMegolmDecryption( Timber.tag(loggerTag.value).i("onRoomKeyEvent addInboundGroupSession ${roomKeyContent.sessionId}") val addSessionResult = olmDevice.addInboundGroupSession( - roomKeyContent.sessionId, - roomKeyContent.sessionKey, - roomKeyContent.roomId, - senderKey, - forwardingCurve25519KeyChain, - keysClaimed, - exportFormat + sessionId = roomKeyContent.sessionId, + sessionKey = roomKeyContent.sessionKey, + roomId = roomKeyContent.roomId, + senderKey = senderKey, + forwardingCurve25519KeyChain = forwardingCurve25519KeyChain, + keysClaimed = keysClaimed, + exportFormat = exportFormat, + sharedHistory = roomKeyContent.getSharedKey() ) when (addSessionResult) { @@ -296,6 +299,14 @@ internal class MXMegolmDecryption( } } + /** + * Returns boolean shared key flag, if enabled with respect to matrix configuration. + */ + private fun RoomKeyContent.getSharedKey(): Boolean { + if (!cryptoStore.isShareKeysOnInviteEnabled()) return false + return sharedHistory ?: false + } + /** * Check if the some messages can be decrypted with a new session. * diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryptionFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryptionFactory.kt index 81a6fb28c0..414416a0f6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryptionFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmDecryptionFactory.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.crypto.algorithms.megolm import dagger.Lazy +import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.internal.crypto.MXOlmDevice import org.matrix.android.sdk.internal.crypto.OutgoingKeyRequestManager import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore @@ -27,6 +28,7 @@ internal class MXMegolmDecryptionFactory @Inject constructor( private val olmDevice: MXOlmDevice, private val outgoingKeyRequestManager: OutgoingKeyRequestManager, private val cryptoStore: IMXCryptoStore, + private val matrixConfiguration: MatrixConfiguration, private val eventsManager: Lazy ) { @@ -35,7 +37,7 @@ internal class MXMegolmDecryptionFactory @Inject constructor( olmDevice, outgoingKeyRequestManager, cryptoStore, - eventsManager - ) + matrixConfiguration, + eventsManager) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmEncryption.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmEncryption.kt index 7bfbae6edf..48a25f2a8b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmEncryption.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXMegolmEncryption.kt @@ -32,6 +32,7 @@ import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.content.RoomKeyWithHeldContent import org.matrix.android.sdk.api.session.events.model.content.WithHeldCode import org.matrix.android.sdk.internal.crypto.DeviceListManager +import org.matrix.android.sdk.internal.crypto.InboundGroupSessionHolder import org.matrix.android.sdk.internal.crypto.MXOlmDevice import org.matrix.android.sdk.internal.crypto.actions.EnsureOlmSessionsForDevicesAction import org.matrix.android.sdk.internal.crypto.actions.MessageEncrypter @@ -151,14 +152,27 @@ internal class MXMegolmEncryption( "ed25519" to olmDevice.deviceEd25519Key!! ) + val sharedHistory = cryptoStore.shouldShareHistory(roomId) + Timber.tag(loggerTag.value).v("prepareNewSessionInRoom() as sharedHistory $sharedHistory") olmDevice.addInboundGroupSession( - sessionId!!, olmDevice.getSessionKey(sessionId)!!, roomId, olmDevice.deviceCurve25519Key!!, - emptyList(), keysClaimedMap, false + sessionId = sessionId!!, + sessionKey = olmDevice.getSessionKey(sessionId)!!, + roomId = roomId, + senderKey = olmDevice.deviceCurve25519Key!!, + forwardingCurve25519KeyChain = emptyList(), + keysClaimed = keysClaimedMap, + exportFormat = false, + sharedHistory = sharedHistory ) defaultKeysBackupService.maybeBackupKeys() - return MXOutboundSessionInfo(sessionId, SharedWithHelper(roomId, sessionId, cryptoStore), clock) + return MXOutboundSessionInfo( + sessionId = sessionId, + sharedWithHelper = SharedWithHelper(roomId, sessionId, cryptoStore), + clock = clock, + sharedHistory = sharedHistory + ) } /** @@ -172,6 +186,8 @@ internal class MXMegolmEncryption( if (session == null || // Need to make a brand new session? session.needsRotation(sessionRotationPeriodMsgs, sessionRotationPeriodMs) || + // Is there a room history visibility change since the last outboundSession + cryptoStore.shouldShareHistory(roomId) != session.sharedHistory || // Determine if we have shared with anyone we shouldn't have session.sharedWithTooManyDevices(devicesInRoom)) { Timber.tag(loggerTag.value).d("roomId:$roomId Starting new megolm session because we need to rotate.") @@ -231,26 +247,27 @@ internal class MXMegolmEncryption( /** * Share the device keys of a an user. * - * @param session the session info + * @param sessionInfo the session info * @param devicesByUser the devices map */ - private suspend fun shareUserDevicesKey( - session: MXOutboundSessionInfo, - devicesByUser: Map> - ) { - val sessionKey = olmDevice.getSessionKey(session.sessionId) - val chainIndex = olmDevice.getMessageIndex(session.sessionId) + private suspend fun shareUserDevicesKey(sessionInfo: MXOutboundSessionInfo, + devicesByUser: Map>) { + val sessionKey = olmDevice.getSessionKey(sessionInfo.sessionId) ?: return Unit.also { + Timber.tag(loggerTag.value).v("shareUserDevicesKey() Failed to share session, failed to export") + } + val chainIndex = olmDevice.getMessageIndex(sessionInfo.sessionId) - val submap = HashMap() - submap["algorithm"] = MXCRYPTO_ALGORITHM_MEGOLM - submap["room_id"] = roomId - submap["session_id"] = session.sessionId - submap["session_key"] = sessionKey!! - submap["chain_index"] = chainIndex - - val payload = HashMap() - payload["type"] = EventType.ROOM_KEY - payload["content"] = submap + val payload = mapOf( + "type" to EventType.ROOM_KEY, + "content" to mapOf( + "algorithm" to MXCRYPTO_ALGORITHM_MEGOLM, + "room_id" to roomId, + "session_id" to sessionInfo.sessionId, + "session_key" to sessionKey, + "chain_index" to chainIndex, + "org.matrix.msc3061.shared_history" to sessionInfo.sharedHistory + ) + ) var t0 = clock.epochMillis() Timber.tag(loggerTag.value).v("shareUserDevicesKey() : starts") @@ -292,7 +309,7 @@ internal class MXMegolmEncryption( // for dead devices on every message. for ((_, devicesToShareWith) in devicesByUser) { for (deviceInfo in devicesToShareWith) { - session.sharedWithHelper.markedSessionAsShared(deviceInfo, chainIndex) + sessionInfo.sharedWithHelper.markedSessionAsShared(deviceInfo, chainIndex) // XXX is it needed to add it to the audit trail? // For now decided that no, we are more interested by forward trail } @@ -300,8 +317,8 @@ internal class MXMegolmEncryption( if (haveTargets) { t0 = clock.epochMillis() - Timber.tag(loggerTag.value).i("shareUserDevicesKey() ${session.sessionId} : has target") - Timber.tag(loggerTag.value).d("sending to device room key for ${session.sessionId} to ${contentMap.toDebugString()}") + Timber.tag(loggerTag.value).i("shareUserDevicesKey() ${sessionInfo.sessionId} : has target") + Timber.tag(loggerTag.value).d("sending to device room key for ${sessionInfo.sessionId} to ${contentMap.toDebugString()}") val sendToDeviceParams = SendToDeviceTask.Params(EventType.ENCRYPTED, contentMap) try { withContext(coroutineDispatchers.io) { @@ -310,7 +327,7 @@ internal class MXMegolmEncryption( Timber.tag(loggerTag.value).i("shareUserDevicesKey() : sendToDevice succeeds after ${clock.epochMillis() - t0} ms") } catch (failure: Throwable) { // What to do here... - Timber.tag(loggerTag.value).e("shareUserDevicesKey() : Failed to share <${session.sessionId}>") + Timber.tag(loggerTag.value).e("shareUserDevicesKey() : Failed to share <${sessionInfo.sessionId}>") } } else { Timber.tag(loggerTag.value).i("shareUserDevicesKey() : no need to share key") @@ -320,7 +337,7 @@ internal class MXMegolmEncryption( // XXX offload?, as they won't read the message anyhow? notifyKeyWithHeld( noOlmToNotify, - session.sessionId, + sessionInfo.sessionId, olmDevice.deviceCurve25519Key, WithHeldCode.NO_OLM ) @@ -514,6 +531,51 @@ internal class MXMegolmEncryption( } } + @Throws + override suspend fun shareHistoryKeysWithDevice(inboundSessionWrapper: InboundGroupSessionHolder, deviceInfo: CryptoDeviceInfo) { + if (!inboundSessionWrapper.wrapper.sessionData.sharedHistory) throw IllegalArgumentException("This key can't be shared") + Timber.tag(loggerTag.value).i("process shareHistoryKeys for ${inboundSessionWrapper.wrapper.safeSessionId} to ${deviceInfo.shortDebugString()}") + val userId = deviceInfo.userId + val deviceId = deviceInfo.deviceId + val devicesByUser = mapOf(userId to listOf(deviceInfo)) + val usersDeviceMap = try { + ensureOlmSessionsForDevicesAction.handle(devicesByUser) + } catch (failure: Throwable) { + Timber.tag(loggerTag.value).i(failure, "process shareHistoryKeys failed to ensure olm") + // process anyway? + null + } + val olmSessionResult = usersDeviceMap?.getObject(userId, deviceId) + if (olmSessionResult?.sessionId == null) { + Timber.tag(loggerTag.value).w("shareHistoryKeys: no session with this device, probably because there were no one-time keys") + return + } + + val export = inboundSessionWrapper.mutex.withLock { + inboundSessionWrapper.wrapper.exportKeys() + } ?: return Unit.also { + Timber.tag(loggerTag.value).e("shareHistoryKeys: failed to export group session ${inboundSessionWrapper.wrapper.safeSessionId}") + } + + val payloadJson = mapOf( + "type" to EventType.FORWARDED_ROOM_KEY, + "content" to export + ) + + val encodedPayload = + withContext(coroutineDispatchers.computation) { + messageEncrypter.encryptMessage(payloadJson, listOf(deviceInfo)) + } + val sendToDeviceMap = MXUsersDevicesMap() + sendToDeviceMap.setObject(userId, deviceId, encodedPayload) + Timber.tag(loggerTag.value) + .d("shareHistoryKeys() : sending session ${inboundSessionWrapper.wrapper.safeSessionId} to ${deviceInfo.shortDebugString()}") + val sendToDeviceParams = SendToDeviceTask.Params(EventType.ENCRYPTED, sendToDeviceMap) + withContext(coroutineDispatchers.io) { + sendToDeviceTask.execute(sendToDeviceParams) + } + } + data class DeviceInRoomInfo( val allowedDevices: MXUsersDevicesMap = MXUsersDevicesMap(), val withHeldDevices: MXUsersDevicesMap = MXUsersDevicesMap() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXOutboundSessionInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXOutboundSessionInfo.kt index 28d925d8fd..e0caa0d9a5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXOutboundSessionInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXOutboundSessionInfo.kt @@ -28,6 +28,7 @@ internal class MXOutboundSessionInfo( private val clock: Clock, // When the session was created private val creationTime: Long = clock.epochMillis(), + val sharedHistory: Boolean = false ) { // Number of times this session has been used diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/keysbackup/DefaultKeysBackupService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/keysbackup/DefaultKeysBackupService.kt index 5eaa106af3..49cf60d051 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/keysbackup/DefaultKeysBackupService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/keysbackup/DefaultKeysBackupService.kt @@ -24,8 +24,10 @@ import androidx.annotation.WorkerThread import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.matrix.android.sdk.api.MatrixCallback +import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.api.auth.data.Credentials import org.matrix.android.sdk.api.crypto.MXCRYPTO_ALGORITHM_MEGOLM_BACKUP @@ -50,6 +52,7 @@ import org.matrix.android.sdk.api.session.crypto.keysbackup.toKeysVersionResult import org.matrix.android.sdk.api.session.crypto.model.ImportRoomKeysResult import org.matrix.android.sdk.api.util.awaitCallback import org.matrix.android.sdk.api.util.fromBase64 +import org.matrix.android.sdk.internal.crypto.InboundGroupSessionStore import org.matrix.android.sdk.internal.crypto.MXOlmDevice import org.matrix.android.sdk.internal.crypto.MegolmSessionData import org.matrix.android.sdk.internal.crypto.ObjectSigner @@ -71,7 +74,7 @@ import org.matrix.android.sdk.internal.crypto.keysbackup.tasks.GetRoomSessionsDa import org.matrix.android.sdk.internal.crypto.keysbackup.tasks.GetSessionsDataTask import org.matrix.android.sdk.internal.crypto.keysbackup.tasks.StoreSessionsDataTask import org.matrix.android.sdk.internal.crypto.keysbackup.tasks.UpdateKeysBackupVersionTask -import org.matrix.android.sdk.internal.crypto.model.OlmInboundGroupSessionWrapper2 +import org.matrix.android.sdk.internal.crypto.model.MXInboundMegolmSessionWrapper import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore import org.matrix.android.sdk.internal.crypto.store.db.model.KeysBackupDataEntity import org.matrix.android.sdk.internal.di.MoshiProvider @@ -118,6 +121,8 @@ internal class DefaultKeysBackupService @Inject constructor( private val updateKeysBackupVersionTask: UpdateKeysBackupVersionTask, // Task executor private val taskExecutor: TaskExecutor, + private val matrixConfiguration: MatrixConfiguration, + private val inboundGroupSessionStore: InboundGroupSessionStore, private val coroutineDispatchers: MatrixCoroutineDispatchers, private val cryptoCoroutineScope: CoroutineScope ) : KeysBackupService { @@ -1316,7 +1321,7 @@ internal class DefaultKeysBackupService @Inject constructor( olmInboundGroupSessionWrappers.forEach { olmInboundGroupSessionWrapper -> val roomId = olmInboundGroupSessionWrapper.roomId ?: return@forEach - val olmInboundGroupSession = olmInboundGroupSessionWrapper.olmInboundGroupSession ?: return@forEach + val olmInboundGroupSession = olmInboundGroupSessionWrapper.session try { encryptGroupSession(olmInboundGroupSessionWrapper) @@ -1405,19 +1410,29 @@ internal class DefaultKeysBackupService @Inject constructor( @VisibleForTesting @WorkerThread - fun encryptGroupSession(olmInboundGroupSessionWrapper: OlmInboundGroupSessionWrapper2): KeyBackupData? { + suspend fun encryptGroupSession(olmInboundGroupSessionWrapper: MXInboundMegolmSessionWrapper): KeyBackupData? { + olmInboundGroupSessionWrapper.safeSessionId ?: return null + olmInboundGroupSessionWrapper.senderKey ?: return null // Gather information for each key - val device = olmInboundGroupSessionWrapper.senderKey?.let { cryptoStore.deviceWithIdentityKey(it) } + val device = cryptoStore.deviceWithIdentityKey(olmInboundGroupSessionWrapper.senderKey) // Build the m.megolm_backup.v1.curve25519-aes-sha2 data as defined at // https://github.com/uhoreg/matrix-doc/blob/e2e_backup/proposals/1219-storing-megolm-keys-serverside.md#mmegolm_backupv1curve25519-aes-sha2-key-format - val sessionData = olmInboundGroupSessionWrapper.exportKeys() ?: return null + val sessionData = inboundGroupSessionStore + .getInboundGroupSession(olmInboundGroupSessionWrapper.safeSessionId, olmInboundGroupSessionWrapper.senderKey) + ?.let { + withContext(coroutineDispatchers.computation) { + it.mutex.withLock { it.wrapper.exportKeys() } + } + } + ?: return null val sessionBackupData = mapOf( "algorithm" to sessionData.algorithm, "sender_key" to sessionData.senderKey, "sender_claimed_keys" to sessionData.senderClaimedKeys, "forwarding_curve25519_key_chain" to (sessionData.forwardingCurve25519KeyChain.orEmpty()), - "session_key" to sessionData.sessionKey + "session_key" to sessionData.sessionKey, + "org.matrix.msc3061.shared_history" to sessionData.sharedHistory ) val json = MoshiProvider.providesMoshi() @@ -1425,7 +1440,9 @@ internal class DefaultKeysBackupService @Inject constructor( .toJson(sessionBackupData) val encryptedSessionBackupData = try { - backupOlmPkEncryption?.encrypt(json) + withContext(coroutineDispatchers.computation) { + backupOlmPkEncryption?.encrypt(json) + } } catch (e: OlmException) { Timber.e(e, "OlmException") null @@ -1435,14 +1452,14 @@ internal class DefaultKeysBackupService @Inject constructor( // Build backup data for that key return KeyBackupData( firstMessageIndex = try { - olmInboundGroupSessionWrapper.olmInboundGroupSession?.firstKnownIndex ?: 0 + olmInboundGroupSessionWrapper.session.firstKnownIndex } catch (e: OlmException) { Timber.e(e, "OlmException") 0L }, - forwardedCount = olmInboundGroupSessionWrapper.forwardingCurve25519KeyChain.orEmpty().size, + forwardedCount = olmInboundGroupSessionWrapper.sessionData.forwardingCurve25519KeyChain.orEmpty().size, isVerified = device?.isVerified == true, - + sharedHistory = olmInboundGroupSessionWrapper.getSharedKey(), sessionData = mapOf( "ciphertext" to encryptedSessionBackupData.mCipherText, "mac" to encryptedSessionBackupData.mMac, @@ -1451,6 +1468,14 @@ internal class DefaultKeysBackupService @Inject constructor( ) } + /** + * Returns boolean shared key flag, if enabled with respect to matrix configuration. + */ + private fun MXInboundMegolmSessionWrapper.getSharedKey(): Boolean { + if (!cryptoStore.isShareKeysOnInviteEnabled()) return false + return sessionData.sharedHistory + } + @VisibleForTesting @WorkerThread fun decryptKeyBackupData(keyBackupData: KeyBackupData, sessionId: String, roomId: String, decryption: OlmPkDecryption): MegolmSessionData? { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/keysbackup/model/rest/KeyBackupData.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/keysbackup/model/rest/KeyBackupData.kt index 5c3d0c12b0..1817b18e2a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/keysbackup/model/rest/KeyBackupData.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/keysbackup/model/rest/KeyBackupData.kt @@ -50,5 +50,12 @@ internal data class KeyBackupData( * Algorithm-dependent data. */ @Json(name = "session_data") - val sessionData: JsonDict + val sessionData: JsonDict, + + /** + * Flag that indicates whether or not the current inboundSession will be shared to + * invited users to decrypt past messages. + */ + @Json(name = "org.matrix.msc3061.shared_history") + val sharedHistory: Boolean = false ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/InboundGroupSessionData.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/InboundGroupSessionData.kt new file mode 100644 index 0000000000..2ce36aa209 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/InboundGroupSessionData.kt @@ -0,0 +1,51 @@ +/* + * 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.crypto.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class InboundGroupSessionData( + + /** The room in which this session is used. */ + @Json(name = "room_id") + var roomId: String? = null, + + /** The base64-encoded curve25519 key of the sender. */ + @Json(name = "sender_key") + var senderKey: String? = null, + + /** Other keys the sender claims. */ + @Json(name = "keys_claimed") + var keysClaimed: Map? = null, + + /** Devices which forwarded this session to us (normally emty). */ + @Json(name = "forwarding_curve25519_key_chain") + var forwardingCurve25519KeyChain: List? = emptyList(), + + /** Not yet used, will be in backup v2 + val untrusted?: Boolean = false */ + + /** + * Flag that indicates whether or not the current inboundSession will be shared to + * invited users to decrypt past messages. + */ + @Json(name = "shared_history") + val sharedHistory: Boolean = false, + + ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/MXInboundMegolmSessionWrapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/MXInboundMegolmSessionWrapper.kt new file mode 100644 index 0000000000..2772b34835 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/MXInboundMegolmSessionWrapper.kt @@ -0,0 +1,97 @@ +/* + * 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.crypto.model + +import org.matrix.android.sdk.api.crypto.MXCRYPTO_ALGORITHM_MEGOLM +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.internal.crypto.MegolmSessionData +import org.matrix.olm.OlmInboundGroupSession +import timber.log.Timber + +data class MXInboundMegolmSessionWrapper( + // olm object + val session: OlmInboundGroupSession, + // data about the session + val sessionData: InboundGroupSessionData +) { + // shortcut + val roomId = sessionData.roomId + val senderKey = sessionData.senderKey + val safeSessionId = tryOrNull("Fail to get megolm session Id") { session.sessionIdentifier() } + + /** + * Export the inbound group session keys. + * @param index the index to export. If null, the first known index will be used + * @return the inbound group session as MegolmSessionData if the operation succeeds + */ + internal fun exportKeys(index: Long? = null): MegolmSessionData? { + return try { + val keysClaimed = sessionData.keysClaimed ?: return null + val wantedIndex = index ?: session.firstKnownIndex + + MegolmSessionData( + senderClaimedEd25519Key = sessionData.keysClaimed?.get("ed25519"), + forwardingCurve25519KeyChain = sessionData.forwardingCurve25519KeyChain?.toList().orEmpty(), + sessionKey = session.export(wantedIndex), + senderClaimedKeys = keysClaimed, + roomId = sessionData.roomId, + sessionId = session.sessionIdentifier(), + senderKey = senderKey, + algorithm = MXCRYPTO_ALGORITHM_MEGOLM, + sharedHistory = sessionData.sharedHistory + ) + } catch (e: Exception) { + Timber.e(e, "## Failed to export megolm : sessionID ${tryOrNull { session.sessionIdentifier() }} failed") + null + } + } + + companion object { + + /** + * @exportFormat true if the megolm keys are in export format + * (ie, they lack an ed25519 signature) + */ + @Throws + internal fun newFromMegolmData(megolmSessionData: MegolmSessionData, exportFormat: Boolean): MXInboundMegolmSessionWrapper { + val exportedKey = megolmSessionData.sessionKey ?: throw IllegalArgumentException("key data not found") + val inboundSession = if (exportFormat) { + OlmInboundGroupSession.importSession(exportedKey) + } else { + OlmInboundGroupSession(exportedKey) + } + .also { + if (it.sessionIdentifier() != megolmSessionData.sessionId) { + it.releaseSession() + throw IllegalStateException("Mismatched group session Id") + } + } + val data = InboundGroupSessionData( + roomId = megolmSessionData.roomId, + senderKey = megolmSessionData.senderKey, + keysClaimed = megolmSessionData.senderClaimedKeys, + forwardingCurve25519KeyChain = megolmSessionData.forwardingCurve25519KeyChain, + sharedHistory = megolmSessionData.sharedHistory, + ) + + return MXInboundMegolmSessionWrapper( + inboundSession, + data + ) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/OlmInboundGroupSessionWrapper2.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/OlmInboundGroupSessionWrapper2.kt index 289c169d6d..600fcb1003 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/OlmInboundGroupSessionWrapper2.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/OlmInboundGroupSessionWrapper2.kt @@ -26,6 +26,8 @@ import java.io.Serializable * This class adds more context to a OlmInboundGroupSession object. * This allows additional checks. The class implements Serializable so that the context can be stored. */ +// Note used anymore, just for database migration +// Deprecated("Use MXInboundMegolmSessionWrapper") internal class OlmInboundGroupSessionWrapper2 : Serializable { // The associated olm inbound group session. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/OutboundGroupSessionWrapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/OutboundGroupSessionWrapper.kt index 4ac87f44ce..5a6d1f4bc1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/OutboundGroupSessionWrapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/OutboundGroupSessionWrapper.kt @@ -20,5 +20,9 @@ import org.matrix.olm.OlmOutboundGroupSession internal data class OutboundGroupSessionWrapper( val outboundGroupSession: OlmOutboundGroupSession, - val creationTime: Long + val creationTime: Long, + /** + * As per MSC 3061, declares if this key could be shared when inviting a new user to the room. + */ + val sharedHistory: Boolean = false ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/SessionInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/SessionInfo.kt new file mode 100644 index 0000000000..b3a2ba4dfe --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/SessionInfo.kt @@ -0,0 +1,22 @@ +/* + * 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.crypto.model + +data class SessionInfo( + val sessionId: String, + val senderKey: String +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt index b5b8d8e974..0413fc730c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt @@ -35,7 +35,7 @@ import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.content.RoomKeyWithHeldContent import org.matrix.android.sdk.api.session.events.model.content.WithHeldCode import org.matrix.android.sdk.api.util.Optional -import org.matrix.android.sdk.internal.crypto.model.OlmInboundGroupSessionWrapper2 +import org.matrix.android.sdk.internal.crypto.model.MXInboundMegolmSessionWrapper import org.matrix.android.sdk.internal.crypto.model.OlmSessionWrapper import org.matrix.android.sdk.internal.crypto.model.OutboundGroupSessionWrapper import org.matrix.android.sdk.internal.crypto.store.db.model.KeysBackupDataEntity @@ -64,7 +64,15 @@ internal interface IMXCryptoStore { * * @return the list of all known group sessions, to export them. */ - fun getInboundGroupSessions(): List + fun getInboundGroupSessions(): List + + /** + * Retrieve the known inbound group sessions for the specified room. + * + * @param roomId The roomId that the sessions will be returned + * @return the list of all known group sessions, for the provided roomId + */ + fun getInboundGroupSessions(roomId: String): List /** * @return true to unilaterally blacklist all unverified devices. @@ -90,6 +98,20 @@ internal interface IMXCryptoStore { fun isKeyGossipingEnabled(): Boolean + /** + * As per MSC3061. + * If true will make it possible to share part of e2ee room history + * on invite depending on the room visibility setting. + */ + fun enableShareKeyOnInvite(enable: Boolean) + + /** + * As per MSC3061. + * If true will make it possible to share part of e2ee room history + * on invite depending on the room visibility setting. + */ + fun isShareKeysOnInviteEnabled(): Boolean + /** * Provides the rooms ids list in which the messages are not encrypted for the unverified devices. * @@ -250,6 +272,17 @@ internal interface IMXCryptoStore { fun setShouldEncryptForInvitedMembers(roomId: String, shouldEncryptForInvitedMembers: Boolean) + fun shouldShareHistory(roomId: String): Boolean + + /** + * Sets a boolean flag that will determine whether or not room history (existing inbound sessions) + * will be shared to new user invites. + * + * @param roomId the room id + * @param shouldShareHistory The boolean flag + */ + fun setShouldShareHistory(roomId: String, shouldShareHistory: Boolean) + /** * Store a session between the logged-in user and another device. * @@ -290,7 +323,7 @@ internal interface IMXCryptoStore { * * @param sessions the inbound group sessions to store. */ - fun storeInboundGroupSessions(sessions: List) + fun storeInboundGroupSessions(sessions: List) /** * Retrieve an inbound group session. @@ -299,7 +332,17 @@ internal interface IMXCryptoStore { * @param senderKey the base64-encoded curve25519 key of the sender. * @return an inbound group session. */ - fun getInboundGroupSession(sessionId: String, senderKey: String): OlmInboundGroupSessionWrapper2? + fun getInboundGroupSession(sessionId: String, senderKey: String): MXInboundMegolmSessionWrapper? + + /** + * Retrieve an inbound group session, filtering shared history. + * + * @param sessionId the session identifier. + * @param senderKey the base64-encoded curve25519 key of the sender. + * @param sharedHistory filter inbound session with respect to shared history field + * @return an inbound group session. + */ + fun getInboundGroupSession(sessionId: String, senderKey: String, sharedHistory: Boolean): MXInboundMegolmSessionWrapper? /** * Get the current outbound group session for this encrypted room. @@ -333,7 +376,7 @@ internal interface IMXCryptoStore { * * @param olmInboundGroupSessionWrappers the sessions */ - fun markBackupDoneForInboundGroupSessions(olmInboundGroupSessionWrappers: List) + fun markBackupDoneForInboundGroupSessions(olmInboundGroupSessionWrappers: List) /** * Retrieve inbound group sessions that are not yet backed up. @@ -341,7 +384,7 @@ internal interface IMXCryptoStore { * @param limit the maximum number of sessions to return. * @return an array of non backed up inbound group sessions. */ - fun inboundGroupSessionsToBackup(limit: Int): List + fun inboundGroupSessionsToBackup(limit: Int): List /** * Number of stored inbound group sessions. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt index 028d8f73f9..20ca357d1a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt @@ -50,7 +50,7 @@ import org.matrix.android.sdk.api.session.events.model.content.RoomKeyWithHeldCo import org.matrix.android.sdk.api.session.events.model.content.WithHeldCode import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.toOptional -import org.matrix.android.sdk.internal.crypto.model.OlmInboundGroupSessionWrapper2 +import org.matrix.android.sdk.internal.crypto.model.MXInboundMegolmSessionWrapper import org.matrix.android.sdk.internal.crypto.model.OlmSessionWrapper import org.matrix.android.sdk.internal.crypto.model.OutboundGroupSessionWrapper import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore @@ -657,12 +657,28 @@ internal class RealmCryptoStore @Inject constructor( ?: false } + override fun shouldShareHistory(roomId: String): Boolean { + if (!isShareKeysOnInviteEnabled()) return false + return doWithRealm(realmConfiguration) { + CryptoRoomEntity.getById(it, roomId)?.shouldShareHistory + } + ?: false + } + override fun setShouldEncryptForInvitedMembers(roomId: String, shouldEncryptForInvitedMembers: Boolean) { doRealmTransaction(realmConfiguration) { CryptoRoomEntity.getOrCreate(it, roomId).shouldEncryptForInvitedMembers = shouldEncryptForInvitedMembers } } + override fun setShouldShareHistory(roomId: String, shouldShareHistory: Boolean) { + Timber.tag(loggerTag.value) + .v("setShouldShareHistory for room $roomId is $shouldShareHistory") + doRealmTransaction(realmConfiguration) { + CryptoRoomEntity.getOrCreate(it, roomId).shouldShareHistory = shouldShareHistory + } + } + override fun storeSession(olmSessionWrapper: OlmSessionWrapper, deviceKey: String) { var sessionIdentifier: String? = null @@ -727,54 +743,55 @@ internal class RealmCryptoStore @Inject constructor( } } - override fun storeInboundGroupSessions(sessions: List) { + override fun storeInboundGroupSessions(sessions: List) { if (sessions.isEmpty()) { return } doRealmTransaction(realmConfiguration) { realm -> - sessions.forEach { session -> - var sessionIdentifier: String? = null + sessions.forEach { wrapper -> - try { - sessionIdentifier = session.olmInboundGroupSession?.sessionIdentifier() + val sessionIdentifier = try { + wrapper.session.sessionIdentifier() } catch (e: OlmException) { Timber.e(e, "## storeInboundGroupSession() : sessionIdentifier failed") + return@forEach } - if (sessionIdentifier != null) { - val key = OlmInboundGroupSessionEntity.createPrimaryKey(sessionIdentifier, session.senderKey) +// val shouldShareHistory = session.roomId?.let { roomId -> +// CryptoRoomEntity.getById(realm, roomId)?.shouldShareHistory +// } ?: false + val key = OlmInboundGroupSessionEntity.createPrimaryKey(sessionIdentifier, wrapper.sessionData.senderKey) - val existing = realm.where() - .equalTo(OlmInboundGroupSessionEntityFields.PRIMARY_KEY, key) - .findFirst() - - if (existing != null) { - // we want to keep the existing backup status - existing.putInboundGroupSession(session) - } else { - val realmOlmInboundGroupSession = OlmInboundGroupSessionEntity().apply { - primaryKey = key - sessionId = sessionIdentifier - senderKey = session.senderKey - putInboundGroupSession(session) - } - - realm.insertOrUpdate(realmOlmInboundGroupSession) - } + val realmOlmInboundGroupSession = OlmInboundGroupSessionEntity().apply { + primaryKey = key + store(wrapper) } + Timber.i("## CRYPTO | shouldShareHistory: ${wrapper.sessionData.sharedHistory} for $key") + realm.insertOrUpdate(realmOlmInboundGroupSession) } } } - override fun getInboundGroupSession(sessionId: String, senderKey: String): OlmInboundGroupSessionWrapper2? { + override fun getInboundGroupSession(sessionId: String, senderKey: String): MXInboundMegolmSessionWrapper? { val key = OlmInboundGroupSessionEntity.createPrimaryKey(sessionId, senderKey) - return doWithRealm(realmConfiguration) { - it.where() + return doWithRealm(realmConfiguration) { realm -> + realm.where() .equalTo(OlmInboundGroupSessionEntityFields.PRIMARY_KEY, key) .findFirst() - ?.getInboundGroupSession() + ?.toModel() + } + } + + override fun getInboundGroupSession(sessionId: String, senderKey: String, sharedHistory: Boolean): MXInboundMegolmSessionWrapper? { + val key = OlmInboundGroupSessionEntity.createPrimaryKey(sessionId, senderKey) + return doWithRealm(realmConfiguration) { + it.where() + .equalTo(OlmInboundGroupSessionEntityFields.SHARED_HISTORY, sharedHistory) + .equalTo(OlmInboundGroupSessionEntityFields.PRIMARY_KEY, key) + .findFirst() + ?.toModel() } } @@ -786,7 +803,8 @@ internal class RealmCryptoStore @Inject constructor( entity.getOutboundGroupSession()?.let { OutboundGroupSessionWrapper( it, - entity.creationTime ?: 0 + entity.creationTime ?: 0, + entity.shouldShareHistory ) } } @@ -806,6 +824,8 @@ internal class RealmCryptoStore @Inject constructor( if (outboundGroupSession != null) { val info = realm.createObject(OutboundGroupSessionInfoEntity::class.java).apply { creationTime = clock.epochMillis() + // Store the room history visibility on the outbound session creation + shouldShareHistory = entity.shouldShareHistory putOutboundGroupSession(outboundGroupSession) } entity.outboundSessionInfo = info @@ -814,17 +834,32 @@ internal class RealmCryptoStore @Inject constructor( } } +// override fun needsRotationDueToVisibilityChange(roomId: String): Boolean { +// return doWithRealm(realmConfiguration) { realm -> +// CryptoRoomEntity.getById(realm, roomId)?.let { entity -> +// entity.shouldShareHistory != entity.outboundSessionInfo?.shouldShareHistory +// } +// } ?: false +// } + /** * Note: the result will be only use to export all the keys and not to use the OlmInboundGroupSessionWrapper2, * so there is no need to use or update `inboundGroupSessionToRelease` for native memory management. */ - override fun getInboundGroupSessions(): List { - return doWithRealm(realmConfiguration) { - it.where() + override fun getInboundGroupSessions(): List { + return doWithRealm(realmConfiguration) { realm -> + realm.where() .findAll() - .mapNotNull { inboundGroupSessionEntity -> - inboundGroupSessionEntity.getInboundGroupSession() - } + .mapNotNull { it.toModel() } + } + } + + override fun getInboundGroupSessions(roomId: String): List { + return doWithRealm(realmConfiguration) { realm -> + realm.where() + .equalTo(OlmInboundGroupSessionEntityFields.ROOM_ID, roomId) + .findAll() + .mapNotNull { it.toModel() } } } @@ -885,7 +920,7 @@ internal class RealmCryptoStore @Inject constructor( } } - override fun markBackupDoneForInboundGroupSessions(olmInboundGroupSessionWrappers: List) { + override fun markBackupDoneForInboundGroupSessions(olmInboundGroupSessionWrappers: List) { if (olmInboundGroupSessionWrappers.isEmpty()) { return } @@ -893,10 +928,13 @@ internal class RealmCryptoStore @Inject constructor( doRealmTransaction(realmConfiguration) { realm -> olmInboundGroupSessionWrappers.forEach { olmInboundGroupSessionWrapper -> try { - val sessionIdentifier = olmInboundGroupSessionWrapper.olmInboundGroupSession?.sessionIdentifier() + val sessionIdentifier = + tryOrNull("Failed to get session identifier") { + olmInboundGroupSessionWrapper.session.sessionIdentifier() + } ?: return@forEach val key = OlmInboundGroupSessionEntity.createPrimaryKey( sessionIdentifier, - olmInboundGroupSessionWrapper.senderKey + olmInboundGroupSessionWrapper.sessionData.senderKey ) val existing = realm.where() @@ -909,9 +947,7 @@ internal class RealmCryptoStore @Inject constructor( // ... might be in cache but not yet persisted, create a record to persist backedup state val realmOlmInboundGroupSession = OlmInboundGroupSessionEntity().apply { primaryKey = key - sessionId = sessionIdentifier - senderKey = olmInboundGroupSessionWrapper.senderKey - putInboundGroupSession(olmInboundGroupSessionWrapper) + store(olmInboundGroupSessionWrapper) backedUp = true } @@ -924,15 +960,13 @@ internal class RealmCryptoStore @Inject constructor( } } - override fun inboundGroupSessionsToBackup(limit: Int): List { + override fun inboundGroupSessionsToBackup(limit: Int): List { return doWithRealm(realmConfiguration) { it.where() .equalTo(OlmInboundGroupSessionEntityFields.BACKED_UP, false) .limit(limit.toLong()) .findAll() - .mapNotNull { inboundGroupSession -> - inboundGroupSession.getInboundGroupSession() - } + .mapNotNull { it.toModel() } } } @@ -973,6 +1007,18 @@ internal class RealmCryptoStore @Inject constructor( } ?: false } + override fun isShareKeysOnInviteEnabled(): Boolean { + return doWithRealm(realmConfiguration) { + it.where().findFirst()?.enableKeyForwardingOnInvite + } ?: false + } + + override fun enableShareKeyOnInvite(enable: Boolean) { + doRealmTransaction(realmConfiguration) { + it.where().findFirst()?.enableKeyForwardingOnInvite = enable + } + } + override fun setDeviceKeysUploaded(uploaded: Boolean) { doRealmTransaction(realmConfiguration) { it.where().findFirst()?.deviceKeysSentToServer = uploaded diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt index 02c2a27dec..4ca9d44f98 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt @@ -34,6 +34,7 @@ import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo014 import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo015 import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo016 +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo017 import org.matrix.android.sdk.internal.util.time.Clock import timber.log.Timber import javax.inject.Inject @@ -51,7 +52,7 @@ internal class RealmCryptoStoreMigration @Inject constructor( // 0, 1, 2: legacy Riot-Android // 3: migrate to RiotX schema // 4, 5, 6, 7, 8, 9: migrations from RiotX (which was previously 1, 2, 3, 4, 5, 6) - val schemaVersion = 16L + val schemaVersion = 17L override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { Timber.d("Migrating Realm Crypto from $oldVersion to $newVersion") @@ -72,5 +73,6 @@ internal class RealmCryptoStoreMigration @Inject constructor( if (oldVersion < 14) MigrateCryptoTo014(realm).perform() if (oldVersion < 15) MigrateCryptoTo015(realm).perform() if (oldVersion < 16) MigrateCryptoTo016(realm).perform() + if (oldVersion < 17) MigrateCryptoTo017(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo017.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo017.kt new file mode 100644 index 0000000000..8904c412cd --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo017.kt @@ -0,0 +1,101 @@ +/* + * 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.crypto.store.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.internal.crypto.model.InboundGroupSessionData +import org.matrix.android.sdk.internal.crypto.store.db.deserializeFromRealm +import org.matrix.android.sdk.internal.crypto.store.db.model.CryptoMetadataEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.model.CryptoRoomEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.model.OlmInboundGroupSessionEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.model.OutboundGroupSessionInfoEntityFields +import org.matrix.android.sdk.internal.crypto.store.db.serializeForRealm +import org.matrix.android.sdk.internal.di.MoshiProvider +import org.matrix.android.sdk.internal.util.database.RealmMigrator +import timber.log.Timber + +/** + * Version 17L enhance OlmInboundGroupSessionEntity to support shared history for MSC3061. + * Also migrates how megolm session are stored to avoid additional serialized frozen class. + */ +internal class MigrateCryptoTo017(realm: DynamicRealm) : RealmMigrator(realm, 17) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("CryptoRoomEntity") + ?.addField(CryptoRoomEntityFields.SHOULD_SHARE_HISTORY, Boolean::class.java)?.transform { + // We don't have access to the session database to check for the state here and set the good value. + // But for now as it's behind a lab flag, will set to false and force initial sync when enabled + it.setBoolean(CryptoRoomEntityFields.SHOULD_SHARE_HISTORY, false) + } + + realm.schema.get("OutboundGroupSessionInfoEntity") + ?.addField(OutboundGroupSessionInfoEntityFields.SHOULD_SHARE_HISTORY, Boolean::class.java)?.transform { + // We don't have access to the session database to check for the state here and set the good value. + // But for now as it's behind a lab flag, will set to false and force initial sync when enabled + it.setBoolean(OutboundGroupSessionInfoEntityFields.SHOULD_SHARE_HISTORY, false) + } + + realm.schema.get("CryptoMetadataEntity") + ?.addField(CryptoMetadataEntityFields.ENABLE_KEY_FORWARDING_ON_INVITE, Boolean::class.java) + ?.transform { obj -> + // default to false + obj.setBoolean(CryptoMetadataEntityFields.ENABLE_KEY_FORWARDING_ON_INVITE, false) + } + + val moshiAdapter = MoshiProvider.providesMoshi().adapter(InboundGroupSessionData::class.java) + + realm.schema.get("OlmInboundGroupSessionEntity") + ?.addField(OlmInboundGroupSessionEntityFields.SHARED_HISTORY, Boolean::class.java) + ?.addField(OlmInboundGroupSessionEntityFields.ROOM_ID, String::class.java) + ?.addField(OlmInboundGroupSessionEntityFields.INBOUND_GROUP_SESSION_DATA_JSON, String::class.java) + ?.addField(OlmInboundGroupSessionEntityFields.SERIALIZED_OLM_INBOUND_GROUP_SESSION, String::class.java) + ?.transform { dynamicObject -> + try { + // we want to convert the old wrapper frozen class into a + // map of sessionData & the pickled session herself + dynamicObject.getString(OlmInboundGroupSessionEntityFields.OLM_INBOUND_GROUP_SESSION_DATA)?.let { oldData -> + val oldWrapper = tryOrNull("Failed to convert megolm inbound group data") { + @Suppress("DEPRECATION") + deserializeFromRealm(oldData) + } + val groupSession = oldWrapper?.olmInboundGroupSession + ?: return@transform Unit.also { + Timber.w("Failed to migrate megolm session, no olmInboundGroupSession") + } + // now convert to new data + val data = InboundGroupSessionData( + senderKey = oldWrapper.senderKey, + roomId = oldWrapper.roomId, + keysClaimed = oldWrapper.keysClaimed, + forwardingCurve25519KeyChain = oldWrapper.forwardingCurve25519KeyChain, + sharedHistory = false, + ) + + dynamicObject.setString(OlmInboundGroupSessionEntityFields.INBOUND_GROUP_SESSION_DATA_JSON, moshiAdapter.toJson(data)) + dynamicObject.setString(OlmInboundGroupSessionEntityFields.SERIALIZED_OLM_INBOUND_GROUP_SESSION, serializeForRealm(groupSession)) + + // denormalized fields + dynamicObject.setString(OlmInboundGroupSessionEntityFields.ROOM_ID, oldWrapper.roomId) + dynamicObject.setBoolean(OlmInboundGroupSessionEntityFields.SHARED_HISTORY, false) + } + } catch (failure: Throwable) { + Timber.e(failure, "Failed to migrate megolm session") + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/CryptoMetadataEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/CryptoMetadataEntity.kt index 63ed0e537e..88708f824e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/CryptoMetadataEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/CryptoMetadataEntity.kt @@ -35,6 +35,11 @@ internal open class CryptoMetadataEntity( var globalBlacklistUnverifiedDevices: Boolean = false, // setting to enable or disable key gossiping var globalEnableKeyGossiping: Boolean = true, + + // MSC3061: Sharing room keys for past messages + // If set to true key history will be shared to invited users with respect to room setting + var enableKeyForwardingOnInvite: Boolean = false, + // The keys backup version currently used. Null means no backup. var backupVersion: String? = null, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/CryptoRoomEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/CryptoRoomEntity.kt index 114a596964..be57586163 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/CryptoRoomEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/CryptoRoomEntity.kt @@ -24,6 +24,8 @@ internal open class CryptoRoomEntity( var algorithm: String? = null, var shouldEncryptForInvitedMembers: Boolean? = null, var blacklistUnverifiedDevices: Boolean = false, + // Determines whether or not room history should be shared on new member invites + var shouldShareHistory: Boolean = false, // Store the current outbound session for this room, // to avoid re-create and re-share at each startup (if rotation not needed..) // This is specific to megolm but not sure how to model it better diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/OlmInboundGroupSessionEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/OlmInboundGroupSessionEntity.kt index a4f6c279ac..62ab73e379 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/OlmInboundGroupSessionEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/OlmInboundGroupSessionEntity.kt @@ -18,9 +18,12 @@ package org.matrix.android.sdk.internal.crypto.store.db.model import io.realm.RealmObject import io.realm.annotations.PrimaryKey -import org.matrix.android.sdk.internal.crypto.model.OlmInboundGroupSessionWrapper2 +import org.matrix.android.sdk.internal.crypto.model.InboundGroupSessionData +import org.matrix.android.sdk.internal.crypto.model.MXInboundMegolmSessionWrapper import org.matrix.android.sdk.internal.crypto.store.db.deserializeFromRealm import org.matrix.android.sdk.internal.crypto.store.db.serializeForRealm +import org.matrix.android.sdk.internal.di.MoshiProvider +import org.matrix.olm.OlmInboundGroupSession import timber.log.Timber internal fun OlmInboundGroupSessionEntity.Companion.createPrimaryKey(sessionId: String?, senderKey: String?) = "$sessionId|$senderKey" @@ -28,27 +31,83 @@ internal fun OlmInboundGroupSessionEntity.Companion.createPrimaryKey(sessionId: internal open class OlmInboundGroupSessionEntity( // Combined value to build a primary key @PrimaryKey var primaryKey: String? = null, + + // denormalization for faster querying (these fields are in the inboundGroupSessionDataJson) var sessionId: String? = null, var senderKey: String? = null, - // olmInboundGroupSessionData contains Json + var roomId: String? = null, + + // Deprecated, used for migration / olmInboundGroupSessionData contains Json + // keep it in case of problem to have a chance to recover var olmInboundGroupSessionData: String? = null, + + // Stores the session data in an extensible format + // to allow to store data not yet supported for later use + var inboundGroupSessionDataJson: String? = null, + + // The pickled session + var serializedOlmInboundGroupSession: String? = null, + + // Flag that indicates whether or not the current inboundSession will be shared to + // invited users to decrypt past messages + var sharedHistory: Boolean = false, // Indicate if the key has been backed up to the homeserver var backedUp: Boolean = false ) : RealmObject() { - fun getInboundGroupSession(): OlmInboundGroupSessionWrapper2? { + fun store(wrapper: MXInboundMegolmSessionWrapper) { + this.serializedOlmInboundGroupSession = serializeForRealm(wrapper.session) + this.inboundGroupSessionDataJson = adapter.toJson(wrapper.sessionData) + this.roomId = wrapper.sessionData.roomId + this.senderKey = wrapper.sessionData.senderKey + this.sessionId = wrapper.session.sessionIdentifier() + this.sharedHistory = wrapper.sessionData.sharedHistory + } +// fun getInboundGroupSession(): OlmInboundGroupSessionWrapper2? { +// return try { +// deserializeFromRealm(olmInboundGroupSessionData) +// } catch (failure: Throwable) { +// Timber.e(failure, "## Deserialization failure") +// return null +// } +// } +// +// fun putInboundGroupSession(olmInboundGroupSessionWrapper: OlmInboundGroupSessionWrapper2?) { +// olmInboundGroupSessionData = serializeForRealm(olmInboundGroupSessionWrapper) +// } + + fun getOlmGroupSession(): OlmInboundGroupSession? { return try { - deserializeFromRealm(olmInboundGroupSessionData) + deserializeFromRealm(serializedOlmInboundGroupSession) } catch (failure: Throwable) { Timber.e(failure, "## Deserialization failure") return null } } - fun putInboundGroupSession(olmInboundGroupSessionWrapper: OlmInboundGroupSessionWrapper2?) { - olmInboundGroupSessionData = serializeForRealm(olmInboundGroupSessionWrapper) + fun getData(): InboundGroupSessionData? { + return try { + inboundGroupSessionDataJson?.let { + adapter.fromJson(it) + } + } catch (failure: Throwable) { + Timber.e(failure, "## Deserialization failure") + return null + } } - companion object + fun toModel(): MXInboundMegolmSessionWrapper? { + val data = getData() ?: return null + val session = getOlmGroupSession() ?: return null + return MXInboundMegolmSessionWrapper( + session = session, + sessionData = data + ) + } + + companion object { + private val adapter = MoshiProvider.providesMoshi() + .adapter(InboundGroupSessionData::class.java) + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/OutboundGroupSessionInfoEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/OutboundGroupSessionInfoEntity.kt index d50db78415..2ebd550201 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/OutboundGroupSessionInfoEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/OutboundGroupSessionInfoEntity.kt @@ -24,7 +24,8 @@ import timber.log.Timber internal open class OutboundGroupSessionInfoEntity( var serializedOutboundSessionData: String? = null, - var creationTime: Long? = null + var creationTime: Long? = null, + var shouldShareHistory: Boolean = false ) : RealmObject() { fun getOutboundGroupSession(): OlmOutboundGroupSession? { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendEventTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendEventTask.kt index fbd9d245d9..bb14b417dd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendEventTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendEventTask.kt @@ -15,7 +15,6 @@ */ package org.matrix.android.sdk.internal.crypto.tasks -import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.internal.network.GlobalErrorReceiver @@ -48,8 +47,12 @@ internal class DefaultSendEventTask @Inject constructor( params.event.roomId ?.takeIf { params.encrypt } ?.let { roomId -> - tryOrNull { + try { loadRoomMembersTask.execute(LoadRoomMembersTask.Params(roomId)) + } catch (failure: Throwable) { + // send any way? + // the result is that some users won't probably be able to decrypt :/ + Timber.w(failure, "SendEvent: failed to load members in room ${params.event.roomId}") } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt index 234caec970..221abe0df5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt @@ -18,7 +18,11 @@ package org.matrix.android.sdk.internal.database.helper import io.realm.Realm import io.realm.kotlin.createObject +import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent +import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.RoomMemberContent +import org.matrix.android.sdk.internal.crypto.model.SessionInfo +import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity @@ -31,6 +35,7 @@ import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntityFie import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields import org.matrix.android.sdk.internal.database.query.find +import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection @@ -180,3 +185,12 @@ internal fun ChunkEntity.isMoreRecentThan(chunkToCheck: ChunkEntity): Boolean { // We don't know, so we assume it's false return false } + +internal fun ChunkEntity.Companion.findLatestSessionInfo(realm: Realm, roomId: String): Set? = + ChunkEntity.findLastForwardChunkOfRoom(realm, roomId)?.timelineEvents?.mapNotNull { timelineEvent -> + timelineEvent?.root?.asDomain()?.content?.toModel()?.let { content -> + content.sessionId ?: return@mapNotNull null + content.senderKey ?: return@mapNotNull null + SessionInfo(content.sessionId, content.senderKey) + } + }?.toSet() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/NetworkModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/NetworkModule.kt index b5b46a3f5a..113e780e5c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/NetworkModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/NetworkModule.kt @@ -21,6 +21,7 @@ import com.squareup.moshi.Moshi import dagger.Module import dagger.Provides import okhttp3.ConnectionSpec +import okhttp3.Dispatcher import okhttp3.OkHttpClient import okhttp3.Protocol import okhttp3.logging.HttpLoggingInterceptor @@ -73,7 +74,9 @@ internal object NetworkModule { apiInterceptor: ApiInterceptor ): OkHttpClient { val spec = ConnectionSpec.Builder(matrixConfiguration.connectionSpec).build() - + val dispatcher = Dispatcher().apply { + maxRequestsPerHost = 20 + } return OkHttpClient.Builder() // workaround for #4669 .protocols(listOf(Protocol.HTTP_1_1)) @@ -94,6 +97,7 @@ internal object NetworkModule { addInterceptor(curlLoggingInterceptor) } } + .dispatcher(dispatcher) .connectionSpecs(Collections.singletonList(spec)) .applyMatrixConfiguration(matrixConfiguration) .build() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/legacy/DefaultLegacySessionImporter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/legacy/DefaultLegacySessionImporter.kt index 56d9cc2143..7d52d9b2bf 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/legacy/DefaultLegacySessionImporter.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/legacy/DefaultLegacySessionImporter.kt @@ -20,6 +20,7 @@ import android.content.Context import io.realm.Realm import io.realm.RealmConfiguration import kotlinx.coroutines.runBlocking +import org.matrix.android.sdk.api.auth.LoginType import org.matrix.android.sdk.api.auth.data.Credentials import org.matrix.android.sdk.api.auth.data.DiscoveryInformation import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig @@ -145,7 +146,8 @@ internal class DefaultLegacySessionImporter @Inject constructor( forceUsageTlsVersions = legacyConfig.forceUsageOfTlsVersions() ), // If token is not valid, this boolean will be updated later - isTokenValid = true + isTokenValid = true, + loginType = LoginType.UNKNOWN, ) Timber.d("Migration: save session") diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkCallbackStrategy.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkCallbackStrategy.kt index f75fb01746..90d2719e25 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkCallbackStrategy.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkCallbackStrategy.kt @@ -70,7 +70,15 @@ internal class PreferredNetworkCallbackStrategy @Inject constructor(context: Con override fun register(hasChanged: () -> Unit) { hasChangedCallback = hasChanged - conn.registerDefaultNetworkCallback(networkCallback) + // Add a try catch for safety + // XXX: It happens when running all tests in CI, at some points we reach a limit here causing TooManyRequestsException + // and crashing the sync thread. We might have problem here, would need some investigation + // for now adding a catch to allow CI to continue running + try { + conn.registerDefaultNetworkCallback(networkCallback) + } catch (t: Throwable) { + Timber.e(t, "Unable to register default network callback") + } } override fun unregister() { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt index 20320cad23..a8a9691ce9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt @@ -72,7 +72,7 @@ internal class DefaultLocationSharingService @AssistedInject constructor( return sendLiveLocationTask.execute(params) } - override suspend fun startLiveLocationShare(timeoutMillis: Long): UpdateLiveLocationShareResult { + override suspend fun startLiveLocationShare(timeoutMillis: Long, description: String): UpdateLiveLocationShareResult { // Ensure to stop any active live before starting a new one if (checkIfExistingActiveLive()) { val result = stopLiveLocationShare() @@ -82,7 +82,8 @@ internal class DefaultLocationSharingService @AssistedInject constructor( } val params = StartLiveLocationShareTask.Params( roomId = roomId, - timeoutMillis = timeoutMillis + timeoutMillis = timeoutMillis, + description = description ) return startLiveLocationShareTask.execute(params) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt index b943c27977..79019e4765 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt @@ -30,6 +30,7 @@ internal interface StartLiveLocationShareTask : Task return TextContent("sent an audio file.") MessageType.MSGTYPE_IMAGE -> return TextContent("sent an image.") MessageType.MSGTYPE_VIDEO -> return TextContent("sent a video.") + MessageType.MSGTYPE_BEACON_INFO -> return TextContent(content.body.ensureNotEmpty() ?: "shared live location.") MessageType.MSGTYPE_POLL_START -> { return TextContent((content as? MessagePollContent)?.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "") } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt index d1eb8794bf..4eaac67e5a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt @@ -20,6 +20,7 @@ import io.realm.Realm import io.realm.RealmConfiguration import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.android.asCoroutineDispatcher import kotlinx.coroutines.cancelChildren @@ -116,6 +117,7 @@ internal class DefaultTimeline( ) private var strategy: LoadTimelineStrategy = buildStrategy(LoadTimelineStrategy.Mode.Live) + private var startTimelineJob: Job? = null override val isLive: Boolean get() = !getPaginationState(Timeline.Direction.FORWARDS).hasMoreToLoad @@ -143,7 +145,7 @@ internal class DefaultTimeline( timelineScope.launch { loadRoomMembersIfNeeded() } - timelineScope.launch { + startTimelineJob = timelineScope.launch { sequencer.post { if (isStarted.compareAndSet(false, true)) { isFromThreadTimeline = rootThreadEventId != null @@ -174,8 +176,10 @@ internal class DefaultTimeline( override fun restartWithEventId(eventId: String?) { timelineScope.launch { - openAround(eventId, rootThreadEventId) - postSnapshot() + sequencer.post { + openAround(eventId, rootThreadEventId) + postSnapshot() + } } } @@ -185,6 +189,7 @@ internal class DefaultTimeline( override fun paginate(direction: Timeline.Direction, count: Int) { timelineScope.launch { + startTimelineJob?.join() val postSnapshot = loadMore(count, direction, fetchOnServerIfNeeded = true) if (postSnapshot) { postSnapshot() @@ -193,6 +198,7 @@ internal class DefaultTimeline( } override suspend fun awaitPaginate(direction: Timeline.Direction, count: Int): List { + startTimelineJob?.join() withContext(timelineDispatcher) { loadMore(count, direction, fetchOnServerIfNeeded = true) } @@ -279,6 +285,7 @@ internal class DefaultTimeline( direction = Timeline.Direction.BACKWARDS, fetchOnServerIfNeeded = false ) + Timber.v("$baseLogMessage finished") } @@ -312,9 +319,11 @@ internal class DefaultTimeline( private fun onLimitedTimeline() { timelineScope.launch { - initPaginationStates(null) - loadMore(settings.initialSize, Timeline.Direction.BACKWARDS, false) - postSnapshot() + sequencer.post { + initPaginationStates(null) + loadMore(settings.initialSize, Timeline.Direction.BACKWARDS, false) + postSnapshot() + } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt index c5d4d346fd..d81a115676 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt @@ -22,6 +22,7 @@ import io.realm.Realm import io.realm.RealmConfiguration import io.realm.RealmResults import io.realm.kotlin.createObject +import io.realm.kotlin.executeTransactionAwait import kotlinx.coroutines.CompletableDeferred import org.matrix.android.sdk.api.MatrixCoroutineDispatchers import org.matrix.android.sdk.api.extensions.orFalse @@ -265,7 +266,7 @@ internal class LoadTimelineStrategy constructor( } } - private fun getChunkEntity(realm: Realm): RealmResults { + private suspend fun getChunkEntity(realm: Realm): RealmResults { return when (mode) { is Mode.Live -> { ChunkEntity.where(realm, roomId) @@ -289,8 +290,8 @@ internal class LoadTimelineStrategy constructor( * Clear any existing thread chunk entity and create a new one, with the * rootThreadEventId included. */ - private fun recreateThreadChunkEntity(realm: Realm, rootThreadEventId: String) { - realm.executeTransaction { + private suspend fun recreateThreadChunkEntity(realm: Realm, rootThreadEventId: String) { + realm.executeTransactionAwait { // Lets delete the chunk and start a new one ChunkEntity.findLastForwardChunkOfThread(it, roomId, rootThreadEventId)?.deleteAndClearThreadEvents()?.let { Timber.i("###THREADS LoadTimelineStrategy [onStart] thread chunk cleared..") @@ -309,8 +310,8 @@ internal class LoadTimelineStrategy constructor( /** * Clear any existing thread chunk. */ - private fun clearThreadChunkEntity(realm: Realm, rootThreadEventId: String) { - realm.executeTransaction { + private suspend fun clearThreadChunkEntity(realm: Realm, rootThreadEventId: String) { + realm.executeTransactionAwait { ChunkEntity.findLastForwardChunkOfThread(it, roomId, rootThreadEventId)?.deleteAndClearThreadEvents()?.let { Timber.i("###THREADS LoadTimelineStrategy [onStop] thread chunk cleared..") } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt index e13f3f454f..7fa36969b1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt @@ -490,38 +490,11 @@ internal class TimelineChunk( private fun handleDatabaseChangeSet(results: RealmResults, changeSet: OrderedCollectionChangeSet) { val insertions = changeSet.insertionRanges for (range in insertions) { - // Check if the insertion's displayIndices match our expectations - or skip this insertion. - // Inconsistencies (missing messages) can happen otherwise if we get insertions before having loaded all timeline events of the chunk. - if (builtEvents.isNotEmpty()) { - // Check consistency to item before insertions - if (range.startIndex > 0) { - val firstInsertion = results[range.startIndex]!! - val lastBeforeInsertion = builtEvents[range.startIndex - 1] - if (firstInsertion.displayIndex + 1 != lastBeforeInsertion.displayIndex) { - Timber.i( - "handleDatabaseChangeSet: skip insertion at ${range.startIndex}/${builtEvents.size}, " + - "displayIndex mismatch at ${range.startIndex}: ${firstInsertion.displayIndex} -> ${lastBeforeInsertion.displayIndex}" - ) - continue - } - } - // Check consistency to item after insertions - if (range.startIndex < builtEvents.size) { - val lastInsertion = results[range.startIndex + range.length - 1]!! - val firstAfterInsertion = builtEvents[range.startIndex] - if (firstAfterInsertion.displayIndex + 1 != lastInsertion.displayIndex) { - Timber.i( - "handleDatabaseChangeSet: skip insertion at ${range.startIndex}/${builtEvents.size}, " + - "displayIndex mismatch at ${range.startIndex + range.length}: " + - "${firstAfterInsertion.displayIndex} -> ${lastInsertion.displayIndex}" - ) - continue - } - } - } + if (!validateInsertion(range, results)) continue val newItems = results .subList(range.startIndex, range.startIndex + range.length) .map { it.buildAndDecryptIfNeeded() } + builtEventsIndexes.entries.filter { it.value >= range.startIndex }.forEach { it.setValue(it.value + range.length) } newItems.mapIndexed { index, timelineEvent -> if (timelineEvent.root.type == EventType.STATE_ROOM_CREATE) { @@ -536,12 +509,9 @@ internal class TimelineChunk( for (range in modifications) { for (modificationIndex in (range.startIndex until range.startIndex + range.length)) { val updatedEntity = results[modificationIndex] ?: continue - val displayIndex = builtEventsIndexes[updatedEntity.eventId] - if (displayIndex == null) { - continue - } + val builtEventIndex = builtEventsIndexes[updatedEntity.eventId] ?: continue try { - builtEvents[displayIndex] = updatedEntity.buildAndDecryptIfNeeded() + builtEvents[builtEventIndex] = updatedEntity.buildAndDecryptIfNeeded() } catch (failure: Throwable) { Timber.v("Fail to update items at index: $modificationIndex") } @@ -558,6 +528,21 @@ internal class TimelineChunk( } } + private fun validateInsertion(range: OrderedCollectionChangeSet.Range, results: RealmResults): Boolean { + // Insertion can only happen from LastForward chunk after a sync. + if (isLastForward.get()) { + val firstBuiltEvent = builtEvents.firstOrNull() + if (firstBuiltEvent != null) { + val lastInsertion = results[range.startIndex + range.length - 1] ?: return false + if (firstBuiltEvent.displayIndex + 1 != lastInsertion.displayIndex) { + Timber.v("There is no continuation in the chunk, chunk is not fully loaded yet, skip insert.") + return false + } + } + } + return true + } + private fun getNextDisplayIndex(direction: Timeline.Direction): Int? { if (timelineEventEntities.isEmpty()) { return null diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/BackgroundDetectionObserver.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/BackgroundDetectionObserver.kt index 2dd16d8375..901d0eca8f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/BackgroundDetectionObserver.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/BackgroundDetectionObserver.kt @@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.util import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import timber.log.Timber +import java.util.concurrent.CopyOnWriteArraySet internal interface BackgroundDetectionObserver : DefaultLifecycleObserver { val isInBackground: Boolean @@ -37,7 +38,7 @@ internal class DefaultBackgroundDetectionObserver : BackgroundDetectionObserver override var isInBackground: Boolean = true private set - private val listeners = LinkedHashSet() + private val listeners = CopyOnWriteArraySet() override fun register(listener: BackgroundDetectionObserver.Listener) { listeners.add(listener) diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo005Test.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo005Test.kt new file mode 100644 index 0000000000..95b226411b --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/auth/db/migration/MigrateAuthTo005Test.kt @@ -0,0 +1,33 @@ +/* + * 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.auth.db.migration + +import org.junit.Test +import org.matrix.android.sdk.test.fakes.internal.auth.db.migration.Fake005MigrationRealm + +class MigrateAuthTo005Test { + + private val fakeRealm = Fake005MigrationRealm() + private val migrator = MigrateAuthTo005(fakeRealm.instance) + + @Test + fun `when doMigrate, then LoginType field added`() { + migrator.doMigrate(fakeRealm.instance) + + fakeRealm.verifyLoginTypeAdded() + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/auth/login/LoginTypeTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/auth/login/LoginTypeTest.kt new file mode 100644 index 0000000000..495302acb2 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/auth/login/LoginTypeTest.kt @@ -0,0 +1,46 @@ +/* + * 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.auth.login + +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldNotBeEqualTo +import org.junit.Test +import org.matrix.android.sdk.api.auth.LoginType + +class LoginTypeTest { + + @Test + fun `when getting type fromName, then map correctly`() { + LoginType.fromName(LoginType.PASSWORD.name) shouldBeEqualTo LoginType.PASSWORD + LoginType.fromName(LoginType.SSO.name) shouldBeEqualTo LoginType.SSO + LoginType.fromName(LoginType.UNSUPPORTED.name) shouldBeEqualTo LoginType.UNSUPPORTED + LoginType.fromName(LoginType.CUSTOM.name) shouldBeEqualTo LoginType.CUSTOM + LoginType.fromName(LoginType.DIRECT.name) shouldBeEqualTo LoginType.DIRECT + LoginType.fromName(LoginType.UNKNOWN.name) shouldBeEqualTo LoginType.UNKNOWN + } + + @Test // The failure of this test means that an existing type has not been correctly added to fromValue + fun `given non-unknown type name, when getting type fromName, then type is not UNKNOWN`() { + val types = LoginType.values() + + types.forEach { type -> + if (type != LoginType.UNKNOWN) { + LoginType.fromName(type.name) shouldNotBeEqualTo LoginType.UNKNOWN + } + } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingServiceTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingServiceTest.kt index de91206531..ef9bde2c49 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingServiceTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingServiceTest.kt @@ -51,6 +51,7 @@ private const val A_LATITUDE = 1.4 private const val A_LONGITUDE = 40.0 private const val AN_UNCERTAINTY = 5.0 private const val A_TIMEOUT = 15_000L +private const val A_DESCRIPTION = "description" @ExperimentalCoroutinesApi internal class DefaultLocationSharingServiceTest { @@ -137,7 +138,7 @@ internal class DefaultLocationSharingServiceTest { coEvery { stopLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Success("stopped-event-id") coEvery { startLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Success(AN_EVENT_ID) - val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT) + val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT, A_DESCRIPTION) result shouldBeEqualTo UpdateLiveLocationShareResult.Success(AN_EVENT_ID) val expectedCheckExistingParams = CheckIfExistingActiveLiveTask.Params( @@ -150,7 +151,8 @@ internal class DefaultLocationSharingServiceTest { coVerify { stopLiveLocationShareTask.execute(expectedStopParams) } val expectedStartParams = StartLiveLocationShareTask.Params( roomId = A_ROOM_ID, - timeoutMillis = A_TIMEOUT + timeoutMillis = A_TIMEOUT, + description = A_DESCRIPTION ) coVerify { startLiveLocationShareTask.execute(expectedStartParams) } } @@ -161,7 +163,7 @@ internal class DefaultLocationSharingServiceTest { val error = Throwable() coEvery { stopLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Failure(error) - val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT) + val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT, A_DESCRIPTION) result shouldBeEqualTo UpdateLiveLocationShareResult.Failure(error) val expectedCheckExistingParams = CheckIfExistingActiveLiveTask.Params( @@ -179,7 +181,7 @@ internal class DefaultLocationSharingServiceTest { coEvery { checkIfExistingActiveLiveTask.execute(any()) } returns false coEvery { startLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Success(AN_EVENT_ID) - val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT) + val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT, A_DESCRIPTION) result shouldBeEqualTo UpdateLiveLocationShareResult.Success(AN_EVENT_ID) val expectedCheckExistingParams = CheckIfExistingActiveLiveTask.Params( @@ -188,7 +190,8 @@ internal class DefaultLocationSharingServiceTest { coVerify { checkIfExistingActiveLiveTask.execute(expectedCheckExistingParams) } val expectedStartParams = StartLiveLocationShareTask.Params( roomId = A_ROOM_ID, - timeoutMillis = A_TIMEOUT + timeoutMillis = A_TIMEOUT, + description = A_DESCRIPTION ) coVerify { startLiveLocationShareTask.execute(expectedStartParams) } } 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 909ba5d048..aa8826243f 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 @@ -34,6 +34,7 @@ import org.matrix.android.sdk.test.fakes.FakeSendStateTask private const val A_USER_ID = "user-id" private const val A_ROOM_ID = "room-id" private const val AN_EVENT_ID = "event-id" +private const val A_DESCRIPTION = "description" private const val A_TIMEOUT = 15_000L private const val AN_EPOCH = 1655210176L @@ -58,7 +59,8 @@ internal class DefaultStartLiveLocationShareTaskTest { fun `given parameters and no error when calling the task then result is success`() = runTest { val params = StartLiveLocationShareTask.Params( roomId = A_ROOM_ID, - timeoutMillis = A_TIMEOUT + timeoutMillis = A_TIMEOUT, + description = A_DESCRIPTION ) fakeClock.givenEpoch(AN_EPOCH) fakeSendStateTask.givenExecuteRetryReturns(AN_EVENT_ID) @@ -67,6 +69,7 @@ internal class DefaultStartLiveLocationShareTaskTest { result shouldBeEqualTo UpdateLiveLocationShareResult.Success(AN_EVENT_ID) val expectedBeaconContent = MessageBeaconInfoContent( + body = A_DESCRIPTION, timeout = params.timeoutMillis, isLive = true, unstableTimestampMillis = AN_EPOCH @@ -87,7 +90,8 @@ internal class DefaultStartLiveLocationShareTaskTest { fun `given parameters and an empty returned event id when calling the task then result is failure`() = runTest { val params = StartLiveLocationShareTask.Params( roomId = A_ROOM_ID, - timeoutMillis = A_TIMEOUT + timeoutMillis = A_TIMEOUT, + description = A_DESCRIPTION ) fakeClock.givenEpoch(AN_EPOCH) fakeSendStateTask.givenExecuteRetryReturns("") @@ -101,7 +105,8 @@ internal class DefaultStartLiveLocationShareTaskTest { fun `given parameters and error during event sending when calling the task then result is failure`() = runTest { val params = StartLiveLocationShareTask.Params( roomId = A_ROOM_ID, - timeoutMillis = A_TIMEOUT + timeoutMillis = A_TIMEOUT, + description = A_DESCRIPTION ) fakeClock.givenEpoch(AN_EPOCH) val error = Throwable() diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/api/FakeSession.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/api/FakeSession.kt new file mode 100644 index 0000000000..df22455fb1 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/api/FakeSession.kt @@ -0,0 +1,25 @@ +/* + * 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.test.fakes.api + +import io.mockk.mockk +import org.matrix.android.sdk.api.session.Session + +class FakeSession { + + val instance: Session = mockk() +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakeSessionManager.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakeSessionManager.kt new file mode 100644 index 0000000000..2232ad9b4f --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakeSessionManager.kt @@ -0,0 +1,45 @@ +/* + * 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.test.fakes.internal + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.amshove.kluent.shouldBeEqualTo +import org.matrix.android.sdk.api.auth.data.SessionParams +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.internal.SessionManager +import org.matrix.android.sdk.test.fakes.api.FakeSession + +internal class FakeSessionManager { + + val instance: SessionManager = mockk() + + init { + every { instance.getOrCreateSession(any()) } returns fakeSession.instance + } + + fun assertSessionCreatedWithParams(session: Session, sessionParams: SessionParams) { + verify { instance.getOrCreateSession(sessionParams) } + + session shouldBeEqualTo fakeSession.instance + } + + companion object { + private val fakeSession = FakeSession() + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/FakeIsValidClientServerApiTask.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/FakeIsValidClientServerApiTask.kt new file mode 100644 index 0000000000..40681748c1 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/FakeIsValidClientServerApiTask.kt @@ -0,0 +1,45 @@ +/* + * 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.test.fakes.internal.auth + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig +import org.matrix.android.sdk.internal.auth.IsValidClientServerApiTask +import org.matrix.android.sdk.internal.auth.IsValidClientServerApiTask.Params + +internal class FakeIsValidClientServerApiTask { + + init { + coEvery { instance.execute(any()) } returns true + } + + val instance: IsValidClientServerApiTask = mockk() + + fun givenValidationFails() { + coEvery { instance.execute(any()) } returns false + } + + fun verifyExecutionWithConfig(config: HomeServerConnectionConfig) { + coVerify { instance.execute(Params(config)) } + } + + fun verifyNoExecution() { + coVerify(inverse = true) { instance.execute(any()) } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/FakePendingSessionStore.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/FakePendingSessionStore.kt new file mode 100644 index 0000000000..8a18b75ca2 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/FakePendingSessionStore.kt @@ -0,0 +1,35 @@ +/* + * 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.test.fakes.internal.auth + +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.mockk +import org.matrix.android.sdk.internal.auth.PendingSessionStore + +internal class FakePendingSessionStore { + + val instance: PendingSessionStore = mockk() + + init { + coJustRun { instance.delete() } + } + + fun verifyPendingSessionDataCleared() { + coVerify { instance.delete() } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/FakeSessionParamsCreator.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/FakeSessionParamsCreator.kt new file mode 100644 index 0000000000..f64e5a451d --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/FakeSessionParamsCreator.kt @@ -0,0 +1,51 @@ +/* + * 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.test.fakes.internal.auth + +import android.net.Uri +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import org.matrix.android.sdk.api.auth.LoginType +import org.matrix.android.sdk.api.auth.data.Credentials +import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig +import org.matrix.android.sdk.internal.auth.SessionParamsCreator +import org.matrix.android.sdk.test.fixtures.SessionParamsFixture.aSessionParams + +internal class FakeSessionParamsCreator { + + val instance: SessionParamsCreator = mockk() + + init { + mockkStatic(Uri::class) + every { Uri.parse(any()) } returns mockk() + coEvery { instance.create(any(), any(), any()) } returns sessionParams + } + + fun verifyCreatedWithParameters( + credentials: Credentials, + homeServerConnectionConfig: HomeServerConnectionConfig, + loginType: LoginType, + ) { + coVerify { instance.create(credentials, homeServerConnectionConfig, loginType) } + } + + companion object { + val sessionParams = aSessionParams() + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/FakeSessionParamsStore.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/FakeSessionParamsStore.kt new file mode 100644 index 0000000000..22e8a32a32 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/FakeSessionParamsStore.kt @@ -0,0 +1,36 @@ +/* + * 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.test.fakes.internal.auth + +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.mockk +import org.matrix.android.sdk.api.auth.data.SessionParams +import org.matrix.android.sdk.internal.auth.SessionParamsStore + +internal class FakeSessionParamsStore { + + val instance: SessionParamsStore = mockk() + + init { + coJustRun { instance.save(any()) } + } + + fun verifyParamsSaved(sessionParams: SessionParams) { + coVerify { instance.save(sessionParams) } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/db/migration/Fake005MigrationRealm.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/db/migration/Fake005MigrationRealm.kt new file mode 100644 index 0000000000..13fd4a972c --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/db/migration/Fake005MigrationRealm.kt @@ -0,0 +1,54 @@ +/* + * 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.test.fakes.internal.auth.db.migration + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verifyOrder +import io.realm.DynamicRealm +import io.realm.RealmObjectSchema +import io.realm.RealmSchema +import org.matrix.android.sdk.internal.auth.db.SessionParamsEntityFields + +class Fake005MigrationRealm { + + val instance: DynamicRealm = mockk() + + private val schema: RealmSchema = mockk() + private val objectSchema: RealmObjectSchema = mockk() + + init { + every { instance.schema } returns schema + every { schema.get("SessionParamsEntity") } returns objectSchema + every { objectSchema.addField(any(), any()) } returns objectSchema + every { objectSchema.setRequired(any(), any()) } returns objectSchema + every { objectSchema.transform(any()) } returns objectSchema + } + + fun verifyLoginTypeAdded() { + verifyLoginTypeFieldAddedAndTransformed() + } + + private fun verifyLoginTypeFieldAddedAndTransformed() { + verifyOrder { + objectSchema["SessionParamsEntity"] + objectSchema.addField(SessionParamsEntityFields.LOGIN_TYPE, String::class.java) + objectSchema.setRequired(SessionParamsEntityFields.LOGIN_TYPE, true) + objectSchema.transform(any()) + } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/db/sessionparams/FakeCredentialsJsonAdapter.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/db/sessionparams/FakeCredentialsJsonAdapter.kt new file mode 100644 index 0000000000..f1cb4071fd --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/db/sessionparams/FakeCredentialsJsonAdapter.kt @@ -0,0 +1,48 @@ +/* + * 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.test.fakes.internal.auth.db.sessionparams + +import com.squareup.moshi.JsonAdapter +import io.mockk.every +import io.mockk.mockk +import org.matrix.android.sdk.api.auth.data.Credentials +import org.matrix.android.sdk.test.fakes.internal.auth.db.sessionparams.FakeSessionParamsMapperMoshi.Companion.sessionParams +import org.matrix.android.sdk.test.fakes.internal.auth.db.sessionparams.FakeSessionParamsMapperMoshi.Companion.sessionParamsEntity +import org.matrix.android.sdk.test.fixtures.CredentialsFixture.aCredentials + +internal class FakeCredentialsJsonAdapter { + + val instance: JsonAdapter = mockk() + + init { + every { instance.fromJson(sessionParamsEntity.credentialsJson) } returns credentials + every { instance.toJson(sessionParams.credentials) } returns CREDENTIALS_JSON + } + + fun givenNullDeserialization() { + every { instance.fromJson(sessionParamsEntity.credentialsJson) } returns null + } + + fun givenNullSerialization() { + every { instance.toJson(credentials) } returns null + } + + companion object { + val credentials = aCredentials() + const val CREDENTIALS_JSON = "credentials_json" + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/db/sessionparams/FakeHomeServerConnectionConfigJsonAdapter.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/db/sessionparams/FakeHomeServerConnectionConfigJsonAdapter.kt new file mode 100644 index 0000000000..f85d6e2778 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/db/sessionparams/FakeHomeServerConnectionConfigJsonAdapter.kt @@ -0,0 +1,47 @@ +/* + * 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.test.fakes.internal.auth.db.sessionparams + +import com.squareup.moshi.JsonAdapter +import io.mockk.every +import io.mockk.mockk +import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig +import org.matrix.android.sdk.test.fakes.internal.auth.db.sessionparams.FakeSessionParamsMapperMoshi.Companion.sessionParams +import org.matrix.android.sdk.test.fakes.internal.auth.db.sessionparams.FakeSessionParamsMapperMoshi.Companion.sessionParamsEntity + +internal class FakeHomeServerConnectionConfigJsonAdapter { + + val instance: JsonAdapter = mockk() + + init { + every { instance.fromJson(sessionParamsEntity.homeServerConnectionConfigJson) } returns homeServerConnectionConfig + every { instance.toJson(sessionParams.homeServerConnectionConfig) } returns HOME_SERVER_CONNECTION_CONFIG_JSON + } + + fun givenNullDeserialization() { + every { instance.fromJson(sessionParamsEntity.credentialsJson) } returns null + } + + fun givenNullSerialization() { + every { instance.toJson(homeServerConnectionConfig) } returns null + } + + companion object { + val homeServerConnectionConfig = HomeServerConnectionConfig.Builder().withHomeServerUri("homeserver").build() + const val HOME_SERVER_CONNECTION_CONFIG_JSON = "home_server_connection_config_json" + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/db/sessionparams/FakeSessionParamsMapperMoshi.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/db/sessionparams/FakeSessionParamsMapperMoshi.kt new file mode 100644 index 0000000000..ed0ddb1179 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/auth/db/sessionparams/FakeSessionParamsMapperMoshi.kt @@ -0,0 +1,86 @@ +/* + * 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.test.fakes.internal.auth.db.sessionparams + +import android.net.Uri +import com.squareup.moshi.Moshi +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeNull +import org.matrix.android.sdk.api.auth.LoginType +import org.matrix.android.sdk.api.auth.data.Credentials +import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig +import org.matrix.android.sdk.api.auth.data.SessionParams +import org.matrix.android.sdk.api.auth.data.sessionId +import org.matrix.android.sdk.internal.auth.db.SessionParamsEntity +import org.matrix.android.sdk.test.fakes.internal.auth.db.sessionparams.FakeCredentialsJsonAdapter.Companion.CREDENTIALS_JSON +import org.matrix.android.sdk.test.fakes.internal.auth.db.sessionparams.FakeCredentialsJsonAdapter.Companion.credentials +import org.matrix.android.sdk.test.fakes.internal.auth.db.sessionparams.FakeHomeServerConnectionConfigJsonAdapter.Companion.HOME_SERVER_CONNECTION_CONFIG_JSON +import org.matrix.android.sdk.test.fakes.internal.auth.db.sessionparams.FakeHomeServerConnectionConfigJsonAdapter.Companion.homeServerConnectionConfig +import org.matrix.android.sdk.test.fixtures.SessionParamsEntityFixture.aSessionParamsEntity +import org.matrix.android.sdk.test.fixtures.SessionParamsFixture.aSessionParams + +internal class FakeSessionParamsMapperMoshi { + + val instance: Moshi = mockk() + private val credentialsJsonAdapter = FakeCredentialsJsonAdapter() + private val homeServerConnectionConfigAdapter = FakeHomeServerConnectionConfigJsonAdapter() + + init { + mockkStatic(Uri::class) + every { Uri.parse(any()) } returns mockk() + every { instance.adapter(Credentials::class.java) } returns credentialsJsonAdapter.instance + every { instance.adapter(HomeServerConnectionConfig::class.java) } returns homeServerConnectionConfigAdapter.instance + } + + fun assertSessionParamsWasMappedSuccessfully(sessionParams: SessionParams?) { + sessionParams shouldBeEqualTo SessionParams( + credentials, + homeServerConnectionConfig, + sessionParamsEntity.isTokenValid, + LoginType.fromName(sessionParamsEntity.loginType) + ) + } + + fun assertSessionParamsIsNull(sessionParams: SessionParams?) { + sessionParams.shouldBeNull() + } + + fun assertSessionParamsEntityWasMappedSuccessfully(sessionParamsEntity: SessionParamsEntity?) { + sessionParamsEntity shouldBeEqualTo SessionParamsEntity( + sessionParams.credentials.sessionId(), + sessionParams.userId, + CREDENTIALS_JSON, + HOME_SERVER_CONNECTION_CONFIG_JSON, + sessionParams.isTokenValid, + sessionParams.loginType.name, + ) + } + + fun assertSessionParamsEntityIsNull(sessionParamsEntity: SessionParamsEntity?) { + sessionParamsEntity.shouldBeNull() + } + + companion object { + val sessionParams = aSessionParams() + val sessionParamsEntity = aSessionParamsEntity() + val nullSessionParams: SessionParams? = null + val nullSessionParamsEntity: SessionParamsEntity? = null + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/CredentialsFixture.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/CredentialsFixture.kt new file mode 100644 index 0000000000..2e7b36ff63 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/CredentialsFixture.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.test.fixtures + +import org.matrix.android.sdk.api.auth.data.Credentials +import org.matrix.android.sdk.api.auth.data.DiscoveryInformation + +object CredentialsFixture { + fun aCredentials( + userId: String = "", + accessToken: String = "", + refreshToken: String? = null, + homeServer: String? = null, + deviceId: String? = null, + discoveryInformation: DiscoveryInformation? = null, + ) = Credentials( + userId, + accessToken, + refreshToken, + homeServer, + deviceId, + discoveryInformation, + ) +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/DiscoveryInformationFixture.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/DiscoveryInformationFixture.kt new file mode 100644 index 0000000000..c929a27d23 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/DiscoveryInformationFixture.kt @@ -0,0 +1,30 @@ +/* + * 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.test.fixtures + +import org.matrix.android.sdk.api.auth.data.DiscoveryInformation +import org.matrix.android.sdk.api.auth.data.WellKnownBaseConfig + +object DiscoveryInformationFixture { + fun aDiscoveryInformation( + homeServer: WellKnownBaseConfig? = null, + identityServer: WellKnownBaseConfig? = null, + ) = DiscoveryInformation( + homeServer, + identityServer + ) +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/SessionParamsEntityFixture.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/SessionParamsEntityFixture.kt new file mode 100644 index 0000000000..bbea232a22 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/SessionParamsEntityFixture.kt @@ -0,0 +1,37 @@ +/* + * 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.test.fixtures + +import org.matrix.android.sdk.internal.auth.db.SessionParamsEntity + +internal object SessionParamsEntityFixture { + fun aSessionParamsEntity( + sessionId: String = "", + userId: String = "", + credentialsJson: String = "", + homeServerConnectionConfigJson: String = "", + isTokenValid: Boolean = true, + loginType: String = "", + ) = SessionParamsEntity( + sessionId, + userId, + credentialsJson, + homeServerConnectionConfigJson, + isTokenValid, + loginType, + ) +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/SessionParamsFixture.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/SessionParamsFixture.kt new file mode 100644 index 0000000000..5cbbe1a47a --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/SessionParamsFixture.kt @@ -0,0 +1,37 @@ +/* + * 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.test.fixtures + +import org.matrix.android.sdk.api.auth.LoginType +import org.matrix.android.sdk.api.auth.data.Credentials +import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig +import org.matrix.android.sdk.api.auth.data.SessionParams +import org.matrix.android.sdk.test.fixtures.CredentialsFixture.aCredentials + +object SessionParamsFixture { + fun aSessionParams( + credentials: Credentials = aCredentials(), + homeServerConnectionConfig: HomeServerConnectionConfig = HomeServerConnectionConfig.Builder().withHomeServerUri("homeserver").build(), + isTokenValid: Boolean = false, + loginType: LoginType = LoginType.UNKNOWN, + ) = SessionParams( + credentials, + homeServerConnectionConfig, + isTokenValid, + loginType, + ) +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/WellKnownBaseConfigFixture.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/WellKnownBaseConfigFixture.kt new file mode 100644 index 0000000000..a33308dbd6 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/WellKnownBaseConfigFixture.kt @@ -0,0 +1,27 @@ +/* + * 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.test.fixtures + +import org.matrix.android.sdk.api.auth.data.WellKnownBaseConfig + +object WellKnownBaseConfigFixture { + fun aWellKnownBaseConfig( + baseUrl: String? = null, + ) = WellKnownBaseConfig( + baseUrl, + ) +} diff --git a/tools/check/check_code_quality.sh b/tools/check/check_code_quality.sh index 910616176c..79a42083d3 100755 --- a/tools/check/check_code_quality.sh +++ b/tools/check/check_code_quality.sh @@ -16,6 +16,21 @@ # limitations under the License. # +####################################################################################################################### +# Check frozen class modification +####################################################################################################################### + +echo "Check if frozen class modified" +git diff "HEAD@{1}" --name-only | grep -e OlmInboundGroupSessionWrapper.kt -e OlmInboundGroupSessionWrapper2.kt +FROZEN_CHANGED=$? +if [ ${FROZEN_CHANGED} -eq 0 ]; then + echo "❌ FROZEN CLASS CHANGED ERROR" + exit 1 +else + echo "Frozen check OK" +fi + + ####################################################################################################################### # Check drawable quantity ####################################################################################################################### diff --git a/tools/check/forbidden_strings_in_code.txt b/tools/check/forbidden_strings_in_code.txt index 962a14843d..b12f15fa5d 100755 --- a/tools/check/forbidden_strings_in_code.txt +++ b/tools/check/forbidden_strings_in_code.txt @@ -180,3 +180,8 @@ System\.currentTimeMillis\(\)===2 ### Remove extra space between the name and the description \* @\w+ \w+ + + +### Please use the MenuProvider interface now +onCreateOptionsMenu +onOptionsItemSelected +onPrepareOptionsMenu diff --git a/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt b/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt new file mode 100644 index 0000000000..9e23e76f0c --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt @@ -0,0 +1,91 @@ +/* + * 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.core.utils + +import android.graphics.Canvas +import android.graphics.Paint +import android.text.Layout +import android.text.Spannable +import androidx.core.text.getSpans +import im.vector.app.features.html.HtmlCodeSpan +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import io.noties.markwon.core.spans.EmphasisSpan +import io.noties.markwon.core.spans.OrderedListItemSpan +import io.noties.markwon.core.spans.StrongEmphasisSpan + +fun Spannable.toTestSpan(): String { + var output = toString() + readSpansWithContent().forEach { + val tags = it.span.readTags() + val remappedContent = it.span.remapContent(source = this, originalContent = it.content) + output = output.replace(it.content, "${tags.open}$remappedContent${tags.close}") + } + return output +} + +private fun Spannable.readSpansWithContent() = getSpans().map { span -> + val start = getSpanStart(span) + val end = getSpanEnd(span) + SpanWithContent( + content = substring(start, end), + span = span + ) +}.reversed() + +private fun Any.readTags(): SpanTags { + return when (this::class) { + OrderedListItemSpan::class -> SpanTags("[list item]", "[/list item]") + HtmlCodeSpan::class -> SpanTags("[code]", "[/code]") + StrongEmphasisSpan::class -> SpanTags("[bold]", "[/bold]") + EmphasisSpan::class -> SpanTags("[italic]", "[/italic]") + else -> throw IllegalArgumentException("Unknown ${this::class}") + } +} + +private fun Any.remapContent(source: CharSequence, originalContent: String): String { + return when (this::class) { + OrderedListItemSpan::class -> { + val prefix = (this as OrderedListItemSpan).collectNumber(source) + "$prefix$originalContent" + } + else -> originalContent + } +} + +private fun OrderedListItemSpan.collectNumber(text: CharSequence): String { + val fakeCanvas = mockk() + val fakeLayout = mockk() + justRun { fakeCanvas.drawText(any(), any(), any(), any()) } + val paint = Paint() + drawLeadingMargin(fakeCanvas, paint, 0, 0, 0, 0, 0, text, 0, text.length - 1, true, fakeLayout) + val slot = slot() + verify { fakeCanvas.drawText(capture(slot), any(), any(), any()) } + return slot.captured +} + +private data class SpanTags( + val open: String, + val close: String, +) + +private data class SpanWithContent( + val content: String, + val span: Any +) diff --git a/vector/src/androidTest/java/im/vector/app/features/html/EventHtmlRendererTest.kt b/vector/src/androidTest/java/im/vector/app/features/html/EventHtmlRendererTest.kt new file mode 100644 index 0000000000..41c0f51322 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/features/html/EventHtmlRendererTest.kt @@ -0,0 +1,82 @@ +/* + * 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.html + +import androidx.core.text.toSpannable +import androidx.test.platform.app.InstrumentationRegistry +import im.vector.app.core.resources.ColorProvider +import im.vector.app.core.utils.toTestSpan +import im.vector.app.features.settings.VectorPreferences +import io.mockk.every +import io.mockk.mockk +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import kotlin.text.Typography.nbsp + +@RunWith(JUnit4::class) +class EventHtmlRendererTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val fakeVectorPreferences = mockk().also { + every { it.latexMathsIsEnabled() } returns false + } + + private val renderer = EventHtmlRenderer( + MatrixHtmlPluginConfigure(ColorProvider(context), context.resources), + context, + fakeVectorPreferences + ) + + @Test + fun takesInitialListPositionIntoAccount() { + val result = """
  1. first entry
""".renderAsTestSpan() + + result shouldBeEqualTo "[list item]5.${nbsp}first entry[/list item]\n" + } + + @Test + fun doesNotProcessMarkdownWithinCodeBlocks() { + val result = """__italic__ **bold**""".renderAsTestSpan() + + result shouldBeEqualTo "[code]__italic__ **bold**[/code]" + } + + @Test + fun doesNotProcessMarkdownBoldAndItalic() { + val result = """__italic__ **bold**""".renderAsTestSpan() + + result shouldBeEqualTo "__italic__ **bold**" + } + + @Test + fun processesHtmlWithinCodeBlocks() { + val result = """italic bold""".renderAsTestSpan() + + result shouldBeEqualTo "[code][italic]italic[/italic] [bold]bold[/bold][/code]" + } + + @Test + fun processesHtmlEntities() { + val result = """& < > ' """".renderAsTestSpan() + + result shouldBeEqualTo """& < > ' """" + } + + private fun String.renderAsTestSpan() = renderer.render(this).toSpannable().toTestSpan() +} diff --git a/vector/src/fdroid/AndroidManifest.xml b/vector/src/fdroid/AndroidManifest.xml index f9adc521c9..29dac6533e 100644 --- a/vector/src/fdroid/AndroidManifest.xml +++ b/vector/src/fdroid/AndroidManifest.xml @@ -43,7 +43,7 @@ diff --git a/vector/src/fdroid/java/im/vector/app/fdroid/service/FDroidGuardServiceStarter.kt b/vector/src/fdroid/java/im/vector/app/fdroid/service/FDroidGuardServiceStarter.kt index d421c8bb87..61fd70adc1 100644 --- a/vector/src/fdroid/java/im/vector/app/fdroid/service/FDroidGuardServiceStarter.kt +++ b/vector/src/fdroid/java/im/vector/app/fdroid/service/FDroidGuardServiceStarter.kt @@ -33,7 +33,7 @@ class FDroidGuardServiceStarter @Inject constructor( if (preferences.isBackgroundSyncEnabled()) { try { Timber.i("## Sync: starting GuardService") - val intent = Intent(appContext, GuardService::class.java) + val intent = Intent(appContext, GuardAndroidService::class.java) ContextCompat.startForegroundService(appContext, intent) } catch (ex: Throwable) { Timber.e("## Sync: ERROR starting GuardService") @@ -42,7 +42,7 @@ class FDroidGuardServiceStarter @Inject constructor( } override fun stop() { - val intent = Intent(appContext, GuardService::class.java) + val intent = Intent(appContext, GuardAndroidService::class.java) appContext.stopService(intent) } } diff --git a/vector/src/fdroid/java/im/vector/app/fdroid/service/GuardService.kt b/vector/src/fdroid/java/im/vector/app/fdroid/service/GuardAndroidService.kt similarity index 93% rename from vector/src/fdroid/java/im/vector/app/fdroid/service/GuardService.kt rename to vector/src/fdroid/java/im/vector/app/fdroid/service/GuardAndroidService.kt index 053cf87c17..f46b8f9820 100644 --- a/vector/src/fdroid/java/im/vector/app/fdroid/service/GuardService.kt +++ b/vector/src/fdroid/java/im/vector/app/fdroid/service/GuardAndroidService.kt @@ -18,7 +18,7 @@ package im.vector.app.fdroid.service import android.content.Intent import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R -import im.vector.app.core.services.VectorService +import im.vector.app.core.services.VectorAndroidService import im.vector.app.features.notifications.NotificationUtils import javax.inject.Inject @@ -29,7 +29,7 @@ import javax.inject.Inject * when the app is not in the foreground. */ @AndroidEntryPoint -class GuardService : VectorService() { +class GuardAndroidService : VectorAndroidService() { @Inject lateinit var notificationUtils: NotificationUtils diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 032e256bfa..9c010d12f0 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -351,7 +351,7 @@ @@ -366,7 +366,7 @@ tools:ignore="Instantiatable" /> @@ -375,12 +375,12 @@ diff --git a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt index e76f0ad672..bb11ec0dc5 100644 --- a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt @@ -110,6 +110,8 @@ import im.vector.app.features.onboarding.ftueauth.FtueAuthLegacyStyleCaptchaFrag import im.vector.app.features.onboarding.ftueauth.FtueAuthLegacyWaitForEmailFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthLoginFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthPersonalizationCompleteFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthPhoneConfirmationFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthPhoneEntryFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordMailConfirmationFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordSuccessFragment @@ -509,6 +511,16 @@ interface FragmentModule { @FragmentKey(FtueAuthEmailEntryFragment::class) fun bindFtueAuthEmailEntryFragment(fragment: FtueAuthEmailEntryFragment): Fragment + @Binds + @IntoMap + @FragmentKey(FtueAuthPhoneEntryFragment::class) + fun bindFtueAuthPhoneEntryFragment(fragment: FtueAuthPhoneEntryFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(FtueAuthPhoneConfirmationFragment::class) + fun bindFtueAuthPhoneConfirmationFragment(fragment: FtueAuthPhoneConfirmationFragment): Fragment + @Binds @IntoMap @FragmentKey(FtueAuthChooseDisplayNameFragment::class) diff --git a/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt b/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt index bc0bccfa1b..cbd34fa05b 100644 --- a/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt @@ -21,6 +21,7 @@ import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.SharedPreferences import android.content.res.Resources +import com.google.i18n.phonenumbers.PhoneNumberUtil import dagger.Binds import dagger.Module import dagger.Provides @@ -193,6 +194,9 @@ object VectorStaticModule { return analyticsConfig } + @Provides + fun providesPhoneNumberUtil(): PhoneNumberUtil = PhoneNumberUtil.getInstance() + @Provides @Singleton fun providesBuildMeta() = BuildMeta() diff --git a/vector/src/main/java/im/vector/app/core/extensions/TextInputLayout.kt b/vector/src/main/java/im/vector/app/core/extensions/TextInputLayout.kt index 40019c5d64..d3ee765780 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/TextInputLayout.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/TextInputLayout.kt @@ -16,9 +16,11 @@ package im.vector.app.core.extensions +import android.os.Build import android.text.Editable import android.view.View import android.view.inputmethod.EditorInfo +import androidx.autofill.HintConstants import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import com.google.android.material.textfield.TextInputLayout @@ -79,3 +81,12 @@ fun TextInputLayout.setOnFocusLostListener(action: () -> Unit) { } } } + +fun TextInputLayout.autofillPhoneNumber() = setAutofillHint(HintConstants.AUTOFILL_HINT_PHONE_NUMBER) +fun TextInputLayout.autofillEmail() = setAutofillHint(HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS) + +private fun TextInputLayout.setAutofillHint(hintType: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + setAutofillHints(hintType) + } +} diff --git a/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt b/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt index 571d2d38c0..9bad0f8e90 100644 --- a/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt +++ b/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt @@ -19,11 +19,11 @@ package im.vector.app.core.platform import android.annotation.SuppressLint import android.app.Activity import android.content.Context -import android.content.res.Configuration import android.os.Build import android.os.Bundle import android.os.Parcelable import android.view.Menu +import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.WindowInsetsController @@ -31,15 +31,18 @@ import android.view.WindowManager import android.widget.TextView import androidx.annotation.CallSuper import androidx.annotation.MainThread -import androidx.annotation.MenuRes import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.app.MultiWindowModeChangedInfo import androidx.core.content.ContextCompat +import androidx.core.util.Consumer +import androidx.core.view.MenuProvider import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentFactory import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.viewbinding.ViewBinding @@ -86,6 +89,7 @@ import im.vector.app.features.themes.ThemeUtils import im.vector.app.receivers.DebugReceiver import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.failure.GlobalError import org.matrix.android.sdk.api.failure.InitialSyncRequestReason @@ -199,6 +203,8 @@ abstract class VectorBaseActivity : AppCompatActivity(), Maver supportFragmentManager.fragmentFactory = fragmentFactory viewModelFactory = activityEntryPoint.viewModelFactory() super.onCreate(savedInstanceState) + addOnMultiWindowModeChangedListener(onMultiWindowModeChangedListener) + setupMenu() configurationViewModel = viewModelProvider.get(ConfigurationViewModel::class.java) bugReporter = singletonEntryPoint.bugReporter() pinLocker = singletonEntryPoint.pinLocker() @@ -249,6 +255,32 @@ abstract class VectorBaseActivity : AppCompatActivity(), Maver } } + private fun setupMenu() { + // Always add a MenuProvider to handle the back action from the Toolbar + val vectorMenuProvider = this as? VectorMenuProvider + addMenuProvider( + object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + vectorMenuProvider?.let { + menuInflater.inflate(it.getMenuRes(), menu) + it.handlePostCreateMenu(menu) + } + } + + override fun onPrepareMenu(menu: Menu) { + vectorMenuProvider?.handlePrepareMenu(menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return vectorMenuProvider?.handleMenuItemSelected(menuItem).orFalse() || + handleMenuItemHome(menuItem) + } + }, + this, + Lifecycle.State.RESUMED + ) + } + /** * This method has to be called for the font size setting be supported correctly. */ @@ -332,6 +364,7 @@ abstract class VectorBaseActivity : AppCompatActivity(), Maver } override fun onDestroy() { + removeOnMultiWindowModeChangedListener(onMultiWindowModeChangedListener) super.onDestroy() Timber.i("onDestroy Activity ${javaClass.simpleName}") } @@ -417,11 +450,9 @@ abstract class VectorBaseActivity : AppCompatActivity(), Maver } } - override fun onMultiWindowModeChanged(isInMultiWindowMode: Boolean, newConfig: Configuration?) { - super.onMultiWindowModeChanged(isInMultiWindowMode, newConfig) - - Timber.w("onMultiWindowModeChanged. isInMultiWindowMode: $isInMultiWindowMode") - bugReporter.inMultiWindowMode = isInMultiWindowMode + private val onMultiWindowModeChangedListener = Consumer { + Timber.w("onMultiWindowModeChanged. isInMultiWindowMode: ${it.isInMultiWindowMode}") + bugReporter.inMultiWindowMode = it.isInMultiWindowMode } protected fun createFragment(fragmentClass: Class, argsParcelable: Parcelable? = null): Fragment { @@ -463,28 +494,14 @@ abstract class VectorBaseActivity : AppCompatActivity(), Maver } } - /* ========================================================================================== - * MENU MANAGEMENT - * ========================================================================================== */ - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - val menuRes = getMenuRes() - - if (menuRes != -1) { - menuInflater.inflate(menuRes, menu) - return true + private fun handleMenuItemHome(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + onBackPressed(true) + true + } + else -> false } - - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == android.R.id.home) { - onBackPressed(true) - return true - } - - return super.onOptionsItemSelected(item) } override fun onBackPressed() { @@ -587,9 +604,6 @@ abstract class VectorBaseActivity : AppCompatActivity(), Maver @StringRes open fun getTitleRes() = -1 - @MenuRes - open fun getMenuRes() = -1 - /** * Return a object containing other themes for this activity. */ diff --git a/vector/src/main/java/im/vector/app/core/platform/VectorBaseFragment.kt b/vector/src/main/java/im/vector/app/core/platform/VectorBaseFragment.kt index 38667b774f..340c906a6d 100644 --- a/vector/src/main/java/im/vector/app/core/platform/VectorBaseFragment.kt +++ b/vector/src/main/java/im/vector/app/core/platform/VectorBaseFragment.kt @@ -22,12 +22,16 @@ import android.os.Parcelable import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.annotation.CallSuper import androidx.annotation.MainThread import androidx.appcompat.app.AlertDialog +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.viewbinding.ViewBinding @@ -126,9 +130,7 @@ abstract class VectorBaseFragment : Fragment(), MavericksView @CallSuper override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (getMenuRes() != -1) { - setHasOptionsMenu(true) - } + Timber.i("onCreate Fragment ${javaClass.simpleName}") } final override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -158,6 +160,31 @@ abstract class VectorBaseFragment : Fragment(), MavericksView override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) Timber.i("onViewCreated Fragment ${javaClass.simpleName}") + setupMenu() + } + + private fun setupMenu() { + if (this !is VectorMenuProvider) return + if (getMenuRes() == -1) return + val menuHost: MenuHost = requireActivity() + menuHost.addMenuProvider( + object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(getMenuRes(), menu) + handlePostCreateMenu(menu) + } + + override fun onPrepareMenu(menu: Menu) { + handlePrepareMenu(menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return handleMenuItemSelected(menuItem) + } + }, + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) } open fun showLoading(message: CharSequence?) { @@ -270,16 +297,6 @@ abstract class VectorBaseFragment : Fragment(), MavericksView * MENU MANAGEMENT * ========================================================================================== */ - open fun getMenuRes() = -1 - - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - val menuRes = getMenuRes() - - if (menuRes != -1) { - inflater.inflate(menuRes, menu) - } - } - // This should be provided by the framework protected fun invalidateOptionsMenu() = requireActivity().invalidateOptionsMenu() diff --git a/vector/src/main/java/im/vector/app/core/platform/VectorMenuProvider.kt b/vector/src/main/java/im/vector/app/core/platform/VectorMenuProvider.kt new file mode 100644 index 0000000000..05d710a185 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/platform/VectorMenuProvider.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.platform + +import android.view.Menu +import android.view.MenuItem +import androidx.annotation.MenuRes + +/** + * Let your Activity of Fragment implement this interface if they provide a Menu. + */ +interface VectorMenuProvider { + @MenuRes + fun getMenuRes(): Int + + // No op by default + fun handlePostCreateMenu(menu: Menu) {} + + // No op by default + fun handlePrepareMenu(menu: Menu) {} + + fun handleMenuItemSelected(item: MenuItem): Boolean +} diff --git a/vector/src/main/java/im/vector/app/core/services/CallService.kt b/vector/src/main/java/im/vector/app/core/services/CallAndroidService.kt similarity index 97% rename from vector/src/main/java/im/vector/app/core/services/CallService.kt rename to vector/src/main/java/im/vector/app/core/services/CallAndroidService.kt index 4edc4d6ace..7a078ce1c8 100644 --- a/vector/src/main/java/im/vector/app/core/services/CallService.kt +++ b/vector/src/main/java/im/vector/app/core/services/CallAndroidService.kt @@ -51,7 +51,7 @@ private val loggerTag = LoggerTag("CallService", LoggerTag.VOIP) * Foreground service to manage calls. */ @AndroidEntryPoint -class CallService : VectorService() { +class CallAndroidService : VectorAndroidService() { private val connections = mutableMapOf() private val knownCalls = mutableMapOf() @@ -98,7 +98,7 @@ class CallService : VectorService() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Timber.tag(loggerTag.value).v("onStartCommand $intent") if (mediaSession == null) { - mediaSession = MediaSessionCompat(applicationContext, CallService::class.java.name).apply { + mediaSession = MediaSessionCompat(applicationContext, CallAndroidService::class.java.name).apply { setCallback(mediaSessionButtonCallback) } } @@ -326,7 +326,7 @@ class CallService : VectorService() { callId: String, isInBackground: Boolean ) { - val intent = Intent(context, CallService::class.java) + val intent = Intent(context, CallAndroidService::class.java) .apply { action = ACTION_INCOMING_RINGING_CALL putExtra(EXTRA_CALL_ID, callId) @@ -339,7 +339,7 @@ class CallService : VectorService() { context: Context, callId: String ) { - val intent = Intent(context, CallService::class.java) + val intent = Intent(context, CallAndroidService::class.java) .apply { action = ACTION_OUTGOING_RINGING_CALL putExtra(EXTRA_CALL_ID, callId) @@ -351,7 +351,7 @@ class CallService : VectorService() { context: Context, callId: String ) { - val intent = Intent(context, CallService::class.java) + val intent = Intent(context, CallAndroidService::class.java) .apply { action = ACTION_ONGOING_CALL putExtra(EXTRA_CALL_ID, callId) @@ -365,7 +365,7 @@ class CallService : VectorService() { endCallReason: EndCallReason, rejected: Boolean ) { - val intent = Intent(context, CallService::class.java) + val intent = Intent(context, CallAndroidService::class.java) .apply { action = ACTION_CALL_TERMINATED putExtra(EXTRA_CALL_ID, callId) @@ -377,8 +377,8 @@ class CallService : VectorService() { } inner class CallServiceBinder : Binder() { - fun getCallService(): CallService { - return this@CallService + fun getCallService(): CallAndroidService { + return this@CallAndroidService } } } diff --git a/vector/src/main/java/im/vector/app/core/services/VectorService.kt b/vector/src/main/java/im/vector/app/core/services/VectorAndroidService.kt similarity index 93% rename from vector/src/main/java/im/vector/app/core/services/VectorService.kt rename to vector/src/main/java/im/vector/app/core/services/VectorAndroidService.kt index cc816c21a1..f30a74e9de 100644 --- a/vector/src/main/java/im/vector/app/core/services/VectorService.kt +++ b/vector/src/main/java/im/vector/app/core/services/VectorAndroidService.kt @@ -22,9 +22,9 @@ import android.os.IBinder import timber.log.Timber /** - * Parent class for all services. + * Parent class for all Android Services. */ -abstract class VectorService : Service() { +abstract class VectorAndroidService : Service() { /** * Tells if the service self destroyed. diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt index ffa83b608a..d0843b3b64 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt @@ -45,7 +45,6 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab } fun onContentAttachmentsReady(attachments: List) - fun onAttachmentsProcessFailed() } // Capture path allows to handle camera image picking. It must be restored if the activity gets killed. @@ -188,41 +187,4 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab .map { it.toContentAttachmentData() } ) } - - /** - * This methods aims to handle share intent. - * - * @return true if it can handle the intent data, false otherwise - */ - fun handleShareIntent(context: Context, intent: Intent): Boolean { - val type = intent.resolveType(context) ?: return false - if (type.startsWith("image")) { - callback.onContentAttachmentsReady( - MultiPicker.get(MultiPicker.IMAGE).getIncomingFiles(context, intent).map { - it.toContentAttachmentData() - } - ) - } else if (type.startsWith("video")) { - callback.onContentAttachmentsReady( - MultiPicker.get(MultiPicker.VIDEO).getIncomingFiles(context, intent).map { - it.toContentAttachmentData() - } - ) - } else if (type.startsWith("audio")) { - callback.onContentAttachmentsReady( - MultiPicker.get(MultiPicker.AUDIO).getIncomingFiles(context, intent).map { - it.toContentAttachmentData() - } - ) - } else if (type.startsWith("application") || type.startsWith("file") || type.startsWith("text") || type.startsWith("*")) { - callback.onContentAttachmentsReady( - MultiPicker.get(MultiPicker.FILE).getIncomingFiles(context, intent).map { - it.toContentAttachmentData() - } - ) - } else { - return false - } - return true - } } diff --git a/vector/src/main/java/im/vector/app/features/attachments/ShareIntentHandler.kt b/vector/src/main/java/im/vector/app/features/attachments/ShareIntentHandler.kt new file mode 100644 index 0000000000..06ca949025 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/attachments/ShareIntentHandler.kt @@ -0,0 +1,82 @@ +/* + * 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.attachments + +import android.content.Context +import android.content.Intent +import im.vector.lib.multipicker.MultiPicker +import org.matrix.android.sdk.api.session.content.ContentAttachmentData +import javax.inject.Inject + +class ShareIntentHandler @Inject constructor() { + + /** + * This methods aims to handle incoming share intents. + * + * @return true if it can handle the intent data, false otherwise + */ + fun handleIncomingShareIntent(context: Context, intent: Intent, onFile: (List) -> Unit, onPlainText: (String) -> Unit): Boolean { + val type = intent.resolveType(context) ?: return false + return when { + type == "text/plain" -> handlePlainText(intent, onPlainText) + type.startsWith("image") -> { + onFile( + MultiPicker.get(MultiPicker.IMAGE).getIncomingFiles(context, intent).map { + it.toContentAttachmentData() + } + ) + true + } + type.startsWith("video") -> { + onFile( + MultiPicker.get(MultiPicker.VIDEO).getIncomingFiles(context, intent).map { + it.toContentAttachmentData() + } + ) + true + } + type.startsWith("audio") -> { + onFile( + MultiPicker.get(MultiPicker.AUDIO).getIncomingFiles(context, intent).map { + it.toContentAttachmentData() + } + ) + true + } + + type.startsWith("application") || type.startsWith("file") || type.startsWith("text") || type.startsWith("*") -> { + onFile( + MultiPicker.get(MultiPicker.FILE).getIncomingFiles(context, intent).map { + it.toContentAttachmentData() + } + ) + true + } + else -> false + } + } + + private fun handlePlainText(intent: Intent, onPlainText: (String) -> Unit): Boolean { + val content = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString() + return if (content?.isNotEmpty() == true) { + onPlainText(content) + true + } else { + false + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/attachments/preview/AttachmentsPreviewFragment.kt b/vector/src/main/java/im/vector/app/features/attachments/preview/AttachmentsPreviewFragment.kt index dfe74fa47e..c3a4ae7df2 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/preview/AttachmentsPreviewFragment.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/preview/AttachmentsPreviewFragment.kt @@ -44,6 +44,7 @@ import im.vector.app.core.extensions.cleanup import im.vector.app.core.extensions.insertBeforeLast import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.resources.ColorProvider import im.vector.app.core.time.Clock import im.vector.app.core.utils.OnSnapPositionChangeListener @@ -67,7 +68,9 @@ class AttachmentsPreviewFragment @Inject constructor( private val attachmentBigPreviewController: AttachmentBigPreviewController, private val colorProvider: ColorProvider, private val clock: Clock, -) : VectorBaseFragment(), AttachmentMiniaturePreviewController.Callback { +) : VectorBaseFragment(), + AttachmentMiniaturePreviewController.Callback, + VectorMenuProvider { private val fragmentArgs: AttachmentsPreviewArgs by args() private val viewModel: AttachmentsPreviewViewModel by fragmentViewModel() @@ -97,7 +100,7 @@ class AttachmentsPreviewFragment @Inject constructor( } } - override fun onOptionsItemSelected(item: MenuItem): Boolean { + override fun handleMenuItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.attachmentsPreviewRemoveAction -> { handleRemoveAction() @@ -107,20 +110,16 @@ class AttachmentsPreviewFragment @Inject constructor( handleEditAction() true } - else -> { - super.onOptionsItemSelected(item) - } + else -> false } } - override fun onPrepareOptionsMenu(menu: Menu) { + override fun handlePrepareMenu(menu: Menu) { withState(viewModel) { state -> val editMenuItem = menu.findItem(R.id.attachmentsPreviewEditAction) val showEditMenuItem = state.attachments.getOrNull(state.currentAttachmentIndex)?.isEditable().orFalse() editMenuItem.setVisible(showEditMenuItem) } - - super.onPrepareOptionsMenu(menu) } override fun getMenuRes() = R.menu.vector_attachments_preview diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt index 579a5851c6..9d7ada9d63 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt @@ -22,7 +22,6 @@ import android.app.PictureInPictureParams import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP -import android.content.res.Configuration import android.graphics.Color import android.media.projection.MediaProjection import android.media.projection.MediaProjectionManager @@ -35,8 +34,10 @@ import android.view.View import android.view.WindowManager import androidx.activity.result.ActivityResult import androidx.annotation.StringRes +import androidx.core.app.PictureInPictureModeChangedInfo import androidx.core.content.ContextCompat import androidx.core.content.getSystemService +import androidx.core.util.Consumer import androidx.core.view.isInvisible import androidx.core.view.isVisible import com.airbnb.mvrx.Fail @@ -50,6 +51,7 @@ import im.vector.app.R import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.setTextOrHide import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.utils.PERMISSIONS_FOR_AUDIO_IP_CALL import im.vector.app.core.utils.PERMISSIONS_FOR_VIDEO_IP_CALL import im.vector.app.core.utils.checkPermissions @@ -59,7 +61,7 @@ import im.vector.app.features.call.dialpad.CallDialPadBottomSheet import im.vector.app.features.call.dialpad.DialPadFragment import im.vector.app.features.call.transfer.CallTransferActivity import im.vector.app.features.call.utils.EglUtils -import im.vector.app.features.call.webrtc.ScreenCaptureService +import im.vector.app.features.call.webrtc.ScreenCaptureAndroidService import im.vector.app.features.call.webrtc.ScreenCaptureServiceConnection import im.vector.app.features.call.webrtc.WebRtcCall import im.vector.app.features.call.webrtc.WebRtcCallManager @@ -94,7 +96,10 @@ data class CallArgs( private val loggerTag = LoggerTag("VectorCallActivity", LoggerTag.VOIP) @AndroidEntryPoint -class VectorCallActivity : VectorBaseActivity(), CallControlsView.InteractionListener { +class VectorCallActivity : + VectorBaseActivity(), + CallControlsView.InteractionListener, + VectorMenuProvider { override fun getBinding() = ActivityCallBinding.inflate(layoutInflater) @@ -128,6 +133,7 @@ class VectorCallActivity : VectorBaseActivity(), CallContro window.statusBarColor = Color.TRANSPARENT window.navigationBarColor = Color.BLACK super.onCreate(savedInstanceState) + addOnPictureInPictureModeChangedListener(pictureInPictureModeChangedInfoConsumer) Timber.tag(loggerTag.value).v("EXTRA_MODE is ${intent.getStringExtra(EXTRA_MODE)}") if (intent.getStringExtra(EXTRA_MODE) == INCOMING_RINGING) { @@ -210,25 +216,31 @@ class VectorCallActivity : VectorBaseActivity(), CallContro return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInPictureInPictureMode } - override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) = withState(callViewModel) { - renderState(it) + private val pictureInPictureModeChangedInfoConsumer = Consumer { + withState(callViewModel) { + renderState(it) + } } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.menu_call_open_chat) { - returnToChat() - return true - } else if (item.itemId == android.R.id.home) { - // We check here as we want PiP in some cases - onBackPressed() - return true + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.menu_call_open_chat -> { + returnToChat() + true + } + android.R.id.home -> { + // We check here as we want PiP in some cases + onBackPressed() + true + } + else -> false } - return super.onOptionsItemSelected(item) } override fun onDestroy() { detachRenderersIfNeeded() turnScreenOffAndKeyguardOn() + removeOnPictureInPictureModeChangedListener(pictureInPictureModeChangedInfoConsumer) super.onDestroy() } @@ -663,7 +675,7 @@ class VectorCallActivity : VectorBaseActivity(), CallContro private fun startScreenSharingService(activityResult: ActivityResult) { ContextCompat.startForegroundService( this, - Intent(this, ScreenCaptureService::class.java) + Intent(this, ScreenCaptureAndroidService::class.java) ) bindToScreenCaptureService(activityResult) } diff --git a/vector/src/main/java/im/vector/app/features/call/conference/VectorJitsiActivity.kt b/vector/src/main/java/im/vector/app/features/call/conference/VectorJitsiActivity.kt index 86136eab15..5bf05d353c 100644 --- a/vector/src/main/java/im/vector/app/features/call/conference/VectorJitsiActivity.kt +++ b/vector/src/main/java/im/vector/app/features/call/conference/VectorJitsiActivity.kt @@ -19,12 +19,13 @@ package im.vector.app.features.call.conference import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.content.res.Configuration import android.os.Build import android.os.Bundle import android.os.Parcelable import android.widget.FrameLayout import android.widget.Toast +import androidx.core.app.PictureInPictureModeChangedInfo +import androidx.core.util.Consumer import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import com.airbnb.mvrx.Fail @@ -66,6 +67,7 @@ class VectorJitsiActivity : VectorBaseActivity(), JitsiMee override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + addOnPictureInPictureModeChangedListener(pictureInPictureModeChangedInfoConsumer) jitsiViewModel.onEach { renderState(it) @@ -109,6 +111,7 @@ class VectorJitsiActivity : VectorBaseActivity(), JitsiMee ConferenceEventEmitter(this).emitConferenceEnded() } JitsiMeetActivityDelegate.onHostDestroy(this) + removeOnPictureInPictureModeChangedListener(pictureInPictureModeChangedInfoConsumer) super.onDestroy() } @@ -138,13 +141,9 @@ class VectorJitsiActivity : VectorBaseActivity(), JitsiMee .show() } - override fun onPictureInPictureModeChanged( - isInPictureInPictureMode: Boolean, - newConfig: Configuration - ) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + private val pictureInPictureModeChangedInfoConsumer = Consumer { checkIfActivityShouldBeFinished() - Timber.w("onPictureInPictureModeChanged($isInPictureInPictureMode)") + Timber.w("onPictureInPictureModeChanged(${it.isInPictureInPictureMode})") } private fun checkIfActivityShouldBeFinished() { diff --git a/vector/src/main/java/im/vector/app/features/call/telecom/VectorConnectionService.kt b/vector/src/main/java/im/vector/app/features/call/telecom/VectorConnectionAndroidService.kt similarity index 92% rename from vector/src/main/java/im/vector/app/features/call/telecom/VectorConnectionService.kt rename to vector/src/main/java/im/vector/app/features/call/telecom/VectorConnectionAndroidService.kt index 4a630dc451..f2f2209c35 100644 --- a/vector/src/main/java/im/vector/app/features/call/telecom/VectorConnectionService.kt +++ b/vector/src/main/java/im/vector/app/features/call/telecom/VectorConnectionAndroidService.kt @@ -29,7 +29,7 @@ import android.telecom.PhoneAccountHandle import android.telecom.StatusHints import android.telecom.TelecomManager import androidx.annotation.RequiresApi -import im.vector.app.core.services.CallService +import im.vector.app.core.services.CallAndroidService /** * No active calls in other apps @@ -47,7 +47,7 @@ import im.vector.app.core.services.CallService * the parameter followed by a call to the destroy() method if the user rejects the incoming call. * */ -@RequiresApi(Build.VERSION_CODES.M) class VectorConnectionService : ConnectionService() { +@RequiresApi(Build.VERSION_CODES.M) class VectorConnectionAndroidService : ConnectionService() { /** * The telecom subsystem calls this method in response to your app calling placeCall(Uri, Bundle) to create a new outgoing call. @@ -69,14 +69,14 @@ import im.vector.app.core.services.CallService connection.setCallerDisplayName("Element Caller", TelecomManager.PRESENTATION_ALLOWED) connection.statusHints = StatusHints("Testing Hint...", null, null) - bindService(Intent(applicationContext, CallService::class.java), CallServiceConnection(connection), 0) + bindService(Intent(applicationContext, CallAndroidService::class.java), CallServiceConnection(connection), 0) connection.setInitializing() return connection } inner class CallServiceConnection(private val callConnection: CallConnection) : ServiceConnection { override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { - val callSrvBinder = binder as CallService.CallServiceBinder + val callSrvBinder = binder as CallAndroidService.CallServiceBinder callSrvBinder.getCallService().addConnection(callConnection) unbindService(this) } diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferPagerAdapter.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferPagerAdapter.kt index 2bb544bdbb..3ec8f61978 100644 --- a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferPagerAdapter.kt +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferPagerAdapter.kt @@ -49,6 +49,7 @@ class CallTransferPagerAdapter( fragment.arguments = UserListFragmentArgs( title = "", menuResId = -1, + submitMenuItemId = -1, singleSelection = true, showInviteActions = false, showToolbar = false, diff --git a/vector/src/main/java/im/vector/app/features/call/webrtc/ScreenCaptureService.kt b/vector/src/main/java/im/vector/app/features/call/webrtc/ScreenCaptureAndroidService.kt similarity index 88% rename from vector/src/main/java/im/vector/app/features/call/webrtc/ScreenCaptureService.kt rename to vector/src/main/java/im/vector/app/features/call/webrtc/ScreenCaptureAndroidService.kt index 489b2d1eae..e7cebfb9c9 100644 --- a/vector/src/main/java/im/vector/app/features/call/webrtc/ScreenCaptureService.kt +++ b/vector/src/main/java/im/vector/app/features/call/webrtc/ScreenCaptureAndroidService.kt @@ -20,13 +20,13 @@ import android.content.Intent import android.os.Binder import android.os.IBinder import dagger.hilt.android.AndroidEntryPoint -import im.vector.app.core.services.VectorService +import im.vector.app.core.services.VectorAndroidService import im.vector.app.core.time.Clock import im.vector.app.features.notifications.NotificationUtils import javax.inject.Inject @AndroidEntryPoint -class ScreenCaptureService : VectorService() { +class ScreenCaptureAndroidService : VectorAndroidService() { @Inject lateinit var notificationUtils: NotificationUtils @Inject lateinit var clock: Clock @@ -53,6 +53,6 @@ class ScreenCaptureService : VectorService() { } inner class LocalBinder : Binder() { - fun getService(): ScreenCaptureService = this@ScreenCaptureService + fun getService(): ScreenCaptureAndroidService = this@ScreenCaptureAndroidService } } diff --git a/vector/src/main/java/im/vector/app/features/call/webrtc/ScreenCaptureServiceConnection.kt b/vector/src/main/java/im/vector/app/features/call/webrtc/ScreenCaptureServiceConnection.kt index aa7c7f450a..f55a3559bd 100644 --- a/vector/src/main/java/im/vector/app/features/call/webrtc/ScreenCaptureServiceConnection.kt +++ b/vector/src/main/java/im/vector/app/features/call/webrtc/ScreenCaptureServiceConnection.kt @@ -32,7 +32,7 @@ class ScreenCaptureServiceConnection @Inject constructor( } private var isBound = false - private var screenCaptureService: ScreenCaptureService? = null + private var screenCaptureAndroidService: ScreenCaptureAndroidService? = null private var callback: Callback? = null fun bind(callback: Callback) { @@ -41,25 +41,25 @@ class ScreenCaptureServiceConnection @Inject constructor( if (isBound) { callback.onServiceConnected() } else { - Intent(context, ScreenCaptureService::class.java).also { intent -> + Intent(context, ScreenCaptureAndroidService::class.java).also { intent -> context.bindService(intent, this, 0) } } } fun stopScreenCapturing() { - screenCaptureService?.stopService() + screenCaptureAndroidService?.stopService() } override fun onServiceConnected(className: ComponentName, binder: IBinder) { - screenCaptureService = (binder as ScreenCaptureService.LocalBinder).getService() + screenCaptureAndroidService = (binder as ScreenCaptureAndroidService.LocalBinder).getService() isBound = true callback?.onServiceConnected() } override fun onServiceDisconnected(className: ComponentName) { isBound = false - screenCaptureService = null + screenCaptureAndroidService = null callback = null } } diff --git a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt index 79c3930c89..00b9a76de7 100644 --- a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt +++ b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCall.kt @@ -19,7 +19,7 @@ package im.vector.app.features.call.webrtc import android.content.Context import android.hardware.camera2.CameraManager import androidx.core.content.getSystemService -import im.vector.app.core.services.CallService +import im.vector.app.core.services.CallAndroidService import im.vector.app.core.utils.PublishDataSource import im.vector.app.core.utils.TextUtils.formatDuration import im.vector.app.features.call.CameraEventsHandlerAdapter @@ -477,7 +477,7 @@ class WebRtcCall( val turnServerResponse = getTurnServer() // Update service state withContext(Dispatchers.Main) { - CallService.onPendingCall( + CallAndroidService.onPendingCall( context = context, callId = mxCall.callId ) diff --git a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt index db03e7dc5d..b35ab774be 100644 --- a/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt +++ b/vector/src/main/java/im/vector/app/features/call/webrtc/WebRtcCallManager.kt @@ -22,7 +22,7 @@ import androidx.lifecycle.LifecycleOwner import im.vector.app.ActiveSessionDataSource import im.vector.app.BuildConfig import im.vector.app.core.pushers.UnifiedPushHelper -import im.vector.app.core.services.CallService +import im.vector.app.core.services.CallAndroidService import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.analytics.plan.CallEnded import im.vector.app.features.analytics.plan.CallStarted @@ -254,7 +254,7 @@ class WebRtcCallManager @Inject constructor( Timber.tag(loggerTag.value).v("On call ended for unknown call $callId") } webRtcCall.trackCallEnded() - CallService.onCallTerminated(context, callId, endCallReason, rejected) + CallAndroidService.onCallTerminated(context, callId, endCallReason, rejected) callsByRoomId[webRtcCall.signalingRoomId]?.remove(webRtcCall) callsByRoomId[webRtcCall.nativeRoomId]?.remove(webRtcCall) transferees.remove(callId) @@ -305,7 +305,7 @@ class WebRtcCallManager @Inject constructor( if (transferee != null) { transferees[webRtcCall.callId] = transferee } - CallService.onOutgoingCallRinging( + CallAndroidService.onOutgoingCallRinging( context = context.applicationContext, callId = mxCall.callId ) @@ -370,7 +370,7 @@ class WebRtcCallManager @Inject constructor( offerSdp = callInviteContent.offer } // Start background service with notification - CallService.onIncomingCallRinging( + CallAndroidService.onIncomingCallRinging( context = context, callId = mxCall.callId, isInBackground = isInBackground @@ -395,7 +395,7 @@ class WebRtcCallManager @Inject constructor( } val mxCall = call.mxCall // Update service state - CallService.onPendingCall( + CallAndroidService.onPendingCall( context = context, callId = mxCall.callId ) diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt index 377acd2271..707b78d328 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt @@ -81,7 +81,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { when (action) { UserListSharedAction.Close -> finish() UserListSharedAction.GoBack -> onBackPressed() - is UserListSharedAction.OnMenuItemSelected -> onMenuItemSelected(action) + is UserListSharedAction.OnMenuItemSubmitClick -> handleOnMenuItemSubmitClick(action) UserListSharedAction.OpenPhoneBook -> openPhoneBook() UserListSharedAction.AddByQrCode -> openAddByQrCode() } @@ -93,7 +93,8 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { UserListFragment::class.java, UserListFragmentArgs( title = getString(R.string.fab_menu_create_chat), - menuResId = R.menu.vector_create_direct_room + menuResId = R.menu.vector_create_direct_room, + submitMenuItemId = R.id.action_create_direct_room, ) ) } @@ -159,10 +160,8 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { } } - private fun onMenuItemSelected(action: UserListSharedAction.OnMenuItemSelected) { - if (action.itemId == R.id.action_create_direct_room) { - viewModel.handle(CreateDirectRoomAction.PrepareRoomWithSelectedUsers(action.selections)) - } + private fun handleOnMenuItemSubmitClick(action: UserListSharedAction.OnMenuItemSubmitClick) { + viewModel.handle(CreateDirectRoomAction.PrepareRoomWithSelectedUsers(action.selections)) } private fun renderCreateAndInviteState(state: Async) { diff --git a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt index 7478adb165..077bcc2cf3 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt @@ -187,16 +187,6 @@ class KeysBackupSetupActivity : SimpleFragmentActivity() { } } -// I think this code is useful, but it violates the code quality rules -// override fun onOptionsItemSelected(item: MenuItem): Boolean { -// if (item.itemId == android .R. id. home) { -// onBackPressed() -// return true -// } -// -// return super.onOptionsItemSelected(item) -// } - companion object { const val KEYS_VERSION = "KEYS_VERSION" const val MANUAL_EXPORT = "MANUAL_EXPORT" diff --git a/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolActivity.kt b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolActivity.kt index 6f661c5164..774460eb1f 100644 --- a/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolActivity.kt +++ b/vector/src/main/java/im/vector/app/features/devtools/RoomDevToolActivity.kt @@ -21,7 +21,6 @@ import android.content.Intent import android.os.Parcelable import android.view.Menu import android.view.MenuItem -import androidx.core.view.forEach import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import com.airbnb.mvrx.Fail @@ -36,6 +35,7 @@ import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.platform.SimpleFragmentActivity +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.resources.ColorProvider import im.vector.app.core.utils.createJSonViewerStyleProvider import kotlinx.parcelize.Parcelize @@ -43,7 +43,10 @@ import org.billcarsonfr.jsonviewer.JSonViewerFragment import javax.inject.Inject @AndroidEntryPoint -class RoomDevToolActivity : SimpleFragmentActivity(), FragmentManager.OnBackStackChangedListener { +class RoomDevToolActivity : + SimpleFragmentActivity(), + FragmentManager.OnBackStackChangedListener, + VectorMenuProvider { @Inject lateinit var colorProvider: ColorProvider @@ -133,16 +136,18 @@ class RoomDevToolActivity : SimpleFragmentActivity(), FragmentManager.OnBackStac } } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.menuItemEdit) { - viewModel.handle(RoomDevToolAction.MenuEdit) - return true + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.menuItemEdit -> { + viewModel.handle(RoomDevToolAction.MenuEdit) + true + } + R.id.menuItemSend -> { + viewModel.handle(RoomDevToolAction.MenuItemSend) + true + } + else -> false } - if (item.itemId == R.id.menuItemSend) { - viewModel.handle(RoomDevToolAction.MenuItemSend) - return true - } - return super.onOptionsItemSelected(item) } override fun onBackPressed() { @@ -174,21 +179,12 @@ class RoomDevToolActivity : SimpleFragmentActivity(), FragmentManager.OnBackStac super.onDestroy() } - override fun onPrepareOptionsMenu(menu: Menu?): Boolean = withState(viewModel) { state -> - menu?.forEach { - val isVisible = when (it.itemId) { - R.id.menuItemEdit -> { - state.displayMode is RoomDevToolViewState.Mode.StateEventDetail - } - R.id.menuItemSend -> { - state.displayMode is RoomDevToolViewState.Mode.EditEventContent || - state.displayMode is RoomDevToolViewState.Mode.SendEventForm - } - else -> true - } - it.isVisible = isVisible + override fun handlePrepareMenu(menu: Menu) { + withState(viewModel) { state -> + menu.findItem(R.id.menuItemEdit).isVisible = state.displayMode == RoomDevToolViewState.Mode.StateEventDetail + menu.findItem(R.id.menuItemSend).isVisible = state.displayMode == RoomDevToolViewState.Mode.EditEventContent || + state.displayMode is RoomDevToolViewState.Mode.SendEventForm } - return@withState true } companion object { 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 f2690fa18a..1c66cdda64 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 @@ -43,6 +43,7 @@ import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.extensions.validateBackPressed import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.pushers.PushersManager import im.vector.app.core.pushers.UnifiedPushHelper import im.vector.app.databinding.ActivityHomeBinding @@ -103,7 +104,8 @@ class HomeActivity : VectorBaseActivity(), NavigationInterceptor, SpaceInviteBottomSheet.InteractionListener, - MatrixToBottomSheet.InteractionListener { + MatrixToBottomSheet.InteractionListener, + VectorMenuProvider { private lateinit var sharedActionViewModel: HomeSharedActionViewModel @@ -547,47 +549,45 @@ class HomeActivity : override fun getMenuRes() = R.menu.home - override fun onPrepareOptionsMenu(menu: Menu): Boolean { + override fun handlePrepareMenu(menu: Menu) { menu.findItem(R.id.menu_home_init_sync_legacy).isVisible = vectorPreferences.developerMode() menu.findItem(R.id.menu_home_init_sync_optimized).isVisible = vectorPreferences.developerMode() - return super.onPrepareOptionsMenu(menu) } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { R.id.menu_home_suggestion -> { bugReporter.openBugReportScreen(this, ReportType.SUGGESTION) - return true + true } R.id.menu_home_report_bug -> { bugReporter.openBugReportScreen(this, ReportType.BUG_REPORT) - return true + true } R.id.menu_home_init_sync_legacy -> { // Configure the SDK initialSyncStrategy = InitialSyncStrategy.Legacy // And clear cache MainActivity.restartApp(this, MainActivityArgs(clearCache = true)) - return true + true } R.id.menu_home_init_sync_optimized -> { // Configure the SDK initialSyncStrategy = InitialSyncStrategy.Optimized() // And clear cache MainActivity.restartApp(this, MainActivityArgs(clearCache = true)) - return true + true } R.id.menu_home_filter -> { navigator.openRoomsFiltering(this) - return true + true } R.id.menu_home_setting -> { navigator.openSettings(this) - return true + true } + else -> false } - - return super.onOptionsItemSelected(item) } override fun onBackPressed() { 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 9deae0970b..a3bc5144d1 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 @@ -36,6 +36,7 @@ import im.vector.app.core.extensions.toMvRxBundle import im.vector.app.core.platform.OnBackPressed import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.resources.ColorProvider import im.vector.app.core.ui.views.CurrentCallsView import im.vector.app.core.ui.views.CurrentCallsViewPresenter @@ -71,7 +72,8 @@ class HomeDetailFragment @Inject constructor( ) : VectorBaseFragment(), KeysBackupBanner.Delegate, CurrentCallsView.Callback, - OnBackPressed { + OnBackPressed, + VectorMenuProvider { private val viewModel: HomeDetailViewModel by fragmentViewModel() private val unknownDeviceDetectorSharedViewModel: UnknownDeviceDetectorSharedViewModel by activityViewModel() @@ -91,23 +93,21 @@ class HomeDetailFragment @Inject constructor( override fun getMenuRes() = R.menu.room_list - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { R.id.menu_home_mark_all_as_read -> { viewModel.handle(HomeDetailAction.MarkAllRoomsRead) - return true + true } + else -> false } - - return super.onOptionsItemSelected(item) } - override fun onPrepareOptionsMenu(menu: Menu) { + override fun handlePrepareMenu(menu: Menu) { withState(viewModel) { state -> val isRoomList = state.currentTab is HomeTab.RoomList menu.findItem(R.id.menu_home_mark_all_as_read).isVisible = isRoomList && hasUnreadRooms } - super.onPrepareOptionsMenu(menu) } override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentHomeDetailBinding { 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 a3d342ef48..d327bb48d4 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 @@ -30,7 +30,6 @@ import android.view.HapticFeedbackConstants import android.view.KeyEvent import android.view.LayoutInflater import android.view.Menu -import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup @@ -74,6 +73,7 @@ import im.vector.app.core.animations.play import im.vector.app.core.dialogs.ConfirmationDialogBuilder import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper import im.vector.app.core.epoxy.LayoutManagerStateRestorer +import im.vector.app.core.error.fatalError import im.vector.app.core.extensions.cleanup import im.vector.app.core.extensions.containsRtLOverride import im.vector.app.core.extensions.ensureEndsLeftToRight @@ -89,6 +89,7 @@ import im.vector.app.core.hardware.vibrate import im.vector.app.core.intent.getFilenameFromUri import im.vector.app.core.intent.getMimeTypeFromUri import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.platform.lifecycleAwareLazy import im.vector.app.core.platform.showOptimizedSnackbar import im.vector.app.core.resources.ColorProvider @@ -130,6 +131,7 @@ import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.attachments.AttachmentTypeSelectorView import im.vector.app.features.attachments.AttachmentsHelper import im.vector.app.features.attachments.ContactAttachment +import im.vector.app.features.attachments.ShareIntentHandler import im.vector.app.features.attachments.preview.AttachmentsPreviewActivity import im.vector.app.features.attachments.preview.AttachmentsPreviewArgs import im.vector.app.features.attachments.toGroupedContentAttachmentData @@ -272,6 +274,7 @@ class TimelineFragment @Inject constructor( private val pillsPostProcessorFactory: PillsPostProcessor.Factory, private val callManager: WebRtcCallManager, private val audioMessagePlaybackTracker: AudioMessagePlaybackTracker, + private val shareIntentHandler: ShareIntentHandler, private val clock: Clock ) : VectorBaseFragment(), @@ -280,7 +283,8 @@ class TimelineFragment @Inject constructor( AttachmentTypeSelectorView.Callback, AttachmentsHelper.Callback, GalleryOrCameraDialogHelper.Listener, - CurrentCallsView.Callback { + CurrentCallsView.Callback, + VectorMenuProvider { companion object { @@ -1055,15 +1059,14 @@ class TimelineFragment @Inject constructor( } @SuppressLint("RestrictedApi") - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + override fun handlePostCreateMenu(menu: Menu) { if (isThreadTimeLine()) { if (menu is MenuBuilder) menu.setOptionalIconsVisible(true) } - super.onCreateOptionsMenu(menu, inflater) // We use a custom layout for this menu item, so we need to set a ClickListener menu.findItem(R.id.open_matrix_apps)?.let { menuItem -> menuItem.actionView.debouncedClicks { - onOptionsItemSelected(menuItem) + handleMenuItemSelected(menuItem) } } val joinConfItem = menu.findItem(R.id.join_conference) @@ -1073,13 +1076,13 @@ class TimelineFragment @Inject constructor( // Custom thread notification menu item menu.findItem(R.id.menu_timeline_thread_list)?.let { menuItem -> - menuItem.actionView.setOnClickListener { - onOptionsItemSelected(menuItem) + menuItem.actionView.debouncedClicks { + handleMenuItemSelected(menuItem) } } } - override fun onPrepareOptionsMenu(menu: Menu) { + override fun handlePrepareMenu(menu: Menu) { menu.forEach { it.isVisible = timelineViewModel.isMenuItemVisible(it.itemId) } @@ -1121,7 +1124,7 @@ class TimelineFragment @Inject constructor( } } - override fun onOptionsItemSelected(item: MenuItem): Boolean { + override fun handleMenuItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.invite -> { navigator.openInviteUsersToRoom(requireActivity(), timelineArgs.roomId) @@ -1174,7 +1177,7 @@ class TimelineFragment @Inject constructor( } true } - else -> super.onOptionsItemSelected(item) + else -> false } } @@ -1259,6 +1262,7 @@ class TimelineFragment @Inject constructor( val nonFormattedBody = when (messageContent) { is MessageAudioContent -> getAudioContentBodyText(messageContent) is MessagePollContent -> messageContent.getBestPollCreationInfo()?.question?.getBestQuestion() + is MessageBeaconInfoContent -> getString(R.string.sent_live_location) else -> messageContent?.body.orEmpty() } var formattedBody: CharSequence? = null @@ -1618,7 +1622,9 @@ class TimelineFragment @Inject constructor( private fun sendUri(uri: Uri): Boolean { val shareIntent = Intent(Intent.ACTION_SEND, uri) - val isHandled = attachmentsHelper.handleShareIntent(requireContext(), shareIntent) + val isHandled = shareIntentHandler.handleIncomingShareIntent(requireContext(), shareIntent, ::onContentAttachmentsReady, onPlainText = { + fatalError("Should not happen as we're generating a File based share Intent", vectorPreferences.failFast()) + }) if (!isHandled) { Toast.makeText(requireContext(), R.string.error_handling_incoming_share, Toast.LENGTH_SHORT).show() } @@ -2635,10 +2641,6 @@ class TimelineFragment @Inject constructor( } } - override fun onAttachmentsProcessFailed() { - Toast.makeText(requireContext(), R.string.error_attachment, Toast.LENGTH_SHORT).show() - } - override fun onContactAttachmentReady(contactAttachment: ContactAttachment) { super.onContactAttachmentReady(contactAttachment) val formattedContact = contactAttachment.toHumanReadable() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCase.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCase.kt new file mode 100644 index 0000000000..c312ef31b7 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCase.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.action + +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageType +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import javax.inject.Inject + +class CheckIfCanReplyEventUseCase @Inject constructor() { + + fun execute(event: TimelineEvent, messageContent: MessageContent?, actionPermissions: ActionPermissions): Boolean { + // Only EventType.MESSAGE, EventType.POLL_START and EventType.STATE_ROOM_BEACON_INFO event types are supported for the moment + if (event.root.getClearType() !in EventType.STATE_ROOM_BEACON_INFO + EventType.POLL_START + EventType.MESSAGE) return false + if (!actionPermissions.canSendMessage) return false + return when (messageContent?.msgType) { + MessageType.MSGTYPE_TEXT, + MessageType.MSGTYPE_NOTICE, + MessageType.MSGTYPE_EMOTE, + MessageType.MSGTYPE_IMAGE, + MessageType.MSGTYPE_VIDEO, + MessageType.MSGTYPE_AUDIO, + MessageType.MSGTYPE_FILE, + MessageType.MSGTYPE_POLL_START, + MessageType.MSGTYPE_BEACON_INFO, + MessageType.MSGTYPE_LOCATION -> true + else -> false + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt index 05089cce81..30786dc77a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt @@ -80,7 +80,8 @@ class MessageActionsViewModel @AssistedInject constructor( private val errorFormatter: ErrorFormatter, private val stringProvider: StringProvider, private val pillsPostProcessorFactory: PillsPostProcessor.Factory, - private val vectorPreferences: VectorPreferences + private val vectorPreferences: VectorPreferences, + private val checkIfCanReplyEventUseCase: CheckIfCanReplyEventUseCase, ) : VectorViewModel(initialState) { private val informationData = initialState.informationData @@ -436,21 +437,7 @@ class MessageActionsViewModel @AssistedInject constructor( } private fun canReply(event: TimelineEvent, messageContent: MessageContent?, actionPermissions: ActionPermissions): Boolean { - // Only EventType.MESSAGE and EventType.POLL_START event types are supported for the moment - if (event.root.getClearType() !in EventType.POLL_START + EventType.MESSAGE) return false - if (!actionPermissions.canSendMessage) return false - return when (messageContent?.msgType) { - MessageType.MSGTYPE_TEXT, - MessageType.MSGTYPE_NOTICE, - MessageType.MSGTYPE_EMOTE, - MessageType.MSGTYPE_IMAGE, - MessageType.MSGTYPE_VIDEO, - MessageType.MSGTYPE_AUDIO, - MessageType.MSGTYPE_FILE, - MessageType.MSGTYPE_POLL_START, - MessageType.MSGTYPE_LOCATION -> true - else -> false - } + return checkIfCanReplyEventUseCase.execute(event, messageContent, actionPermissions) } /** diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index 853fef8bc8..28e256c064 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -244,7 +244,7 @@ class MessageItemFactory @Inject constructor( .eventId(informationData.eventId) .pollQuestion(createPollQuestion(informationData, pollViewState.question, callback)) .canVote(pollViewState.canVote) - .totalVotesText(pollViewState.totalVotes) + .votesStatus(pollViewState.votesStatus) .optionViewStates(pollViewState.optionViewStates) .edited(informationData.hasBeenEdited) .highlighted(highlight) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactory.kt index 8da0f2d279..13f63e86c4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactory.kt @@ -65,7 +65,7 @@ class PollItemViewStateFactory @Inject constructor( private fun createSendingPollViewState(question: String, pollCreationInfo: PollCreationInfo?): PollViewState { return PollViewState( question = question, - totalVotes = stringProvider.getString(R.string.poll_no_votes_cast), + votesStatus = stringProvider.getString(R.string.poll_no_votes_cast), canVote = false, optionViewStates = pollCreationInfo?.answers?.map { answer -> PollOptionViewState.PollSending( @@ -85,7 +85,7 @@ class PollItemViewStateFactory @Inject constructor( ): PollViewState { return PollViewState( question = question, - totalVotes = stringProvider.getQuantityString(R.plurals.poll_total_vote_count_after_ended, totalVotes, totalVotes), + votesStatus = stringProvider.getQuantityString(R.plurals.poll_total_vote_count_after_ended, totalVotes, totalVotes), canVote = false, optionViewStates = pollCreationInfo?.answers?.map { answer -> val voteSummary = pollResponseSummary?.getVoteSummaryOfAnOption(answer.id ?: "") @@ -107,7 +107,7 @@ class PollItemViewStateFactory @Inject constructor( ): PollViewState { return PollViewState( question = question, - totalVotes = "", + votesStatus = stringProvider.getString(R.string.poll_undisclosed_not_ended), canVote = true, optionViewStates = pollCreationInfo?.answers?.map { answer -> val isMyVote = pollResponseSummary?.myVote == answer.id @@ -128,7 +128,7 @@ class PollItemViewStateFactory @Inject constructor( ): PollViewState { return PollViewState( question = question, - totalVotes = stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, totalVotes, totalVotes), + votesStatus = stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, totalVotes, totalVotes), canVote = true, optionViewStates = pollCreationInfo?.answers?.map { answer -> val isMyVote = pollResponseSummary?.myVote == answer.id @@ -152,7 +152,7 @@ class PollItemViewStateFactory @Inject constructor( } return PollViewState( question = question, - totalVotes = totalVotesText, + votesStatus = totalVotesText, canVote = true, optionViewStates = pollCreationInfo?.answers?.map { answer -> PollOptionViewState.PollReady( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/PollItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/PollItem.kt index d00a66b225..5dd721b007 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/PollItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/PollItem.kt @@ -42,7 +42,7 @@ abstract class PollItem : AbsMessageItem() { var canVote: Boolean = false @EpoxyAttribute - var totalVotesText: String? = null + var votesStatus: String? = null @EpoxyAttribute var edited: Boolean = false @@ -58,7 +58,7 @@ abstract class PollItem : AbsMessageItem() { renderSendState(holder.view, holder.questionTextView) holder.questionTextView.text = pollQuestion?.charSequence - holder.totalVotesTextView.text = totalVotesText + holder.votesStatusTextView.text = votesStatus while (holder.optionsContainer.childCount < optionViewStates.size) { holder.optionsContainer.addView(PollOptionView(holder.view.context)) @@ -88,7 +88,7 @@ abstract class PollItem : AbsMessageItem() { class Holder : AbsMessageItem.Holder(STUB_ID) { val questionTextView by bind(R.id.questionTextView) val optionsContainer by bind(R.id.optionsContainer) - val totalVotesTextView by bind(R.id.optionsTotalVotesTextView) + val votesStatusTextView by bind(R.id.optionsVotesStatusTextView) } companion object { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/PollOptionView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/PollOptionView.kt index b21b1a152d..20aa6e3af2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/PollOptionView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/PollOptionView.kt @@ -82,6 +82,7 @@ class PollOptionView @JvmOverloads constructor( private fun renderPollUndisclosed(state: PollOptionViewState.PollUndisclosed) { views.optionCheckImageView.isVisible = true views.optionWinnerImageView.isVisible = false + hideVotes() renderVoteSelection(state.isSelected) } 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 13bf056301..aaa9846c39 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 @@ -19,7 +19,6 @@ package im.vector.app.features.home.room.threads.list.views import android.os.Bundle import android.view.LayoutInflater import android.view.Menu -import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup @@ -31,6 +30,7 @@ import im.vector.app.R import im.vector.app.core.extensions.cleanup import im.vector.app.core.extensions.configureWith import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.databinding.FragmentThreadListBinding import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.home.AvatarRenderer @@ -54,7 +54,8 @@ class ThreadListFragment @Inject constructor( private val threadListController: ThreadListController, val threadListViewModelFactory: ThreadListViewModel.Factory ) : VectorBaseFragment(), - ThreadListController.Listener { + ThreadListController.Listener, + VectorMenuProvider { private val threadListViewModel: ThreadListViewModel by fragmentViewModel() @@ -71,27 +72,26 @@ class ThreadListFragment @Inject constructor( analyticsScreenName = MobileScreen.ScreenName.ThreadList } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - super.onCreateOptionsMenu(menu, inflater) - + override fun handlePostCreateMenu(menu: Menu) { + // We use a custom layout for this menu item, so we need to set a ClickListener menu.findItem(R.id.menu_thread_list_filter)?.let { menuItem -> - menuItem.actionView.setOnClickListener { - onOptionsItemSelected(menuItem) + menuItem.actionView.debouncedClicks { + handleMenuItemSelected(menuItem) } } } - override fun onOptionsItemSelected(item: MenuItem): Boolean { + override fun handleMenuItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.menu_thread_list_filter -> { ThreadListBottomSheet().show(childFragmentManager, "Filtering") true } - else -> super.onOptionsItemSelected(item) + else -> false } } - override fun onPrepareOptionsMenu(menu: Menu) { + override fun handlePrepareMenu(menu: Menu) { withState(threadListViewModel) { state -> val filterIcon = menu.findItem(R.id.menu_thread_list_filter).actionView val filterBadge = filterIcon.findViewById(R.id.threadListFilterBadge) diff --git a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt index 48d2c1b685..412b28862a 100644 --- a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt @@ -14,6 +14,15 @@ * limitations under the License. */ +/* + * This file renders the formatted_body of an event to a formatted Android Spannable. + * The core of this work is done with Markwon, a general-purpose Markdown+HTML formatter. + * Since formatted_body is HTML only, Markwon is configured to only handle HTML, not Markdown. + * The EventHtmlRenderer class is next used in the method buildFormattedTextItem + * in the file MessageItemFactory.kt. + * Effectively, this is used in the chat messages view and the room list message previews. + */ + package im.vector.app.features.html import android.content.Context @@ -30,6 +39,7 @@ import io.noties.markwon.PrecomputedFutureTextSetterCompat import io.noties.markwon.ext.latex.JLatexMathPlugin import io.noties.markwon.ext.latex.JLatexMathTheme import io.noties.markwon.html.HtmlPlugin +import io.noties.markwon.inlineparser.EntityInlineProcessor import io.noties.markwon.inlineparser.HtmlInlineProcessor import io.noties.markwon.inlineparser.MarkwonInlineParser import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin @@ -54,8 +64,10 @@ class EventHtmlRenderer @Inject constructor( .usePlugin(HtmlPlugin.create(htmlConfigure)) private val markwon = if (vectorPreferences.latexMathsIsEnabled()) { + // If latex maths is enabled in app preferences, refomat it so Markwon recognises it + // It needs to be in this specific format: https://noties.io/Markwon/docs/v4/ext-latex builder - .usePlugin(object : AbstractMarkwonPlugin() { // Markwon expects maths to be in a specific format: https://noties.io/Markwon/docs/v4/ext-latex + .usePlugin(object : AbstractMarkwonPlugin() { override fun processMarkdown(markdown: String): String { return markdown .replace(Regex(""".*?""")) { matchResult -> @@ -75,11 +87,20 @@ class EventHtmlRenderer @Inject constructor( } .usePlugin( MarkwonInlineParserPlugin.create( - MarkwonInlineParser.factoryBuilderNoDefaults().addInlineProcessor(HtmlInlineProcessor()) + /* Configuring the Markwon inline formatting processor. + * Default settings are all Markdown features. Turn those off, only using the + * inline HTML processor and HTML entities processor. + */ + MarkwonInlineParser.factoryBuilderNoDefaults() + .addInlineProcessor(HtmlInlineProcessor()) // use inline HTML processor + .addInlineProcessor(EntityInlineProcessor()) // use HTML entities processor ) ) .usePlugin(object : AbstractMarkwonPlugin() { override fun configureParser(builder: Parser.Builder) { + /* Configuring the Markwon block formatting processor. + * Default settings are all Markdown blocks. Turn those off. + */ builder.enabledBlockTypes(kotlin.collections.emptySet()) } }) @@ -132,6 +153,7 @@ class MatrixHtmlPluginConfigure @Inject constructor(private val colorProvider: C override fun configureHtml(plugin: HtmlPlugin) { plugin + .addHandler(ListHandlerWithInitialStart()) .addHandler(FontTagHandler()) .addHandler(ParagraphHandler(DimensionConverter(resources))) .addHandler(MxReplyTagHandler()) diff --git a/vector/src/main/java/im/vector/app/features/html/ListHandlerWithInitialStart.java b/vector/src/main/java/im/vector/app/features/html/ListHandlerWithInitialStart.java new file mode 100644 index 0000000000..c7ba881da0 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/html/ListHandlerWithInitialStart.java @@ -0,0 +1,111 @@ +/* + * 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.html; + +import androidx.annotation.NonNull; + +import org.commonmark.node.ListItem; + +import java.util.Arrays; +import java.util.Collection; + +import io.noties.markwon.MarkwonConfiguration; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.RenderProps; +import io.noties.markwon.SpanFactory; +import io.noties.markwon.SpannableBuilder; +import io.noties.markwon.core.CoreProps; +import io.noties.markwon.html.HtmlTag; +import io.noties.markwon.html.MarkwonHtmlRenderer; +import io.noties.markwon.html.TagHandler; + +/** + * Copied from https://github.com/noties/Markwon/blob/master/markwon-html/src/main/java/io/noties/markwon/html/tag/ListHandler.java#L44 + * With a modification on the starting list position + */ +public class ListHandlerWithInitialStart extends TagHandler { + + private static final String START_KEY = "start"; + + @Override + public void handle( + @NonNull MarkwonVisitor visitor, + @NonNull MarkwonHtmlRenderer renderer, + @NonNull HtmlTag tag) { + + if (!tag.isBlock()) { + return; + } + + final HtmlTag.Block block = tag.getAsBlock(); + final boolean ol = "ol".equals(block.name()); + final boolean ul = "ul".equals(block.name()); + + if (!ol && !ul) { + return; + } + + final MarkwonConfiguration configuration = visitor.configuration(); + final RenderProps renderProps = visitor.renderProps(); + final SpanFactory spanFactory = configuration.spansFactory().get(ListItem.class); + + // Modified line + int number = Integer.parseInt(block.attributes().containsKey(START_KEY) ? block.attributes().get(START_KEY) : "1"); + + final int bulletLevel = currentBulletListLevel(block); + + for (HtmlTag.Block child : block.children()) { + + visitChildren(visitor, renderer, child); + + if (spanFactory != null && "li".equals(child.name())) { + + // insert list item here + if (ol) { + CoreProps.LIST_ITEM_TYPE.set(renderProps, CoreProps.ListItemType.ORDERED); + CoreProps.ORDERED_LIST_ITEM_NUMBER.set(renderProps, number++); + } else { + CoreProps.LIST_ITEM_TYPE.set(renderProps, CoreProps.ListItemType.BULLET); + CoreProps.BULLET_LIST_ITEM_LEVEL.set(renderProps, bulletLevel); + } + + SpannableBuilder.setSpans( + visitor.builder(), + spanFactory.getSpans(configuration, renderProps), + child.start(), + child.end()); + } + } + } + + @NonNull + @Override + public Collection supportedTags() { + return Arrays.asList("ol", "ul"); + } + + private static int currentBulletListLevel(@NonNull HtmlTag.Block block) { + int level = 0; + while ((block = block.parent()) != null) { + if ("ul".equals(block.name()) + || "ol".equals(block.name())) { + level += 1; + } + } + return level; + } +} diff --git a/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomActivity.kt b/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomActivity.kt index 304ec72ce2..883c879e90 100644 --- a/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomActivity.kt +++ b/vector/src/main/java/im/vector/app/features/invite/InviteUsersToRoomActivity.kt @@ -71,7 +71,7 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() { when (sharedAction) { UserListSharedAction.Close -> finish() UserListSharedAction.GoBack -> onBackPressed() - is UserListSharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction) + is UserListSharedAction.OnMenuItemSubmitClick -> handleOnMenuItemSubmitClick(sharedAction) UserListSharedAction.OpenPhoneBook -> openPhoneBook() // not exhaustive because it's a sharedAction else -> Unit @@ -85,6 +85,7 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() { UserListFragmentArgs( title = getString(R.string.invite_users_to_room_title), menuResId = R.menu.vector_invite_users_to_room, + submitMenuItemId = R.id.action_invite_users_to_room_invite, excludedUserIds = viewModel.getUserIdsOfRoomMembers(), showInviteActions = false ) @@ -94,10 +95,8 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() { viewModel.observeViewEvents { renderInviteEvents(it) } } - private fun onMenuItemSelected(action: UserListSharedAction.OnMenuItemSelected) { - if (action.itemId == R.id.action_invite_users_to_room_invite) { - viewModel.handle(InviteUsersToRoomAction.InviteSelectedUsers(action.selections)) - } + private fun handleOnMenuItemSubmitClick(action: UserListSharedAction.OnMenuItemSubmitClick) { + viewModel.handle(InviteUsersToRoomAction.InviteSelectedUsers(action.selections)) } private fun openPhoneBook() { diff --git a/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt b/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt index 7fce09cad7..131119a7aa 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt @@ -26,6 +26,7 @@ import com.airbnb.mvrx.args import com.mapbox.mapboxsdk.maps.MapView import im.vector.app.R import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.utils.openLocation import im.vector.app.databinding.FragmentLocationPreviewBinding import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider @@ -38,7 +39,8 @@ import javax.inject.Inject class LocationPreviewFragment @Inject constructor( private val urlMapProvider: UrlMapProvider, private val locationPinProvider: LocationPinProvider -) : VectorBaseFragment() { +) : VectorBaseFragment(), + VectorMenuProvider { private val args: LocationSharingArgs by args() @@ -99,14 +101,14 @@ class LocationPreviewFragment @Inject constructor( override fun getMenuRes() = R.menu.menu_location_preview - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { R.id.share_external -> { onShareLocationExternal() - return true + true } + else -> false } - return super.onOptionsItemSelected(item) } private fun onShareLocationExternal() { diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingAndroidService.kt similarity index 93% rename from vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt rename to vector/src/main/java/im/vector/app/features/location/LocationSharingAndroidService.kt index 8073aaaa35..69ffc0e89e 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingAndroidService.kt @@ -21,8 +21,9 @@ import android.os.Binder import android.os.IBinder import android.os.Parcelable import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder -import im.vector.app.core.services.VectorService +import im.vector.app.core.services.VectorAndroidService import im.vector.app.features.location.live.GetLiveLocationShareSummaryUseCase import im.vector.app.features.notifications.NotificationUtils import im.vector.app.features.session.coroutineScope @@ -41,7 +42,7 @@ import timber.log.Timber import javax.inject.Inject @AndroidEntryPoint -class LocationSharingService : VectorService(), LocationTracker.Callback { +class LocationSharingAndroidService : VectorAndroidService(), LocationTracker.Callback { @Parcelize data class RoomArgs( @@ -79,7 +80,7 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { launchWithActiveSession { session -> val job = locationTracker.locations - .onEach(this@LocationSharingService::onLocationUpdate) + .onEach(this@LocationSharingAndroidService::onLocationUpdate) .launchIn(session.coroutineScope) jobs.add(job) } @@ -112,7 +113,10 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { val updateLiveResult = session .getRoom(roomArgs.roomId) ?.locationSharingService() - ?.startLiveLocationShare(timeoutMillis = roomArgs.durationMillis) + ?.startLiveLocationShare( + timeoutMillis = roomArgs.durationMillis, + description = getString(R.string.sent_live_location) + ) updateLiveResult ?.let { result -> @@ -221,7 +225,7 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { } inner class LocalBinder : Binder() { - fun getService(): LocationSharingService = this@LocationSharingService + fun getService(): LocationSharingAndroidService = this@LocationSharingAndroidService } interface Callback { diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt index f881e694b6..be5f0aed6f 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt @@ -179,10 +179,10 @@ class LocationSharingFragment @Inject constructor( } private fun handleStartLiveLocationService(event: LocationSharingViewEvents.StartLiveLocationService) { - val args = LocationSharingService.RoomArgs(event.sessionId, event.roomId, event.durationMillis) + val args = LocationSharingAndroidService.RoomArgs(event.sessionId, event.roomId, event.durationMillis) - Intent(requireContext(), LocationSharingService::class.java) - .putExtra(LocationSharingService.EXTRA_ROOM_ARGS, args) + Intent(requireContext(), LocationSharingAndroidService::class.java) + .putExtra(LocationSharingAndroidService.EXTRA_ROOM_ARGS, args) .also { ContextCompat.startForegroundService(requireContext(), it) } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingServiceConnection.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingServiceConnection.kt index db79564462..495b780188 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingServiceConnection.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingServiceConnection.kt @@ -27,7 +27,8 @@ import javax.inject.Singleton @Singleton class LocationSharingServiceConnection @Inject constructor( private val context: Context -) : ServiceConnection, LocationSharingService.Callback { +) : ServiceConnection, + LocationSharingAndroidService.Callback { interface Callback { fun onLocationServiceRunning() @@ -37,7 +38,7 @@ class LocationSharingServiceConnection @Inject constructor( private val callbacks = mutableSetOf() private var isBound = false - private var locationSharingService: LocationSharingService? = null + private var locationSharingAndroidService: LocationSharingAndroidService? = null fun bind(callback: Callback) { addCallback(callback) @@ -45,7 +46,7 @@ class LocationSharingServiceConnection @Inject constructor( if (isBound) { callback.onLocationServiceRunning() } else { - Intent(context, LocationSharingService::class.java).also { intent -> + Intent(context, LocationSharingAndroidService::class.java).also { intent -> context.bindService(intent, this, 0) } } @@ -56,7 +57,7 @@ class LocationSharingServiceConnection @Inject constructor( } override fun onServiceConnected(className: ComponentName, binder: IBinder) { - locationSharingService = (binder as LocationSharingService.LocalBinder).getService().also { + locationSharingAndroidService = (binder as LocationSharingAndroidService.LocalBinder).getService().also { it.callback = this } isBound = true @@ -65,8 +66,8 @@ class LocationSharingServiceConnection @Inject constructor( override fun onServiceDisconnected(className: ComponentName) { isBound = false - locationSharingService?.callback = null - locationSharingService = null + locationSharingAndroidService?.callback = null + locationSharingAndroidService = null onCallbackActionNoArg(Callback::onLocationServiceStopped) } diff --git a/vector/src/main/java/im/vector/app/features/login/LoginActivity.kt b/vector/src/main/java/im/vector/app/features/login/LoginActivity.kt index 722133f585..4cbebd67a3 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginActivity.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginActivity.kt @@ -37,6 +37,7 @@ import im.vector.app.core.extensions.addFragment import im.vector.app.core.extensions.addFragmentToBackstack import im.vector.app.core.extensions.validateBackPressed import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.core.utils.openUrlInChromeCustomTab import im.vector.app.databinding.ActivityLoginBinding import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.home.HomeActivity @@ -231,9 +232,9 @@ open class LoginActivity : VectorBaseActivity(), UnlockedA } private fun inferAuthDescription(loginViewState: LoginViewState) = when (loginViewState.signMode) { - SignMode.Unknown -> null - SignMode.SignUp -> AuthenticationDescription.Register(type = AuthenticationDescription.AuthenticationType.Other) - SignMode.SignIn -> AuthenticationDescription.Login + SignMode.Unknown -> null + SignMode.SignUp -> AuthenticationDescription.Register(type = AuthenticationDescription.AuthenticationType.Other) + SignMode.SignIn -> AuthenticationDescription.Login SignMode.SignInWithMatrixId -> AuthenticationDescription.Login } @@ -272,8 +273,8 @@ open class LoginActivity : VectorBaseActivity(), UnlockedA SignMode.SignIn -> { // It depends on the LoginMode when (state.loginMode) { - LoginMode.Unknown, - is LoginMode.Sso -> error("Developer error") + LoginMode.Unknown -> error("Developer error") + is LoginMode.Sso -> launchSsoFlow() is LoginMode.SsoAndPassword, LoginMode.Password -> addFragmentToBackstack( views.loginFragmentContainer, @@ -293,6 +294,16 @@ open class LoginActivity : VectorBaseActivity(), UnlockedA } } + private fun launchSsoFlow() = withState(loginViewModel) { state -> + loginViewModel.getSsoUrl( + redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL, + deviceId = state.deviceId, + providerId = null, + )?.let { ssoUrl -> + openUrlInChromeCustomTab(this, null, ssoUrl) + } + } + /** * Handle the SSO redirection here. */ 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 c0fc231c8a..71c8167788 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 @@ -50,7 +50,7 @@ import im.vector.app.R import im.vector.app.core.extensions.createIgnoredUri import im.vector.app.core.platform.PendingIntentCompat import im.vector.app.core.resources.StringProvider -import im.vector.app.core.services.CallService +import im.vector.app.core.services.CallAndroidService import im.vector.app.core.time.Clock import im.vector.app.core.utils.startNotificationChannelSettingsIntent import im.vector.app.features.call.VectorCallActivity @@ -512,7 +512,7 @@ class NotificationUtils @Inject constructor( /** * Build notification for the CallService, when a call is missed. */ - fun buildCallMissedNotification(callInformation: CallService.CallInformation): Notification { + fun buildCallMissedNotification(callInformation: CallAndroidService.CallInformation): Notification { val builder = NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID) .setContentTitle(callInformation.opponentMatrixItem?.getBestName() ?: callInformation.opponentUserId) .apply { 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 b5f5682be1..27c501176e 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 @@ -302,9 +302,12 @@ class OnboardingViewModel @AssistedInject constructor( authenticationDescription = awaitState().selectedAuthenticationState.description ?: AuthenticationDescription.Register(AuthenticationDescription.AuthenticationType.Other) ) - RegistrationActionHandler.Result.StartRegistration -> _viewEvents.post(OnboardingViewEvents.DisplayStartRegistration) + RegistrationActionHandler.Result.StartRegistration -> { + overrideNextStage?.invoke() ?: _viewEvents.post(OnboardingViewEvents.DisplayStartRegistration) + } RegistrationActionHandler.Result.UnsupportedStage -> _viewEvents.post(OnboardingViewEvents.DisplayRegistrationFallback) is RegistrationActionHandler.Result.SendEmailSuccess -> _viewEvents.post(OnboardingViewEvents.OnSendEmailSuccess(it.email)) + is RegistrationActionHandler.Result.SendMsisdnSuccess -> _viewEvents.post(OnboardingViewEvents.OnSendMsisdnSuccess(it.msisdn.msisdn)) is RegistrationActionHandler.Result.Error -> _viewEvents.post(OnboardingViewEvents.Failure(it.cause)) RegistrationActionHandler.Result.MissingNextStage -> { _viewEvents.post(OnboardingViewEvents.Failure(IllegalStateException("No next registration stage found"))) diff --git a/vector/src/main/java/im/vector/app/features/onboarding/RegistrationActionHandler.kt b/vector/src/main/java/im/vector/app/features/onboarding/RegistrationActionHandler.kt index 76b1492cc3..488a96beb9 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/RegistrationActionHandler.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/RegistrationActionHandler.kt @@ -26,6 +26,7 @@ import im.vector.app.features.onboarding.ftueauth.MatrixOrgRegistrationStagesCom import kotlinx.coroutines.flow.first import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.auth.registration.FlowResult +import org.matrix.android.sdk.api.auth.registration.RegisterThreePid import org.matrix.android.sdk.api.auth.registration.Stage import org.matrix.android.sdk.api.session.Session import javax.inject.Inject @@ -47,7 +48,8 @@ class RegistrationActionHandler @Inject constructor( else -> when (result) { is RegistrationResult.Complete -> Result.RegistrationComplete(result.session) is RegistrationResult.NextStep -> processFlowResult(result, state) - is RegistrationResult.SendEmailSuccess -> Result.SendEmailSuccess(result.email) + is RegistrationResult.SendEmailSuccess -> Result.SendEmailSuccess(result.email.email) + is RegistrationResult.SendMsisdnSuccess -> Result.SendMsisdnSuccess(result.msisdn) is RegistrationResult.Error -> Result.Error(result.cause) } } @@ -95,6 +97,7 @@ class RegistrationActionHandler @Inject constructor( data class NextStage(val stage: Stage) : Result data class Error(val cause: Throwable) : Result data class SendEmailSuccess(val email: String) : Result + data class SendMsisdnSuccess(val msisdn: RegisterThreePid.Msisdn) : Result object MissingNextStage : Result object StartRegistration : Result object UnsupportedStage : Result diff --git a/vector/src/main/java/im/vector/app/features/onboarding/RegistrationWizardActionDelegate.kt b/vector/src/main/java/im/vector/app/features/onboarding/RegistrationWizardActionDelegate.kt index e27aa9b2ab..8635c1e203 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/RegistrationWizardActionDelegate.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/RegistrationWizardActionDelegate.kt @@ -59,7 +59,8 @@ class RegistrationWizardActionDelegate @Inject constructor( onSuccess = { it.toRegistrationResult() }, onFailure = { when { - action.threePid is RegisterThreePid.Email && it.is401() -> RegistrationResult.SendEmailSuccess(action.threePid.email) + action.threePid is RegisterThreePid.Email && it.is401() -> RegistrationResult.SendEmailSuccess(action.threePid) + action.threePid is RegisterThreePid.Msisdn && it.is401() -> RegistrationResult.SendMsisdnSuccess(action.threePid) else -> RegistrationResult.Error(it) } } @@ -95,7 +96,8 @@ sealed interface RegistrationResult { data class Error(val cause: Throwable) : RegistrationResult data class Complete(val session: Session) : RegistrationResult data class NextStep(val flowResult: FlowResult) : RegistrationResult - data class SendEmailSuccess(val email: String) : RegistrationResult + data class SendEmailSuccess(val email: RegisterThreePid.Email) : RegistrationResult + data class SendMsisdnSuccess(val msisdn: RegisterThreePid.Msisdn) : RegistrationResult } sealed interface RegisterAction { diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthEmailEntryFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthEmailEntryFragment.kt index 523d576120..1d85c75fa1 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthEmailEntryFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthEmailEntryFragment.kt @@ -21,6 +21,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import im.vector.app.core.extensions.associateContentStateWith +import im.vector.app.core.extensions.autofillEmail import im.vector.app.core.extensions.clearErrorOnChange import im.vector.app.core.extensions.content import im.vector.app.core.extensions.isEmail @@ -47,6 +48,7 @@ class FtueAuthEmailEntryFragment @Inject constructor() : AbstractFtueAuthFragmen views.emailEntryInput.setOnImeDoneListener { updateEmail() } views.emailEntryInput.clearErrorOnChange(viewLifecycleOwner) views.emailEntrySubmit.debouncedClicks { updateEmail() } + views.emailEntryInput.autofillEmail() } private fun updateEmail() { diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthGenericTextInputFormFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthGenericTextInputFormFragment.kt index 5e4c954300..edfbcd89b6 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthGenericTextInputFormFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthGenericTextInputFormFragment.kt @@ -36,7 +36,6 @@ import im.vector.app.core.extensions.setTextOrHide import im.vector.app.databinding.FragmentLoginGenericTextInputFormBinding import im.vector.app.features.login.TextInputFormFragmentMode import im.vector.app.features.onboarding.OnboardingAction -import im.vector.app.features.onboarding.OnboardingViewEvents import im.vector.app.features.onboarding.RegisterAction import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -226,12 +225,7 @@ class FtueAuthGenericTextInputFormFragment @Inject constructor() : AbstractFtueA views.loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable) } TextInputFormFragmentMode.SetMsisdn -> { - if (throwable.is401()) { - // This is normal use case, we go to the enter code screen - viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnSendMsisdnSuccess(viewModel.currentThreePid ?: ""))) - } else { - views.loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable) - } + views.loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable) } TextInputFormFragmentMode.ConfirmMsisdn -> { when { diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthPhoneConfirmationFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthPhoneConfirmationFragment.kt new file mode 100644 index 0000000000..39577efa19 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthPhoneConfirmationFragment.kt @@ -0,0 +1,80 @@ +/* + * 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.onboarding.ftueauth + +import android.os.Bundle +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.mvrx.args +import im.vector.app.R +import im.vector.app.core.extensions.associateContentStateWith +import im.vector.app.core.extensions.clearErrorOnChange +import im.vector.app.core.extensions.content +import im.vector.app.core.extensions.setOnImeDoneListener +import im.vector.app.databinding.FragmentFtuePhoneConfirmationBinding +import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.RegisterAction +import kotlinx.parcelize.Parcelize +import org.matrix.android.sdk.api.failure.Failure +import javax.inject.Inject + +@Parcelize +data class FtueAuthPhoneConfirmationFragmentArgument( + val msisdn: String +) : Parcelable + +class FtueAuthPhoneConfirmationFragment @Inject constructor() : AbstractFtueAuthFragment() { + + private val params: FtueAuthPhoneConfirmationFragmentArgument by args() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtuePhoneConfirmationBinding { + return FragmentFtuePhoneConfirmationBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupViews() + } + + private fun setupViews() { + views.phoneConfirmationHeaderSubtitle.text = getString(R.string.ftue_auth_phone_confirmation_subtitle, params.msisdn) + views.phoneConfirmationInput.associateContentStateWith(button = views.phoneConfirmationSubmit) + views.phoneConfirmationInput.setOnImeDoneListener { submitConfirmationCode() } + views.phoneConfirmationInput.clearErrorOnChange(viewLifecycleOwner) + views.phoneConfirmationSubmit.debouncedClicks { submitConfirmationCode() } + views.phoneConfirmationResend.debouncedClicks { viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.SendAgainThreePid)) } + } + + private fun submitConfirmationCode() { + val code = views.phoneConfirmationInput.content() + viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.ValidateThreePid(code))) + } + + override fun onError(throwable: Throwable) { + views.phoneConfirmationInput.error = when (throwable) { + // The entered code is not correct + is Failure.SuccessError -> getString(R.string.login_validation_code_is_not_correct) + else -> errorFormatter.toHumanReadable(throwable) + } + } + + override fun resetViewModel() { + viewModel.handle(OnboardingAction.ResetAuthenticationAttempt) + } +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthPhoneEntryFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthPhoneEntryFragment.kt new file mode 100644 index 0000000000..905af75639 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthPhoneEntryFragment.kt @@ -0,0 +1,87 @@ +/* + * 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.onboarding.ftueauth + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import im.vector.app.R +import im.vector.app.core.extensions.associateContentStateWith +import im.vector.app.core.extensions.autofillPhoneNumber +import im.vector.app.core.extensions.content +import im.vector.app.core.extensions.editText +import im.vector.app.core.extensions.setOnImeDoneListener +import im.vector.app.databinding.FragmentFtuePhoneInputBinding +import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.RegisterAction +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.matrix.android.sdk.api.auth.registration.RegisterThreePid +import reactivecircus.flowbinding.android.widget.textChanges +import javax.inject.Inject + +class FtueAuthPhoneEntryFragment @Inject constructor( + private val phoneNumberParser: PhoneNumberParser +) : AbstractFtueAuthFragment() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtuePhoneInputBinding { + return FragmentFtuePhoneInputBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setupViews() + } + + private fun setupViews() { + views.phoneEntryInput.associateContentStateWith(button = views.phoneEntrySubmit) + views.phoneEntryInput.setOnImeDoneListener { updatePhoneNumber() } + views.phoneEntrySubmit.debouncedClicks { updatePhoneNumber() } + + views.phoneEntryInput.editText().textChanges() + .onEach { + views.phoneEntryInput.error = null + views.phoneEntrySubmit.isEnabled = it.isNotBlank() + } + .launchIn(viewLifecycleOwner.lifecycleScope) + + views.phoneEntryInput.autofillPhoneNumber() + } + + private fun updatePhoneNumber() { + val number = views.phoneEntryInput.content() + + when (val result = phoneNumberParser.parseInternationalNumber(number)) { + PhoneNumberParser.Result.ErrorInvalidNumber -> views.phoneEntryInput.error = getString(R.string.login_msisdn_error_other) + PhoneNumberParser.Result.ErrorMissingInternationalCode -> views.phoneEntryInput.error = getString(R.string.login_msisdn_error_not_international) + is PhoneNumberParser.Result.Success -> { + val (countryCode, phoneNumber) = result + viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.AddThreePid(RegisterThreePid.Msisdn(phoneNumber, countryCode)))) + } + } + } + + override fun onError(throwable: Throwable) { + views.phoneEntryInput.error = errorFormatter.toHumanReadable(throwable) + } + + override fun resetViewModel() { + viewModel.handle(OnboardingAction.ResetAuthenticationAttempt) + } +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt index 2880b16156..bb8c523b5f 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt @@ -199,12 +199,7 @@ class FtueAuthVariant( openWaitForEmailVerification(viewEvents.email) } is OnboardingViewEvents.OnSendMsisdnSuccess -> { - // Pop the enter Msisdn Fragment - supportFragmentManager.popBackStack(FRAGMENT_REGISTRATION_STAGE_TAG, FragmentManager.POP_BACK_STACK_INCLUSIVE) - addRegistrationStageFragmentToBackstack( - FtueAuthGenericTextInputFormFragment::class.java, - FtueAuthGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.ConfirmMsisdn, true, viewEvents.msisdn), - ) + openMsisdnConfirmation(viewEvents.msisdn) } is OnboardingViewEvents.Failure, is OnboardingViewEvents.Loading -> @@ -388,12 +383,21 @@ class FtueAuthVariant( when (stage) { is Stage.ReCaptcha -> onCaptcha(stage) is Stage.Email -> onEmail(stage) - is Stage.Msisdn -> addRegistrationStageFragmentToBackstack( + is Stage.Msisdn -> onMsisdn(stage) + is Stage.Terms -> onTerms(stage) + else -> Unit // Should not happen + } + } + + private fun onMsisdn(stage: Stage) { + when { + vectorFeatures.isOnboardingCombinedRegisterEnabled() -> addRegistrationStageFragmentToBackstack( + FtueAuthPhoneEntryFragment::class.java + ) + else -> addRegistrationStageFragmentToBackstack( FtueAuthGenericTextInputFormFragment::class.java, FtueAuthGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetMsisdn, stage.mandatory), ) - is Stage.Terms -> onTerms(stage) - else -> Unit // Should not happen } } @@ -423,6 +427,20 @@ class FtueAuthVariant( } } + private fun openMsisdnConfirmation(msisdn: String) { + supportFragmentManager.popBackStack(FRAGMENT_REGISTRATION_STAGE_TAG, FragmentManager.POP_BACK_STACK_INCLUSIVE) + when { + vectorFeatures.isOnboardingCombinedRegisterEnabled() -> addRegistrationStageFragmentToBackstack( + FtueAuthPhoneConfirmationFragment::class.java, + FtueAuthPhoneConfirmationFragmentArgument(msisdn), + ) + else -> addRegistrationStageFragmentToBackstack( + FtueAuthGenericTextInputFormFragment::class.java, + FtueAuthGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.ConfirmMsisdn, true, msisdn), + ) + } + } + private fun onTerms(stage: Stage.Terms) { when { vectorFeatures.isOnboardingCombinedRegisterEnabled() -> addRegistrationStageFragmentToBackstack( diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/PhoneNumberParser.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/PhoneNumberParser.kt new file mode 100644 index 0000000000..6a46a466eb --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/PhoneNumberParser.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.onboarding.ftueauth + +import com.google.i18n.phonenumbers.NumberParseException +import com.google.i18n.phonenumbers.PhoneNumberUtil +import javax.inject.Inject + +class PhoneNumberParser @Inject constructor( + private val phoneNumberUtil: PhoneNumberUtil +) { + + fun parseInternationalNumber(rawPhoneNumber: String): Result { + return when { + rawPhoneNumber.doesNotStartWith("+") -> Result.ErrorMissingInternationalCode + else -> parseNumber(rawPhoneNumber) + } + } + + private fun parseNumber(rawPhoneNumber: String) = try { + val phoneNumber = phoneNumberUtil.parse(rawPhoneNumber, null) + Result.Success(phoneNumberUtil.getRegionCodeForCountryCode(phoneNumber.countryCode), rawPhoneNumber) + } catch (e: NumberParseException) { + Result.ErrorInvalidNumber + } + + sealed interface Result { + object ErrorMissingInternationalCode : Result + object ErrorInvalidNumber : Result + data class Success(val countryCode: String, val phoneNumber: String) : Result + } + + private fun String.doesNotStartWith(input: String) = !startsWith(input) +} diff --git a/vector/src/main/java/im/vector/app/features/poll/PollViewState.kt b/vector/src/main/java/im/vector/app/features/poll/PollViewState.kt index 0f01d58c96..ecbee7438a 100644 --- a/vector/src/main/java/im/vector/app/features/poll/PollViewState.kt +++ b/vector/src/main/java/im/vector/app/features/poll/PollViewState.kt @@ -19,8 +19,8 @@ package im.vector.app.features.poll import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState data class PollViewState( - val question: String, - val totalVotes: String, - val canVote: Boolean, - val optionViewStates: List?, + val question: String, + val votesStatus: String, + val canVote: Boolean, + val optionViewStates: List?, ) diff --git a/vector/src/main/java/im/vector/app/features/rageshake/BugReportActivity.kt b/vector/src/main/java/im/vector/app/features/rageshake/BugReportActivity.kt index c761c498d4..c7f549c5cb 100755 --- a/vector/src/main/java/im/vector/app/features/rageshake/BugReportActivity.kt +++ b/vector/src/main/java/im/vector/app/features/rageshake/BugReportActivity.kt @@ -28,6 +28,7 @@ import com.airbnb.mvrx.withState import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.databinding.ActivityBugReportBinding import org.matrix.android.sdk.api.extensions.tryOrNull import timber.log.Timber @@ -36,7 +37,9 @@ import timber.log.Timber * Form to send a bug report. */ @AndroidEntryPoint -class BugReportActivity : VectorBaseActivity() { +class BugReportActivity : + VectorBaseActivity(), + VectorMenuProvider { override fun getBinding() = ActivityBugReportBinding.inflate(layoutInflater) @@ -120,29 +123,27 @@ class BugReportActivity : VectorBaseActivity() { override fun getMenuRes() = R.menu.bug_report - override fun onPrepareOptionsMenu(menu: Menu): Boolean { + override fun handlePrepareMenu(menu: Menu) { menu.findItem(R.id.ic_action_send_bug_report)?.let { val isValid = !views.bugReportMaskView.isVisible it.isEnabled = isValid it.icon.alpha = if (isValid) 255 else 100 } - - return super.onPrepareOptionsMenu(menu) } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { R.id.ic_action_send_bug_report -> { if (views.bugReportEditText.text.toString().trim().length >= 10) { sendBugReport() } else { views.bugReportTextInputLayout.error = getString(R.string.bug_report_error_too_short) } - return true + true } + else -> false } - return super.onOptionsItemSelected(item) } /** diff --git a/vector/src/main/java/im/vector/app/features/reactions/EmojiReactionPickerActivity.kt b/vector/src/main/java/im/vector/app/features/reactions/EmojiReactionPickerActivity.kt index f47b5b2411..f9be57b13f 100644 --- a/vector/src/main/java/im/vector/app/features/reactions/EmojiReactionPickerActivity.kt +++ b/vector/src/main/java/im/vector/app/features/reactions/EmojiReactionPickerActivity.kt @@ -21,7 +21,6 @@ import android.content.Intent import android.graphics.Typeface import android.util.TypedValue import android.view.Menu -import android.view.MenuInflater import android.view.MenuItem import android.widget.SearchView import androidx.core.view.isVisible @@ -33,6 +32,7 @@ import im.vector.app.EmojiCompatFontProvider import im.vector.app.R import im.vector.app.core.extensions.observeEvent import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.databinding.ActivityEmojiReactionPickerBinding import im.vector.app.features.reactions.data.EmojiDataSource import im.vector.lib.core.utils.flow.throttleFirst @@ -48,13 +48,17 @@ import javax.inject.Inject * TODO Finish Refactor to vector base activity */ @AndroidEntryPoint -class EmojiReactionPickerActivity : VectorBaseActivity(), - EmojiCompatFontProvider.FontProviderListener { +class EmojiReactionPickerActivity : + VectorBaseActivity(), + EmojiCompatFontProvider.FontProviderListener, + VectorMenuProvider { lateinit var viewModel: EmojiChooserViewModel override fun getMenuRes() = R.menu.menu_emoji_reaction_picker + override fun handleMenuItemSelected(item: MenuItem) = false + override fun getBinding() = ActivityEmojiReactionPickerBinding.inflate(layoutInflater) override fun getCoordinatorLayout() = views.coordinatorLayout @@ -138,10 +142,7 @@ class EmojiReactionPickerActivity : VectorBaseActivity searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { @@ -175,7 +176,6 @@ class EmojiReactionPickerActivity : VectorBaseActivity(), - PublicRoomsController.Callback { + PublicRoomsController.Callback, + VectorMenuProvider { private val viewModel: RoomDirectoryViewModel by activityViewModel() private lateinit var sharedActionViewModel: RoomDirectorySharedActionViewModel @@ -105,14 +107,13 @@ class PublicRoomsFragment @Inject constructor( super.onDestroyView() } - override fun onOptionsItemSelected(item: MenuItem): Boolean { + override fun handleMenuItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.menu_room_directory_change_protocol -> { sharedActionViewModel.post(RoomDirectorySharedAction.ChangeProtocol) true } - else -> - super.onOptionsItemSelected(item) + else -> false } } diff --git a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileFragment.kt b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileFragment.kt index b82da8aa55..88a27f246c 100644 --- a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileFragment.kt @@ -42,6 +42,7 @@ import im.vector.app.core.extensions.copyOnLongClick import im.vector.app.core.extensions.setTextOrHide import im.vector.app.core.platform.StateView import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.utils.startSharePlainTextIntent import im.vector.app.databinding.DialogBaseEditTextBinding import im.vector.app.databinding.DialogShareQrCodeBinding @@ -74,7 +75,8 @@ class RoomMemberProfileFragment @Inject constructor( private val roomDetailPendingActionStore: RoomDetailPendingActionStore, private val matrixItemColorProvider: MatrixItemColorProvider ) : VectorBaseFragment(), - RoomMemberProfileController.Callback { + RoomMemberProfileController.Callback, + VectorMenuProvider { private lateinit var headerViews: ViewStubRoomMemberProfileHeaderBinding @@ -160,14 +162,14 @@ class RoomMemberProfileFragment @Inject constructor( } } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { R.id.roomMemberProfileShareAction -> { viewModel.handle(RoomMemberProfileAction.ShareRoomMemberProfile) - return true + true } + else -> false } - return super.onOptionsItemSelected(item) } private fun handleStartVerification(startVerification: RoomMemberProfileViewEvents.StartVerification) { diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt index 1b9912d2e0..2f8128b7af 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt @@ -39,6 +39,7 @@ import im.vector.app.core.extensions.configureWith import im.vector.app.core.extensions.copyOnLongClick import im.vector.app.core.extensions.setTextOrHide import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.utils.copyToClipboard import im.vector.app.core.utils.startSharePlainTextIntent import im.vector.app.databinding.FragmentMatrixProfileBinding @@ -70,7 +71,8 @@ class RoomProfileFragment @Inject constructor( private val roomDetailPendingActionStore: RoomDetailPendingActionStore, ) : VectorBaseFragment(), - RoomProfileController.Callback { + RoomProfileController.Callback, + VectorMenuProvider { private lateinit var headerViews: ViewStubRoomProfileHeaderBinding @@ -170,14 +172,14 @@ class RoomProfileFragment @Inject constructor( headerViews.roomProfileAliasView.copyOnLongClick() } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { R.id.roomProfileShareAction -> { roomProfileViewModel.handle(RoomProfileAction.ShareRoomProfile) - return true + true } + else -> false } - return super.onOptionsItemSelected(item) } private fun handleQuickActions(action: RoomListQuickActionsSharedAction) = when (action) { diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/settings/RoomSettingsFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/settings/RoomSettingsFragment.kt index 4f92524efc..45c8461fa7 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/settings/RoomSettingsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/settings/RoomSettingsFragment.kt @@ -36,6 +36,7 @@ import im.vector.app.core.extensions.configureWith import im.vector.app.core.intent.getFilenameFromUri import im.vector.app.core.platform.OnBackPressed import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.resources.ColorProvider import im.vector.app.core.time.Clock import im.vector.app.core.utils.toast @@ -64,7 +65,8 @@ class RoomSettingsFragment @Inject constructor( VectorBaseFragment(), RoomSettingsController.Callback, OnBackPressed, - GalleryOrCameraDialogHelper.Listener { + GalleryOrCameraDialogHelper.Listener, + VectorMenuProvider { private val viewModel: RoomSettingsViewModel by fragmentViewModel() private lateinit var roomProfileSharedActionViewModel: RoomProfileSharedActionViewModel @@ -139,18 +141,20 @@ class RoomSettingsFragment @Inject constructor( super.onDestroyView() } - override fun onPrepareOptionsMenu(menu: Menu) { + override fun handlePrepareMenu(menu: Menu) { withState(viewModel) { state -> menu.findItem(R.id.roomSettingsSaveAction).isVisible = state.showSaveAction } - super.onPrepareOptionsMenu(menu) } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.roomSettingsSaveAction) { - viewModel.handle(RoomSettingsAction.Save) + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.roomSettingsSaveAction -> { + viewModel.handle(RoomSettingsAction.Save) + true + } + else -> false } - return super.onOptionsItemSelected(item) } override fun invalidate() = withState(viewModel) { viewState -> 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 6d91dc3348..ea039f0ed8 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 @@ -168,6 +168,8 @@ class VectorPreferences @Inject constructor( private const val SETTINGS_DEVELOPER_MODE_FAIL_FAST_PREFERENCE_KEY = "SETTINGS_DEVELOPER_MODE_FAIL_FAST_PREFERENCE_KEY" private const val SETTINGS_DEVELOPER_MODE_SHOW_INFO_ON_SCREEN_KEY = "SETTINGS_DEVELOPER_MODE_SHOW_INFO_ON_SCREEN_KEY" + const val SETTINGS_LABS_MSC3061_SHARE_KEYS_HISTORY = "SETTINGS_LABS_MSC3061_SHARE_KEYS_HISTORY" + // SETTINGS_LABS_HIDE_TECHNICAL_E2E_ERRORS private const val SETTINGS_LABS_SHOW_COMPLETE_HISTORY_IN_ENCRYPTED_ROOM = "SETTINGS_LABS_SHOW_COMPLETE_HISTORY_IN_ENCRYPTED_ROOM" const val SETTINGS_LABS_UNREAD_NOTIFICATIONS_AS_TAB = "SETTINGS_LABS_UNREAD_NOTIFICATIONS_AS_TAB" diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsLabsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsLabsFragment.kt index 3b9fdc5e55..70908d7560 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsLabsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsLabsFragment.kt @@ -20,6 +20,7 @@ import android.os.Bundle import android.text.method.LinkMovementMethod import android.widget.TextView import androidx.preference.Preference +import androidx.preference.SwitchPreference import com.google.android.material.dialog.MaterialAlertDialogBuilder import im.vector.app.R import im.vector.app.core.preference.VectorSwitchPreference @@ -57,6 +58,17 @@ class VectorSettingsLabsFragment @Inject constructor( false } } + + findPreference(VectorPreferences.SETTINGS_LABS_MSC3061_SHARE_KEYS_HISTORY)?.let { pref -> + // ensure correct default + pref.isChecked = session.cryptoService().isShareKeysOnInviteEnabled() + + pref.onPreferenceClickListener = Preference.OnPreferenceClickListener { + session.cryptoService().enableShareKeyOnInvite(pref.isChecked) + MainActivity.restartApp(requireActivity(), MainActivityArgs(clearCache = true)) + true + } + } } /** diff --git a/vector/src/main/java/im/vector/app/features/settings/devtools/KeyRequestsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devtools/KeyRequestsFragment.kt index 4721fa7bfb..5684e941f1 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devtools/KeyRequestsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devtools/KeyRequestsFragment.kt @@ -26,8 +26,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter -import androidx.viewpager2.widget.ViewPager2 -import androidx.viewpager2.widget.ViewPager2.SCROLL_STATE_IDLE import com.airbnb.mvrx.Loading import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState @@ -36,6 +34,7 @@ import im.vector.app.R import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.safeOpenOutputStream import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.time.Clock import im.vector.app.core.utils.selectTxtFileToWrite import im.vector.app.databinding.FragmentDevtoolKeyrequestsBinding @@ -44,7 +43,8 @@ import javax.inject.Inject class KeyRequestsFragment @Inject constructor( private val clock: Clock, -) : VectorBaseFragment() { +) : VectorBaseFragment(), + VectorMenuProvider { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentDevtoolKeyrequestsBinding { return FragmentDevtoolKeyrequestsBinding.inflate(inflater, container, false) @@ -61,19 +61,6 @@ class KeyRequestsFragment @Inject constructor( override fun getMenuRes(): Int = R.menu.menu_audit - private val pageAdapterListener = object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - invalidateOptionsMenu() - } - - override fun onPageScrollStateChanged(state: Int) { - childFragmentManager.fragments.forEach { - it.setHasOptionsMenu(state == SCROLL_STATE_IDLE) - } - invalidateOptionsMenu() - } - } - override fun invalidate() = withState(viewModel) { when (it.exporting) { is Loading -> views.exportWaitingView.isVisible = true @@ -81,16 +68,10 @@ class KeyRequestsFragment @Inject constructor( } } - override fun onDestroy() { - invalidateOptionsMenu() - super.onDestroy() - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) mPagerAdapter = KeyReqPagerAdapter(this) views.devToolKeyRequestPager.adapter = mPagerAdapter - views.devToolKeyRequestPager.registerOnPageChangeCallback(pageAdapterListener) TabLayoutMediator(views.devToolKeyRequestTabs, views.devToolKeyRequestPager) { tab, position -> when (position) { @@ -119,25 +100,26 @@ class KeyRequestsFragment @Inject constructor( } override fun onDestroyView() { - views.devToolKeyRequestPager.unregisterOnPageChangeCallback(pageAdapterListener) mPagerAdapter = null super.onDestroyView() } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.audit_export) { - selectTxtFileToWrite( - activity = requireActivity(), - activityResultLauncher = epxortAuditForActivityResult, - defaultFileName = "audit-export_${clock.epochMillis()}.txt", - chooserHint = "Export Audit" - ) - return true + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.audit_export -> { + selectTxtFileToWrite( + activity = requireActivity(), + activityResultLauncher = exportAuditForActivityResult, + defaultFileName = "audit-export_${clock.epochMillis()}.txt", + chooserHint = "Export Audit" + ) + true + } + else -> false } - return super.onOptionsItemSelected(item) } - private val epxortAuditForActivityResult = registerStartForActivityResult { activityResult -> + private val exportAuditForActivityResult = registerStartForActivityResult { activityResult -> if (activityResult.resultCode == Activity.RESULT_OK) { val uri = activityResult.data?.data if (uri != null) { diff --git a/vector/src/main/java/im/vector/app/features/settings/push/PushGatewaysFragment.kt b/vector/src/main/java/im/vector/app/features/settings/push/PushGatewaysFragment.kt index 82462d7cc0..da06f067c6 100644 --- a/vector/src/main/java/im/vector/app/features/settings/push/PushGatewaysFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/push/PushGatewaysFragment.kt @@ -29,6 +29,7 @@ import im.vector.app.R import im.vector.app.core.extensions.cleanup import im.vector.app.core.extensions.configureWith import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.databinding.FragmentGenericRecyclerBinding import org.matrix.android.sdk.api.session.pushers.Pusher import javax.inject.Inject @@ -36,7 +37,8 @@ import javax.inject.Inject // Referenced in vector_settings_notifications.xml class PushGatewaysFragment @Inject constructor( private val epoxyController: PushGateWayController -) : VectorBaseFragment() { +) : VectorBaseFragment(), + VectorMenuProvider { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentGenericRecyclerBinding { return FragmentGenericRecyclerBinding.inflate(inflater, container, false) @@ -46,14 +48,13 @@ class PushGatewaysFragment @Inject constructor( override fun getMenuRes() = R.menu.menu_push_gateways - override fun onOptionsItemSelected(item: MenuItem): Boolean { + override fun handleMenuItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.refresh -> { viewModel.handle(PushGatewayAction.Refresh) true } - else -> - super.onOptionsItemSelected(item) + else -> false } } diff --git a/vector/src/main/java/im/vector/app/features/share/IncomingShareFragment.kt b/vector/src/main/java/im/vector/app/features/share/IncomingShareFragment.kt index 6baedcd125..a113c8105d 100644 --- a/vector/src/main/java/im/vector/app/features/share/IncomingShareFragment.kt +++ b/vector/src/main/java/im/vector/app/features/share/IncomingShareFragment.kt @@ -17,7 +17,6 @@ package im.vector.app.features.share import android.app.Activity -import android.content.ClipDescription import android.content.Intent import android.os.Bundle import android.view.LayoutInflater @@ -38,10 +37,9 @@ import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.databinding.FragmentIncomingShareBinding import im.vector.app.features.analytics.plan.ViewRoom -import im.vector.app.features.attachments.AttachmentsHelper +import im.vector.app.features.attachments.ShareIntentHandler import im.vector.app.features.attachments.preview.AttachmentsPreviewActivity import im.vector.app.features.attachments.preview.AttachmentsPreviewArgs -import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.getRoomSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import javax.inject.Inject @@ -52,13 +50,12 @@ import javax.inject.Inject */ class IncomingShareFragment @Inject constructor( private val incomingShareController: IncomingShareController, - private val sessionHolder: ActiveSessionHolder + private val sessionHolder: ActiveSessionHolder, + private val shareIntentHandler: ShareIntentHandler, ) : VectorBaseFragment(), - AttachmentsHelper.Callback, IncomingShareController.Callback { - private lateinit var attachmentsHelper: AttachmentsHelper private val viewModel: IncomingShareViewModel by fragmentViewModel() override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentIncomingShareBinding { @@ -75,7 +72,6 @@ class IncomingShareFragment @Inject constructor( super.onViewCreated(view, savedInstanceState) setupRecyclerView() setupToolbar(views.incomingShareToolbar) - attachmentsHelper = AttachmentsHelper(requireContext(), this).register() viewModel.observeViewEvents { when (it) { @@ -88,20 +84,15 @@ class IncomingShareFragment @Inject constructor( val intent = vectorBaseActivity.intent val isShareManaged = when (intent?.action) { Intent.ACTION_SEND -> { - var isShareManaged = attachmentsHelper.handleShareIntent(requireContext(), intent) - if (!isShareManaged) { - isShareManaged = handleTextShare(intent) - } - + val isShareManaged = handleIncomingShareIntent(intent) // Direct share if (intent.hasExtra(Intent.EXTRA_SHORTCUT_ID)) { val roomId = intent.getStringExtra(Intent.EXTRA_SHORTCUT_ID)!! sessionHolder.getSafeActiveSession()?.getRoomSummary(roomId)?.let { viewModel.handle(IncomingShareAction.ShareToRoom(it)) } } - isShareManaged } - Intent.ACTION_SEND_MULTIPLE -> attachmentsHelper.handleShareIntent(requireContext(), intent) + Intent.ACTION_SEND_MULTIPLE -> handleIncomingShareIntent(intent) else -> false } @@ -124,6 +115,19 @@ class IncomingShareFragment @Inject constructor( } } + private fun handleIncomingShareIntent(intent: Intent) = shareIntentHandler.handleIncomingShareIntent( + requireContext(), + intent, + onFile = { + val sharedData = SharedData.Attachments(it) + viewModel.handle(IncomingShareAction.UpdateSharedData(sharedData)) + }, + onPlainText = { + val sharedData = SharedData.Text(it) + viewModel.handle(IncomingShareAction.UpdateSharedData(sharedData)) + } + ) + private fun handleMultipleRoomsShareDone(viewEvent: IncomingShareViewEvents.MultipleRoomsShareDone) { requireActivity().let { navigator.openRoom( @@ -173,34 +177,11 @@ class IncomingShareFragment @Inject constructor( incomingShareController.callback = this } - override fun onContentAttachmentsReady(attachments: List) { - val sharedData = SharedData.Attachments(attachments) - viewModel.handle(IncomingShareAction.UpdateSharedData(sharedData)) - } - - override fun onAttachmentsProcessFailed() { - cannotManageShare(R.string.error_handling_incoming_share) - } - private fun cannotManageShare(@StringRes messageResId: Int) { Toast.makeText(requireContext(), messageResId, Toast.LENGTH_LONG).show() requireActivity().finish() } - private fun handleTextShare(intent: Intent): Boolean { - if (intent.type == ClipDescription.MIMETYPE_TEXT_PLAIN) { - val sharedText = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString() - return if (sharedText.isNullOrEmpty()) { - false - } else { - val sharedData = SharedData.Text(sharedText) - viewModel.handle(IncomingShareAction.UpdateSharedData(sharedData)) - true - } - } - return false - } - private fun showConfirmationDialog(roomSummary: RoomSummary, sharedData: SharedData) { MaterialAlertDialogBuilder(requireActivity()) .setTitle(R.string.send_attachment) diff --git a/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutController.kt b/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutController.kt index 1214ac6819..265cf3199e 100644 --- a/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutController.kt +++ b/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutController.kt @@ -18,6 +18,7 @@ package im.vector.app.features.signout.soft import com.airbnb.epoxy.EpoxyController import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Incomplete import com.airbnb.mvrx.Loading import com.airbnb.mvrx.Success import com.airbnb.mvrx.Uninitialized @@ -35,6 +36,7 @@ import im.vector.app.features.signout.soft.epoxy.loginRedButtonItem import im.vector.app.features.signout.soft.epoxy.loginTextItem import im.vector.app.features.signout.soft.epoxy.loginTitleItem import im.vector.app.features.signout.soft.epoxy.loginTitleSmallItem +import org.matrix.android.sdk.api.auth.LoginType import javax.inject.Inject class SoftLogoutController @Inject constructor( @@ -91,55 +93,76 @@ class SoftLogoutController @Inject constructor( } } - private fun buildForm(state: SoftLogoutViewState) { + private fun buildForm(state: SoftLogoutViewState) = when (state.asyncHomeServerLoginFlowRequest) { + is Fail -> buildLoginErrorWithRetryItem(state.asyncHomeServerLoginFlowRequest.error) + is Success -> buildLoginSuccessItem(state) + is Loading, Uninitialized -> buildLoadingItem() + is Incomplete -> Unit + } + + private fun buildLoadingItem() { + loadingItem { + id("loading") + } + } + + private fun buildLoginErrorWithRetryItem(error: Throwable) { val host = this - when (state.asyncHomeServerLoginFlowRequest) { - Uninitialized, - is Loading -> { - loadingItem { - id("loading") - } - } - is Fail -> { - loginErrorWithRetryItem { - id("errorRetry") - text(host.errorFormatter.toHumanReadable(state.asyncHomeServerLoginFlowRequest.error)) - listener { host.listener?.retry() } - } - } - is Success -> { - when (state.asyncHomeServerLoginFlowRequest.invoke()) { - LoginMode.Password -> { - loginPasswordFormItem { - id("passwordForm") - stringProvider(host.stringProvider) - passwordValue(state.enteredPassword) - submitEnabled(state.enteredPassword.isNotEmpty()) - onPasswordEdited { host.listener?.passwordEdited(it) } - errorText((state.asyncLoginAction as? Fail)?.error?.let { host.errorFormatter.toHumanReadable(it) }) - forgetPasswordClickListener { host.listener?.forgetPasswordClicked() } - submitClickListener { host.listener?.submit() } - } - } - is LoginMode.Sso -> { - loginCenterButtonItem { - id("sso") - text(host.stringProvider.getString(R.string.login_signin_sso)) - listener { host.listener?.signinFallbackSubmit() } - } - } - is LoginMode.SsoAndPassword -> { - } - LoginMode.Unsupported -> { - loginCenterButtonItem { - id("fallback") - text(host.stringProvider.getString(R.string.login_signin)) - listener { host.listener?.signinFallbackSubmit() } - } - } - LoginMode.Unknown -> Unit // Should not happen - } - } + loginErrorWithRetryItem { + id("errorRetry") + text(host.errorFormatter.toHumanReadable(error)) + listener { host.listener?.retry() } + } + } + + private fun buildLoginSuccessItem(state: SoftLogoutViewState) = when (state.asyncHomeServerLoginFlowRequest.invoke()) { + LoginMode.Password -> buildLoginPasswordForm(state) + is LoginMode.Sso -> buildLoginSSOForm() + is LoginMode.SsoAndPassword -> disambiguateLoginSSOAndPasswordForm(state) + LoginMode.Unsupported -> buildLoginUnsupportedForm() + LoginMode.Unknown, null -> Unit // Should not happen + } + + private fun buildLoginPasswordForm(state: SoftLogoutViewState) { + val host = this + loginPasswordFormItem { + id("passwordForm") + stringProvider(host.stringProvider) + passwordValue(state.enteredPassword) + submitEnabled(state.enteredPassword.isNotEmpty()) + onPasswordEdited { host.listener?.passwordEdited(it) } + errorText((state.asyncLoginAction as? Fail)?.error?.let { host.errorFormatter.toHumanReadable(it) }) + forgetPasswordClickListener { host.listener?.forgetPasswordClicked() } + submitClickListener { host.listener?.submit() } + } + } + + private fun buildLoginSSOForm() { + val host = this + loginCenterButtonItem { + id("sso") + text(host.stringProvider.getString(R.string.login_signin_sso)) + listener { host.listener?.signinFallbackSubmit() } + } + } + + private fun disambiguateLoginSSOAndPasswordForm(state: SoftLogoutViewState) { + when (state.loginType) { + LoginType.PASSWORD -> buildLoginPasswordForm(state) + LoginType.SSO -> buildLoginSSOForm() + LoginType.DIRECT, + LoginType.CUSTOM, + LoginType.UNSUPPORTED -> buildLoginUnsupportedForm() + LoginType.UNKNOWN -> Unit + } + } + + private fun buildLoginUnsupportedForm() { + val host = this + loginCenterButtonItem { + id("fallback") + text(host.stringProvider.getString(R.string.login_signin)) + listener { host.listener?.signinFallbackSubmit() } } } diff --git a/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutViewModel.kt b/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutViewModel.kt index 34d001caad..9d0580638b 100644 --- a/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutViewModel.kt @@ -35,14 +35,12 @@ import im.vector.app.core.platform.VectorViewModel import im.vector.app.features.login.LoginMode import kotlinx.coroutines.launch import org.matrix.android.sdk.api.auth.AuthenticationService +import org.matrix.android.sdk.api.auth.LoginType import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.getUser import timber.log.Timber -/** - * TODO Test push: disable the pushers? - */ class SoftLogoutViewModel @AssistedInject constructor( @Assisted initialState: SoftLogoutViewState, private val session: Session, @@ -70,7 +68,8 @@ class SoftLogoutViewModel @AssistedInject constructor( userId = userId, deviceId = session.sessionParams.deviceId.orEmpty(), userDisplayName = session.getUser(userId)?.displayName ?: userId, - hasUnsavedKeys = session.hasUnsavedKeys() + hasUnsavedKeys = session.hasUnsavedKeys(), + loginType = session.sessionParams.loginType, ) } else { SoftLogoutViewState( @@ -78,7 +77,8 @@ class SoftLogoutViewModel @AssistedInject constructor( userId = "", deviceId = "", userDisplayName = "", - hasUnsavedKeys = false + hasUnsavedKeys = false, + loginType = LoginType.UNKNOWN, ) } } diff --git a/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutViewState.kt b/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutViewState.kt index 511711ab2f..28c8273412 100644 --- a/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutViewState.kt +++ b/vector/src/main/java/im/vector/app/features/signout/soft/SoftLogoutViewState.kt @@ -22,6 +22,7 @@ import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.Success import com.airbnb.mvrx.Uninitialized import im.vector.app.features.login.LoginMode +import org.matrix.android.sdk.api.auth.LoginType data class SoftLogoutViewState( val asyncHomeServerLoginFlowRequest: Async = Uninitialized, @@ -31,7 +32,8 @@ data class SoftLogoutViewState( val deviceId: String, val userDisplayName: String, val hasUnsavedKeys: Boolean, - val enteredPassword: String = "" + val loginType: LoginType, + val enteredPassword: String = "", ) : MavericksState { fun isLoading(): Boolean { diff --git a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryFragment.kt index 4cbbbc876d..3818f4278a 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryFragment.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/explore/SpaceDirectoryFragment.kt @@ -38,6 +38,7 @@ import im.vector.app.core.extensions.configureWith import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.platform.OnBackPressed import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.resources.ColorProvider import im.vector.app.core.utils.colorizeMatchingText import im.vector.app.core.utils.isValidUrl @@ -67,7 +68,8 @@ class SpaceDirectoryFragment @Inject constructor( ) : VectorBaseFragment(), SpaceDirectoryController.InteractionListener, TimelineEventController.UrlClickCallback, - OnBackPressed { + OnBackPressed, + VectorMenuProvider { override fun getMenuRes() = R.menu.menu_space_directory @@ -177,40 +179,41 @@ class SpaceDirectoryFragment @Inject constructor( views.addOrCreateChatRoomButton.isVisible = state.canAddRooms } - override fun onPrepareOptionsMenu(menu: Menu) = withState(viewModel) { state -> - menu.findItem(R.id.spaceAddRoom)?.isVisible = state.canAddRooms - menu.findItem(R.id.spaceCreateRoom)?.isVisible = false // Not yet implemented + override fun handlePrepareMenu(menu: Menu) { + withState(viewModel) { state -> + menu.findItem(R.id.spaceAddRoom)?.isVisible = state.canAddRooms + menu.findItem(R.id.spaceCreateRoom)?.isVisible = false // Not yet implemented - menu.findItem(R.id.spaceSearch)?.let { searchItem -> - val searchView = searchItem.actionView as SearchView - searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String?): Boolean { - return true - } + menu.findItem(R.id.spaceSearch)?.let { searchItem -> + val searchView = searchItem.actionView as SearchView + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + return true + } - override fun onQueryTextChange(newText: String?): Boolean { - onFilterQueryChanged(newText) - return true - } - }) + override fun onQueryTextChange(newText: String?): Boolean { + onFilterQueryChanged(newText) + return true + } + }) + } } - super.onPrepareOptionsMenu(menu) } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { R.id.spaceAddRoom -> { withState(viewModel) { state -> addExistingRooms(state.spaceId) } - return true + true } R.id.spaceCreateRoom -> { // not implemented yet - return true + true } + else -> false } - return super.onOptionsItemSelected(item) } override fun onFilterQueryChanged(query: String?) { diff --git a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedFragment.kt index 071dadb3b4..de1273b8d5 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedFragment.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/leave/SpaceLeaveAdvancedFragment.kt @@ -32,6 +32,7 @@ import im.vector.app.R import im.vector.app.core.extensions.cleanup import im.vector.app.core.extensions.configureWith import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.utils.ToggleableAppBarLayoutBehavior import im.vector.app.databinding.FragmentSpaceLeaveAdvancedBinding import org.matrix.android.sdk.api.session.room.model.RoomSummary @@ -40,7 +41,8 @@ import javax.inject.Inject class SpaceLeaveAdvancedFragment @Inject constructor( val controller: SelectChildrenController ) : VectorBaseFragment(), - SelectChildrenController.Listener { + SelectChildrenController.Listener, + VectorMenuProvider { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?) = FragmentSpaceLeaveAdvancedBinding.inflate(layoutInflater, container, false) @@ -49,6 +51,8 @@ class SpaceLeaveAdvancedFragment @Inject constructor( override fun getMenuRes() = R.menu.menu_space_leave + override fun handleMenuItemSelected(item: MenuItem) = false + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -89,7 +93,7 @@ class SpaceLeaveAdvancedFragment @Inject constructor( } } - override fun onPrepareOptionsMenu(menu: Menu) { + override fun handlePrepareMenu(menu: Menu) { menu.findItem(R.id.menu_space_leave_search)?.let { searchItem -> searchItem.bind( onExpanded = { viewModel.handle(SpaceLeaveAdvanceViewAction.SetFilteringEnabled(isEnabled = true)) }, @@ -97,7 +101,6 @@ class SpaceLeaveAdvancedFragment @Inject constructor( onTextChanged = { viewModel.handle(SpaceLeaveAdvanceViewAction.UpdateFilter(it)) } ) } - super.onPrepareOptionsMenu(menu) } override fun onDestroyView() { diff --git a/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceAddRoomFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceAddRoomFragment.kt index 28d33bd1bf..848c17deb6 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceAddRoomFragment.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceAddRoomFragment.kt @@ -36,6 +36,7 @@ import im.vector.app.R import im.vector.app.core.extensions.cleanup import im.vector.app.core.platform.OnBackPressed import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.databinding.FragmentSpaceAddRoomsBinding import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.debounce @@ -51,7 +52,9 @@ class SpaceAddRoomFragment @Inject constructor( private val roomEpoxyController: AddRoomListController, private val dmEpoxyController: AddRoomListController, ) : VectorBaseFragment(), - OnBackPressed, AddRoomListController.Listener { + OnBackPressed, + AddRoomListController.Listener, + VectorMenuProvider { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?) = FragmentSpaceAddRoomsBinding.inflate(layoutInflater, container, false) @@ -151,17 +154,18 @@ class SpaceAddRoomFragment @Inject constructor( } } - override fun onPrepareOptionsMenu(menu: Menu) { - super.onPrepareOptionsMenu(menu) + override fun handlePrepareMenu(menu: Menu) { menu.findItem(R.id.spaceAddRoomSaveItem).isVisible = saveNeeded } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.spaceAddRoomSaveItem) { - viewModel.handle(SpaceAddRoomActions.Save) - return true + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.spaceAddRoomSaveItem -> { + viewModel.handle(SpaceAddRoomActions.Save) + true + } + else -> false } - return super.onOptionsItemSelected(item) } override fun onDestroyView() { diff --git a/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceSettingsFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceSettingsFragment.kt index b99fe0f025..eb1de4fe60 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceSettingsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/manage/SpaceSettingsFragment.kt @@ -37,6 +37,7 @@ import im.vector.app.core.extensions.configureWith import im.vector.app.core.intent.getFilenameFromUri import im.vector.app.core.platform.OnBackPressed import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.resources.ColorProvider import im.vector.app.core.time.Clock import im.vector.app.core.utils.toast @@ -66,7 +67,8 @@ class SpaceSettingsFragment @Inject constructor( ) : VectorBaseFragment(), SpaceSettingsController.Callback, GalleryOrCameraDialogHelper.Listener, - OnBackPressed { + OnBackPressed, + VectorMenuProvider { private val viewModel: RoomSettingsViewModel by fragmentViewModel() private val sharedViewModel: SpaceManageSharedViewModel by activityViewModel() @@ -111,18 +113,20 @@ class SpaceSettingsFragment @Inject constructor( super.onDestroyView() } - override fun onPrepareOptionsMenu(menu: Menu) { + override fun handlePrepareMenu(menu: Menu) { withState(viewModel) { state -> menu.findItem(R.id.roomSettingsSaveAction).isVisible = state.showSaveAction } - super.onPrepareOptionsMenu(menu) } - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.roomSettingsSaveAction) { - viewModel.handle(RoomSettingsAction.Save) + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.roomSettingsSaveAction -> { + viewModel.handle(RoomSettingsAction.Save) + true + } + else -> false } - return super.onOptionsItemSelected(item) } private fun renderRoomSummary(state: RoomSettingsViewState) { diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragment.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragment.kt index e893fdf0f3..b31833e37c 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragment.kt @@ -23,7 +23,6 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.ScrollView -import androidx.core.view.forEach import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.airbnb.mvrx.activityViewModel @@ -37,6 +36,7 @@ import im.vector.app.core.extensions.configureWith import im.vector.app.core.extensions.hideKeyboard import im.vector.app.core.extensions.setupAsSearch import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.showIdentityServerConsentDialog import im.vector.app.core.utils.startSharePlainTextIntent @@ -55,7 +55,8 @@ class UserListFragment @Inject constructor( private val userListController: UserListController, private val dimensionConverter: DimensionConverter, ) : VectorBaseFragment(), - UserListController.Callback { + UserListController.Callback, + VectorMenuProvider { private val args: UserListFragmentArgs by args() private val viewModel: UserListViewModel by activityViewModel() @@ -113,19 +114,24 @@ class UserListFragment @Inject constructor( super.onDestroyView() } - override fun onPrepareOptionsMenu(menu: Menu) { + override fun handlePrepareMenu(menu: Menu) { + if (args.submitMenuItemId == -1) return withState(viewModel) { val showMenuItem = it.pendingSelections.isNotEmpty() - menu.forEach { menuItem -> - menuItem.isVisible = showMenuItem - } + menu.findItem(args.submitMenuItemId).isVisible = showMenuItem } - super.onPrepareOptionsMenu(menu) } - override fun onOptionsItemSelected(item: MenuItem): Boolean = withState(viewModel) { - sharedActionViewModel.post(UserListSharedAction.OnMenuItemSelected(item.itemId, it.pendingSelections)) - return@withState true + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + args.submitMenuItemId -> { + withState(viewModel) { + sharedActionViewModel.post(UserListSharedAction.OnMenuItemSubmitClick(it.pendingSelections)) + } + true + } + else -> false + } } private fun setupRecyclerView() { diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragmentArgs.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragmentArgs.kt index 795d45272c..d6e55c29ae 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragmentArgs.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListFragmentArgs.kt @@ -23,6 +23,7 @@ import kotlinx.parcelize.Parcelize data class UserListFragmentArgs( val title: String, val menuResId: Int, + val submitMenuItemId: Int, val excludedUserIds: Set? = null, val singleSelection: Boolean = false, val showInviteActions: Boolean = true, diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/UserListSharedAction.kt b/vector/src/main/java/im/vector/app/features/userdirectory/UserListSharedAction.kt index fca771793b..fb63b05e2f 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/UserListSharedAction.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/UserListSharedAction.kt @@ -21,7 +21,7 @@ import im.vector.app.core.platform.VectorSharedAction sealed class UserListSharedAction : VectorSharedAction { object Close : UserListSharedAction() object GoBack : UserListSharedAction() - data class OnMenuItemSelected(val itemId: Int, val selections: Set) : UserListSharedAction() + data class OnMenuItemSubmitClick(val selections: Set) : UserListSharedAction() object OpenPhoneBook : UserListSharedAction() object AddByQrCode : UserListSharedAction() } diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetActivity.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetActivity.kt index 0c2df7856f..954f622801 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetActivity.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetActivity.kt @@ -64,8 +64,6 @@ class WidgetActivity : VectorBaseActivity() { override fun getBinding() = ActivityWidgetBinding.inflate(layoutInflater) - override fun getMenuRes() = R.menu.menu_widget - override fun getTitleRes() = R.string.room_widget_activity_title override fun initUiAndData() { diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt index cbd4b8e1ee..5501031e92 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetFragment.kt @@ -42,6 +42,7 @@ import im.vector.app.R import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.platform.OnBackPressed import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.utils.openUrlInExternalBrowser import im.vector.app.databinding.FragmentRoomWidgetBinding import im.vector.app.features.webview.WebEventListener @@ -68,7 +69,8 @@ class WidgetFragment @Inject constructor( ) : VectorBaseFragment(), WebEventListener, - OnBackPressed { + OnBackPressed, + VectorMenuProvider { private val fragmentArgs: WidgetArgs by args() private val viewModel: WidgetViewModel by activityViewModel() @@ -79,7 +81,6 @@ class WidgetFragment @Inject constructor( override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - setHasOptionsMenu(true) views.widgetWebView.setupForWidget(this) if (fragmentArgs.kind.isAdmin()) { viewModel.getPostAPIMediator().setWebView(views.widgetWebView) @@ -136,53 +137,64 @@ class WidgetFragment @Inject constructor( } } - override fun onPrepareOptionsMenu(menu: Menu) = withState(viewModel) { state -> - val widget = state.asyncWidget() - menu.findItem(R.id.action_edit)?.isVisible = state.widgetKind != WidgetKind.INTEGRATION_MANAGER - if (widget == null) { - menu.findItem(R.id.action_refresh)?.isVisible = false - menu.findItem(R.id.action_widget_open_ext)?.isVisible = false - menu.findItem(R.id.action_delete)?.isVisible = false - menu.findItem(R.id.action_revoke)?.isVisible = false - } else { - menu.findItem(R.id.action_refresh)?.isVisible = true - menu.findItem(R.id.action_widget_open_ext)?.isVisible = true - menu.findItem(R.id.action_delete)?.isVisible = state.canManageWidgets && widget.isAddedByMe - menu.findItem(R.id.action_revoke)?.isVisible = state.status == WidgetStatus.WIDGET_ALLOWED && !widget.isAddedByMe + override fun getMenuRes() = R.menu.menu_widget + + override fun handlePrepareMenu(menu: Menu) { + withState(viewModel) { state -> + val widget = state.asyncWidget() + menu.findItem(R.id.action_edit)?.isVisible = state.widgetKind != WidgetKind.INTEGRATION_MANAGER + if (widget == null) { + menu.findItem(R.id.action_refresh)?.isVisible = false + menu.findItem(R.id.action_widget_open_ext)?.isVisible = false + menu.findItem(R.id.action_delete)?.isVisible = false + menu.findItem(R.id.action_revoke)?.isVisible = false + } else { + menu.findItem(R.id.action_refresh)?.isVisible = true + menu.findItem(R.id.action_widget_open_ext)?.isVisible = true + menu.findItem(R.id.action_delete)?.isVisible = state.canManageWidgets && widget.isAddedByMe + menu.findItem(R.id.action_revoke)?.isVisible = state.status == WidgetStatus.WIDGET_ALLOWED && !widget.isAddedByMe + } } - super.onPrepareOptionsMenu(menu) } - override fun onOptionsItemSelected(item: MenuItem): Boolean = withState(viewModel) { state -> - when (item.itemId) { - R.id.action_edit -> { - navigator.openIntegrationManager( - requireContext(), - integrationManagerActivityResultLauncher, - state.roomId, - state.widgetId, - state.widgetKind.screenId - ) - return@withState true - } - R.id.action_delete -> { - deleteWidget() - return@withState true - } - R.id.action_refresh -> if (state.formattedURL.complete) { - views.widgetWebView.reload() - return@withState true - } - R.id.action_widget_open_ext -> if (state.formattedURL.complete) { - openUrlInExternalBrowser(requireContext(), state.formattedURL.invoke()) - return@withState true - } - R.id.action_revoke -> if (state.status == WidgetStatus.WIDGET_ALLOWED) { - revokeWidget() - return@withState true + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return withState(viewModel) { state -> + return@withState when (item.itemId) { + R.id.action_edit -> { + navigator.openIntegrationManager( + requireContext(), + integrationManagerActivityResultLauncher, + state.roomId, + state.widgetId, + state.widgetKind.screenId + ) + true + } + R.id.action_delete -> { + deleteWidget() + true + } + R.id.action_refresh -> { + if (state.formattedURL.complete) { + views.widgetWebView.reload() + } + true + } + R.id.action_widget_open_ext -> { + if (state.formattedURL.complete) { + openUrlInExternalBrowser(requireContext(), state.formattedURL.invoke()) + } + true + } + R.id.action_revoke -> { + if (state.status == WidgetStatus.WIDGET_ALLOWED) { + revokeWidget() + } + true + } + else -> false } } - return@withState super.onOptionsItemSelected(item) } override fun onBackPressed(toolbarButton: Boolean): Boolean = withState(viewModel) { state -> diff --git a/vector/src/main/res/drawable/ic_ftue_phone.xml b/vector/src/main/res/drawable/ic_ftue_phone.xml new file mode 100644 index 0000000000..53884d6d96 --- /dev/null +++ b/vector/src/main/res/drawable/ic_ftue_phone.xml @@ -0,0 +1,11 @@ + + + diff --git a/vector/src/main/res/layout/fragment_ftue_phone_confirmation.xml b/vector/src/main/res/layout/fragment_ftue_phone_confirmation.xml new file mode 100644 index 0000000000..fe913d3d7d --- /dev/null +++ b/vector/src/main/res/layout/fragment_ftue_phone_confirmation.xml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +