diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 483291de1f..36fd225674 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -37,7 +37,7 @@ jobs: mv towncrier.toml towncrier.toml.bak sed 's/CHANGES\.md/CHANGES_NIGHTLY\.md/' towncrier.toml.bak > towncrier.toml rm towncrier.toml.bak - yes n | towncrier --version nightly + yes n | towncrier build --version nightly - name: Build and upload Gplay Nightly APK run: | ./gradlew assembleGplayNightly appDistributionUploadGplayNightly $CI_GRADLE_ARG_PROPERTIES --stacktrace diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml index d2aa72308d..90f03779a6 100644 --- a/.github/workflows/triage-labelled.yml +++ b/.github/workflows/triage-labelled.yml @@ -248,9 +248,12 @@ jobs: # Skip in forks if: > github.repository == 'vector-im/element-android' && - (contains(github.event.issue.labels.*.name, 'Z-ElementX-Alpha') || - contains(github.event.issue.labels.*.name, 'Z-ElementX-Beta') || - contains(github.event.issue.labels.*.name, 'Z-ElementX')) + (contains(github.event.issue.labels.*.name, 'Z-BBQ-Alpha') || + contains(github.event.issue.labels.*.name, 'Z-BBQ-Beta') || + contains(github.event.issue.labels.*.name, 'Z-BBQ-Release') || + contains(github.event.issue.labels.*.name, 'Z-Banquet-Alpha') || + contains(github.event.issue.labels.*.name, 'Z-Banquet-Beta') || + contains(github.event.issue.labels.*.name, 'Z-Banquet-Release')) steps: - uses: octokit/graphql-action@v2.x with: diff --git a/changelog.d/2585.feature b/changelog.d/2585.feature new file mode 100644 index 0000000000..eedbac1e88 --- /dev/null +++ b/changelog.d/2585.feature @@ -0,0 +1 @@ +FTUE - Enable improved login and register onboarding flows diff --git a/changelog.d/5115.bugfix b/changelog.d/5115.bugfix new file mode 100644 index 0000000000..6b3ca4a7b4 --- /dev/null +++ b/changelog.d/5115.bugfix @@ -0,0 +1 @@ +Stop using unstable names for withheld codes diff --git a/changelog.d/6314.misc b/changelog.d/6314.misc new file mode 100644 index 0000000000..865d965d33 --- /dev/null +++ b/changelog.d/6314.misc @@ -0,0 +1 @@ +Improves performance on search screen by replacing flattenParents with directParentName in RoomSummary diff --git a/changelog.d/6341.bugfix b/changelog.d/6341.bugfix new file mode 100644 index 0000000000..6866d8c89d --- /dev/null +++ b/changelog.d/6341.bugfix @@ -0,0 +1 @@ +Fixed issues with reporting sync state events from different threads diff --git a/changelog.d/6395.bugfix b/changelog.d/6395.bugfix new file mode 100644 index 0000000000..ebc22dc41a --- /dev/null +++ b/changelog.d/6395.bugfix @@ -0,0 +1 @@ +Display specific message when verification QR code is malformed diff --git a/changelog.d/6466.bugfix b/changelog.d/6466.bugfix new file mode 100644 index 0000000000..31fef9f69d --- /dev/null +++ b/changelog.d/6466.bugfix @@ -0,0 +1 @@ +When there is no way to verify a device (no 4S nor other device) propose to reset verification keys diff --git a/changelog.d/6522.feature b/changelog.d/6522.feature new file mode 100644 index 0000000000..fb5e535108 --- /dev/null +++ b/changelog.d/6522.feature @@ -0,0 +1 @@ +Improve lock screen implementation with extra security measures diff --git a/changelog.d/6548.feature b/changelog.d/6548.feature new file mode 100644 index 0000000000..8c40a37063 --- /dev/null +++ b/changelog.d/6548.feature @@ -0,0 +1 @@ +Move initialization of the Session to a background thread. MainActivity is restoring the session now, instead of VectorApplication. Useful when for instance a long migration of a database is required. diff --git a/changelog.d/6607.misc b/changelog.d/6607.misc new file mode 100644 index 0000000000..c56c3fca92 --- /dev/null +++ b/changelog.d/6607.misc @@ -0,0 +1 @@ +[Location sharing] - Small improvements of UI for live diff --git a/changelog.d/6609.misc b/changelog.d/6609.misc new file mode 100644 index 0000000000..bf1a9efe14 --- /dev/null +++ b/changelog.d/6609.misc @@ -0,0 +1 @@ +Live Location Sharing - Reset zoom level while focusing a user diff --git a/changelog.d/6612.misc b/changelog.d/6612.misc new file mode 100644 index 0000000000..ba80ff3e9d --- /dev/null +++ b/changelog.d/6612.misc @@ -0,0 +1 @@ +Fix a typo in the terms and conditions step during registration. diff --git a/changelog.d/6616.feature b/changelog.d/6616.feature new file mode 100644 index 0000000000..d013771764 --- /dev/null +++ b/changelog.d/6616.feature @@ -0,0 +1 @@ +Support element call widget diff --git a/changelog.d/6620.feature b/changelog.d/6620.feature new file mode 100644 index 0000000000..ad192edd5c --- /dev/null +++ b/changelog.d/6620.feature @@ -0,0 +1 @@ +FTUE - Test session feedback diff --git a/changelog.d/6621.feature b/changelog.d/6621.feature new file mode 100644 index 0000000000..b893c968b4 --- /dev/null +++ b/changelog.d/6621.feature @@ -0,0 +1 @@ +FTUE - Improved reset password error message diff --git a/changelog.d/6622.feature b/changelog.d/6622.feature new file mode 100644 index 0000000000..b3c8791ff0 --- /dev/null +++ b/changelog.d/6622.feature @@ -0,0 +1 @@ +FTUE - Allows the email address to be changed during the verification process diff --git a/changelog.d/6625.misc b/changelog.d/6625.misc new file mode 100644 index 0000000000..68a58c38fa --- /dev/null +++ b/changelog.d/6625.misc @@ -0,0 +1 @@ +[Location sharing] - OnTap on the top live status bar, display the expanded map view diff --git a/changelog.d/6634.bugfix b/changelog.d/6634.bugfix new file mode 100644 index 0000000000..e135795ec2 --- /dev/null +++ b/changelog.d/6634.bugfix @@ -0,0 +1 @@ +Put EC permission shortcuts behind labs flag (PSG-630) diff --git a/changelog.d/6635.misc b/changelog.d/6635.misc new file mode 100644 index 0000000000..6546659d11 --- /dev/null +++ b/changelog.d/6635.misc @@ -0,0 +1 @@ +[Location Share] - Expanded map state when no more live location shares diff --git a/dependencies.gradle b/dependencies.gradle index 97b4ad2ea3..e8e39dc5f7 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -15,6 +15,7 @@ def gradle = "7.1.3" def kotlin = "1.6.21" def kotlinCoroutines = "1.6.4" def dagger = "2.42" +def appDistribution = "16.0.0-beta03" def retrofit = "2.9.0" def arrow = "0.8.2" def markwon = "4.6.2" @@ -49,9 +50,7 @@ ext.libs = [ 'coroutinesTest' : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinCoroutines" ], androidx : [ - 'annotation' : "androidx.annotation:annotation: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", 'core' : "androidx.core:core-ktx:1.8.0", @@ -83,7 +82,9 @@ ext.libs = [ 'transition' : "androidx.transition:transition:1.2.0", ], google : [ - 'material' : "com.google.android.material:material:1.6.1" + 'material' : "com.google.android.material:material:1.6.1", + 'appdistributionApi' : "com.google.firebase:firebase-appdistribution-api-ktx:$appDistribution", + 'appdistribution' : "com.google.firebase:firebase-appdistribution:$appDistribution", ], dagger : [ 'dagger' : "com.google.dagger:dagger:$dagger", diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 41d9927a4d..249e5832f0 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e1e0c8dc42..ef80eb5051 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=e6d864e3b5bc05cc62041842b306383fc1fefcec359e70cebb1d470a6094ca82 -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-all.zip +distributionSha256Sum=97a52d145762adc241bad7fd18289bf7f6801e08ece6badf80402fe2b9f250b1 +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1b6c787337..a69d9cb6c2 100755 --- a/gradlew +++ b/gradlew @@ -205,6 +205,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/gradlew.bat b/gradlew.bat index ac1b06f938..53a6b238d4 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +75,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/library/ui-styles/src/main/res/values/stylable_location_live_ended_banner_view.xml b/library/ui-styles/src/main/res/values/stylable_location_live_ended_banner_view.xml new file mode 100644 index 0000000000..81e377d39b --- /dev/null +++ b/library/ui-styles/src/main/res/values/stylable_location_live_ended_banner_view.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/WithHeldTests.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/WithHeldTests.kt index ae420a09b3..0aac4297e4 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/WithHeldTests.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/gossiping/WithHeldTests.kt @@ -21,7 +21,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest 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 @@ -47,7 +46,6 @@ import org.matrix.android.sdk.mustFail @RunWith(AndroidJUnit4::class) @FixMethodOrder(MethodSorters.JVM) @LargeTest -@Ignore class WithHeldTests : InstrumentedTest { @get:Rule val rule = RetryTestRule(3) 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 80020665f8..18645fd6d9 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 @@ -610,4 +610,82 @@ class SpaceHierarchyTest : InstrumentedTest { } } } + + @Test + fun testDirectParentNames() = runSessionTest(context()) { commonTestHelper -> + val aliceSession = commonTestHelper.createAccount("Alice", SessionTestParams(true)) + + val spaceAInfo = createPublicSpace( + commonTestHelper, + aliceSession, "SpaceA", + listOf( + Triple("A1", true /*auto-join*/, true/*canonical*/), + Triple("A2", true, true) + ) + ) + + val spaceBInfo = createPublicSpace( + commonTestHelper, + aliceSession, "SpaceB", + listOf( + Triple("B1", true /*auto-join*/, true/*canonical*/), + Triple("B2", true, true), + Triple("B3", true, true) + ) + ) + + // also add B1 in space A + + val B1roomId = spaceBInfo.roomIds.first() + val viaServers = listOf(aliceSession.sessionParams.homeServerHost ?: "") + + val spaceA = aliceSession.spaceService().getSpace(spaceAInfo.spaceId) + val spaceB = aliceSession.spaceService().getSpace(spaceBInfo.spaceId) + commonTestHelper.runBlockingTest { + spaceA!!.addChildren(B1roomId, viaServers, null, true) + } + + commonTestHelper.waitWithLatch { latch -> + commonTestHelper.retryPeriodicallyWithLatch(latch) { + val roomSummary = aliceSession.getRoomSummary(B1roomId) + roomSummary != null && + roomSummary.directParentNames.size == 2 && + roomSummary.directParentNames.contains(spaceA!!.spaceSummary()!!.name) && + roomSummary.directParentNames.contains(spaceB!!.spaceSummary()!!.name) + } + } + + commonTestHelper.waitWithLatch { latch -> + commonTestHelper.retryPeriodicallyWithLatch(latch) { + val roomSummary = aliceSession.getRoomSummary(spaceAInfo.roomIds.first()) + roomSummary != null && + roomSummary.directParentNames.size == 1 && + roomSummary.directParentNames.contains(spaceA!!.spaceSummary()!!.name) + } + } + + val newAName = "FooBar" + commonTestHelper.runBlockingTest { + spaceA!!.asRoom().stateService().updateName(newAName) + } + + commonTestHelper.waitWithLatch { latch -> + commonTestHelper.retryPeriodicallyWithLatch(latch) { + val roomSummary = aliceSession.getRoomSummary(B1roomId) + roomSummary != null && + roomSummary.directParentNames.size == 2 && + roomSummary.directParentNames.contains(newAName) && + roomSummary.directParentNames.contains(spaceB!!.spaceSummary()!!.name) + } + } + + commonTestHelper.waitWithLatch { latch -> + commonTestHelper.retryPeriodicallyWithLatch(latch) { + val roomSummary = aliceSession.getRoomSummary(spaceAInfo.roomIds.first()) + roomSummary != null && + roomSummary.directParentNames.size == 1 && + roomSummary.directParentNames.contains(newAName) + } + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Extensions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Extensions.kt index d3cc8fc8e4..6e198fb98c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Extensions.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/failure/Extensions.kt @@ -86,6 +86,10 @@ fun Throwable.isInvalidUIAAuth() = this is Failure.ServerError && fun Throwable.isHomeserverUnavailable() = this is Failure.NetworkConnection && this.ioException is UnknownHostException +fun Throwable.isMissingEmailVerification() = this is Failure.ServerError && + error.code == MatrixError.M_UNAUTHORIZED && + error.message == "Unable to get validated threepid" + /** * Try to convert to a RegistrationFlowResponse. Return null in the cases it's not possible */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/securestorage/SecretStoringUtils.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/securestorage/SecretStoringUtils.kt index bd2a1078b2..e701e0f3ba 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/securestorage/SecretStoringUtils.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/securestorage/SecretStoringUtils.kt @@ -180,11 +180,11 @@ class SecretStoringUtils @Inject constructor( is KeyStore.PrivateKeyEntry -> keyEntry.certificate.publicKey else -> throw IllegalStateException("Unknown KeyEntry type.") } - val cipherMode = when { + val cipherAlgorithm = when { buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M -> AES_MODE else -> RSA_MODE } - val cipher = Cipher.getInstance(cipherMode) + val cipher = Cipher.getInstance(cipherAlgorithm) cipher.init(Cipher.ENCRYPT_MODE, key) return cipher } @@ -204,13 +204,17 @@ class SecretStoringUtils @Inject constructor( .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(128) + .setUserAuthenticationRequired(keyNeedsUserAuthentication) .apply { - setUserAuthenticationRequired(keyNeedsUserAuthentication) - if (buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.N) { - setInvalidatedByBiometricEnrollment(true) + if (keyNeedsUserAuthentication) { + buildVersionSdkIntProvider.whenAtLeast(Build.VERSION_CODES.N) { + setInvalidatedByBiometricEnrollment(true) + } + buildVersionSdkIntProvider.whenAtLeast(Build.VERSION_CODES.P) { + setUnlockedDeviceRequired(true) + } } } - .setUserAuthenticationRequired(keyNeedsUserAuthentication) .build() generator.init(keyGenSpec) return generator.generateKey() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt index fa3a9f6acd..8fdbba21c5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt @@ -87,7 +87,10 @@ object EventType { // Key share events const val ROOM_KEY_REQUEST = "m.room_key_request" const val FORWARDED_ROOM_KEY = "m.forwarded_room_key" - const val ROOM_KEY_WITHHELD = "org.matrix.room_key.withheld" + val ROOM_KEY_WITHHELD = StableUnstableId( + stable = "m.room_key.withheld", + unstable = "org.matrix.room_key.withheld" + ) const val REQUEST_SECRET = "m.secret.request" const val SEND_SECRET = "m.secret.send" diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/StableUnstableId.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/StableUnstableId.kt new file mode 100644 index 0000000000..c68a9e47f9 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/StableUnstableId.kt @@ -0,0 +1,24 @@ +/* + * 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.session.events.model + +data class StableUnstableId( + val stable: String, + val unstable: String, +) { + val values = listOf(stable, unstable) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt index 90f3f323b2..ad8106c9c1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/RoomService.kt @@ -243,14 +243,11 @@ interface RoomService { * @param queryParams The filter to use * @param pagedListConfig The paged list configuration (page size, initial load, prefetch distance...) * @param sortOrder defines how to sort the results - * @param getFlattenParents When true, the list of known parents and grand parents summaries will be resolved. - * This can have significant impact on performance, better be used only on manageable list (filtered by displayName, ..). */ fun getFilteredPagedRoomSummariesLive( queryParams: RoomSummaryQueryParams, pagedListConfig: PagedList.Config = defaultPagedListConfig, sortOrder: RoomSortOrder = RoomSortOrder.ACTIVITY, - getFlattenParents: Boolean = false, ): UpdatableLivePageResult /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomSummary.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomSummary.kt index 1ab23b7a11..ff4977491f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomSummary.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/RoomSummary.kt @@ -164,9 +164,9 @@ data class RoomSummary( */ val spaceChildren: List? = null, /** - * List of all the space parents. Will be empty by default, you have to explicitly request it. + * The names of the room's direct space parents if any. */ - val flattenParents: List = emptyList(), + val directParentNames: List = emptyList(), /** * List of all the space parent Ids. */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/SyncService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/SyncService.kt index 5b2bf651af..71f7ab8494 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/SyncService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/SyncService.kt @@ -60,9 +60,9 @@ interface SyncService { fun getSyncStateLive(): LiveData /** - * Get the [SyncRequestState] as a LiveData. + * Get the [SyncRequestState] as a SharedFlow. */ - fun getSyncRequestStateLive(): LiveData + fun getSyncRequestStateFlow(): SharedFlow /** * This method returns a flow of SyncResponse. New value will be pushed through the sync thread. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/WidgetType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/WidgetType.kt index ee098f9bf2..f02fe4f9de 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/WidgetType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/widgets/model/WidgetType.kt @@ -28,7 +28,8 @@ private val DEFINED_TYPES by lazy { WidgetType.StickerPicker, WidgetType.Grafana, WidgetType.Custom, - WidgetType.IntegrationManager + WidgetType.IntegrationManager, + WidgetType.ElementCall, ) } @@ -47,6 +48,7 @@ sealed class WidgetType(open val preferred: String, open val legacy: String = pr object Grafana : WidgetType("m.grafana") object Custom : WidgetType("m.custom") object IntegrationManager : WidgetType("m.integration_manager") + object ElementCall : WidgetType("io.element.call") data class Fallback(override val preferred: String) : WidgetType(preferred) fun matches(type: String): Boolean { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/BuildVersionSdkIntProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/BuildVersionSdkIntProvider.kt index b7ea187ec5..900a2e237f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/BuildVersionSdkIntProvider.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/BuildVersionSdkIntProvider.kt @@ -21,4 +21,14 @@ interface BuildVersionSdkIntProvider { * Return the current version of the Android SDK. */ fun get(): Int + + /** + * Checks the if the current OS version is equal or greater than [version]. + * @return A `non-null` result if true, `null` otherwise. + */ + fun whenAtLeast(version: Int, result: () -> T): T? { + return if (get() >= version) { + result() + } else null + } } 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 850a4379ca..35c066dea8 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 @@ -820,7 +820,7 @@ internal class DefaultCryptoService @Inject constructor( EventType.SEND_SECRET -> { onSecretSendReceived(event) } - EventType.ROOM_KEY_WITHHELD -> { + in EventType.ROOM_KEY_WITHHELD.values -> { onKeyWithHeldReceived(event) } else -> { @@ -869,7 +869,7 @@ internal class DefaultCryptoService @Inject constructor( senderKey = withHeldContent.senderKey, fromDevice = withHeldContent.fromDevice, event = Event( - type = EventType.ROOM_KEY_WITHHELD, + type = EventType.ROOM_KEY_WITHHELD.stable, senderId = senderId, content = event.getClearContent() ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/IncomingKeyRequestManager.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/IncomingKeyRequestManager.kt index 7f36224dae..729b4481e4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/IncomingKeyRequestManager.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/IncomingKeyRequestManager.kt @@ -315,7 +315,7 @@ internal class IncomingKeyRequestManager @Inject constructor( ) val params = SendToDeviceTask.Params( - EventType.ROOM_KEY_WITHHELD, + EventType.ROOM_KEY_WITHHELD.stable, MXUsersDevicesMap().apply { setObject(request.requestingUserId, request.requestingDeviceId, withHeldContent) } 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 ceaee582c7..96d97a41d7 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 @@ -365,7 +365,7 @@ internal class MXMegolmEncryption( fromDevice = myDeviceId ) val params = SendToDeviceTask.Params( - EventType.ROOM_KEY_WITHHELD, + EventType.ROOM_KEY_WITHHELD.stable, MXUsersDevicesMap().apply { targets.forEach { setObject(it.userId, it.deviceId, withHeldContent) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/OutgoingKeyRequestEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/OutgoingKeyRequestEntity.kt index 854d148b76..b10e7501d6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/OutgoingKeyRequestEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/OutgoingKeyRequestEntity.kt @@ -117,7 +117,7 @@ internal open class OutgoingKeyRequestEntity( private fun eventToResult(event: Event): RequestResult? { return when (event.getClearType()) { - EventType.ROOM_KEY_WITHHELD -> { + in EventType.ROOM_KEY_WITHHELD.values -> { event.content.toModel()?.code?.let { RequestResult.Failure(it) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/qrcode/DefaultQrCodeVerificationTransaction.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/qrcode/DefaultQrCodeVerificationTransaction.kt index 690ac12268..5b1a4752f1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/qrcode/DefaultQrCodeVerificationTransaction.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/qrcode/DefaultQrCodeVerificationTransaction.kt @@ -84,7 +84,7 @@ internal class DefaultQrCodeVerificationTransaction( // Perform some checks if (otherQrCodeData.transactionId != transactionId) { Timber.d("## Verification QR: Invalid transaction actual ${otherQrCodeData.transactionId} expected:$transactionId") - cancel(CancelCode.QrCodeInvalid) + cancel(CancelCode.UnknownTransaction) return } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt index 64e69bb3e9..b733aa6fc0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt @@ -51,6 +51,7 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo031 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo032 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo033 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo034 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo035 import org.matrix.android.sdk.internal.util.Normalizer import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration import javax.inject.Inject @@ -59,7 +60,7 @@ internal class RealmSessionStoreMigration @Inject constructor( private val normalizer: Normalizer ) : MatrixRealmMigration( dbName = "Session", - schemaVersion = 34L, + schemaVersion = 35L, ) { /** * Forces all RealmSessionStoreMigration instances to be equal. @@ -103,5 +104,6 @@ internal class RealmSessionStoreMigration @Inject constructor( if (oldVersion < 32) MigrateSessionTo032(realm).perform() if (oldVersion < 33) MigrateSessionTo033(realm).perform() if (oldVersion < 34) MigrateSessionTo034(realm).perform() + if (oldVersion < 35) MigrateSessionTo035(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/RoomSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/RoomSummaryMapper.kt index 735cfe411c..72b0f7a043 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/RoomSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/RoomSummaryMapper.kt @@ -106,6 +106,7 @@ internal class RoomSummaryMapper @Inject constructor( worldReadable = it.childSummaryEntity?.joinRules == RoomJoinRules.PUBLIC ) }, + directParentNames = roomSummaryEntity.directParentNames.toList(), flattenParentIds = roomSummaryEntity.flattenParentIds?.split("|") ?: emptyList(), roomEncryptionAlgorithm = when (val alg = roomSummaryEntity.e2eAlgorithm) { // I should probably use #hasEncryptorClassForAlgorithm but it says it supports diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo035.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo035.kt new file mode 100644 index 0000000000..5b3c95b4a2 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo035.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.migration + +import io.realm.DynamicRealm +import io.realm.RealmList +import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +internal class MigrateSessionTo035(realm: DynamicRealm) : RealmMigrator(realm, 35) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("RoomSummaryEntity") + ?.addRealmListField(RoomSummaryEntityFields.DIRECT_PARENT_NAMES.`$`, String::class.java) + ?.transform { it.setList(RoomSummaryEntityFields.DIRECT_PARENT_NAMES.`$`, RealmList("")) } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomSummaryEntity.kt index 5fb4c3f3d8..471bec59af 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomSummaryEntity.kt @@ -34,7 +34,8 @@ internal open class RoomSummaryEntity( @PrimaryKey var roomId: String = "", var roomType: String? = null, var parents: RealmList = RealmList(), - var children: RealmList = RealmList() + var children: RealmList = RealmList(), + var directParentNames: RealmList = RealmList(), ) : RealmObject() { private var displayName: String? = "" diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt index 6fd4f752a8..989bcaee44 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoomService.kt @@ -152,9 +152,8 @@ internal class DefaultRoomService @Inject constructor( queryParams: RoomSummaryQueryParams, pagedListConfig: PagedList.Config, sortOrder: RoomSortOrder, - getFlattenParents: Boolean ): UpdatableLivePageResult { - return roomSummaryDataSource.getUpdatablePagedRoomSummariesLive(queryParams, pagedListConfig, sortOrder, getFlattenParents) + return roomSummaryDataSource.getUpdatablePagedRoomSummariesLive(queryParams, pagedListConfig, sortOrder) } override fun getRoomCountLive(queryParams: RoomSummaryQueryParams): LiveData { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt index f9fa64ddfe..4fbc91e9ec 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt @@ -701,7 +701,7 @@ internal class LocalEchoEventFactory @Inject constructor( MessageType.MSGTYPE_AUDIO -> 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_BEACON_INFO -> return TextContent(content.body.ensureNotEmpty() ?: "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/summary/RoomSummaryDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt index 80e27a1415..9d14ebffdd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryDataSource.kt @@ -200,14 +200,13 @@ internal class RoomSummaryDataSource @Inject constructor( queryParams: RoomSummaryQueryParams, pagedListConfig: PagedList.Config, sortOrder: RoomSortOrder, - getFlattenedParents: Boolean = false ): UpdatableLivePageResult { val realmDataSourceFactory = monarchy.createDataSourceFactory { realm -> roomSummariesQuery(realm, queryParams).process(sortOrder) } val dataSourceFactory = realmDataSourceFactory.map { roomSummaryMapper.map(it) - }.map { if (getFlattenedParents) it.getWithParents() else it } + } val boundaries = MutableLiveData(ResultBoundaries()) @@ -246,13 +245,6 @@ internal class RoomSummaryDataSource @Inject constructor( } } - private fun RoomSummary.getWithParents(): RoomSummary { - val parents = flattenParentIds.mapNotNull { parentId -> - getRoomSummary(parentId) - } - return copy(flattenParents = parents) - } - fun getCountLive(queryParams: RoomSummaryQueryParams): LiveData { val liveRooms = monarchy.findAllManagedWithChanges { roomSummariesQuery(it, queryParams) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt index a721aeb935..7e064a84ec 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt @@ -223,6 +223,7 @@ internal class RoomSummaryUpdater @Inject constructor( .sort(RoomSummaryEntityFields.ROOM_ID) .findAll().map { it.flattenParentIds = null + it.directParentNames.clear() it to emptyList().toMutableSet() } .toMap() @@ -350,39 +351,29 @@ internal class RoomSummaryUpdater @Inject constructor( } val acyclicGraph = graph.withoutEdges(backEdges) -// Timber.v("## SPACES: acyclicGraph $acyclicGraph") val flattenSpaceParents = acyclicGraph.flattenDestination().map { it.key.name to it.value.map { it.name } }.toMap() -// Timber.v("## SPACES: flattenSpaceParents ${flattenSpaceParents.map { it.key.name to it.value.map { it.name } }.joinToString("\n") { -// it.first + ": [" + it.second.joinToString(",") + "]" -// }}") - -// Timber.v("## SPACES: lookup map ${lookupMap.map { it.key.name to it.value.map { it.name } }.toMap()}") lookupMap.entries .filter { it.key.roomType == RoomType.SPACE && it.key.membership == Membership.JOIN } .forEach { entry -> val parent = RoomSummaryEntity.where(realm, entry.key.roomId).findFirst() if (parent != null) { -// Timber.v("## SPACES: check hierarchy of ${parent.name} id ${parent.roomId}") -// Timber.v("## SPACES: flat known parents of ${parent.name} are ${flattenSpaceParents[parent.roomId]}") val flattenParentsIds = (flattenSpaceParents[parent.roomId] ?: emptyList()) + listOf(parent.roomId) -// Timber.v("## SPACES: flatten known parents of children of ${parent.name} are ${flattenParentsIds}") + entry.value.forEach { child -> RoomSummaryEntity.where(realm, child.roomId).findFirst()?.let { childSum -> + childSum.directParentNames.add(parent.displayName()) -// Timber.w("## SPACES: ${childSum.name} is ${childSum.roomId} fc: ${childSum.flattenParentIds}") -// var allParents = childSum.flattenParentIds ?: "" - if (childSum.flattenParentIds == null) childSum.flattenParentIds = "" + if (childSum.flattenParentIds == null) { + childSum.flattenParentIds = "" + } flattenParentsIds.forEach { if (childSum.flattenParentIds?.contains(it) != true) { childSum.flattenParentIds += "|$it" } } -// childSum.flattenParentIds = "$allParents|" - -// Timber.v("## SPACES: flatten of ${childSum.name} is ${childSum.flattenParentIds}") } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/DefaultSyncService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/DefaultSyncService.kt index 37869b88f9..691dd7b20d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/DefaultSyncService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/DefaultSyncService.kt @@ -16,8 +16,6 @@ package org.matrix.android.sdk.internal.session.sync -import androidx.lifecycle.LiveData -import org.matrix.android.sdk.api.session.sync.SyncRequestState import org.matrix.android.sdk.api.session.sync.SyncService import org.matrix.android.sdk.internal.di.SessionId import org.matrix.android.sdk.internal.di.WorkManagerProvider @@ -75,9 +73,7 @@ internal class DefaultSyncService @Inject constructor( override fun getSyncState() = getSyncThread().currentState() - override fun getSyncRequestStateLive(): LiveData { - return syncRequestStateTracker.syncRequestState - } + override fun getSyncRequestStateFlow() = syncRequestStateTracker.syncRequestState override fun hasAlreadySynced(): Boolean { return syncTokenStore.getLastToken() != null diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncRequestStateTracker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncRequestStateTracker.kt index bcc5fcf9ab..03ce8cb3f2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncRequestStateTracker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncRequestStateTracker.kt @@ -16,23 +16,26 @@ package org.matrix.android.sdk.internal.session.sync -import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch import org.matrix.android.sdk.api.session.sync.InitialSyncStep import org.matrix.android.sdk.api.session.sync.SyncRequestState import org.matrix.android.sdk.internal.session.SessionScope import javax.inject.Inject @SessionScope -internal class SyncRequestStateTracker @Inject constructor() : - ProgressReporter { +internal class SyncRequestStateTracker @Inject constructor( + private val coroutineScope: CoroutineScope +) : ProgressReporter { - val syncRequestState = MutableLiveData() + val syncRequestState = MutableSharedFlow() private var rootTask: TaskInfo? = null // Only to be used for incremental sync fun setSyncRequestState(newSyncRequestState: SyncRequestState.IncrementalSyncRequestState) { - syncRequestState.postValue(newSyncRequestState) + emitSyncState(newSyncRequestState) } /** @@ -42,7 +45,9 @@ internal class SyncRequestStateTracker @Inject constructor() : initialSyncStep: InitialSyncStep, totalProgress: Int ) { - endAll() + if (rootTask != null) { + endAll() + } rootTask = TaskInfo(initialSyncStep, totalProgress, null, 1F) reportProgress(0F) } @@ -71,7 +76,7 @@ internal class SyncRequestStateTracker @Inject constructor() : // Update the progress of the leaf and all its parents leaf.setProgress(progress) // Then update the live data using leaf wording and root progress - syncRequestState.postValue(SyncRequestState.InitialSyncProgressing(leaf.initialSyncStep, root.currentProgress.toInt())) + emitSyncState(SyncRequestState.InitialSyncProgressing(leaf.initialSyncStep, root.currentProgress.toInt())) } } } @@ -86,13 +91,19 @@ internal class SyncRequestStateTracker @Inject constructor() : // And close it endedTask.parent.child = null } else { - syncRequestState.postValue(SyncRequestState.Idle) + emitSyncState(SyncRequestState.Idle) } } } fun endAll() { rootTask = null - syncRequestState.postValue(SyncRequestState.Idle) + emitSyncState(SyncRequestState.Idle) + } + + private fun emitSyncState(state: SyncRequestState) { + coroutineScope.launch { + syncRequestState.emit(state) + } } } diff --git a/vector/build.gradle b/vector/build.gradle index e4313770c4..0f65da4c2e 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -449,6 +449,12 @@ dependencies { implementation libs.airbnb.epoxyPaging implementation libs.airbnb.mavericks + // Nightly + // API-only library + gplayImplementation libs.google.appdistributionApi + // Full SDK implementation + gplayImplementation libs.google.appdistribution + // Work implementation libs.androidx.work diff --git a/vector/src/androidTest/java/im/vector/app/CantVerifyTest.kt b/vector/src/androidTest/java/im/vector/app/CantVerifyTest.kt new file mode 100644 index 0000000000..ba844e56b7 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/CantVerifyTest.kt @@ -0,0 +1,75 @@ +/* + * 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 + +import android.view.View +import androidx.test.espresso.Espresso +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import im.vector.app.features.MainActivity +import im.vector.app.ui.robot.ElementRobot +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +@LargeTest +class CantVerifyTest : VerificationTestBase() { + + @get:Rule + val activityRule = ActivityScenarioRule(MainActivity::class.java) + + private val elementRobot = ElementRobot() + var userName: String = "loginTest_${UUID.randomUUID()}" + + @Test + fun checkCantVerifyPopup() { + // Let' create an account + // This first session will create cross signing keys then logout + elementRobot.signUp(userName) + Espresso.onView(ViewMatchers.isRoot()).perform(SleepViewAction.sleep(2000)) + + elementRobot.signout(false) + Espresso.onView(ViewMatchers.isRoot()).perform(SleepViewAction.sleep(2000)) + + // Let's login again now + // There are no methods to verify (no other devices, nor 4S) + // So it should ask to reset all + elementRobot.login(userName) + + val activity = EspressoHelper.getCurrentActivity()!! + Espresso.onView(ViewMatchers.isRoot()) + .perform(waitForView(ViewMatchers.withText(R.string.crosssigning_cannot_verify_this_session))) + + // check that the text is correct + val popup = activity.findViewById(com.tapadoo.alerter.R.id.llAlertBackground)!! + activity.runOnUiThread { popup.performClick() } + + // ensure that it's the 4S setup bottomsheet + Espresso.onView(ViewMatchers.withId(R.id.bottomSheetFragmentContainer)) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) + + Espresso.onView(ViewMatchers.isRoot()).perform(SleepViewAction.sleep(2000)) + + Espresso.onView(ViewMatchers.withText(R.string.bottom_sheet_setup_secure_backup_title)) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) + } +} diff --git a/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/biometrics/BiometricHelperTests.kt b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/biometrics/BiometricHelperTests.kt index b519d2f623..53c154ae30 100644 --- a/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/biometrics/BiometricHelperTests.kt +++ b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/biometrics/BiometricHelperTests.kt @@ -24,13 +24,16 @@ import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL import androidx.biometric.BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS +import androidx.biometric.BiometricPrompt import androidx.lifecycle.lifecycleScope import androidx.test.core.app.ActivityScenario +import androidx.test.filters.SdkSuppress import androidx.test.platform.app.InstrumentationRegistry import im.vector.app.TestBuildVersionSdkIntProvider import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguration import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguratorProvider import im.vector.app.features.pin.lockscreen.configuration.LockScreenMode +import im.vector.app.features.pin.lockscreen.crypto.LockScreenCryptoConstants import im.vector.app.features.pin.lockscreen.crypto.LockScreenKeyRepository import im.vector.app.features.pin.lockscreen.tests.LockScreenTestActivity import im.vector.app.features.pin.lockscreen.ui.fallbackprompt.FallbackBiometricDialogFragment @@ -56,6 +59,9 @@ import org.amshove.kluent.shouldBeTrue import org.junit.Before import org.junit.Ignore import org.junit.Test +import org.matrix.android.sdk.api.securestorage.SecretStoringUtils +import java.security.KeyStore +import java.util.UUID import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -64,6 +70,13 @@ class BiometricHelperTests { private val biometricManager = mockk(relaxed = true) private val lockScreenKeyRepository = mockk(relaxed = true) private val buildVersionSdkIntProvider = TestBuildVersionSdkIntProvider() + private val keyStore = KeyStore.getInstance(LockScreenCryptoConstants.ANDROID_KEY_STORE).also { it.load(null) } + private val secretStoringUtils = SecretStoringUtils( + InstrumentationRegistry.getInstrumentation().targetContext, + keyStore, + buildVersionSdkIntProvider, + false, + ) @Before fun setup() { @@ -188,8 +201,10 @@ class BiometricHelperTests { } @OptIn(ExperimentalCoroutinesApi::class) + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R) // Due to some issues with mockk and CryptoObject initialization @Test fun authenticateInDeviceWithIssuesShowsFallbackPromptDialog() = runTest { + buildVersionSdkIntProvider.value = Build.VERSION_CODES.M mockkStatic("kotlinx.coroutines.flow.FlowKt") val mockAuthChannel: Channel = mockk(relaxed = true) { // Empty flow to keep the dialog open @@ -201,6 +216,9 @@ class BiometricHelperTests { mockkObject(DevicePromptCheck) every { DevicePromptCheck.isDeviceWithNoBiometricUI } returns true every { lockScreenKeyRepository.isSystemKeyValid() } returns true + + val keyAlias = UUID.randomUUID().toString() + every { biometricUtils.getAuthCryptoObject() } returns BiometricPrompt.CryptoObject(secretStoringUtils.getEncryptCipher(keyAlias)) val latch = CountDownLatch(1) val intent = Intent(InstrumentationRegistry.getInstrumentation().targetContext, LockScreenTestActivity::class.java) with(ActivityScenario.launch(intent)) { @@ -214,11 +232,13 @@ class BiometricHelperTests { } } latch.await(1, TimeUnit.SECONDS) + keyStore.deleteEntry(keyAlias) unmockkObject(DevicePromptCheck) unmockkStatic("kotlinx.coroutines.flow.FlowKt") } @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.R) // Due to some issues with mockk and CryptoObject initialization fun authenticateCreatesSystemKeyIfNeededOnSuccessOnAndroidM() = runTest { buildVersionSdkIntProvider.value = Build.VERSION_CODES.M every { lockScreenKeyRepository.isSystemKeyValid() } returns true diff --git a/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/KeyStoreCryptoTests.kt b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/KeyStoreCryptoTests.kt index 68e1244791..6e02cc0262 100644 --- a/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/KeyStoreCryptoTests.kt +++ b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/KeyStoreCryptoTests.kt @@ -43,7 +43,9 @@ class KeyStoreCryptoTests { private val versionProvider = TestBuildVersionSdkIntProvider().also { it.value = Build.VERSION_CODES.M } private val secretStoringUtils = spyk(SecretStoringUtils(context, keyStore, versionProvider)) private val keyStoreCrypto = spyk( - KeyStoreCrypto(alias, false, context, versionProvider, keyStore, secretStoringUtils) + KeyStoreCrypto(alias, false, context, versionProvider, keyStore).also { + it.secretStoringUtils = secretStoringUtils + } ) @After @@ -146,10 +148,10 @@ class KeyStoreCryptoTests { @Test fun getCryptoObjectUsesCipherFromSecretStoringUtils() { - keyStoreCrypto.getCryptoObject() + keyStoreCrypto.getAuthCryptoObject() verify { secretStoringUtils.getEncryptCipher(any()) } every { secretStoringUtils.getEncryptCipher(any()) } throws KeyPermanentlyInvalidatedException() - invoking { keyStoreCrypto.getCryptoObject() } shouldThrow KeyPermanentlyInvalidatedException::class + invoking { keyStoreCrypto.getAuthCryptoObject() } shouldThrow KeyPermanentlyInvalidatedException::class } } diff --git a/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/LockScreenKeyRepositoryTests.kt b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/LockScreenKeyRepositoryTests.kt index 23eefe6577..924dbfee9e 100644 --- a/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/LockScreenKeyRepositoryTests.kt +++ b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/LockScreenKeyRepositoryTests.kt @@ -16,21 +16,16 @@ package im.vector.app.features.pin.lockscreen.crypto -import android.security.keystore.KeyPermanentlyInvalidatedException import androidx.test.platform.app.InstrumentationRegistry +import im.vector.app.features.pin.lockscreen.crypto.migrations.LegacyPinCodeMigrator import im.vector.app.features.settings.VectorPreferences import io.mockk.clearAllMocks -import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.spyk -import io.mockk.verify -import kotlinx.coroutines.test.runTest -import org.amshove.kluent.coInvoking import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeFalse import org.amshove.kluent.shouldBeTrue -import org.amshove.kluent.shouldNotThrow import org.junit.After import org.junit.Before import org.junit.Test @@ -49,7 +44,7 @@ class LockScreenKeyRepositoryTests { } private lateinit var lockScreenKeyRepository: LockScreenKeyRepository - private val pinCodeMigrator: PinCodeMigrator = mockk(relaxed = true) + private val legacyPinCodeMigrator: LegacyPinCodeMigrator = mockk(relaxed = true) private val vectorPreferences: VectorPreferences = mockk(relaxed = true) private val keyStore: KeyStore by lazy { @@ -58,7 +53,7 @@ class LockScreenKeyRepositoryTests { @Before fun setup() { - lockScreenKeyRepository = spyk(LockScreenKeyRepository("base", pinCodeMigrator, vectorPreferences, keyStoreCryptoFactory)) + lockScreenKeyRepository = spyk(LockScreenKeyRepository("base.pin_code", "base.system", keyStoreCryptoFactory)) } @After @@ -141,44 +136,4 @@ class LockScreenKeyRepositoryTests { lockScreenKeyRepository.hasPinCodeKey().shouldBeFalse() } - - @Test - fun migrateKeysIfNeededReturnsEarlyIfNotNeeded() = runTest { - every { pinCodeMigrator.isMigrationNeeded() } returns false - - lockScreenKeyRepository.migrateKeysIfNeeded() - - coVerify(exactly = 0) { pinCodeMigrator.migrate(any()) } - } - - @Test - fun migrateKeysIfNeededWillMigratePinCodeAndKeys() = runTest { - every { pinCodeMigrator.isMigrationNeeded() } returns true - - lockScreenKeyRepository.migrateKeysIfNeeded() - - coVerify { pinCodeMigrator.migrate(any()) } - } - - @Test - fun migrateKeysIfNeededWillCreateSystemKeyIfNeeded() = runTest { - every { pinCodeMigrator.isMigrationNeeded() } returns true - every { vectorPreferences.useBiometricsToUnlock() } returns true - every { lockScreenKeyRepository.ensureSystemKey() } returns mockk() - - lockScreenKeyRepository.migrateKeysIfNeeded() - - verify { lockScreenKeyRepository.ensureSystemKey() } - } - - @Test - fun migrateKeysIfNeededWillHandleKeyPermanentlyInvalidatedException() = runTest { - every { pinCodeMigrator.isMigrationNeeded() } returns true - every { vectorPreferences.useBiometricsToUnlock() } returns true - every { lockScreenKeyRepository.ensureSystemKey() } throws KeyPermanentlyInvalidatedException() - - coInvoking { lockScreenKeyRepository.migrateKeysIfNeeded() } shouldNotThrow KeyPermanentlyInvalidatedException::class - - verify { lockScreenKeyRepository.ensureSystemKey() } - } } diff --git a/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/PinCodeMigratorTests.kt b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/migrations/LegacyPinCodeMigratorTests.kt similarity index 89% rename from vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/PinCodeMigratorTests.kt rename to vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/migrations/LegacyPinCodeMigratorTests.kt index 297793c7a4..44c5db89c8 100644 --- a/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/PinCodeMigratorTests.kt +++ b/vector/src/androidTest/java/im/vector/app/features/pin/lockscreen/crypto/migrations/LegacyPinCodeMigratorTests.kt @@ -16,7 +16,7 @@ @file:Suppress("DEPRECATION") -package im.vector.app.features.pin.lockscreen.crypto +package im.vector.app.features.pin.lockscreen.crypto.migrations import android.os.Build import android.security.KeyPairGeneratorSpec @@ -57,7 +57,7 @@ import javax.crypto.spec.PSource import javax.security.auth.x500.X500Principal import kotlin.math.abs -class PinCodeMigratorTests { +class LegacyPinCodeMigratorTests { private val alias = UUID.randomUUID().toString() @@ -72,7 +72,9 @@ class PinCodeMigratorTests { private val secretStoringUtils: SecretStoringUtils = spyk( SecretStoringUtils(context, keyStore, buildVersionSdkIntProvider) ) - private val pinCodeMigrator = spyk(PinCodeMigrator(pinCodeStore, keyStore, secretStoringUtils, buildVersionSdkIntProvider)) + private val legacyPinCodeMigrator = spyk( + LegacyPinCodeMigrator(alias, pinCodeStore, keyStore, secretStoringUtils, buildVersionSdkIntProvider) + ) @After fun tearDown() { @@ -87,21 +89,21 @@ class PinCodeMigratorTests { @Test fun isMigrationNeededReturnsTrueIfLegacyKeyExists() { - pinCodeMigrator.isMigrationNeeded() shouldBe false + legacyPinCodeMigrator.isMigrationNeeded() shouldBe false generateLegacyKey() - pinCodeMigrator.isMigrationNeeded() shouldBe true + legacyPinCodeMigrator.isMigrationNeeded() shouldBe true } @Test fun migrateWillReturnEarlyIfPinCodeDoesNotExist() = runTest { - every { pinCodeMigrator.isMigrationNeeded() } returns false + every { legacyPinCodeMigrator.isMigrationNeeded() } returns false coEvery { pinCodeStore.getPinCode() } returns null - pinCodeMigrator.migrate(alias) + legacyPinCodeMigrator.migrate() - coVerify(exactly = 0) { pinCodeMigrator.getDecryptedPinCode() } + coVerify(exactly = 0) { legacyPinCodeMigrator.getDecryptedPinCode() } verify(exactly = 0) { secretStoringUtils.securelyStoreBytes(any(), any()) } coVerify(exactly = 0) { pinCodeStore.savePinCode(any()) } verify(exactly = 0) { keyStore.deleteEntry(LEGACY_PIN_CODE_KEY_ALIAS) } @@ -109,13 +111,13 @@ class PinCodeMigratorTests { @Test fun migrateWillReturnEarlyIfIsNotNeeded() = runTest { - every { pinCodeMigrator.isMigrationNeeded() } returns false - coEvery { pinCodeMigrator.getDecryptedPinCode() } returns "1234" + every { legacyPinCodeMigrator.isMigrationNeeded() } returns false + coEvery { legacyPinCodeMigrator.getDecryptedPinCode() } returns "1234" every { secretStoringUtils.securelyStoreBytes(any(), any()) } returns ByteArray(0) - pinCodeMigrator.migrate(alias) + legacyPinCodeMigrator.migrate() - coVerify(exactly = 0) { pinCodeMigrator.getDecryptedPinCode() } + coVerify(exactly = 0) { legacyPinCodeMigrator.getDecryptedPinCode() } verify(exactly = 0) { secretStoringUtils.securelyStoreBytes(any(), any()) } coVerify(exactly = 0) { pinCodeStore.savePinCode(any()) } verify(exactly = 0) { keyStore.deleteEntry(LEGACY_PIN_CODE_KEY_ALIAS) } @@ -126,9 +128,9 @@ class PinCodeMigratorTests { val pinCode = "1234" saveLegacyPinCode(pinCode) - pinCodeMigrator.migrate(alias) + legacyPinCodeMigrator.migrate() - coVerify { pinCodeMigrator.getDecryptedPinCode() } + coVerify { legacyPinCodeMigrator.getDecryptedPinCode() } verify { secretStoringUtils.securelyStoreBytes(any(), any()) } coVerify { pinCodeStore.savePinCode(any()) } verify { keyStore.deleteEntry(LEGACY_PIN_CODE_KEY_ALIAS) } @@ -145,9 +147,9 @@ class PinCodeMigratorTests { every { buildVersionSdkIntProvider.get() } returns Build.VERSION_CODES.LOLLIPOP saveLegacyPinCode(pinCode) - pinCodeMigrator.migrate(alias) + legacyPinCodeMigrator.migrate() - coVerify { pinCodeMigrator.getDecryptedPinCode() } + coVerify { legacyPinCodeMigrator.getDecryptedPinCode() } verify { secretStoringUtils.securelyStoreBytes(any(), any()) } coVerify { pinCodeStore.savePinCode(any()) } verify { keyStore.deleteEntry(LEGACY_PIN_CODE_KEY_ALIAS) } diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt index 3b54a8607b..b6fbfc23ab 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt @@ -23,7 +23,6 @@ import androidx.test.espresso.Espresso.pressBack import androidx.test.espresso.action.ViewActions import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions -import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText @@ -182,13 +181,8 @@ class ElementRobot { val activity = EspressoHelper.getCurrentActivity()!! val popup = activity.findViewById(com.tapadoo.alerter.R.id.llAlertBackground)!! activity.runOnUiThread { popup.performClick() } - - waitUntilViewVisible(withId(R.id.bottomSheetFragmentContainer)) - waitUntilViewVisible(ViewMatchers.withText(R.string.action_skip)) - clickOn(R.string.action_skip) - assertDisplayed(R.string.are_you_sure) - clickOn(R.string.action_skip) waitUntilViewVisible(withId(R.id.bottomSheetFragmentContainer)) + pressBack() }.onFailure { Timber.w(it, "Verification popup missing") } } diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt index 350bbf8ba3..e72535c116 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt @@ -34,31 +34,46 @@ import im.vector.app.waitForView class OnboardingRobot { + private val defaultVectorFeatures = DefaultVectorFeatures() + fun crawl() { waitUntilViewVisible(withId(R.id.loginSplashSubmit)) - crawlGetStarted() + crawlCreateAccount() crawlAlreadyHaveAccount() } - private fun crawlGetStarted() { - clickOn(R.id.loginSplashSubmit) - assertDisplayed(R.id.useCaseHeaderTitle, R.string.ftue_auth_use_case_title) - clickOn(R.id.useCaseOptionOne) - OnboardingServersRobot().crawlSignUp() - pressBack() - pressBack() + private fun crawlCreateAccount() { + if (defaultVectorFeatures.isOnboardingCombinedRegisterEnabled()) { + // TODO https://github.com/vector-im/element-android/issues/6652 + } else { + clickOn(R.id.loginSplashSubmit) + assertDisplayed(R.id.useCaseHeaderTitle, R.string.ftue_auth_use_case_title) + clickOn(R.id.useCaseOptionOne) + OnboardingServersRobot().crawlSignUp() + pressBack() + pressBack() + } } private fun crawlAlreadyHaveAccount() { - clickOn(R.id.loginSplashAlreadyHaveAccount) - OnboardingServersRobot().crawlSignIn() - pressBack() + if (defaultVectorFeatures.isOnboardingCombinedLoginEnabled()) { + // TODO https://github.com/vector-im/element-android/issues/6652 + } else { + clickOn(R.id.loginSplashAlreadyHaveAccount) + OnboardingServersRobot().crawlSignIn() + pressBack() + } } fun createAccount(userId: String, password: String = "password", homeServerUrl: String = "http://10.0.2.2:8080") { - initSession(true, userId, password, homeServerUrl) + if (defaultVectorFeatures.isOnboardingCombinedRegisterEnabled()) { + createAccountViaCombinedRegister(homeServerUrl, userId, password) + } else { + initSession(true, userId, password, homeServerUrl) + } + waitUntilViewVisible(withText(R.string.ftue_account_created_congratulations_title)) - if (DefaultVectorFeatures().isOnboardingPersonalizeEnabled()) { + if (defaultVectorFeatures.isOnboardingPersonalizeEnabled()) { clickOn(R.string.ftue_account_created_personalize) waitUntilViewVisible(withText(R.string.ftue_display_name_title)) @@ -75,8 +90,47 @@ class OnboardingRobot { } } + private fun createAccountViaCombinedRegister(homeServerUrl: String, userId: String, password: String) { + waitUntilViewVisible(withId(R.id.loginSplashSubmit)) + assertDisplayed(R.id.loginSplashSubmit, R.string.login_splash_create_account) + clickOn(R.id.loginSplashSubmit) + clickOn(R.id.useCaseOptionOne) + + waitUntilViewVisible(withId(R.id.createAccountRoot)) + clickOn(R.id.editServerButton) + writeTo(R.id.chooseServerInput, homeServerUrl) + closeSoftKeyboard() + clickOn(R.id.chooseServerSubmit) + waitUntilViewVisible(withId(R.id.createAccountRoot)) + + writeTo(R.id.createAccountInput, userId) + writeTo(R.id.createAccountPasswordInput, password) + clickOn(R.id.createAccountSubmit) + } + fun login(userId: String, password: String = "password", homeServerUrl: String = "http://10.0.2.2:8080") { - initSession(false, userId, password, homeServerUrl) + if (defaultVectorFeatures.isOnboardingCombinedLoginEnabled()) { + loginViaCombinedLogin(homeServerUrl, userId, password) + } else { + initSession(false, userId, password, homeServerUrl) + } + } + + private fun loginViaCombinedLogin(homeServerUrl: String, userId: String, password: String) { + waitUntilViewVisible(withId(R.id.loginSplashSubmit)) + assertDisplayed(R.id.loginSplashSubmit, R.string.login_splash_create_account) + clickOn(R.id.loginSplashAlreadyHaveAccount) + + waitUntilViewVisible(withId(R.id.loginRoot)) + clickOn(R.id.editServerButton) + writeTo(R.id.chooseServerInput, homeServerUrl) + closeSoftKeyboard() + clickOn(R.id.chooseServerSubmit) + waitUntilViewVisible(withId(R.id.loginRoot)) + + writeTo(R.id.loginInput, userId) + writeTo(R.id.loginPasswordInput, password) + clickOn(R.id.loginSubmit) } private fun initSession( diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsPreferencesRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsPreferencesRobot.kt index bb09ee30f2..126cfafa18 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsPreferencesRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsPreferencesRobot.kt @@ -17,13 +17,12 @@ package im.vector.app.ui.robot.settings import androidx.test.espresso.Espresso.pressBack +import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn import com.adevinta.android.barista.interaction.BaristaDialogInteractions.clickDialogNegativeButton import im.vector.app.R -import im.vector.app.espresso.tools.waitUntilActivityVisible import im.vector.app.espresso.tools.waitUntilViewVisible -import im.vector.app.features.settings.font.FontScaleSettingActivity class SettingsPreferencesRobot { @@ -34,8 +33,7 @@ class SettingsPreferencesRobot { clickOn(R.string.settings_theme) clickDialogNegativeButton() clickOn(R.string.font_size) - waitUntilActivityVisible { - pressBack() - } + waitUntilViewVisible(withId(R.id.fons_scale_recycler)) + pressBack() } } diff --git a/vector/src/fdroid/java/im/vector/app/nightly/NightlyProxy.kt b/vector/src/fdroid/java/im/vector/app/nightly/NightlyProxy.kt new file mode 100644 index 0000000000..eecf3a24f2 --- /dev/null +++ b/vector/src/fdroid/java/im/vector/app/nightly/NightlyProxy.kt @@ -0,0 +1,23 @@ +/* + * 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.nightly + +import javax.inject.Inject + +class NightlyProxy @Inject constructor() { + fun onHomeResumed() = Unit +} diff --git a/vector/src/gplay/java/im/vector/app/nightly/NightlyProxy.kt b/vector/src/gplay/java/im/vector/app/nightly/NightlyProxy.kt new file mode 100644 index 0000000000..7c6685f5ce --- /dev/null +++ b/vector/src/gplay/java/im/vector/app/nightly/NightlyProxy.kt @@ -0,0 +1,76 @@ +/* + * 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.nightly + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.google.firebase.appdistribution.FirebaseAppDistribution +import com.google.firebase.appdistribution.FirebaseAppDistributionException +import im.vector.app.BuildConfig +import im.vector.app.core.di.DefaultPreferences +import im.vector.app.core.time.Clock +import timber.log.Timber +import javax.inject.Inject + +class NightlyProxy @Inject constructor( + private val clock: Clock, + @DefaultPreferences + private val sharedPreferences: SharedPreferences, +) { + fun onHomeResumed() { + if (!canDisplayPopup()) return + val firebaseAppDistribution = FirebaseAppDistribution.getInstance() + firebaseAppDistribution.updateIfNewReleaseAvailable() + .addOnProgressListener { up -> + Timber.d("FirebaseAppDistribution progress: ${up.updateStatus}. ${up.apkBytesDownloaded}/${up.apkFileTotalBytes}") + } + .addOnFailureListener { e -> + if (e is FirebaseAppDistributionException) { + when (e.errorCode) { + FirebaseAppDistributionException.Status.NOT_IMPLEMENTED -> { + // SDK did nothing. This is expected when building for Play. + } + else -> { + // Handle other errors. + Timber.e(e, "FirebaseAppDistribution error, status: ${e.errorCode}") + } + } + } else { + Timber.e(e, "FirebaseAppDistribution - other error") + } + } + } + + private fun canDisplayPopup(): Boolean { + if (BuildConfig.APPLICATION_ID != "im.vector.app.nightly") return false + val today = clock.epochMillis() / A_DAY_IN_MILLIS + val lastDisplayPopupDay = sharedPreferences.getLong(SHARED_PREF_KEY, 0) + return (today > lastDisplayPopupDay) + .also { canDisplayPopup -> + if (canDisplayPopup) { + sharedPreferences.edit { + putLong(SHARED_PREF_KEY, today) + } + } + } + } + + companion object { + private const val A_DAY_IN_MILLIS = 8_600_000L + private const val SHARED_PREF_KEY = "LAST_NIGHTLY_POPUP_DAY" + } +} diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 1c104f3bbf..fa89013707 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -308,7 +308,8 @@ + android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" + android:supportsPictureInPicture="true" /> @@ -380,6 +381,11 @@ android:exported="false" android:foregroundServiceType="location" /> + + () .map { session.spaceService().getRootSpaceSummaries().size } .distinctUntilChanged() diff --git a/vector/src/main/java/im/vector/app/VectorApplication.kt b/vector/src/main/java/im/vector/app/VectorApplication.kt index a0a3b20e11..d3dfbf8c4f 100644 --- a/vector/src/main/java/im/vector/app/VectorApplication.kt +++ b/vector/src/main/java/im/vector/app/VectorApplication.kt @@ -41,8 +41,6 @@ import com.vanniktech.emoji.EmojiManager import com.vanniktech.emoji.google.GoogleEmojiProvider import dagger.hilt.android.HiltAndroidApp import im.vector.app.core.di.ActiveSessionHolder -import im.vector.app.core.extensions.configureAndStart -import im.vector.app.core.extensions.startSyncing import im.vector.app.features.analytics.VectorAnalytics import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.configuration.VectorConfiguration @@ -165,14 +163,6 @@ class VectorApplication : doNotShowDisclaimerDialog(this) } - if (authenticationService.hasAuthenticatedSessions() && !activeSessionHolder.hasActiveSession()) { - val lastAuthenticatedSession = authenticationService.getLastAuthenticatedSession()!! - activeSessionHolder.setActiveSession(lastAuthenticatedSession) - lastAuthenticatedSession.configureAndStart(applicationContext, startSyncing = false) - } - - ProcessLifecycleOwner.get().lifecycle.addObserver(startSyncOnFirstStart) - ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onResume(owner: LifecycleOwner) { Timber.i("App entered foreground") @@ -205,14 +195,6 @@ class VectorApplication : Mapbox.getInstance(this) } - private val startSyncOnFirstStart = object : DefaultLifecycleObserver { - override fun onStart(owner: LifecycleOwner) { - Timber.i("App process started") - authenticationService.getLastAuthenticatedSession()?.startSyncing(appContext) - ProcessLifecycleOwner.get().lifecycle.removeObserver(this) - } - } - private fun enableStrictModeIfNeeded() { if (BuildConfig.ENABLE_STRICT_MODE_LOGS) { StrictMode.setThreadPolicy( diff --git a/vector/src/main/java/im/vector/app/core/di/ActiveSessionSetter.kt b/vector/src/main/java/im/vector/app/core/di/ActiveSessionSetter.kt new file mode 100644 index 0000000000..09479a230f --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/di/ActiveSessionSetter.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.di + +import android.content.Context +import im.vector.app.core.extensions.configureAndStart +import org.matrix.android.sdk.api.auth.AuthenticationService +import javax.inject.Inject + +class ActiveSessionSetter @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, + private val authenticationService: AuthenticationService, + private val applicationContext: Context, +) { + fun shouldSetActionSession(): Boolean { + return authenticationService.hasAuthenticatedSessions() && !activeSessionHolder.hasActiveSession() + } + + fun tryToSetActiveSession(startSync: Boolean) { + if (shouldSetActionSession()) { + val lastAuthenticatedSession = authenticationService.getLastAuthenticatedSession()!! + activeSessionHolder.setActiveSession(lastAuthenticatedSession) + lastAuthenticatedSession.configureAndStart(applicationContext, startSyncing = startSync) + } + } +} 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 675df69f23..08ba53a024 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 @@ -61,6 +61,7 @@ import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsFragment import im.vector.app.features.home.room.detail.TimelineFragment import im.vector.app.features.home.room.detail.search.SearchFragment import im.vector.app.features.home.room.list.RoomListFragment +import im.vector.app.features.home.room.list.home.HomeRoomListFragment import im.vector.app.features.home.room.threads.list.views.ThreadListFragment import im.vector.app.features.location.LocationPreviewFragment import im.vector.app.features.location.LocationSharingFragment @@ -1041,4 +1042,9 @@ interface FragmentModule { @IntoMap @FragmentKey(LocationPreviewFragment::class) fun bindLocationPreviewFragment(fragment: LocationPreviewFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(HomeRoomListFragment::class) + fun binHomeRoomListFragment(fragment: HomeRoomListFragment): Fragment } diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt index a3e08036ff..236622210f 100644 --- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt @@ -51,6 +51,7 @@ import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHist import im.vector.app.features.home.room.detail.timeline.reactions.ViewReactionsViewModel import im.vector.app.features.home.room.detail.upgrade.MigrateRoomViewModel import im.vector.app.features.home.room.list.RoomListViewModel +import im.vector.app.features.home.room.list.home.HomeRoomListViewModel import im.vector.app.features.homeserver.HomeServerCapabilitiesViewModel import im.vector.app.features.invite.InviteUsersToRoomViewModel import im.vector.app.features.location.LocationSharingViewModel @@ -111,6 +112,7 @@ import im.vector.app.features.spaces.manage.SpaceManageSharedViewModel import im.vector.app.features.spaces.people.SpacePeopleViewModel import im.vector.app.features.spaces.preview.SpacePreviewViewModel import im.vector.app.features.spaces.share.ShareSpaceViewModel +import im.vector.app.features.start.StartAppViewModel import im.vector.app.features.terms.ReviewTermsViewModel import im.vector.app.features.usercode.UserCodeSharedViewModel import im.vector.app.features.userdirectory.UserListViewModel @@ -483,6 +485,11 @@ interface MavericksViewModelModule { @MavericksViewModelKey(AnalyticsAccountDataViewModel::class) fun analyticsAccountDataViewModelFactory(factory: AnalyticsAccountDataViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + @Binds + @IntoMap + @MavericksViewModelKey(StartAppViewModel::class) + fun startAppViewModelFactory(factory: StartAppViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + @Binds @IntoMap @MavericksViewModelKey(HomeServerCapabilitiesViewModel::class) @@ -612,4 +619,9 @@ interface MavericksViewModelModule { @IntoMap @MavericksViewModelKey(FontScaleSettingViewModel::class) fun fontScaleSettingViewModelFactory(factory: FontScaleSettingViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + + @Binds + @IntoMap + @MavericksViewModelKey(HomeRoomListViewModel::class) + fun homeRoomListViewModel(factory: HomeRoomListViewModel.Factory): MavericksAssistedViewModelFactory<*, *> } diff --git a/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt b/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt index 8fbd89a6c1..d9a08bd81a 100644 --- a/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt +++ b/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt @@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.failure.MatrixIdFailure import org.matrix.android.sdk.api.failure.isInvalidPassword import org.matrix.android.sdk.api.failure.isLimitExceededError +import org.matrix.android.sdk.api.failure.isMissingEmailVerification import org.matrix.android.sdk.api.session.identity.IdentityServiceError import java.net.HttpURLConnection import java.net.SocketTimeoutException @@ -105,6 +106,9 @@ class DefaultErrorFormatter @Inject constructor( throwable.error.message == "Not allowed to join this room" -> { stringProvider.getString(R.string.room_error_access_unauthorized) } + throwable.isMissingEmailVerification() -> { + stringProvider.getString(R.string.auth_reset_password_error_unverified) + } else -> { throwable.error.message.takeIf { it.isNotEmpty() } ?: throwable.error.code.takeIf { it.isNotEmpty() } diff --git a/vector/src/main/java/im/vector/app/core/pushers/VectorMessagingReceiver.kt b/vector/src/main/java/im/vector/app/core/pushers/VectorMessagingReceiver.kt index 53a5470ff7..be84dfeaba 100644 --- a/vector/src/main/java/im/vector/app/core/pushers/VectorMessagingReceiver.kt +++ b/vector/src/main/java/im/vector/app/core/pushers/VectorMessagingReceiver.kt @@ -27,6 +27,7 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager import dagger.hilt.android.AndroidEntryPoint import im.vector.app.BuildConfig import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.di.ActiveSessionSetter import im.vector.app.core.network.WifiDetector import im.vector.app.core.pushers.model.PushData import im.vector.app.core.services.GuardServiceStarter @@ -59,6 +60,7 @@ class VectorMessagingReceiver : MessagingReceiver() { @Inject lateinit var notificationDrawerManager: NotificationDrawerManager @Inject lateinit var notifiableEventResolver: NotifiableEventResolver @Inject lateinit var pushersManager: PushersManager + @Inject lateinit var activeSessionSetter: ActiveSessionSetter @Inject lateinit var activeSessionHolder: ActiveSessionHolder @Inject lateinit var vectorPreferences: VectorPreferences @Inject lateinit var vectorDataStore: VectorDataStore @@ -177,6 +179,11 @@ class VectorMessagingReceiver : MessagingReceiver() { } val session = activeSessionHolder.getSafeActiveSession() + ?: run { + // Active session may not exist yet, if MainActivity has not been launched + activeSessionSetter.tryToSetActiveSession(startSync = false) + activeSessionHolder.getSafeActiveSession() + } if (session == null) { Timber.tag(loggerTag.value).w("## Can't sync from push, no current session") diff --git a/vector/src/main/java/im/vector/app/core/utils/CheckWebViewPermissionsUseCase.kt b/vector/src/main/java/im/vector/app/core/utils/CheckWebViewPermissionsUseCase.kt new file mode 100644 index 0000000000..df84e24f90 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/utils/CheckWebViewPermissionsUseCase.kt @@ -0,0 +1,52 @@ +/* + * 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.app.Activity +import android.content.pm.PackageManager +import android.webkit.PermissionRequest +import androidx.core.content.ContextCompat +import javax.inject.Inject + +class CheckWebViewPermissionsUseCase @Inject constructor() { + + /** + * Checks if required WebView permissions are already granted system level. + * @param activity the calling Activity that is requesting the permissions (or fragment parent) + * @param request WebView permission request of onPermissionRequest function + * @return true if WebView permissions are already granted, false otherwise + */ + fun execute(activity: Activity, request: PermissionRequest): Boolean { + return request.resources.all { + when (it) { + PermissionRequest.RESOURCE_AUDIO_CAPTURE -> { + PERMISSIONS_FOR_AUDIO_IP_CALL.all { permission -> + ContextCompat.checkSelfPermission(activity.applicationContext, permission) == PackageManager.PERMISSION_GRANTED + } + } + PermissionRequest.RESOURCE_VIDEO_CAPTURE -> { + PERMISSIONS_FOR_VIDEO_IP_CALL.all { permission -> + ContextCompat.checkSelfPermission(activity.applicationContext, permission) == PackageManager.PERMISSION_GRANTED + } + } + else -> { + false + } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/MainActivity.kt b/vector/src/main/java/im/vector/app/features/MainActivity.kt index c2f6f2d778..61127e2c82 100644 --- a/vector/src/main/java/im/vector/app/features/MainActivity.kt +++ b/vector/src/main/java/im/vector/app/features/MainActivity.kt @@ -17,11 +17,15 @@ package im.vector.app.features import android.app.Activity +import android.content.Context import android.content.Intent import android.os.Bundle import android.os.Parcelable +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import com.airbnb.mvrx.viewModel import com.bumptech.glide.Glide import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint @@ -44,9 +48,16 @@ import im.vector.app.features.popup.PopupAlertManager import im.vector.app.features.session.VectorSessionStore import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.signout.hard.SignedOutActivity +import im.vector.app.features.start.StartAppAction +import im.vector.app.features.start.StartAppAndroidService +import im.vector.app.features.start.StartAppViewEvent +import im.vector.app.features.start.StartAppViewModel +import im.vector.app.features.start.StartAppViewState import im.vector.app.features.themes.ActivityOtherThemes import im.vector.app.features.ui.UiStateRepository import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize @@ -73,6 +84,8 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity companion object { private const val EXTRA_ARGS = "EXTRA_ARGS" + private const val EXTRA_NEXT_INTENT = "EXTRA_NEXT_INTENT" + private const val EXTRA_INIT_SESSION = "EXTRA_INIT_SESSION" // Special action to clear cache and/or clear credentials fun restartApp(activity: Activity, args: MainActivityArgs) { @@ -82,8 +95,22 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity intent.putExtra(EXTRA_ARGS, args) activity.startActivity(intent) } + + fun getIntentToInitSession(activity: Activity): Intent { + val intent = Intent(activity, MainActivity::class.java) + intent.putExtra(EXTRA_INIT_SESSION, true) + return intent + } + + fun getIntentWithNextIntent(context: Context, nextIntent: Intent): Intent { + val intent = Intent(context, MainActivity::class.java) + intent.putExtra(EXTRA_NEXT_INTENT, nextIntent) + return intent + } } + private val startAppViewModel: StartAppViewModel by viewModel() + override fun getBinding() = ActivityMainBinding.inflate(layoutInflater) override fun getOtherThemes() = ActivityOtherThemes.Launcher @@ -103,15 +130,58 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - args = parseArgs() - if (args.clearCredentials || args.isUserLoggedOut || args.clearCache) { - clearNotifications() + + startAppViewModel.onEach { + renderState(it) } - // Handle some wanted cleanup - if (args.clearCache || args.clearCredentials) { - doCleanUp() + startAppViewModel.viewEvents.stream() + .onEach(::handleViewEvents) + .launchIn(lifecycleScope) + + startAppViewModel.handle(StartAppAction.StartApp) + } + + private fun renderState(state: StartAppViewState) { + if (state.mayBeLongToProcess) { + views.status.setText(R.string.updating_your_data) + } + views.status.isVisible = state.mayBeLongToProcess + } + + private fun handleViewEvents(event: StartAppViewEvent) { + when (event) { + StartAppViewEvent.StartForegroundService -> handleStartForegroundService() + StartAppViewEvent.AppStarted -> handleAppStarted() + } + } + + private fun handleStartForegroundService() { + if (startAppViewModel.shouldStartApp()) { + // Start foreground service, because the operation may take a while + val intent = Intent(this, StartAppAndroidService::class.java) + ContextCompat.startForegroundService(this, intent) + } + } + + private fun handleAppStarted() { + if (intent.hasExtra(EXTRA_NEXT_INTENT)) { + // Start the next Activity + val nextIntent = intent.getParcelableExtra(EXTRA_NEXT_INTENT) + startIntentAndFinish(nextIntent) + } else if (intent.hasExtra(EXTRA_INIT_SESSION)) { + setResult(RESULT_OK) + finish() } else { - startNextActivityAndFinish() + args = parseArgs() + if (args.clearCredentials || args.isUserLoggedOut || args.clearCache) { + clearNotifications() + } + // Handle some wanted cleanup + if (args.clearCache || args.clearCredentials) { + doCleanUp() + } else { + startNextActivityAndFinish() + } } } @@ -241,7 +311,7 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity // We have a session. // Check it can be opened if (sessionHolder.getActiveSession().isOpenable) { - HomeActivity.newIntent(this, existingSession = true) + HomeActivity.newIntent(this, firstStartMainActivity = false, existingSession = true) } else { // The token is still invalid navigator.softLogout(this) @@ -253,6 +323,10 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity null } } + startIntentAndFinish(intent) + } + + private fun startIntentAndFinish(intent: Intent?) { intent?.let { startActivity(it) } finish() } diff --git a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt index b48fb62a3a..3a56f31b72 100644 --- a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt +++ b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt @@ -46,9 +46,9 @@ class DefaultVectorFeatures : VectorFeatures { override fun isOnboardingAlreadyHaveAccountSplashEnabled() = true override fun isOnboardingSplashCarouselEnabled() = true override fun isOnboardingUseCaseEnabled() = true - override fun isOnboardingPersonalizeEnabled() = false - override fun isOnboardingCombinedRegisterEnabled() = false - override fun isOnboardingCombinedLoginEnabled() = false + override fun isOnboardingPersonalizeEnabled() = true + override fun isOnboardingCombinedRegisterEnabled() = true + override fun isOnboardingCombinedLoginEnabled() = true override fun allowExternalUnifiedPushDistributors(): Boolean = Config.ALLOW_EXTERNAL_UNIFIED_PUSH_DISTRIBUTORS override fun isScreenSharingEnabled(): Boolean = true override fun forceUsageOfOpusEncoder(): Boolean = false diff --git a/vector/src/main/java/im/vector/app/features/analytics/accountdata/AnalyticsAccountDataViewModel.kt b/vector/src/main/java/im/vector/app/features/analytics/accountdata/AnalyticsAccountDataViewModel.kt index 05358decc9..28929127e1 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/accountdata/AnalyticsAccountDataViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/accountdata/AnalyticsAccountDataViewModel.kt @@ -16,7 +16,6 @@ package im.vector.app.features.analytics.accountdata -import androidx.lifecycle.asFlow import com.airbnb.mvrx.MavericksViewModelFactory import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -66,7 +65,7 @@ class AnalyticsAccountDataViewModel @AssistedInject constructor( private fun observeInitSync() { combine( - session.syncService().getSyncRequestStateLive().asFlow(), + session.syncService().getSyncRequestStateFlow(), analytics.getUserConsent(), analytics.getAnalyticsId() ) { status, userConsent, analyticsId -> 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 9d7ada9d63..f8a4c5eeca 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 @@ -604,7 +604,7 @@ class VectorCallActivity : private fun returnToChat() { val roomId = withState(callViewModel) { it.roomId } val args = TimelineArgs(roomId) - val intent = RoomDetailActivity.newIntent(this, args).apply { + val intent = RoomDetailActivity.newIntent(this, args, false).apply { flags = FLAG_ACTIVITY_CLEAR_TOP } startActivity(intent) diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/conclusion/VerificationConclusionController.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/conclusion/VerificationConclusionController.kt index 9c5829eb8e..7bd0e393eb 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/conclusion/VerificationConclusionController.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/conclusion/VerificationConclusionController.kt @@ -86,6 +86,14 @@ class VerificationConclusionController @Inject constructor( bottomGotIt() } + ConclusionState.INVALID_QR_CODE -> { + bottomSheetVerificationNoticeItem { + id("invalid_qr") + notice(host.stringProvider.getString(R.string.verify_invalid_qr_notice).toEpoxyCharSequence()) + } + + bottomGotIt() + } ConclusionState.CANCELLED -> { bottomSheetVerificationNoticeItem { id("notice_cancelled") diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/conclusion/VerificationConclusionViewModel.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/conclusion/VerificationConclusionViewModel.kt index aff2d807ac..8883ffb94e 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/conclusion/VerificationConclusionViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/conclusion/VerificationConclusionViewModel.kt @@ -32,7 +32,8 @@ data class VerificationConclusionViewState( enum class ConclusionState { SUCCESS, WARNING, - CANCELLED + CANCELLED, + INVALID_QR_CODE } class VerificationConclusionViewModel(initialState: VerificationConclusionViewState) : @@ -44,7 +45,9 @@ class VerificationConclusionViewModel(initialState: VerificationConclusionViewSt val args = viewModelContext.args() return when (safeValueOf(args.cancelReason)) { - CancelCode.QrCodeInvalid, + CancelCode.QrCodeInvalid -> { + VerificationConclusionViewState(ConclusionState.INVALID_QR_CODE, args.isMe) + } CancelCode.MismatchedUser, CancelCode.MismatchedSas, CancelCode.MismatchedCommitment, 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 6e77975d46..389b4b7b27 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 @@ -78,6 +78,7 @@ import im.vector.app.features.spaces.invite.SpaceInviteBottomSheet import im.vector.app.features.spaces.share.ShareSpaceBottomSheet import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.workers.signout.ServerBackupStatusViewModel +import im.vector.app.nightly.NightlyProxy import im.vector.app.push.fcm.FcmHelper import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -132,6 +133,7 @@ class HomeActivity : @Inject lateinit var spaceStateHandler: SpaceStateHandler @Inject lateinit var unifiedPushHelper: UnifiedPushHelper @Inject lateinit var fcmHelper: FcmHelper + @Inject lateinit var nightlyProxy: NightlyProxy private val createSpaceResultLauncher = registerStartForActivityResult { activityResult -> if (activityResult.resultCode == Activity.RESULT_OK) { @@ -238,7 +240,8 @@ class HomeActivity : homeActivityViewModel.observeViewEvents { when (it) { is HomeActivityViewEvents.AskPasswordToInitCrossSigning -> handleAskPasswordToInitCrossSigning(it) - is HomeActivityViewEvents.OnNewSession -> handleOnNewSession(it) + is HomeActivityViewEvents.CurrentSessionNotVerified -> handleOnNewSession(it) + is HomeActivityViewEvents.CurrentSessionCannotBeVerified -> handleCantVerify(it) HomeActivityViewEvents.PromptToEnableSessionPush -> handlePromptToEnablePush() HomeActivityViewEvents.StartRecoverySetupFlow -> handleStartRecoverySetup() is HomeActivityViewEvents.ForceVerification -> { @@ -422,7 +425,7 @@ class HomeActivity : } } - private fun handleOnNewSession(event: HomeActivityViewEvents.OnNewSession) { + private fun handleOnNewSession(event: HomeActivityViewEvents.CurrentSessionNotVerified) { // We need to ask promptSecurityEvent( event.userItem, @@ -437,6 +440,17 @@ class HomeActivity : } } + private fun handleCantVerify(event: HomeActivityViewEvents.CurrentSessionCannotBeVerified) { + // We need to ask + promptSecurityEvent( + event.userItem, + R.string.crosssigning_cannot_verify_this_session, + R.string.crosssigning_cannot_verify_this_session_desc + ) { + it.navigator.open4SSetup(it, SetupMode.PASSPHRASE_AND_NEEDED_SECRETS_RESET) + } + } + private fun handlePromptToEnablePush() { popupAlertManager.postVectorAlert( DefaultVectorAlert( @@ -533,6 +547,9 @@ class HomeActivity : // Force remote backup state update to update the banner if needed serverBackupStatusViewModel.refreshRemoteStateIfNeeded() + + // Check nightly + nightlyProxy.onHomeResumed() } override fun getMenuRes() = R.menu.home @@ -611,6 +628,7 @@ class HomeActivity : companion object { fun newIntent( context: Context, + firstStartMainActivity: Boolean, clearNotification: Boolean = false, authenticationDescription: AuthenticationDescription? = null, existingSession: Boolean = false, @@ -623,10 +641,16 @@ class HomeActivity : inviteNotificationRoomId = inviteNotificationRoomId ) - return Intent(context, HomeActivity::class.java) + val intent = Intent(context, HomeActivity::class.java) .apply { putExtra(Mavericks.KEY_ARG, args) } + + return if (firstStartMainActivity) { + MainActivity.getIntentWithNextIntent(context, intent) + } else { + intent + } } } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewEvents.kt index cb31a568e4..170550d5b4 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewEvents.kt @@ -21,7 +21,13 @@ import org.matrix.android.sdk.api.util.MatrixItem sealed interface HomeActivityViewEvents : VectorViewEvents { data class AskPasswordToInitCrossSigning(val userItem: MatrixItem.UserItem?) : HomeActivityViewEvents - data class OnNewSession(val userItem: MatrixItem.UserItem?, val waitForIncomingRequest: Boolean = true) : HomeActivityViewEvents + data class CurrentSessionNotVerified( + val userItem: MatrixItem.UserItem?, + val waitForIncomingRequest: Boolean = true, + ) : HomeActivityViewEvents + data class CurrentSessionCannotBeVerified( + val userItem: MatrixItem.UserItem?, + ) : HomeActivityViewEvents data class OnCrossSignedInvalidated(val userItem: MatrixItem.UserItem) : HomeActivityViewEvents object PromptToEnableSessionPush : HomeActivityViewEvents object ShowAnalyticsOptIn : HomeActivityViewEvents diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt index b45e6fbcb0..eb24a1ba86 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt @@ -16,7 +16,6 @@ package im.vector.app.features.home -import androidx.lifecycle.asFlow import com.airbnb.mvrx.Mavericks import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.ViewModelContext @@ -218,8 +217,7 @@ class HomeActivityViewModel @AssistedInject constructor( private fun observeInitialSync() { val session = activeSessionHolder.getSafeActiveSession() ?: return - session.syncService().getSyncRequestStateLive() - .asFlow() + session.syncService().getSyncRequestStateFlow() .onEach { status -> when (status) { is SyncRequestState.Idle -> { @@ -364,14 +362,30 @@ class HomeActivityViewModel @AssistedInject constructor( // If 4S is forced, force verification _viewEvents.post(HomeActivityViewEvents.ForceVerification(true)) } else { - // New session - _viewEvents.post( - HomeActivityViewEvents.OnNewSession( - session.getUser(session.myUserId)?.toMatrixItem(), - // Always send request instead of waiting for an incoming as per recent EW changes - false - ) - ) + // we wan't to check if there is a way to actually verify this session, + // that means that there is another session to verify against, or + // secure backup is setup + val hasTargetDeviceToVerifyAgainst = session + .cryptoService() + .getUserDevices(session.myUserId) + .size >= 2 // this one + another + val is4Ssetup = session.sharedSecretStorageService().isRecoverySetup() + if (hasTargetDeviceToVerifyAgainst || is4Ssetup) { + // New session + _viewEvents.post( + HomeActivityViewEvents.CurrentSessionNotVerified( + session.getUser(session.myUserId)?.toMatrixItem(), + // Always send request instead of waiting for an incoming as per recent EW changes + false + ) + ) + } else { + _viewEvents.post( + HomeActivityViewEvents.CurrentSessionCannotBeVerified( + session.getUser(session.myUserId)?.toMatrixItem(), + ) + ) + } } } } 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 68e012f16e..d96b44b705 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 @@ -41,6 +41,7 @@ import im.vector.app.core.ui.views.CurrentCallsView import im.vector.app.core.ui.views.CurrentCallsViewPresenter import im.vector.app.core.ui.views.KeysBackupBanner import im.vector.app.databinding.FragmentHomeDetailBinding +import im.vector.app.features.VectorFeatures import im.vector.app.features.call.SharedKnownCallsViewModel import im.vector.app.features.call.VectorCallActivity import im.vector.app.features.call.dialpad.DialPadFragment @@ -48,6 +49,7 @@ import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.home.room.list.RoomListFragment import im.vector.app.features.home.room.list.RoomListParams import im.vector.app.features.home.room.list.UnreadCounterBadgeView +import im.vector.app.features.home.room.list.home.HomeRoomListFragment import im.vector.app.features.popup.PopupAlertManager import im.vector.app.features.popup.VerificationVectorAlert import im.vector.app.features.settings.VectorLocale @@ -66,7 +68,8 @@ class HomeDetailFragment @Inject constructor( private val alertManager: PopupAlertManager, private val callManager: WebRtcCallManager, private val vectorPreferences: VectorPreferences, - private val spaceStateHandler: SpaceStateHandler + private val spaceStateHandler: SpaceStateHandler, + private val vectorFeatures: VectorFeatures, ) : VectorBaseFragment(), KeysBackupBanner.Delegate, CurrentCallsView.Callback, @@ -352,8 +355,12 @@ class HomeDetailFragment @Inject constructor( if (fragmentToShow == null) { when (tab) { is HomeTab.RoomList -> { - val params = RoomListParams(tab.displayMode) - add(R.id.roomListContainer, RoomListFragment::class.java, params.toMvRxBundle(), fragmentTag) + if (vectorFeatures.isNewAppLayoutEnabled()) { + add(R.id.roomListContainer, HomeRoomListFragment::class.java, null, fragmentTag) + } else { + val params = RoomListParams(tab.displayMode) + add(R.id.roomListContainer, RoomListFragment::class.java, params.toMvRxBundle(), fragmentTag) + } } is HomeTab.DialPad -> { add(R.id.roomListContainer, createDialPadFragment(), fragmentTag) diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeDetailViewModel.kt index ede9872a9b..db0c85c419 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDetailViewModel.kt @@ -198,8 +198,7 @@ class HomeDetailViewModel @AssistedInject constructor( copy(syncState = syncState) } - session.syncService().getSyncRequestStateLive() - .asFlow() + session.syncService().getSyncRequestStateFlow() .filterIsInstance() .setOnEach { copy(incrementalSyncRequestState = it) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt index 64670c73ac..c1e3b58a80 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt @@ -117,4 +117,6 @@ sealed class RoomDetailAction : VectorViewModelAction { // Live Location object StopLiveLocationSharing : RoomDetailAction() + + object OpenElementCallWidget : RoomDetailAction() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt index f1e06dd5ef..a58eed42e1 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt @@ -35,6 +35,7 @@ import im.vector.app.core.extensions.keepScreenOn import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivityRoomDetailBinding +import im.vector.app.features.MainActivity import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.ViewRoom import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsFragment @@ -191,10 +192,15 @@ class RoomDetailActivity : const val EXTRA_ROOM_ID = "EXTRA_ROOM_ID" const val ACTION_ROOM_DETAILS_FROM_SHORTCUT = "ROOM_DETAILS_FROM_SHORTCUT" - fun newIntent(context: Context, timelineArgs: TimelineArgs): Intent { - return Intent(context, RoomDetailActivity::class.java).apply { + fun newIntent(context: Context, timelineArgs: TimelineArgs, firstStartMainActivity: Boolean): Intent { + val intent = Intent(context, RoomDetailActivity::class.java).apply { putExtra(EXTRA_ROOM_DETAIL_ARGS, timelineArgs) } + return if (firstStartMainActivity) { + MainActivity.getIntentWithNextIntent(context, intent) + } else { + intent + } } // Shortcuts can't have intents with parcelables diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt index dcfee2d919..3af849e965 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt @@ -84,4 +84,5 @@ sealed class RoomDetailViewEvents : VectorViewEvents { data class StartChatEffect(val type: ChatEffect) : RoomDetailViewEvents() object StopChatEffects : RoomDetailViewEvents() object RoomReplacementStarted : RoomDetailViewEvents() + object OpenElementCallWidget : RoomDetailViewEvents() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt index 8500d1ed96..7aa7d5a877 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt @@ -102,6 +102,8 @@ data class RoomDetailViewState( // It can differs for a short period of time on the JitsiState as its computed async. fun hasActiveJitsiWidget() = activeRoomWidgets()?.any { it.type == WidgetType.Jitsi && it.isActive }.orFalse() + fun hasActiveElementCallWidget() = activeRoomWidgets()?.any { it.type == WidgetType.ElementCall && it.isActive }.orFalse() + fun isDm() = asyncRoomSummary()?.isDirect == true fun isThreadTimeline() = rootThreadEventId != null diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt index ba691de5d2..8d2d086275 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt @@ -47,6 +47,11 @@ class StartCallActionsHandler( } private fun handleCallRequest(isVideoCall: Boolean) = withState(timelineViewModel) { state -> + if (state.hasActiveElementCallWidget() && !isVideoCall) { + timelineViewModel.handle(RoomDetailAction.OpenElementCallWidget) + return@withState + } + val roomSummary = state.asyncRoomSummary.invoke() ?: return@withState when (roomSummary.joinedMembersCount) { 1 -> { 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 31c1004ef9..4c924b32aa 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 @@ -498,6 +498,7 @@ class TimelineFragment @Inject constructor( RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects() is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it) RoomDetailViewEvents.RoomReplacementStarted -> handleRoomReplacement() + RoomDetailViewEvents.OpenElementCallWidget -> handleOpenElementCallWidget() } } @@ -859,6 +860,9 @@ class TimelineFragment @Inject constructor( views.locationLiveStatusIndicator.stopButton.debouncedClicks { timelineViewModel.handle(RoomDetailAction.StopLiveLocationSharing) } + views.locationLiveStatusIndicator.debouncedClicks { + navigateToLocationLiveMap() + } } private fun joinJitsiRoom(jitsiWidget: Widget, enableVideo: Boolean) { @@ -1090,9 +1094,8 @@ class TimelineFragment @Inject constructor( 2 -> state.isAllowedToStartWebRTCCall else -> state.isAllowedToManageWidgets } - setOf(R.id.voice_call, R.id.video_call).forEach { - menu.findItem(it).icon?.alpha = if (callButtonsEnabled) 0xFF else 0x40 - } + menu.findItem(R.id.video_call).icon?.alpha = if (callButtonsEnabled) 0xFF else 0x40 + menu.findItem(R.id.voice_call).icon?.alpha = if (callButtonsEnabled || state.hasActiveElementCallWidget()) 0xFF else 0x40 val matrixAppsMenuItem = menu.findItem(R.id.open_matrix_apps) val widgetsCount = state.activeRoomWidgets.invoke()?.size ?: 0 @@ -1206,9 +1209,9 @@ class TimelineFragment @Inject constructor( getRootThreadEventId()?.let { val newRoom = timelineArgs.copy(threadTimelineArgs = null, eventId = it) context?.let { con -> - val int = RoomDetailActivity.newIntent(con, newRoom) - int.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK - con.startActivity(int) + val intent = RoomDetailActivity.newIntent(con, newRoom, false) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK + con.startActivity(intent) } } } @@ -1257,7 +1260,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) + is MessageBeaconInfoContent -> getString(R.string.live_location_description) else -> messageContent?.body.orEmpty() } var formattedBody: CharSequence? = null @@ -2653,6 +2656,15 @@ class TimelineFragment @Inject constructor( .show(childFragmentManager, "ROOM_WIDGETS_BOTTOM_SHEET") } + private fun handleOpenElementCallWidget() = withState(timelineViewModel) { state -> + state + .activeRoomWidgets() + ?.find { it.type == WidgetType.ElementCall } + ?.also { widget -> + navigator.openRoomWidget(requireContext(), state.roomId, widget) + } + } + override fun onTapToReturnToCall() { callManager.getCurrentCall()?.let { call -> VectorCallActivity.newIntent( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index 2e313f04ae..848bd3aed4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -18,7 +18,6 @@ package im.vector.app.features.home.room.detail import android.net.Uri import androidx.annotation.IdRes -import androidx.lifecycle.asFlow import com.airbnb.mvrx.Async import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Loading @@ -467,6 +466,13 @@ class TimelineViewModel @AssistedInject constructor( } is RoomDetailAction.EndPoll -> handleEndPoll(action.eventId) RoomDetailAction.StopLiveLocationSharing -> handleStopLiveLocationSharing() + RoomDetailAction.OpenElementCallWidget -> handleOpenElementCallWidget() + } + } + + private fun handleOpenElementCallWidget() = withState { state -> + if (state.hasActiveElementCallWidget()) { + _viewEvents.post(RoomDetailViewEvents.OpenElementCallWidget) } } @@ -752,7 +758,7 @@ class TimelineViewModel @AssistedInject constructor( R.id.timeline_setting -> true R.id.invite -> state.canInvite R.id.open_matrix_apps -> true - R.id.voice_call -> state.isCallOptionAvailable() + R.id.voice_call -> state.isCallOptionAvailable() || state.hasActiveElementCallWidget() R.id.video_call -> state.isCallOptionAvailable() || state.jitsiState.confId == null || state.jitsiState.hasJoined // Show Join conference button only if there is an active conf id not joined. Otherwise fallback to default video disabled. ^ R.id.join_conference -> !state.isCallOptionAvailable() && state.jitsiState.confId != null && !state.jitsiState.hasJoined @@ -1145,8 +1151,7 @@ class TimelineViewModel @AssistedInject constructor( copy(syncState = syncState) } - session.syncService().getSyncRequestStateLive() - .asFlow() + session.syncService().getSyncRequestStateFlow() .filterIsInstance() .setOnEach { copy(incrementalSyncRequestState = it) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/LiveLocationShareMessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/LiveLocationShareMessageItemFactory.kt index a4c906d97b..493602a291 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/LiveLocationShareMessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/LiveLocationShareMessageItemFactory.kt @@ -102,7 +102,6 @@ class LiveLocationShareMessageItemFactory @Inject constructor( attributes: AbsMessageItem.Attributes, runningState: LiveLocationShareViewState.Running, ): MessageLiveLocationItem { - // TODO only render location if enabled in preferences: to be handled in a next PR val width = timelineMediaSizeProvider.getMaxSize().first val height = dimensionConverter.dpToPx(MessageItemFactory.MESSAGE_LOCATION_ITEM_HEIGHT_IN_DP) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultLiveLocationShareStatusItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultLiveLocationShareStatusItem.kt index c421efda12..9a729caa31 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultLiveLocationShareStatusItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultLiveLocationShareStatusItem.kt @@ -19,7 +19,10 @@ package im.vector.app.features.home.room.detail.timeline.item import android.content.res.Resources import android.graphics.drawable.ColorDrawable import android.widget.ImageView +import androidx.core.content.ContextCompat import androidx.core.view.updateLayoutParams +import com.bumptech.glide.load.MultiTransformation +import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners import com.bumptech.glide.load.resource.bitmap.RoundedCorners import im.vector.app.R @@ -50,8 +53,8 @@ class DefaultLiveLocationShareStatusItem : LiveLocationShareStatusItem { height = mapHeight } GlideApp.with(mapImageView) - .load(R.drawable.bg_no_location_map) - .transform(mapCornerTransformation) + .load(ContextCompat.getDrawable(mapImageView.context, R.drawable.bg_no_location_map)) + .transform(MultiTransformation(CenterCrop(), mapCornerTransformation)) .into(mapImageView) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationInactiveItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationInactiveItem.kt index bc6e96b0ee..fae8091f06 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationInactiveItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationInactiveItem.kt @@ -42,7 +42,7 @@ abstract class MessageLiveLocationInactiveItem : override fun getViewStubId() = STUB_ID class Holder : AbsMessageItem.Holder(STUB_ID) { - val bannerImageView by bind(R.id.locationLiveInactiveBanner) + val bannerImageView by bind(R.id.locationLiveEndedBannerBackground) val noLocationMapImageView by bind(R.id.locationLiveInactiveMap) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationItem.kt index 84080eaad9..b35b4dff8b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLiveLocationItem.kt @@ -26,8 +26,8 @@ import im.vector.app.core.resources.toTimestamp import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.home.room.detail.RoomDetailAction import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout -import im.vector.app.features.location.live.LocationLiveMessageBannerView import im.vector.app.features.location.live.LocationLiveMessageBannerViewState +import im.vector.app.features.location.live.LocationLiveRunningBannerView import org.threeten.bp.LocalDateTime @EpoxyModelClass @@ -52,9 +52,9 @@ abstract class MessageLiveLocationItem : AbsMessageLocationItem(R.id.locationLiveMessageBanner) + val locationLiveRunningBanner by bind(R.id.locationLiveRunningBanner) } companion object { diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilder.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilder.kt index 8c422e60b4..686302ca36 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilder.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListSectionBuilder.kt @@ -331,7 +331,7 @@ class RoomListSectionBuilder( }, { queryParams -> val name = stringProvider.getString(R.string.bottom_action_rooms) - val updatableFilterLivePageResult = session.roomService().getFilteredPagedRoomSummariesLive(queryParams, getFlattenParents = true) + val updatableFilterLivePageResult = session.roomService().getFilteredPagedRoomSummariesLive(queryParams) onUpdatable(updatableFilterLivePageResult) val itemCountFlow = updatableFilterLivePageResult.livePagedList.asFlow() diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt index f50cec5149..85879e6807 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt @@ -207,9 +207,18 @@ class RoomSummaryItemFactory @Inject constructor( private fun getSearchResultSubtitle(roomSummary: RoomSummary): String { val userId = roomSummary.directUserId - val spaceName = roomSummary.flattenParents.lastOrNull()?.name + val directParent = joinParentNames(roomSummary) val canonicalAlias = roomSummary.canonicalAlias - return (userId ?: spaceName ?: canonicalAlias).orEmpty() + return (userId ?: directParent ?: canonicalAlias).orEmpty() + } + + private fun joinParentNames(roomSummary: RoomSummary) = with(roomSummary) { + when (val size = directParentNames.size) { + 0 -> null + 1 -> directParentNames.first() + 2 -> stringProvider.getString(R.string.search_space_two_parents, directParentNames[0], directParentNames[1]) + else -> stringProvider.getQuantityString(R.plurals.search_space_multiple_parents, size - 1, directParentNames[0], size - 1) + } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListAction.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListAction.kt new file mode 100644 index 0000000000..04c8524b50 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListAction.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.list.home + +import im.vector.app.core.platform.VectorViewModelAction +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.notification.RoomNotificationState + +sealed class HomeRoomListAction : VectorViewModelAction { + data class SelectRoom(val roomSummary: RoomSummary) : HomeRoomListAction() + data class ChangeRoomNotificationState(val roomId: String, val notificationState: RoomNotificationState) : HomeRoomListAction() + data class ToggleTag(val roomId: String, val tag: String) : HomeRoomListAction() + data class LeaveRoom(val roomId: String) : HomeRoomListAction() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListFragment.kt new file mode 100644 index 0000000000..f0eb027785 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListFragment.kt @@ -0,0 +1,226 @@ +/* + * 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.list.home + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.ConcatAdapter +import androidx.recyclerview.widget.LinearLayoutManager +import com.airbnb.epoxy.EpoxyControllerAdapter +import com.airbnb.epoxy.OnModelBuildFinishedListener +import com.airbnb.mvrx.fragmentViewModel +import com.airbnb.mvrx.withState +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import im.vector.app.R +import im.vector.app.core.epoxy.LayoutManagerStateRestorer +import im.vector.app.core.platform.StateView +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.resources.UserPreferencesProvider +import im.vector.app.databinding.FragmentRoomListBinding +import im.vector.app.features.analytics.plan.ViewRoom +import im.vector.app.features.home.RoomListDisplayMode +import im.vector.app.features.home.room.list.RoomListAnimator +import im.vector.app.features.home.room.list.RoomListListener +import im.vector.app.features.home.room.list.RoomSummaryItemFactory +import im.vector.app.features.home.room.list.RoomSummaryPagedController +import im.vector.app.features.home.room.list.actions.RoomListQuickActionsBottomSheet +import im.vector.app.features.home.room.list.actions.RoomListQuickActionsSharedAction +import im.vector.app.features.home.room.list.actions.RoomListQuickActionsSharedActionViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo +import org.matrix.android.sdk.api.session.room.model.tag.RoomTag +import org.matrix.android.sdk.api.session.room.notification.RoomNotificationState +import javax.inject.Inject + +class HomeRoomListFragment @Inject constructor( + private val roomSummaryItemFactory: RoomSummaryItemFactory, + private val userPreferencesProvider: UserPreferencesProvider +) : VectorBaseFragment(), + RoomListListener { + + private val roomListViewModel: HomeRoomListViewModel by fragmentViewModel() + private lateinit var sharedActionViewModel: RoomListQuickActionsSharedActionViewModel + private var concatAdapter = ConcatAdapter() + private var modelBuildListener: OnModelBuildFinishedListener? = null + + private lateinit var stateRestorer: LayoutManagerStateRestorer + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentRoomListBinding { + return FragmentRoomListBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + sharedActionViewModel = activityViewModelProvider.get(RoomListQuickActionsSharedActionViewModel::class.java) + sharedActionViewModel + .stream() + .onEach { handleQuickActions(it) } + .launchIn(viewLifecycleOwner.lifecycleScope) + + views.stateView.contentView = views.roomListView + views.stateView.state = StateView.State.Loading + + roomListViewModel.observeViewEvents { + when (it) { + is HomeRoomListViewEvents.Loading -> showLoading(it.message) + is HomeRoomListViewEvents.Failure -> showFailure(it.throwable) + is HomeRoomListViewEvents.SelectRoom -> handleSelectRoom(it, it.isInviteAlreadyAccepted) + is HomeRoomListViewEvents.Done -> Unit + } + } + + setupRecyclerView() + } + + private fun setupRecyclerView() { + val layoutManager = LinearLayoutManager(context) + stateRestorer = LayoutManagerStateRestorer(layoutManager).register() + views.roomListView.layoutManager = layoutManager + views.roomListView.itemAnimator = RoomListAnimator() + layoutManager.recycleChildrenOnDetach = true + + modelBuildListener = OnModelBuildFinishedListener { it.dispatchTo(stateRestorer) } + + roomListViewModel.sections.onEach { sections -> + setUpAdapters(sections) + }.launchIn(lifecycleScope) + + views.roomListView.adapter = concatAdapter + } + + override fun invalidate() = withState(roomListViewModel) { state -> + views.stateView.state = state.state + } + + private fun setUpAdapters(sections: Set) { + sections.forEach { + concatAdapter.addAdapter(getAdapterForData(it)) + } + } + + private fun handleQuickActions(quickAction: RoomListQuickActionsSharedAction) { + when (quickAction) { + is RoomListQuickActionsSharedAction.NotificationsAllNoisy -> { + roomListViewModel.handle(HomeRoomListAction.ChangeRoomNotificationState(quickAction.roomId, RoomNotificationState.ALL_MESSAGES_NOISY)) + } + is RoomListQuickActionsSharedAction.NotificationsAll -> { + roomListViewModel.handle(HomeRoomListAction.ChangeRoomNotificationState(quickAction.roomId, RoomNotificationState.ALL_MESSAGES)) + } + is RoomListQuickActionsSharedAction.NotificationsMentionsOnly -> { + roomListViewModel.handle(HomeRoomListAction.ChangeRoomNotificationState(quickAction.roomId, RoomNotificationState.MENTIONS_ONLY)) + } + is RoomListQuickActionsSharedAction.NotificationsMute -> { + roomListViewModel.handle(HomeRoomListAction.ChangeRoomNotificationState(quickAction.roomId, RoomNotificationState.MUTE)) + } + is RoomListQuickActionsSharedAction.Settings -> { + navigator.openRoomProfile(requireActivity(), quickAction.roomId) + } + is RoomListQuickActionsSharedAction.Favorite -> { + roomListViewModel.handle(HomeRoomListAction.ToggleTag(quickAction.roomId, RoomTag.ROOM_TAG_FAVOURITE)) + } + is RoomListQuickActionsSharedAction.LowPriority -> { + roomListViewModel.handle(HomeRoomListAction.ToggleTag(quickAction.roomId, RoomTag.ROOM_TAG_LOW_PRIORITY)) + } + is RoomListQuickActionsSharedAction.Leave -> { + roomListViewModel.handle(HomeRoomListAction.LeaveRoom(quickAction.roomId)) + promptLeaveRoom(quickAction.roomId) + } + } + } + + private fun promptLeaveRoom(roomId: String) { + val isPublicRoom = roomListViewModel.isPublicRoom(roomId) + val message = buildString { + append(getString(R.string.room_participants_leave_prompt_msg)) + if (!isPublicRoom) { + append("\n\n") + append(getString(R.string.room_participants_leave_private_warning)) + } + } + MaterialAlertDialogBuilder(requireContext(), if (isPublicRoom) 0 else R.style.ThemeOverlay_Vector_MaterialAlertDialog_Destructive) + .setTitle(R.string.room_participants_leave_prompt_title) + .setMessage(message) + .setPositiveButton(R.string.action_leave) { _, _ -> + roomListViewModel.handle(HomeRoomListAction.LeaveRoom(roomId)) + } + .setNegativeButton(R.string.action_cancel, null) + .show() + } + + private fun getAdapterForData(data: HomeRoomSection): EpoxyControllerAdapter { + return when (data) { + is HomeRoomSection.RoomSummaryData -> { + RoomSummaryPagedController( + roomSummaryItemFactory, + RoomListDisplayMode.ROOMS + ).also { controller -> + controller.listener = this + data.list.observe(viewLifecycleOwner) { list -> + controller.submitList(list) + } + }.adapter + } + } + } + + private fun handleSelectRoom(event: HomeRoomListViewEvents.SelectRoom, isInviteAlreadyAccepted: Boolean) { + navigator.openRoom( + context = requireActivity(), + roomId = event.roomSummary.roomId, + isInviteAlreadyAccepted = isInviteAlreadyAccepted, + trigger = ViewRoom.Trigger.RoomList + ) + } + + // region RoomListListener + + override fun onRoomClicked(room: RoomSummary) { + roomListViewModel.handle(HomeRoomListAction.SelectRoom(room)) + } + + override fun onRoomLongClicked(room: RoomSummary): Boolean { + userPreferencesProvider.neverShowLongClickOnRoomHelpAgain() + RoomListQuickActionsBottomSheet + .newInstance(room.roomId) + .show(childFragmentManager, "ROOM_LIST_QUICK_ACTIONS") + return true + } + + override fun onRejectRoomInvitation(room: RoomSummary) { + TODO("Not yet implemented") + } + + override fun onAcceptRoomInvitation(room: RoomSummary) { + TODO("Not yet implemented") + } + + override fun onJoinSuggestedRoom(room: SpaceChildInfo) { + TODO("Not yet implemented") + } + + override fun onSuggestedRoomClicked(room: SpaceChildInfo) { + TODO("Not yet implemented") + } + + // endregion +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewEvents.kt new file mode 100644 index 0000000000..a80ae9fa93 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewEvents.kt @@ -0,0 +1,27 @@ +/* + * 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.list.home + +import im.vector.app.core.platform.VectorViewEvents +import org.matrix.android.sdk.api.session.room.model.RoomSummary + +sealed class HomeRoomListViewEvents : VectorViewEvents { + data class Loading(val message: CharSequence? = null) : HomeRoomListViewEvents() + data class Failure(val throwable: Throwable) : HomeRoomListViewEvents() + object Done : HomeRoomListViewEvents() + data class SelectRoom(val roomSummary: RoomSummary, val isInviteAlreadyAccepted: Boolean = false) : HomeRoomListViewEvents() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewModel.kt new file mode 100644 index 0000000000..3226ed24f2 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewModel.kt @@ -0,0 +1,206 @@ +/* + * 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.list.home + +import androidx.paging.PagedList +import arrow.core.toOption +import com.airbnb.mvrx.MavericksViewModelFactory +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.AppStateHandler +import im.vector.app.core.di.MavericksAssistedViewModelFactory +import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.platform.StateView +import im.vector.app.core.platform.VectorViewModel +import im.vector.app.features.home.room.list.RoomListViewModel +import im.vector.app.features.settings.VectorPreferences +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.query.SpaceFilter +import org.matrix.android.sdk.api.query.toActiveSpaceOrOrphanRooms +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.tag.RoomTag +import org.matrix.android.sdk.api.session.room.state.isPublic + +class HomeRoomListViewModel @AssistedInject constructor( + @Assisted initialState: HomeRoomListViewState, + private val session: Session, + private val appStateHandler: AppStateHandler, + private val vectorPreferences: VectorPreferences, +) : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: HomeRoomListViewState): HomeRoomListViewModel + } + + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + + private val pagedListConfig = PagedList.Config.Builder() + .setPageSize(10) + .setInitialLoadSizeHint(20) + .setEnablePlaceholders(true) + .setPrefetchDistance(10) + .build() + + private val _sections = MutableSharedFlow>(replay = 1) + val sections = _sections.asSharedFlow() + + init { + configureSections() + } + + private fun configureSections() { + val newSections = mutableSetOf() + + newSections.add(getAllRoomsSection()) + + viewModelScope.launch { + _sections.emit(newSections) + } + + setState { + copy(state = StateView.State.Content) + } + } + + private fun getAllRoomsSection(): HomeRoomSection.RoomSummaryData { + val builder = RoomSummaryQueryParams.Builder().also { + it.memberships = listOf(Membership.JOIN) + } + + val filteredPagedRoomSummariesLive = session.roomService().getFilteredPagedRoomSummariesLive( + builder.build(), + pagedListConfig + ) + + appStateHandler.selectedSpaceFlow + .distinctUntilChanged() + .onStart { + emit(appStateHandler.getCurrentSpace().toOption()) + } + .onEach { selectedSpaceOption -> + val selectedSpace = selectedSpaceOption.orNull() + val strategy = if (!vectorPreferences.prefSpacesShowAllRoomInHome()) { + RoomListViewModel.SpaceFilterStrategy.ALL_IF_SPACE_NULL + } else { + RoomListViewModel.SpaceFilterStrategy.ORPHANS_IF_SPACE_NULL + } + filteredPagedRoomSummariesLive.queryParams = filteredPagedRoomSummariesLive.queryParams.copy( + spaceFilter = getSpaceFilter(selectedSpaceId = selectedSpace?.roomId, strategy) + ) + }.launchIn(viewModelScope) + + return HomeRoomSection.RoomSummaryData( + list = filteredPagedRoomSummariesLive.livePagedList + ) + } + + private fun getSpaceFilter(selectedSpaceId: String?, strategy: RoomListViewModel.SpaceFilterStrategy): SpaceFilter? { + return when (strategy) { + RoomListViewModel.SpaceFilterStrategy.ORPHANS_IF_SPACE_NULL -> { + selectedSpaceId?.toActiveSpaceOrOrphanRooms() + } + RoomListViewModel.SpaceFilterStrategy.ALL_IF_SPACE_NULL -> { + selectedSpaceId?.let { SpaceFilter.ActiveSpace(it) } + } + RoomListViewModel.SpaceFilterStrategy.NONE -> null + } + } + + override fun handle(action: HomeRoomListAction) { + when (action) { + is HomeRoomListAction.SelectRoom -> handleSelectRoom(action) + is HomeRoomListAction.LeaveRoom -> handleLeaveRoom(action) + is HomeRoomListAction.ChangeRoomNotificationState -> handleChangeNotificationMode(action) + is HomeRoomListAction.ToggleTag -> handleToggleTag(action) + } + } + + fun isPublicRoom(roomId: String): Boolean { + return session.getRoom(roomId)?.stateService()?.isPublic().orFalse() + } + + private fun handleSelectRoom(action: HomeRoomListAction.SelectRoom) = withState { + _viewEvents.post(HomeRoomListViewEvents.SelectRoom(action.roomSummary, false)) + } + + private fun handleLeaveRoom(action: HomeRoomListAction.LeaveRoom) { + _viewEvents.post(HomeRoomListViewEvents.Loading(null)) + viewModelScope.launch { + val value = runCatching { session.roomService().leaveRoom(action.roomId) } + .fold({ HomeRoomListViewEvents.Done }, { HomeRoomListViewEvents.Failure(it) }) + _viewEvents.post(value) + } + } + + private fun handleChangeNotificationMode(action: HomeRoomListAction.ChangeRoomNotificationState) { + val room = session.getRoom(action.roomId) + if (room != null) { + viewModelScope.launch { + try { + room.roomPushRuleService().setRoomNotificationState(action.notificationState) + } catch (failure: Exception) { + _viewEvents.post(HomeRoomListViewEvents.Failure(failure)) + } + } + } + } + + private fun handleToggleTag(action: HomeRoomListAction.ToggleTag) { + session.getRoom(action.roomId)?.let { room -> + viewModelScope.launch(Dispatchers.IO) { + try { + if (room.roomSummary()?.hasTag(action.tag) == false) { + // Favorite and low priority tags are exclusive, so maybe delete the other tag first + action.tag.otherTag() + ?.takeIf { room.roomSummary()?.hasTag(it).orFalse() } + ?.let { tagToRemove -> + room.tagsService().deleteTag(tagToRemove) + } + + // Set the tag. We do not handle the order for the moment + room.tagsService().addTag(action.tag, 0.5) + } else { + room.tagsService().deleteTag(action.tag) + } + } catch (failure: Throwable) { + _viewEvents.post(HomeRoomListViewEvents.Failure(failure)) + } + } + } + } + + private fun String.otherTag(): String? { + return when (this) { + RoomTag.ROOM_TAG_FAVOURITE -> RoomTag.ROOM_TAG_LOW_PRIORITY + RoomTag.ROOM_TAG_LOW_PRIORITY -> RoomTag.ROOM_TAG_FAVOURITE + else -> null + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewState.kt new file mode 100644 index 0000000000..bfcaea22e9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewState.kt @@ -0,0 +1,24 @@ +/* + * 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.list.home + +import com.airbnb.mvrx.MavericksState +import im.vector.app.core.platform.StateView + +data class HomeRoomListViewState( + val state: StateView.State = StateView.State.Loading +) : MavericksState diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomSection.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomSection.kt new file mode 100644 index 0000000000..7bfd0a769e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomSection.kt @@ -0,0 +1,27 @@ +/* + * 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.list.home + +import androidx.lifecycle.LiveData +import androidx.paging.PagedList +import org.matrix.android.sdk.api.session.room.model.RoomSummary + +sealed class HomeRoomSection { + data class RoomSummaryData( + val list: LiveData> + ) : HomeRoomSection() +} diff --git a/vector/src/main/java/im/vector/app/features/link/LinkHandlerActivity.kt b/vector/src/main/java/im/vector/app/features/link/LinkHandlerActivity.kt index 6de73cb20f..0bdec53f60 100644 --- a/vector/src/main/java/im/vector/app/features/link/LinkHandlerActivity.kt +++ b/vector/src/main/java/im/vector/app/features/link/LinkHandlerActivity.kt @@ -18,18 +18,23 @@ package im.vector.app.features.link import android.content.Intent import android.net.Uri +import android.os.Bundle import androidx.lifecycle.lifecycleScope +import com.airbnb.mvrx.viewModel import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.error.ErrorFormatter +import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.utils.toast import im.vector.app.databinding.ActivityProgressBinding +import im.vector.app.features.MainActivity import im.vector.app.features.home.HomeActivity import im.vector.app.features.login.LoginConfig import im.vector.app.features.permalink.PermalinkHandler +import im.vector.app.features.start.StartAppViewModel import kotlinx.coroutines.launch import org.matrix.android.sdk.api.session.permalinks.PermalinkService import timber.log.Timber @@ -45,12 +50,33 @@ class LinkHandlerActivity : VectorBaseActivity() { @Inject lateinit var errorFormatter: ErrorFormatter @Inject lateinit var permalinkHandler: PermalinkHandler + private val startAppViewModel: StartAppViewModel by viewModel() + override fun getBinding() = ActivityProgressBinding.inflate(layoutInflater) override fun initUiAndData() { handleIntent() } + private val launcher = registerStartForActivityResult { + if (it.resultCode == RESULT_OK) { + handleIntent() + } else { + // User has pressed back on the MainActivity, so finish also this one. + finish() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (startAppViewModel.shouldStartApp()) { + launcher.launch(MainActivity.getIntentToInitSession(this)) + } else { + handleIntent() + } + } + override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) handleIntent() diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingAndroidService.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingAndroidService.kt index dd18658059..635c0bf87d 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingAndroidService.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingAndroidService.kt @@ -122,7 +122,7 @@ class LocationSharingAndroidService : VectorAndroidService(), LocationTracker.Ca ?.locationSharingService() ?.startLiveLocationShare( timeoutMillis = roomArgs.durationMillis, - description = getString(R.string.sent_live_location) + description = getString(R.string.live_location_description) ) updateLiveResult diff --git a/vector/src/main/java/im/vector/app/features/location/MapBoxMapExt.kt b/vector/src/main/java/im/vector/app/features/location/MapBoxMapExt.kt index cbfdf1dfda..8e917c665a 100644 --- a/vector/src/main/java/im/vector/app/features/location/MapBoxMapExt.kt +++ b/vector/src/main/java/im/vector/app/features/location/MapBoxMapExt.kt @@ -28,10 +28,12 @@ fun MapboxMap?.zoomToLocation(locationData: LocationData, preserveCurrentZoomLev } else { INITIAL_MAP_ZOOM_IN_PREVIEW } - this?.cameraPosition = CameraPosition.Builder() - .target(LatLng(locationData.latitude, locationData.longitude)) - .zoom(zoomLevel) - .build() + this?.easeCamera { + CameraPosition.Builder() + .target(LatLng(locationData.latitude, locationData.longitude)) + .zoom(zoomLevel) + .build() + } } fun MapboxMap?.zoomToBounds(latLngBounds: LatLngBounds) { diff --git a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt index 1f9cb44c91..491386ba64 100644 --- a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt +++ b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt @@ -22,6 +22,7 @@ import android.util.AttributeSet import android.view.Gravity import android.widget.ImageView import androidx.core.content.ContextCompat +import androidx.core.content.res.use import androidx.core.view.marginBottom import androidx.core.view.marginTop import androidx.core.view.updateLayoutParams @@ -60,17 +61,13 @@ class MapTilerMapView @JvmOverloads constructor( private var dimensionConverter: DimensionConverter? = null init { - context.theme.obtainStyledAttributes( + context.obtainStyledAttributes( attrs, R.styleable.MapTilerMapView, 0, 0 - ).run { - try { - setLocateButtonVisibility(this) - } finally { - recycle() - } + ).use { + setLocateButtonVisibility(it) } dimensionConverter = DimensionConverter(resources) } diff --git a/vector/src/main/java/im/vector/app/features/location/live/LocationLiveEndedBannerView.kt b/vector/src/main/java/im/vector/app/features/location/live/LocationLiveEndedBannerView.kt new file mode 100644 index 0000000000..82fa17a625 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/live/LocationLiveEndedBannerView.kt @@ -0,0 +1,65 @@ +/* + * 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.location.live + +import android.content.Context +import android.content.res.TypedArray +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.res.use +import androidx.core.view.updateLayoutParams +import im.vector.app.R +import im.vector.app.databinding.ViewLocationLiveEndedBannerBinding + +private const val BACKGROUND_ALPHA = 0.75f + +class LocationLiveEndedBannerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr) { + + private val binding = ViewLocationLiveEndedBannerBinding.inflate( + LayoutInflater.from(context), + this + ) + + init { + context.obtainStyledAttributes( + attrs, + R.styleable.LocationLiveEndedBannerView, + 0, + 0 + ).use { + setBackgroundAlpha(it) + setIconMarginStart(it) + } + } + + private fun setBackgroundAlpha(typedArray: TypedArray) { + val withAlpha = typedArray.getBoolean(R.styleable.LocationLiveEndedBannerView_locLiveEndedBkgWithAlpha, false) + binding.locationLiveEndedBannerBackground.alpha = if (withAlpha) BACKGROUND_ALPHA else 1f + } + + private fun setIconMarginStart(typedArray: TypedArray) { + val margin = typedArray.getDimensionPixelOffset(R.styleable.LocationLiveEndedBannerView_locLiveEndedIconMarginStart, 0) + binding.locationLiveEndedBannerIcon.updateLayoutParams { + marginStart = margin + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/location/live/LocationLiveMessageBannerView.kt b/vector/src/main/java/im/vector/app/features/location/live/LocationLiveRunningBannerView.kt similarity index 87% rename from vector/src/main/java/im/vector/app/features/location/live/LocationLiveMessageBannerView.kt rename to vector/src/main/java/im/vector/app/features/location/live/LocationLiveRunningBannerView.kt index 51c7caed3a..4ca8475da1 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/LocationLiveMessageBannerView.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/LocationLiveRunningBannerView.kt @@ -31,34 +31,34 @@ import com.bumptech.glide.load.resource.bitmap.GranularRoundedCorners import im.vector.app.R import im.vector.app.core.glide.GlideApp import im.vector.app.core.utils.TextUtils -import im.vector.app.databinding.ViewLocationLiveMessageBannerBinding +import im.vector.app.databinding.ViewLocationLiveRunningBannerBinding import im.vector.app.features.themes.ThemeUtils import org.threeten.bp.Duration private const val REMAINING_TIME_COUNTER_INTERVAL_IN_MS = 1000L -class LocationLiveMessageBannerView @JvmOverloads constructor( +class LocationLiveRunningBannerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr) { - private val binding = ViewLocationLiveMessageBannerBinding.inflate( + private val binding = ViewLocationLiveRunningBannerBinding.inflate( LayoutInflater.from(context), this ) val stopButton: Button - get() = binding.locationLiveMessageBannerStop + get() = binding.locationLiveRunningBannerStop private val background: ImageView - get() = binding.locationLiveMessageBannerBackground + get() = binding.locationLiveRunningBannerBackground private val title: TextView - get() = binding.locationLiveMessageBannerTitle + get() = binding.locationLiveRunningBannerTitle private val subTitle: TextView - get() = binding.locationLiveMessageBannerSubTitle + get() = binding.locationLiveRunningBannerSubTitle private var countDownTimer: CountDownTimer? = null @@ -70,7 +70,7 @@ class LocationLiveMessageBannerView @JvmOverloads constructor( GlideApp.with(context) .load(ColorDrawable(ThemeUtils.getColor(context, android.R.attr.colorBackground))) - .placeholder(binding.locationLiveMessageBannerBackground.drawable) + .placeholder(binding.locationLiveRunningBannerBackground.drawable) .transform(GranularRoundedCorners(0f, 0f, viewState.bottomEndCornerRadiusInDp, viewState.bottomStartCornerRadiusInDp)) .into(background) } @@ -109,14 +109,14 @@ class LocationLiveMessageBannerView @JvmOverloads constructor( if (viewState.isStopButtonCenteredVertically) { constraintSet.connect( - R.id.locationLiveMessageBannerStop, + R.id.locationLiveRunningBannerStop, ConstraintSet.BOTTOM, - R.id.locationLiveMessageBannerBackground, + R.id.locationLiveRunningBannerBackground, ConstraintSet.BOTTOM, 0 ) } else { - constraintSet.clear(R.id.locationLiveMessageBannerStop, ConstraintSet.BOTTOM) + constraintSet.clear(R.id.locationLiveRunningBannerStop, ConstraintSet.BOTTOM) } constraintSet.applyTo(parentLayout) diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewFragment.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewFragment.kt index 1d6afa9cda..3aacd70f0e 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewFragment.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LocationLiveMapViewFragment.kt @@ -22,6 +22,8 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.graphics.drawable.toBitmap +import androidx.core.view.isGone +import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState @@ -45,6 +47,7 @@ import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.openLocation import im.vector.app.databinding.FragmentLocationLiveMapViewBinding +import im.vector.app.features.location.LocationData import im.vector.app.features.location.UrlMapProvider import im.vector.app.features.location.zoomToBounds import im.vector.app.features.location.zoomToLocation @@ -56,7 +59,6 @@ import javax.inject.Inject /** * Screen showing a map with all the current users sharing their live location in a room. */ - @AndroidEntryPoint class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment() { @@ -109,13 +111,6 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment - val bottomSheetHeight = BottomSheetBehavior.from(views.bottomSheet).peekHeight - mapboxMap.uiSettings.apply { - // Place copyright above the user list bottom sheet - setLogoMargins(dimensionConverter.dpToPx(8), 0, 0, bottomSheetHeight + dimensionConverter.dpToPx(8)) - setAttributionMargins(dimensionConverter.dpToPx(96), 0, 0, bottomSheetHeight + dimensionConverter.dpToPx(8)) - } - lifecycleScope.launch { mapboxMap.setStyle(urlMapProvider.getMapUrl()) { style -> mapStyle = style @@ -137,11 +132,9 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment) { + if (userLocations.isEmpty()) { + showEndedLiveBanner() + } else { + showUserList(userLocations) + } + } + + private fun showEndedLiveBanner() { + views.bottomSheet.isGone = true + views.liveLocationMapFragmentEndedBanner.isVisible = true + updateCopyrightMargin(bottomOffset = views.liveLocationMapFragmentEndedBanner.height) + } + + private fun showUserList(userLocations: List) { + val bottomSheetHeight = BottomSheetBehavior.from(views.bottomSheet).peekHeight + updateCopyrightMargin(bottomOffset = bottomSheetHeight) + views.bottomSheet.isVisible = true + views.liveLocationMapFragmentEndedBanner.isGone = true bottomSheetController.setData(userLocations) } + private fun updateCopyrightMargin(bottomOffset: Int) { + getOrCreateSupportMapFragment().getMapAsync { mapboxMap -> + mapboxMap.uiSettings.apply { + // Place copyright above the user list bottom sheet + setLogoMargins( + dimensionConverter.dpToPx(COPYRIGHT_MARGIN_DP), + 0, + 0, + bottomOffset + dimensionConverter.dpToPx(COPYRIGHT_MARGIN_DP) + ) + setAttributionMargins( + dimensionConverter.dpToPx(COPYRIGHT_ATTRIBUTION_MARGIN_DP), + 0, + 0, + bottomOffset + dimensionConverter.dpToPx(COPYRIGHT_MARGIN_DP) + ) + } + } + } + private fun updateMap(userLiveLocations: List) { symbolManager?.let { sManager -> val latLngBoundsBuilder = LatLngBounds.Builder() @@ -273,11 +304,13 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment - mapboxMap?.get()?.zoomToLocation(locationData, preserveCurrentZoomLevel = true) + mapboxMap?.get()?.zoomToLocation(locationData, preserveCurrentZoomLevel = false) } } companion object { private const val MAP_FRAGMENT_TAG = "im.vector.app.features.location.live.map" + private const val COPYRIGHT_MARGIN_DP = 8 + private const val COPYRIGHT_ATTRIBUTION_MARGIN_DP = 96 } } diff --git a/vector/src/main/java/im/vector/app/features/location/option/LocationSharingOptionView.kt b/vector/src/main/java/im/vector/app/features/location/option/LocationSharingOptionView.kt index d11ff00261..a4bc694aaf 100644 --- a/vector/src/main/java/im/vector/app/features/location/option/LocationSharingOptionView.kt +++ b/vector/src/main/java/im/vector/app/features/location/option/LocationSharingOptionView.kt @@ -24,6 +24,7 @@ import android.widget.ImageView import androidx.annotation.ColorInt import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat +import androidx.core.content.res.use import androidx.core.view.setPadding import im.vector.app.R import im.vector.app.core.extensions.tintBackground @@ -45,18 +46,14 @@ class LocationSharingOptionView @JvmOverloads constructor( ) init { - context.theme.obtainStyledAttributes( + context.obtainStyledAttributes( attrs, R.styleable.LocationSharingOptionView, 0, 0 - ).run { - try { - setIcon(this) - setTitle(this) - } finally { - recycle() - } + ).use { + setIcon(it) + setTitle(it) } } 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 4cbebd67a3..763d1eed38 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 @@ -221,7 +221,7 @@ open class LoginActivity : VectorBaseActivity(), UnlockedA analyticsScreenName = MobileScreen.ScreenName.Register } val authDescription = inferAuthDescription(loginViewState) - val intent = HomeActivity.newIntent(this, authenticationDescription = authDescription) + val intent = HomeActivity.newIntent(this, firstStartMainActivity = false, authenticationDescription = authDescription) startActivity(intent) finish() return diff --git a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt index 877aea4ba3..67bc9a78e7 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt @@ -173,7 +173,7 @@ class DefaultNavigator @Inject constructor( } val args = TimelineArgs(roomId = roomId, eventId = eventId, isInviteAlreadyAccepted = isInviteAlreadyAccepted) - val intent = RoomDetailActivity.newIntent(context, args) + val intent = RoomDetailActivity.newIntent(context, args, false) startActivity(context, intent, buildTask) } @@ -203,7 +203,7 @@ class DefaultNavigator @Inject constructor( eventId = null, openShareSpaceForId = spaceId.takeIf { postSwitchSpaceAction.showShareSheet } ) - val intent = RoomDetailActivity.newIntent(context, args) + val intent = RoomDetailActivity.newIntent(context, args, false) startActivity(context, intent, false) } } @@ -290,7 +290,7 @@ class DefaultNavigator @Inject constructor( override fun openRoomForSharingAndFinish(activity: Activity, roomId: String, sharedData: SharedData) { val args = TimelineArgs(roomId, null, sharedData) - val intent = RoomDetailActivity.newIntent(activity, args) + val intent = RoomDetailActivity.newIntent(activity, args, false) activity.startActivity(intent) activity.finish() } @@ -465,6 +465,9 @@ class DefaultNavigator @Inject constructor( val enableVideo = options?.get(JitsiCallViewModel.ENABLE_VIDEO_OPTION) == true context.startActivity(VectorJitsiActivity.newIntent(context, roomId = roomId, widgetId = widget.widgetId, enableVideo = enableVideo)) } + } else if (widget.type is WidgetType.ElementCall) { + val widgetArgs = widgetArgsBuilder.buildElementCallWidgetArgs(roomId, widget) + context.startActivity(WidgetActivity.newIntent(context, widgetArgs)) } else { val widgetArgs = widgetArgsBuilder.buildRoomWidgetArgs(roomId, widget) context.startActivity(WidgetActivity.newIntent(context, widgetArgs)) 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 71c8167788..2948565d58 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 @@ -53,6 +53,7 @@ import im.vector.app.core.resources.StringProvider 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.MainActivity import im.vector.app.features.call.VectorCallActivity import im.vector.app.features.call.service.CallHeadsUpActionReceiver import im.vector.app.features.call.webrtc.WebRtcCall @@ -239,9 +240,10 @@ class NotificationUtils @Inject constructor( @SuppressLint("NewApi") fun buildForegroundServiceNotification(@StringRes subTitleResId: Int, withProgress: Boolean = true): Notification { // build the pending intent go to the home screen if this is clicked. - val i = HomeActivity.newIntent(context) + val i = HomeActivity.newIntent(context, firstStartMainActivity = false) i.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP - val pi = PendingIntent.getActivity(context, 0, i, PendingIntentCompat.FLAG_IMMUTABLE) + val mainIntent = MainActivity.getIntentWithNextIntent(context, i) + val pi = PendingIntent.getActivity(context, 0, mainIntent, PendingIntentCompat.FLAG_IMMUTABLE) val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) @@ -344,7 +346,7 @@ class NotificationUtils @Inject constructor( ) val answerCallPendingIntent = TaskStackBuilder.create(context) - .addNextIntentWithParentStack(HomeActivity.newIntent(context)) + .addNextIntentWithParentStack(HomeActivity.newIntent(context, firstStartMainActivity = false)) .addNextIntent( VectorCallActivity.newIntent( context = context, @@ -468,7 +470,7 @@ class NotificationUtils @Inject constructor( ) val contentPendingIntent = TaskStackBuilder.create(context) - .addNextIntentWithParentStack(HomeActivity.newIntent(context)) + .addNextIntentWithParentStack(HomeActivity.newIntent(context, firstStartMainActivity = false)) .addNextIntent(VectorCallActivity.newIntent(context, call, null)) .getPendingIntent(clock.epochMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE) @@ -530,8 +532,8 @@ class NotificationUtils @Inject constructor( .setCategory(NotificationCompat.CATEGORY_CALL) val contentPendingIntent = TaskStackBuilder.create(context) - .addNextIntentWithParentStack(HomeActivity.newIntent(context)) - .addNextIntent(RoomDetailActivity.newIntent(context, TimelineArgs(callInformation.nativeRoomId))) + .addNextIntentWithParentStack(HomeActivity.newIntent(context, firstStartMainActivity = false)) + .addNextIntent(RoomDetailActivity.newIntent(context, TimelineArgs(callInformation.nativeRoomId), true)) .getPendingIntent(clock.epochMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE) builder.setContentIntent(contentPendingIntent) @@ -566,6 +568,19 @@ class NotificationUtils @Inject constructor( .build() } + /** + * Creates a notification that indicates the application is initializing. + */ + fun buildStartAppNotification(): Notification { + return NotificationCompat.Builder(context, LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID) + .setContentTitle(stringProvider.getString(R.string.updating_your_data)) + .setSmallIcon(R.drawable.sync) + .setColor(ThemeUtils.getColor(context, android.R.attr.colorPrimary)) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + fun buildDownloadFileNotification(uri: Uri, fileName: String, mimeType: String): Notification { return NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID) .setGroup(stringProvider.getString(R.string.app_name)) @@ -765,7 +780,11 @@ class NotificationUtils @Inject constructor( joinIntentPendingIntent ) - val contentIntent = HomeActivity.newIntent(context, inviteNotificationRoomId = inviteNotifiableEvent.roomId) + val contentIntent = HomeActivity.newIntent( + context, + firstStartMainActivity = true, + inviteNotificationRoomId = inviteNotifiableEvent.roomId + ) contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that contentIntent.data = createIgnoredUri(inviteNotifiableEvent.eventId) @@ -806,7 +825,7 @@ class NotificationUtils @Inject constructor( .setColor(accentColor) .setAutoCancel(true) .apply { - val contentIntent = HomeActivity.newIntent(context) + val contentIntent = HomeActivity.newIntent(context, firstStartMainActivity = true) contentIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that contentIntent.data = createIgnoredUri(simpleNotifiableEvent.eventId) @@ -828,14 +847,14 @@ class NotificationUtils @Inject constructor( } private fun buildOpenRoomIntent(roomId: String): PendingIntent? { - val roomIntentTap = RoomDetailActivity.newIntent(context, TimelineArgs(roomId = roomId, switchToParentSpace = true)) + val roomIntentTap = RoomDetailActivity.newIntent(context, TimelineArgs(roomId = roomId, switchToParentSpace = true), true) roomIntentTap.action = TAP_TO_VIEW_ACTION // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that roomIntentTap.data = createIgnoredUri("openRoom?$roomId") // Recreate the back stack return TaskStackBuilder.create(context) - .addNextIntentWithParentStack(HomeActivity.newIntent(context)) + .addNextIntentWithParentStack(HomeActivity.newIntent(context, firstStartMainActivity = false)) .addNextIntent(roomIntentTap) .getPendingIntent( clock.epochMillis().toInt(), @@ -844,13 +863,14 @@ class NotificationUtils @Inject constructor( } private fun buildOpenHomePendingIntentForSummary(): PendingIntent { - val intent = HomeActivity.newIntent(context, clearNotification = true) + val intent = HomeActivity.newIntent(context, firstStartMainActivity = false, clearNotification = true) intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP intent.data = createIgnoredUri("tapSummary") + val mainIntent = MainActivity.getIntentWithNextIntent(context, intent) return PendingIntent.getActivity( context, Random.nextInt(1000), - intent, + mainIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE ) } diff --git a/vector/src/main/java/im/vector/app/features/onboarding/Login2Variant.kt b/vector/src/main/java/im/vector/app/features/onboarding/Login2Variant.kt index 0d7c83e360..7def6d62f0 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/Login2Variant.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/Login2Variant.kt @@ -302,7 +302,8 @@ class Login2Variant( private fun terminate() { val intent = HomeActivity.newIntent( - activity + activity, + firstStartMainActivity = false, ) activity.startActivity(intent) activity.finish() diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewEvents.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewEvents.kt index ea6981a2b5..bbbf13fba9 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewEvents.kt @@ -53,7 +53,7 @@ sealed class OnboardingViewEvents : VectorViewEvents { object OnResetPasswordBreakerConfirmed : OnboardingViewEvents() object OnResetPasswordComplete : OnboardingViewEvents() - data class OnSendEmailSuccess(val email: String) : OnboardingViewEvents() + data class OnSendEmailSuccess(val email: String, val isRestoredSession: Boolean) : OnboardingViewEvents() data class OnSendMsisdnSuccess(val msisdn: String) : OnboardingViewEvents() data class OnWebLoginError(val errorCode: Int, val description: String, val failingUrl: String) : OnboardingViewEvents() 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 52c32d88e4..6cadb4308a 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 @@ -348,7 +348,10 @@ class OnboardingViewModel @AssistedInject constructor( 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.SendEmailSuccess -> { + _viewEvents.post(OnboardingViewEvents.OnSendEmailSuccess(it.email, isRestoredSession = false)) + setState { copy(registrationState = registrationState.copy(email = 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 -> { @@ -412,8 +415,8 @@ class OnboardingViewModel @AssistedInject constructor( authenticationService.cancelPendingLoginOrRegistration() setState { copy( - isLoading = false, - registrationState = RegistrationState(), + isLoading = false, + registrationState = RegistrationState(), ) } } @@ -486,7 +489,7 @@ class OnboardingViewModel @AssistedInject constructor( try { if (registrationWizard.isRegistrationStarted()) { currentThreePid?.let { - handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnSendEmailSuccess(it))) + handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnSendEmailSuccess(it, isRestoredSession = true))) } } } catch (e: Throwable) { diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt index fe2134618d..99678ea5c1 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt @@ -101,6 +101,7 @@ data class SelectedAuthenticationState( @Parcelize data class RegistrationState( + val email: String? = null, val isUserNameAvailable: Boolean = false, val selectedMatrixId: String? = null, ) : Parcelable diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/AbstractFtueAuthFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/AbstractFtueAuthFragment.kt index 7766523de9..072e94bc30 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/AbstractFtueAuthFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/AbstractFtueAuthFragment.kt @@ -46,6 +46,7 @@ abstract class AbstractFtueAuthFragment : VectorBaseFragment : VectorBaseFragment { + displayCancelDialog && viewModel.isRegistrationStarted && backIsHardExit() -> { // Ask for confirmation before cancelling the registration MaterialAlertDialogBuilder(requireActivity()) .setTitle(R.string.login_signup_cancel_confirmation_title) diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedRegisterFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedRegisterFragment.kt index 639045b5c0..c69706a17b 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedRegisterFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedRegisterFragment.kt @@ -75,7 +75,11 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu setupSubmitButton() views.createAccountRoot.realignPercentagesToParent() views.editServerButton.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.EditServerSelection)) } - views.createAccountPasswordInput.setOnImeDoneListener { submit() } + views.createAccountPasswordInput.setOnImeDoneListener { + if (canSubmit(views.createAccountInput.content(), views.createAccountPasswordInput.content())) { + submit() + } + } views.createAccountInput.onTextChange(viewLifecycleOwner) { viewModel.handle(OnboardingAction.ResetSelectedRegistrationUserName) @@ -87,15 +91,19 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu } } + private fun canSubmit(account: CharSequence, password: CharSequence): Boolean { + val accountIsValid = account.isNotEmpty() + val passwordIsValid = password.length >= MINIMUM_PASSWORD_LENGTH + return accountIsValid && passwordIsValid + } + private fun setupSubmitButton() { views.createAccountSubmit.setOnClickListener { submit() } views.createAccountInput.clearErrorOnChange(viewLifecycleOwner) views.createAccountPasswordInput.clearErrorOnChange(viewLifecycleOwner) combine(views.createAccountInput.editText().textChanges(), views.createAccountPasswordInput.editText().textChanges()) { account, password -> - val accountIsValid = account.isNotEmpty() - val passwordIsValid = password.length >= MINIMUM_PASSWORD_LENGTH - views.createAccountSubmit.isEnabled = accountIsValid && passwordIsValid + views.createAccountSubmit.isEnabled = canSubmit(account, password) }.launchIn(viewLifecycleOwner.lifecycleScope) } diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedServerSelectionFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedServerSelectionFragment.kt index bc44a7dbdb..749aac2898 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedServerSelectionFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedServerSelectionFragment.kt @@ -20,18 +20,20 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.inputmethod.EditorInfo 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.editText import im.vector.app.core.extensions.realignPercentagesToParent +import im.vector.app.core.extensions.setOnImeDoneListener import im.vector.app.core.extensions.toReducedUrl import im.vector.app.core.utils.ensureProtocol import im.vector.app.core.utils.ensureTrailingSlash import im.vector.app.core.utils.openUrlInExternalBrowser import im.vector.app.databinding.FragmentFtueServerSelectionCombinedBinding import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.OnboardingFlow import im.vector.app.features.onboarding.OnboardingViewEvents import im.vector.app.features.onboarding.OnboardingViewState import org.matrix.android.sdk.api.failure.isHomeserverUnavailable @@ -53,19 +55,19 @@ class FtueAuthCombinedServerSelectionFragment @Inject constructor() : AbstractFt views.chooseServerToolbar.setNavigationOnClickListener { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnBack)) } - views.chooseServerInput.editText?.setOnEditorActionListener { _, actionId, _ -> - when (actionId) { - EditorInfo.IME_ACTION_DONE -> { - updateServerUrl() - } + views.chooseServerInput.associateContentStateWith(button = views.chooseServerSubmit, enabledPredicate = { canSubmit(it) }) + views.chooseServerInput.setOnImeDoneListener { + if (canSubmit(views.chooseServerInput.content())) { + updateServerUrl() } - false } views.chooseServerGetInTouch.debouncedClicks { openUrlInExternalBrowser(requireContext(), getString(R.string.ftue_ems_url)) } views.chooseServerSubmit.debouncedClicks { updateServerUrl() } views.chooseServerInput.clearErrorOnChange(viewLifecycleOwner) } + private fun canSubmit(url: String) = url.isNotEmpty() + private fun updateServerUrl() { viewModel.handle(OnboardingAction.HomeServerChange.EditHomeServer(views.chooseServerInput.content().ensureProtocol().ensureTrailingSlash())) } @@ -75,6 +77,14 @@ class FtueAuthCombinedServerSelectionFragment @Inject constructor() : AbstractFt } override fun updateWithState(state: OnboardingViewState) { + views.chooseServerHeaderSubtitle.setText( + when (state.onboardingFlow) { + OnboardingFlow.SignIn -> R.string.ftue_auth_choose_server_sign_in_subtitle + OnboardingFlow.SignUp -> R.string.ftue_auth_choose_server_subtitle + else -> throw IllegalStateException("Invalid flow state") + } + ) + if (views.chooseServerInput.content().isEmpty()) { val userUrlInput = state.selectedHomeserver.userFacingUrl?.toReducedUrlKeepingSchemaIfInsecure() views.chooseServerInput.editText().setText(userUrlInput) 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 61da7e0d18..5de8fce82f 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 @@ -25,6 +25,8 @@ 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.editText +import im.vector.app.core.extensions.hasContent import im.vector.app.core.extensions.isEmail import im.vector.app.core.extensions.setOnImeDoneListener import im.vector.app.core.extensions.toReducedUrl @@ -61,6 +63,10 @@ class FtueAuthEmailEntryFragment @Inject constructor() : AbstractFtueAuthFragmen override fun updateWithState(state: OnboardingViewState) { views.emailEntryHeaderSubtitle.text = getString(R.string.ftue_auth_email_subtitle, state.selectedHomeserver.userFacingUrl.toReducedUrl()) + + if (!views.emailEntryInput.hasContent()) { + views.emailEntryInput.editText().setText(state.registrationState.email) + } } override fun onError(throwable: Throwable) { diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthResetPasswordEntryFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthResetPasswordEntryFragment.kt index 6282fded61..61826352bf 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthResetPasswordEntryFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthResetPasswordEntryFragment.kt @@ -31,6 +31,7 @@ import im.vector.app.core.extensions.setOnImeDoneListener import im.vector.app.databinding.FragmentFtueResetPasswordInputBinding import im.vector.app.features.onboarding.OnboardingAction import im.vector.app.features.onboarding.OnboardingViewState +import org.matrix.android.sdk.api.failure.isMissingEmailVerification @AndroidEntryPoint class FtueAuthResetPasswordEntryFragment : AbstractFtueAuthFragment() { @@ -61,7 +62,12 @@ class FtueAuthResetPasswordEntryFragment : AbstractFtueAuthFragment super.onError(throwable) + else -> { + views.newPasswordInput.error = errorFormatter.toHumanReadable(throwable) + } + } } override fun updateWithState(state: OnboardingViewState) { 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 bb8c523b5f..150ab74ec2 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 @@ -196,7 +196,7 @@ class FtueAuthVariant( activity.popBackstack() } is OnboardingViewEvents.OnSendEmailSuccess -> { - openWaitForEmailVerification(viewEvents.email) + openWaitForEmailVerification(viewEvents.email, viewEvents.isRestoredSession) } is OnboardingViewEvents.OnSendMsisdnSuccess -> { openMsisdnConfirmation(viewEvents.msisdn) @@ -413,17 +413,19 @@ class FtueAuthVariant( } } - private fun openWaitForEmailVerification(email: String) { - supportFragmentManager.popBackStack(FRAGMENT_REGISTRATION_STAGE_TAG, FragmentManager.POP_BACK_STACK_INCLUSIVE) + private fun openWaitForEmailVerification(email: String, isRestoredSession: Boolean) { when { vectorFeatures.isOnboardingCombinedRegisterEnabled() -> addRegistrationStageFragmentToBackstack( FtueAuthWaitForEmailFragment::class.java, - FtueAuthWaitForEmailFragmentArgument(email), - ) - else -> addRegistrationStageFragmentToBackstack( - FtueAuthLegacyWaitForEmailFragment::class.java, - FtueAuthWaitForEmailFragmentArgument(email), + FtueAuthWaitForEmailFragmentArgument(email, isRestoredSession), ) + else -> { + supportFragmentManager.popBackStack(FRAGMENT_REGISTRATION_STAGE_TAG, FragmentManager.POP_BACK_STACK_INCLUSIVE) + addRegistrationStageFragmentToBackstack( + FtueAuthLegacyWaitForEmailFragment::class.java, + FtueAuthWaitForEmailFragmentArgument(email, isRestoredSession), + ) + } } } @@ -482,7 +484,11 @@ class FtueAuthVariant( private fun navigateToHome() { withState(onboardingViewModel) { - val intent = HomeActivity.newIntent(activity, authenticationDescription = it.selectedAuthenticationState.description) + val intent = HomeActivity.newIntent( + activity, + firstStartMainActivity = false, + authenticationDescription = it.selectedAuthenticationState.description + ) activity.startActivity(intent) activity.finish() } diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWaitForEmailFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWaitForEmailFragment.kt index 4649c7c799..eb00dc3e21 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWaitForEmailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWaitForEmailFragment.kt @@ -21,7 +21,7 @@ import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.view.isVisible +import androidx.core.view.isInvisible import com.airbnb.mvrx.args import im.vector.app.R import im.vector.app.core.utils.colorTerminatingFullStop @@ -35,7 +35,8 @@ import javax.inject.Inject @Parcelize data class FtueAuthWaitForEmailFragmentArgument( - val email: String + val email: String, + val isRestoredSession: Boolean, ) : Parcelable /** @@ -48,6 +49,8 @@ class FtueAuthWaitForEmailFragment @Inject constructor( private val params: FtueAuthWaitForEmailFragmentArgument by args() private var inferHasLeftAndReturnedToScreen = false + override fun backIsHardExit() = params.isRestoredSession + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueWaitForEmailVerificationBinding { return FragmentFtueWaitForEmailVerificationBinding.inflate(inflater, container, false) } @@ -63,6 +66,7 @@ class FtueAuthWaitForEmailFragment @Inject constructor( .colorTerminatingFullStop(ThemeUtils.getColor(requireContext(), R.attr.colorSecondary)) views.emailVerificationSubtitle.text = getString(R.string.ftue_auth_email_verification_subtitle, params.email) views.emailVerificationResendEmail.debouncedClicks { + hideWaitingForVerificationLoading() viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.SendAgainThreePid)) } } @@ -75,19 +79,32 @@ class FtueAuthWaitForEmailFragment @Inject constructor( private fun showLoadingIfReturningToScreen() { when (inferHasLeftAndReturnedToScreen) { - true -> views.emailVerificationWaiting.isVisible = true + true -> showWaitingForVerificationLoading() false -> { inferHasLeftAndReturnedToScreen = true } } } + private fun hideWaitingForVerificationLoading() { + views.emailVerificationWaiting.isInvisible = true + } + + private fun showWaitingForVerificationLoading() { + views.emailVerificationWaiting.isInvisible = false + } + override fun onPause() { super.onPause() viewModel.handle(OnboardingAction.StopEmailValidationCheck) } override fun resetViewModel() { - viewModel.handle(OnboardingAction.ResetAuthenticationAttempt) + when { + backIsHardExit() -> viewModel.handle(OnboardingAction.ResetAuthenticationAttempt) + else -> { + // delegate to the previous step + } + } } } diff --git a/vector/src/main/java/im/vector/app/features/pin/lockscreen/biometrics/BiometricHelper.kt b/vector/src/main/java/im/vector/app/features/pin/lockscreen/biometrics/BiometricHelper.kt index a34b284193..ae4fa637b4 100644 --- a/vector/src/main/java/im/vector/app/features/pin/lockscreen/biometrics/BiometricHelper.kt +++ b/vector/src/main/java/im/vector/app/features/pin/lockscreen/biometrics/BiometricHelper.kt @@ -53,6 +53,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider import java.security.KeyStore +import javax.crypto.Cipher import javax.inject.Inject import kotlin.coroutines.CoroutineContext @@ -74,22 +75,19 @@ class BiometricHelper @Inject constructor( * Returns true if a weak biometric method (i.e.: some face or iris unlock implementations) can be used. */ val canUseWeakBiometricAuth: Boolean - get() = - configuration.isWeakBiometricsEnabled && biometricManager.canAuthenticate(BIOMETRIC_WEAK) == BIOMETRIC_SUCCESS + get() = configuration.isWeakBiometricsEnabled && biometricManager.canAuthenticate(BIOMETRIC_WEAK) == BIOMETRIC_SUCCESS /** * Returns true if a strong biometric method (i.e.: fingerprint, some face or iris unlock implementations) can be used. */ val canUseStrongBiometricAuth: Boolean - get() = - configuration.isStrongBiometricsEnabled && biometricManager.canAuthenticate(BIOMETRIC_STRONG) == BIOMETRIC_SUCCESS + get() = configuration.isStrongBiometricsEnabled && biometricManager.canAuthenticate(BIOMETRIC_STRONG) == BIOMETRIC_SUCCESS /** * Returns true if the device credentials can be used to unlock (system pin code, password, pattern, etc.). */ val canUseDeviceCredentialsAuth: Boolean - get() = - configuration.isDeviceCredentialUnlockEnabled && biometricManager.canAuthenticate(DEVICE_CREDENTIAL) == BIOMETRIC_SUCCESS + get() = configuration.isDeviceCredentialUnlockEnabled && biometricManager.canAuthenticate(DEVICE_CREDENTIAL) == BIOMETRIC_SUCCESS /** * Returns true if any system authentication method (biometric weak/strong or device credentials) can be used. @@ -120,7 +118,7 @@ class BiometricHelper @Inject constructor( */ @MainThread fun enableAuthentication(activity: FragmentActivity): Flow { - return authenticateInternal(activity, checkSystemKeyExists = false, cryptoObject = null) + return authenticateInternal(activity, checkSystemKeyExists = false, cryptoObject = getAuthCryptoObject()) } /** @@ -140,7 +138,7 @@ class BiometricHelper @Inject constructor( */ @MainThread fun authenticate(activity: FragmentActivity): Flow { - return authenticateInternal(activity, checkSystemKeyExists = true, cryptoObject = null) + return authenticateInternal(activity, checkSystemKeyExists = true, cryptoObject = getAuthCryptoObject()) } /** @@ -157,9 +155,9 @@ class BiometricHelper @Inject constructor( @SuppressLint("NewApi") @OptIn(ExperimentalCoroutinesApi::class) private fun authenticateInternal( - activity: FragmentActivity, - checkSystemKeyExists: Boolean, - cryptoObject: BiometricPrompt.CryptoObject? = null, + activity: FragmentActivity, + checkSystemKeyExists: Boolean, + cryptoObject: BiometricPrompt.CryptoObject, ): Flow { if (checkSystemKeyExists && !isSystemAuthEnabledAndValid) return flowOf(false) @@ -193,9 +191,9 @@ class BiometricHelper @Inject constructor( @VisibleForTesting(otherwise = PRIVATE) internal fun authenticateWithPromptInternal( - activity: FragmentActivity, - cryptoObject: BiometricPrompt.CryptoObject? = null, - channel: Channel, + activity: FragmentActivity, + cryptoObject: BiometricPrompt.CryptoObject, + channel: Channel, ): BiometricPrompt { val executor = ContextCompat.getMainExecutor(context) val callback = createSuspendingAuthCallback(channel, executor.asCoroutineDispatcher()) @@ -214,15 +212,12 @@ class BiometricHelper @Inject constructor( } .setAllowedAuthenticators(authenticators) .build() + return BiometricPrompt(activity, executor, callback).also { showFallbackFragmentIfNeeded(activity, channel.receiveAsFlow(), executor.asCoroutineDispatcher()) { // For some reason this seems to be needed unless we want to receive a fragment transaction exception delay(1L) - if (cryptoObject != null) { - it.authenticate(promptInfo, cryptoObject) - } else { - it.authenticate(promptInfo) - } + it.authenticate(promptInfo, cryptoObject) } } } @@ -270,13 +265,28 @@ class BiometricHelper @Inject constructor( } override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - scope.launch { - channel.send(true) - // Success is a terminal event, should close both the Channel and the CoroutineScope to free resources. - channel.close() - scope.cancel() + val cipher = result.cryptoObject?.cipher + if (isCipherValid(cipher)) { + scope.launch { + channel.send(true) + // Success is a terminal event, should close both the Channel and the CoroutineScope to free resources. + channel.close() + scope.cancel() + } + } else { + scope.launch { + channel.close(IllegalStateException("System key was not valid after authentication.")) + scope.cancel() + } } } + + private fun isCipherValid(cipher: Cipher?): Boolean { + if (cipher == null) return false + return runCatching { + cipher.doFinal("biometric_challenge".toByteArray()) + }.isSuccess + } } /** @@ -321,6 +331,9 @@ class BiometricHelper @Inject constructor( @VisibleForTesting(otherwise = PRIVATE) internal fun createAuthChannel(): Channel = Channel(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + @VisibleForTesting(otherwise = PRIVATE) + internal fun getAuthCryptoObject(): BiometricPrompt.CryptoObject = lockScreenKeyRepository.getSystemKeyAuthCryptoObject() + companion object { private const val FALLBACK_BIOMETRIC_FRAGMENT_TAG = "fragment.biometric_fallback" } diff --git a/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/KeyStoreCrypto.kt b/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/KeyStoreCrypto.kt index 95266d7663..4c52fccbaa 100644 --- a/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/KeyStoreCrypto.kt +++ b/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/KeyStoreCrypto.kt @@ -18,11 +18,11 @@ package im.vector.app.features.pin.lockscreen.crypto import android.annotation.SuppressLint import android.content.Context -import android.hardware.biometrics.BiometricPrompt import android.os.Build import android.security.keystore.KeyPermanentlyInvalidatedException import android.util.Base64 -import androidx.annotation.RequiresApi +import androidx.annotation.VisibleForTesting +import androidx.biometric.BiometricPrompt import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -40,8 +40,6 @@ class KeyStoreCrypto @AssistedInject constructor( context: Context, private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, private val keyStore: KeyStore, - // It's easier to test it this way - private val secretStoringUtils: SecretStoringUtils = SecretStoringUtils(context, keyStore, buildVersionSdkIntProvider, keyNeedsUserAuthentication) ) { @AssistedFactory @@ -49,6 +47,9 @@ class KeyStoreCrypto @AssistedInject constructor( fun provide(alias: String, keyNeedsUserAuthentication: Boolean): KeyStoreCrypto } + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var secretStoringUtils: SecretStoringUtils = SecretStoringUtils(context, keyStore, buildVersionSdkIntProvider, keyNeedsUserAuthentication) + /** * Ensures a [Key] for the [alias] exists and validates it. * @throws KeyPermanentlyInvalidatedException if key is not valid. @@ -137,6 +138,5 @@ class KeyStoreCrypto @AssistedInject constructor( * @throws KeyPermanentlyInvalidatedException if key is invalidated. */ @Throws(KeyPermanentlyInvalidatedException::class) - @RequiresApi(Build.VERSION_CODES.P) - fun getCryptoObject() = BiometricPrompt.CryptoObject(secretStoringUtils.getEncryptCipher(alias)) + fun getAuthCryptoObject() = BiometricPrompt.CryptoObject(secretStoringUtils.getEncryptCipher(alias)) } diff --git a/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/LockScreenKeyRepository.kt b/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/LockScreenKeyRepository.kt index 4a7bce8a52..2581951789 100644 --- a/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/LockScreenKeyRepository.kt +++ b/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/LockScreenKeyRepository.kt @@ -19,22 +19,22 @@ package im.vector.app.features.pin.lockscreen.crypto import android.os.Build import android.security.keystore.KeyPermanentlyInvalidatedException import androidx.annotation.RequiresApi -import im.vector.app.features.settings.VectorPreferences -import timber.log.Timber +import androidx.biometric.BiometricPrompt +import im.vector.app.features.pin.lockscreen.di.BiometricKeyAlias +import im.vector.app.features.pin.lockscreen.di.PinCodeKeyAlias +import javax.inject.Inject +import javax.inject.Singleton /** * Class in charge of managing both the PIN code key and the system authentication keys. */ -class LockScreenKeyRepository( - baseName: String, - private val pinCodeMigrator: PinCodeMigrator, - private val vectorPreferences: VectorPreferences, +@Singleton +class LockScreenKeyRepository @Inject constructor( + @PinCodeKeyAlias private val pinCodeKeyAlias: String, + @BiometricKeyAlias private val systemKeyAlias: String, private val keyStoreCryptoFactory: KeyStoreCrypto.Factory, ) { - private val pinCodeKeyAlias = "$baseName.pin_code" - private val systemKeyAlias = "$baseName.system" - private val pinCodeCrypto: KeyStoreCrypto by lazy { keyStoreCryptoFactory.provide(pinCodeKeyAlias, keyNeedsUserAuthentication = false) } @@ -86,19 +86,7 @@ class LockScreenKeyRepository( fun isSystemKeyValid() = systemKeyCrypto.hasValidKey() /** - * Migrates the PIN code key and encrypted value to use a more secure cipher, also creates a new system key if needed. + * Returns a [BiometricPrompt.CryptoObject] for the system key. */ - suspend fun migrateKeysIfNeeded() { - if (pinCodeMigrator.isMigrationNeeded()) { - pinCodeMigrator.migrate(pinCodeKeyAlias) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && vectorPreferences.useBiometricsToUnlock()) { - try { - ensureSystemKey() - } catch (e: KeyPermanentlyInvalidatedException) { - Timber.e("Could not automatically create biometric key.", e) - } - } - } - } + fun getSystemKeyAuthCryptoObject() = systemKeyCrypto.getAuthCryptoObject() } diff --git a/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/LockScreenKeysMigrator.kt b/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/LockScreenKeysMigrator.kt new file mode 100644 index 0000000000..68acfcebf3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/LockScreenKeysMigrator.kt @@ -0,0 +1,50 @@ +/* + * 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.pin.lockscreen.crypto + +import android.annotation.SuppressLint +import android.os.Build +import im.vector.app.features.pin.lockscreen.crypto.migrations.LegacyPinCodeMigrator +import im.vector.app.features.pin.lockscreen.crypto.migrations.MissingSystemKeyMigrator +import im.vector.app.features.pin.lockscreen.crypto.migrations.SystemKeyV1Migrator +import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider +import javax.inject.Inject + +/** + * Performs all migrations needed for the lock screen keys. + */ +class LockScreenKeysMigrator @Inject constructor( + private val legacyPinCodeMigrator: LegacyPinCodeMigrator, + private val missingSystemKeyMigrator: MissingSystemKeyMigrator, + private val systemKeyV1Migrator: SystemKeyV1Migrator, + private val versionProvider: BuildVersionSdkIntProvider, +) { + /** + * Performs any needed migrations in order. + */ + @SuppressLint("NewApi") + suspend fun migrateIfNeeded() { + if (legacyPinCodeMigrator.isMigrationNeeded()) { + legacyPinCodeMigrator.migrate() + missingSystemKeyMigrator.migrate() + } + + if (systemKeyV1Migrator.isMigrationNeeded() && versionProvider.get() >= Build.VERSION_CODES.M) { + systemKeyV1Migrator.migrate() + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/PinCodeMigrator.kt b/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/migrations/LegacyPinCodeMigrator.kt similarity index 88% rename from vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/PinCodeMigrator.kt rename to vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/migrations/LegacyPinCodeMigrator.kt index 976ee48e5f..5d790ba01a 100644 --- a/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/PinCodeMigrator.kt +++ b/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/migrations/LegacyPinCodeMigrator.kt @@ -14,13 +14,14 @@ * limitations under the License. */ -package im.vector.app.features.pin.lockscreen.crypto +package im.vector.app.features.pin.lockscreen.crypto.migrations import android.os.Build import android.util.Base64 import androidx.annotation.VisibleForTesting import im.vector.app.features.pin.PinCodeStore import im.vector.app.features.pin.lockscreen.crypto.LockScreenCryptoConstants.LEGACY_PIN_CODE_KEY_ALIAS +import im.vector.app.features.pin.lockscreen.di.PinCodeKeyAlias import org.matrix.android.sdk.api.securestorage.SecretStoringUtils import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider import java.security.Key @@ -31,7 +32,8 @@ import javax.inject.Inject /** * Used to migrate from the old PIN code key ciphers to a more secure ones. */ -class PinCodeMigrator @Inject constructor( +class LegacyPinCodeMigrator @Inject constructor( + @PinCodeKeyAlias private val pinCodeKeyAlias: String, private val pinCodeStore: PinCodeStore, private val keyStore: KeyStore, private val secretStoringUtils: SecretStoringUtils, @@ -41,13 +43,13 @@ class PinCodeMigrator @Inject constructor( private val legacyKey: Key get() = keyStore.getKey(LEGACY_PIN_CODE_KEY_ALIAS, null) /** - * Migrates from the old ciphers and [LEGACY_PIN_CODE_KEY_ALIAS] to the [newAlias]. + * Migrates from the old ciphers and renames [LEGACY_PIN_CODE_KEY_ALIAS] to [pinCodeKeyAlias]. */ - suspend fun migrate(newAlias: String) { + suspend fun migrate() { if (!keyStore.containsAlias(LEGACY_PIN_CODE_KEY_ALIAS)) return val pinCode = getDecryptedPinCode() ?: return - val encryptedBytes = secretStoringUtils.securelyStoreBytes(pinCode.toByteArray(), newAlias) + val encryptedBytes = secretStoringUtils.securelyStoreBytes(pinCode.toByteArray(), pinCodeKeyAlias) val encryptedPinCode = Base64.encodeToString(encryptedBytes, Base64.NO_WRAP) pinCodeStore.savePinCode(encryptedPinCode) keyStore.deleteEntry(LEGACY_PIN_CODE_KEY_ALIAS) diff --git a/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/migrations/MissingSystemKeyMigrator.kt b/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/migrations/MissingSystemKeyMigrator.kt new file mode 100644 index 0000000000..f8d3520047 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/migrations/MissingSystemKeyMigrator.kt @@ -0,0 +1,52 @@ +/* + * 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.pin.lockscreen.crypto.migrations + +import android.annotation.SuppressLint +import android.os.Build +import android.security.keystore.KeyPermanentlyInvalidatedException +import im.vector.app.features.pin.lockscreen.crypto.KeyStoreCrypto +import im.vector.app.features.pin.lockscreen.di.BiometricKeyAlias +import im.vector.app.features.settings.VectorPreferences +import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider +import timber.log.Timber +import javax.inject.Inject + +/** + * Creates a new system/biometric key when migrating from the old PFLockScreen implementation. + */ +class MissingSystemKeyMigrator @Inject constructor( + @BiometricKeyAlias private val systemKeyAlias: String, + private val keystoreCryptoFactory: KeyStoreCrypto.Factory, + private val vectorPreferences: VectorPreferences, + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, +) { + + /** + * If user had biometric auth enabled, ensure system key exists, creating one if needed. + */ + @SuppressLint("NewApi") + fun migrate() { + if (buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M && vectorPreferences.useBiometricsToUnlock()) { + try { + keystoreCryptoFactory.provide(systemKeyAlias, true).ensureKey() + } catch (e: KeyPermanentlyInvalidatedException) { + Timber.e("Could not automatically create biometric key.", e) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/migrations/SystemKeyV1Migrator.kt b/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/migrations/SystemKeyV1Migrator.kt new file mode 100644 index 0000000000..25e9d24272 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/pin/lockscreen/crypto/migrations/SystemKeyV1Migrator.kt @@ -0,0 +1,53 @@ +/* + * 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.pin.lockscreen.crypto.migrations + +import android.os.Build +import androidx.annotation.RequiresApi +import im.vector.app.features.pin.lockscreen.crypto.KeyStoreCrypto +import im.vector.app.features.pin.lockscreen.di.BiometricKeyAlias +import java.security.KeyStore +import javax.inject.Inject + +/** + * Migrates from the v1 of the biometric/system key to the new format, adding extra security measures to the new key. + */ +class SystemKeyV1Migrator @Inject constructor( + @BiometricKeyAlias private val systemKeyAlias: String, + private val keyStore: KeyStore, + private val keystoreCryptoFactory: KeyStoreCrypto.Factory, +) { + + /** + * Removes the old v1 system key and creates a new system key. + */ + @RequiresApi(Build.VERSION_CODES.M) + fun migrate() { + keyStore.deleteEntry(SYSTEM_KEY_ALIAS_V1) + val systemKeyStoreCrypto = keystoreCryptoFactory.provide(systemKeyAlias, keyNeedsUserAuthentication = true) + systemKeyStoreCrypto.ensureKey() + } + + /** + * Checks if an entry with [SYSTEM_KEY_ALIAS_V1] exists in the [keyStore]. + */ + fun isMigrationNeeded() = keyStore.containsAlias(SYSTEM_KEY_ALIAS_V1) + + companion object { + internal const val SYSTEM_KEY_ALIAS_V1 = "vector.system" + } +} diff --git a/vector/src/main/java/im/vector/app/features/pin/lockscreen/di/LockScreenModule.kt b/vector/src/main/java/im/vector/app/features/pin/lockscreen/di/LockScreenModule.kt index ef8f03b9e5..fb333b96bb 100644 --- a/vector/src/main/java/im/vector/app/features/pin/lockscreen/di/LockScreenModule.kt +++ b/vector/src/main/java/im/vector/app/features/pin/lockscreen/di/LockScreenModule.kt @@ -30,35 +30,32 @@ import im.vector.app.core.di.MavericksViewModelKey import im.vector.app.features.pin.PinCodeStore import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguration import im.vector.app.features.pin.lockscreen.configuration.LockScreenMode -import im.vector.app.features.pin.lockscreen.crypto.KeyStoreCrypto -import im.vector.app.features.pin.lockscreen.crypto.LockScreenKeyRepository -import im.vector.app.features.pin.lockscreen.crypto.PinCodeMigrator +import im.vector.app.features.pin.lockscreen.crypto.migrations.LegacyPinCodeMigrator import im.vector.app.features.pin.lockscreen.pincode.EncryptedPinCodeStorage import im.vector.app.features.pin.lockscreen.ui.LockScreenViewModel -import im.vector.app.features.settings.VectorPreferences import org.matrix.android.sdk.api.securestorage.SecretStoringUtils import org.matrix.android.sdk.api.util.BuildVersionSdkIntProvider import org.matrix.android.sdk.api.util.DefaultBuildVersionSdkIntProvider import java.security.KeyStore -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object LockScreenModule { + @Provides + @PinCodeKeyAlias + fun providePinCodeKeyAlias(): String = "vector.pin_code" + + @Provides + @BiometricKeyAlias + fun provideSystemKeyAlias(): String = "vector.system_v2" + @Provides fun provideKeyStore(): KeyStore = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) } @Provides fun provideBuildVersionSdkIntProvider(): BuildVersionSdkIntProvider = DefaultBuildVersionSdkIntProvider() - @Provides - fun provideSecretStoringUtils( - @ApplicationContext context: Context, - keyStore: KeyStore, - buildVersionSdkIntProvider: BuildVersionSdkIntProvider, - ) = SecretStoringUtils(context, keyStore, buildVersionSdkIntProvider) - @Provides fun provideLockScreenConfig() = LockScreenConfiguration( mode = LockScreenMode.VERIFY, @@ -70,20 +67,22 @@ object LockScreenModule { ) @Provides - @Singleton - fun provideKeyRepository( - pinCodeMigrator: PinCodeMigrator, - vectorPreferences: VectorPreferences, - keyStoreCryptoFactory: KeyStoreCrypto.Factory, - ) = LockScreenKeyRepository( - baseName = "vector", - pinCodeMigrator, - vectorPreferences, - keyStoreCryptoFactory, - ) + fun provideBiometricManager(@ApplicationContext context: Context) = BiometricManager.from(context) @Provides - fun provideBiometricManager(@ApplicationContext context: Context) = BiometricManager.from(context) + fun provideLegacyPinCodeMigrator( + @PinCodeKeyAlias pinCodeKeyAlias: String, + context: Context, + pinCodeStore: PinCodeStore, + keyStore: KeyStore, + buildVersionSdkIntProvider: BuildVersionSdkIntProvider, + ) = LegacyPinCodeMigrator( + pinCodeKeyAlias, + pinCodeStore, + keyStore, + SecretStoringUtils(context, keyStore, buildVersionSdkIntProvider), + buildVersionSdkIntProvider, + ) } @Module diff --git a/vector/src/main/java/im/vector/app/features/pin/lockscreen/di/LockScreenQualifiers.kt b/vector/src/main/java/im/vector/app/features/pin/lockscreen/di/LockScreenQualifiers.kt new file mode 100644 index 0000000000..0954772772 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/pin/lockscreen/di/LockScreenQualifiers.kt @@ -0,0 +1,27 @@ +/* + * 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.pin.lockscreen.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class PinCodeKeyAlias + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class BiometricKeyAlias diff --git a/vector/src/main/java/im/vector/app/features/pin/lockscreen/pincode/PinCodeHelper.kt b/vector/src/main/java/im/vector/app/features/pin/lockscreen/pincode/PinCodeHelper.kt index bb2861dcda..9b2c2efda5 100644 --- a/vector/src/main/java/im/vector/app/features/pin/lockscreen/pincode/PinCodeHelper.kt +++ b/vector/src/main/java/im/vector/app/features/pin/lockscreen/pincode/PinCodeHelper.kt @@ -55,11 +55,4 @@ class PinCodeHelper @Inject constructor( encryptedStorage.deletePinCode() lockScreenKeyRepository.deletePinCodeKey() } - - /** - * Migrates the PIN code key and encrypted value to use a more secure cipher. - */ - suspend fun migratePinCodeIfNeeded() { - lockScreenKeyRepository.migrateKeysIfNeeded() - } } diff --git a/vector/src/main/java/im/vector/app/features/pin/lockscreen/ui/LockScreenViewModel.kt b/vector/src/main/java/im/vector/app/features/pin/lockscreen/ui/LockScreenViewModel.kt index 39d0937323..79c1967670 100644 --- a/vector/src/main/java/im/vector/app/features/pin/lockscreen/ui/LockScreenViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/pin/lockscreen/ui/LockScreenViewModel.kt @@ -34,6 +34,7 @@ import im.vector.app.features.pin.lockscreen.biometrics.BiometricHelper import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguration import im.vector.app.features.pin.lockscreen.configuration.LockScreenConfiguratorProvider import im.vector.app.features.pin.lockscreen.configuration.LockScreenMode +import im.vector.app.features.pin.lockscreen.crypto.LockScreenKeysMigrator import im.vector.app.features.pin.lockscreen.pincode.PinCodeHelper import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.emitAll @@ -47,6 +48,7 @@ class LockScreenViewModel @AssistedInject constructor( @Assisted val initialState: LockScreenViewState, private val pinCodeHelper: PinCodeHelper, private val biometricHelper: BiometricHelper, + private val lockScreenKeysMigrator: LockScreenKeysMigrator, private val configuratorProvider: LockScreenConfiguratorProvider, private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, ) : VectorViewModel(initialState) { @@ -85,7 +87,7 @@ class LockScreenViewModel @AssistedInject constructor( init { // We need this to run synchronously before we start reading the configurations - runBlocking { pinCodeHelper.migratePinCodeIfNeeded() } + runBlocking { lockScreenKeysMigrator.migrateIfNeeded() } configuratorProvider.configurationFlow .onEach { updateConfiguration(it) } diff --git a/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt b/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt index 3ebcb3f318..40ef6d819e 100644 --- a/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt +++ b/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt @@ -26,6 +26,7 @@ import im.vector.app.R import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.time.Clock import im.vector.app.core.utils.isAnimationEnabled +import im.vector.app.features.MainActivity import im.vector.app.features.analytics.ui.consent.AnalyticsOptInActivity import im.vector.app.features.pin.PinActivity import im.vector.app.features.signout.hard.SignedOutActivity @@ -302,6 +303,7 @@ class PopupAlertManager @Inject constructor( private fun shouldBeDisplayedIn(alert: VectorAlert?, activity: Activity): Boolean { return alert != null && + activity !is MainActivity && activity !is PinActivity && activity !is SignedOutActivity && activity !is AnalyticsOptInActivity && 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 7bee66d722..587e6a59b4 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 @@ -206,6 +206,8 @@ class VectorPreferences @Inject constructor( private const val SETTINGS_LABS_ENABLE_LIVE_LOCATION = "SETTINGS_LABS_ENABLE_LIVE_LOCATION" + private const val SETTINGS_LABS_ENABLE_ELEMENT_CALL_PERMISSION_SHORTCUTS = "SETTINGS_LABS_ENABLE_ELEMENT_CALL_PERMISSION_SHORTCUTS" + // This key will be used to identify clients with the old thread support enabled io.element.thread const val SETTINGS_LABS_ENABLE_THREAD_MESSAGES_OLD_CLIENTS = "SETTINGS_LABS_ENABLE_THREAD_MESSAGES" @@ -1050,6 +1052,10 @@ class VectorPreferences @Inject constructor( } } + fun labsEnableElementCallPermissionShortcuts(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_ELEMENT_CALL_PERMISSION_SHORTCUTS, false) + } + /** * Indicates whether or not thread messages are enabled. */ diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/DeviceVerificationInfoBottomSheetController.kt b/vector/src/main/java/im/vector/app/features/settings/devices/DeviceVerificationInfoBottomSheetController.kt index 85abf846fa..c91c0f457b 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/DeviceVerificationInfoBottomSheetController.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/DeviceVerificationInfoBottomSheetController.kt @@ -106,6 +106,18 @@ class DeviceVerificationInfoBottomSheetController @Inject constructor( .toEpoxyCharSequence() ) } + } else { + genericItem { + id("reset${cryptoDeviceInfo.deviceId}") + style(ItemStyle.BIG_TEXT) + titleIconResourceId(shield) + title(host.stringProvider.getString(R.string.crosssigning_cannot_verify_this_session).toEpoxyCharSequence()) + description( + host.stringProvider + .getString(R.string.crosssigning_cannot_verify_this_session_desc) + .toEpoxyCharSequence() + ) + } } } else { if (!currentSessionIsTrusted) { @@ -141,22 +153,42 @@ class DeviceVerificationInfoBottomSheetController @Inject constructor( description("(${cryptoDeviceInfo.deviceId})".toEpoxyCharSequence()) } - if (isMine && !currentSessionIsTrusted && data.canVerifySession) { - // Add complete security - bottomSheetDividerItem { - id("completeSecurityDiv") - } - bottomSheetVerificationActionItem { - id("completeSecurity") - title(host.stringProvider.getString(R.string.crosssigning_verify_this_session)) - titleColor(host.colorProvider.getColorFromAttribute(R.attr.colorPrimary)) - iconRes(R.drawable.ic_arrow_right) - iconColor(host.colorProvider.getColorFromAttribute(R.attr.colorPrimary)) - listener { - host.callback?.onAction(DevicesAction.CompleteSecurity) + if (isMine) { + if (!currentSessionIsTrusted) { + if (data.canVerifySession) { + // Add complete security + bottomSheetDividerItem { + id("completeSecurityDiv") + } + bottomSheetVerificationActionItem { + id("completeSecurity") + title(host.stringProvider.getString(R.string.crosssigning_verify_this_session)) + titleColor(host.colorProvider.getColorFromAttribute(R.attr.colorPrimary)) + iconRes(R.drawable.ic_arrow_right) + iconColor(host.colorProvider.getColorFromAttribute(R.attr.colorPrimary)) + listener { + host.callback?.onAction(DevicesAction.CompleteSecurity) + } + } + } else { + bottomSheetDividerItem { + id("resetSecurityDiv") + } + bottomSheetVerificationActionItem { + id("resetSecurity") + title(host.stringProvider.getString(R.string.secure_backup_reset_all)) + titleColor(host.colorProvider.getColorFromAttribute(R.attr.colorPrimary)) + iconRes(R.drawable.ic_arrow_right) + iconColor(host.colorProvider.getColorFromAttribute(R.attr.colorPrimary)) + listener { + host.callback?.onAction(DevicesAction.ResetSecurity) + } + } } } - } else if (!isMine) { + } else + /** if (!isMine) **/ + { if (currentSessionIsTrusted) { // we can propose to verify it val isVerified = cryptoDeviceInfo.trustLevel?.crossSigningVerified.orFalse() diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/DevicesAction.kt b/vector/src/main/java/im/vector/app/features/settings/devices/DevicesAction.kt index 8ee0e7636e..b915236329 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/DevicesAction.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/DevicesAction.kt @@ -30,6 +30,7 @@ sealed class DevicesAction : VectorViewModelAction { data class VerifyMyDevice(val deviceId: String) : DevicesAction() data class VerifyMyDeviceManually(val deviceId: String) : DevicesAction() object CompleteSecurity : DevicesAction() + object ResetSecurity : DevicesAction() data class MarkAsManuallyVerified(val cryptoDeviceInfo: CryptoDeviceInfo) : DevicesAction() object SsoAuthDone : DevicesAction() diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewEvents.kt b/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewEvents.kt index c057e2b565..c97f353110 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewEvents.kt @@ -48,4 +48,6 @@ sealed class DevicesViewEvents : VectorViewEvents { ) : DevicesViewEvents() data class ShowManuallyVerify(val cryptoDeviceInfo: CryptoDeviceInfo) : DevicesViewEvents() + + object PromptResetSecrets : DevicesViewEvents() } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewModel.kt index 1d9f2a97c2..1840a97098 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewModel.kt @@ -239,6 +239,7 @@ class DevicesViewModel @AssistedInject constructor( uiaContinuation = null pendingAuth = null } + DevicesAction.ResetSecurity -> _viewEvents.post(DevicesViewEvents.PromptResetSecrets) } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/VectorSettingsDevicesFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/VectorSettingsDevicesFragment.kt index 0c52099f92..a132dc1f49 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/VectorSettingsDevicesFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/VectorSettingsDevicesFragment.kt @@ -37,6 +37,7 @@ import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.databinding.DialogBaseEditTextBinding import im.vector.app.databinding.FragmentGenericRecyclerBinding import im.vector.app.features.auth.ReAuthActivity +import im.vector.app.features.crypto.recover.SetupMode import im.vector.app.features.crypto.verification.VerificationBottomSheet import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo @@ -89,6 +90,9 @@ class VectorSettingsDevicesFragment @Inject constructor( viewModel.handle(DevicesAction.MarkAsManuallyVerified(it.cryptoDeviceInfo)) } } + is DevicesViewEvents.PromptResetSecrets -> { + navigator.open4SSetup(requireContext(), SetupMode.PASSPHRASE_AND_NEEDED_SECRETS_RESET) + } } } } diff --git a/vector/src/main/java/im/vector/app/features/share/IncomingShareAction.kt b/vector/src/main/java/im/vector/app/features/share/IncomingShareAction.kt index 4e9f024b15..70be2b2b6d 100644 --- a/vector/src/main/java/im/vector/app/features/share/IncomingShareAction.kt +++ b/vector/src/main/java/im/vector/app/features/share/IncomingShareAction.kt @@ -22,7 +22,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomSummary sealed class IncomingShareAction : VectorViewModelAction { data class SelectRoom(val roomSummary: RoomSummary, val enableMultiSelect: Boolean) : IncomingShareAction() object ShareToSelectedRooms : IncomingShareAction() - data class ShareToRoom(val roomSummary: RoomSummary) : IncomingShareAction() + data class ShareToRoom(val roomId: String) : IncomingShareAction() data class ShareMedia(val keepOriginalSize: Boolean) : IncomingShareAction() data class FilterWith(val filter: String) : IncomingShareAction() data class UpdateSharedData(val sharedData: SharedData) : IncomingShareAction() diff --git a/vector/src/main/java/im/vector/app/features/share/IncomingShareActivity.kt b/vector/src/main/java/im/vector/app/features/share/IncomingShareActivity.kt index 439d9b64fa..3d603e3f6a 100644 --- a/vector/src/main/java/im/vector/app/features/share/IncomingShareActivity.kt +++ b/vector/src/main/java/im/vector/app/features/share/IncomingShareActivity.kt @@ -16,21 +16,66 @@ package im.vector.app.features.share +import android.content.Intent +import android.os.Bundle +import com.airbnb.mvrx.viewModel import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.extensions.addFragment +import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivitySimpleBinding +import im.vector.app.features.MainActivity +import im.vector.app.features.start.StartAppViewModel +import javax.inject.Inject @AndroidEntryPoint class IncomingShareActivity : VectorBaseActivity() { + private val startAppViewModel: StartAppViewModel by viewModel() + + @Inject lateinit var activeSessionHolder: ActiveSessionHolder + + private val launcher = registerStartForActivityResult { + if (it.resultCode == RESULT_OK) { + handleAppStarted() + } else { + // User has pressed back on the MainActivity, so finish also this one. + finish() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (startAppViewModel.shouldStartApp()) { + launcher.launch(MainActivity.getIntentToInitSession(this)) + } else { + handleAppStarted() + } + } + override fun getBinding() = ActivitySimpleBinding.inflate(layoutInflater) override fun getCoordinatorLayout() = views.coordinatorLayout - override fun initUiAndData() { - if (isFirstCreation()) { - addFragment(views.simpleFragmentContainer, IncomingShareFragment::class.java) + private fun handleAppStarted() { + // If we are not logged in, stop the sharing process and open login screen. + // In the future, we might want to relaunch the sharing process after login. + if (!activeSessionHolder.hasActiveSession()) { + startLoginActivity() + } else { + if (isFirstCreation()) { + addFragment(views.simpleFragmentContainer, IncomingShareFragment::class.java) + } } } + + private fun startLoginActivity() { + navigator.openLogin( + context = this, + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK + ) + finish() + } } 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 3f8923dd68..3e2ddc469c 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 @@ -30,7 +30,6 @@ import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState import com.google.android.material.dialog.MaterialAlertDialogBuilder import im.vector.app.R -import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.extensions.cleanup import im.vector.app.core.extensions.configureWith import im.vector.app.core.extensions.registerStartForActivityResult @@ -40,7 +39,6 @@ import im.vector.app.features.analytics.plan.ViewRoom 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.getRoomSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import javax.inject.Inject @@ -50,7 +48,6 @@ import javax.inject.Inject */ class IncomingShareFragment @Inject constructor( private val incomingShareController: IncomingShareController, - private val sessionHolder: ActiveSessionHolder, private val shareIntentHandler: ShareIntentHandler, ) : VectorBaseFragment(), @@ -63,12 +60,6 @@ class IncomingShareFragment @Inject constructor( } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - // If we are not logged in, stop the sharing process and open login screen. - // In the future, we might want to relaunch the sharing process after login. - if (!sessionHolder.hasActiveSession()) { - startLoginActivity() - return - } super.onViewCreated(view, savedInstanceState) setupRecyclerView() setupToolbar(views.incomingShareToolbar) @@ -88,7 +79,7 @@ class IncomingShareFragment @Inject constructor( // 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)) } + viewModel.handle(IncomingShareAction.ShareToRoom(roomId)) } isShareManaged } @@ -192,14 +183,6 @@ class IncomingShareFragment @Inject constructor( .show() } - private fun startLoginActivity() { - navigator.openLogin( - context = requireActivity(), - flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK - ) - requireActivity().finish() - } - override fun invalidate() = withState(viewModel) { views.sendShareButton.isVisible = it.isInMultiSelectionMode incomingShareController.setData(it) diff --git a/vector/src/main/java/im/vector/app/features/share/IncomingShareViewModel.kt b/vector/src/main/java/im/vector/app/features/share/IncomingShareViewModel.kt index 1191fd04e8..85629ea150 100644 --- a/vector/src/main/java/im/vector/app/features/share/IncomingShareViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/share/IncomingShareViewModel.kt @@ -35,6 +35,7 @@ import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.getRoomSummary import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams import org.matrix.android.sdk.flow.flow @@ -134,7 +135,8 @@ class IncomingShareViewModel @AssistedInject constructor( private fun handleShareToRoom(action: IncomingShareAction.ShareToRoom) = withState { state -> val sharedData = state.sharedData ?: return@withState - _viewEvents.post(IncomingShareViewEvents.ShareToRoom(action.roomSummary, sharedData, showAlert = false)) + val roomSummary = session.getRoomSummary(action.roomId) ?: return@withState + _viewEvents.post(IncomingShareViewEvents.ShareToRoom(roomSummary, sharedData, showAlert = false)) } private fun handleShareMediaToSelectedRooms(action: IncomingShareAction.ShareMedia) = withState { state -> diff --git a/vector/src/main/java/im/vector/app/features/start/StartAppAction.kt b/vector/src/main/java/im/vector/app/features/start/StartAppAction.kt new file mode 100644 index 0000000000..fffb124f12 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/start/StartAppAction.kt @@ -0,0 +1,23 @@ +/* + * 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.start + +import im.vector.app.core.platform.VectorViewModelAction + +sealed interface StartAppAction : VectorViewModelAction { + object StartApp : StartAppAction +} diff --git a/vector/src/main/java/im/vector/app/features/start/StartAppAndroidService.kt b/vector/src/main/java/im/vector/app/features/start/StartAppAndroidService.kt new file mode 100644 index 0000000000..e8e0eac863 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/start/StartAppAndroidService.kt @@ -0,0 +1,63 @@ +/* + * 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.start + +import android.content.Intent +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.di.NamedGlobalScope +import im.vector.app.core.services.VectorAndroidService +import im.vector.app.features.notifications.NotificationUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.random.Random +import kotlin.time.Duration.Companion.seconds + +/** + * A simple foreground service that let the app (and the SDK) time to initialize. + * Will self stop itself once the active session is set. + */ +@AndroidEntryPoint +class StartAppAndroidService : VectorAndroidService() { + + @NamedGlobalScope @Inject lateinit var globalScope: CoroutineScope + @Inject lateinit var notificationUtils: NotificationUtils + @Inject lateinit var activeSessionHolder: ActiveSessionHolder + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + showStickyNotification() + startPollingActiveSession() + return START_STICKY + } + + private fun startPollingActiveSession() { + globalScope.launch { + do { + delay(1.seconds.inWholeMilliseconds) + } while (activeSessionHolder.hasActiveSession().not()) + myStopSelf() + } + } + + private fun showStickyNotification() { + val notificationId = Random.nextInt() + val notification = notificationUtils.buildStartAppNotification() + startForeground(notificationId, notification) + } +} diff --git a/vector/src/main/java/im/vector/app/features/start/StartAppViewEvent.kt b/vector/src/main/java/im/vector/app/features/start/StartAppViewEvent.kt new file mode 100644 index 0000000000..986d41f983 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/start/StartAppViewEvent.kt @@ -0,0 +1,31 @@ +/* + * 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.start + +import im.vector.app.core.platform.VectorViewEvents + +sealed interface StartAppViewEvent : VectorViewEvents { + /** + * Will be sent if the process is taking more than 1 second. + */ + object StartForegroundService : StartAppViewEvent + + /** + * Will be sent when the current Session has been set. + */ + object AppStarted : StartAppViewEvent +} diff --git a/vector/src/main/java/im/vector/app/features/start/StartAppViewModel.kt b/vector/src/main/java/im/vector/app/features/start/StartAppViewModel.kt new file mode 100644 index 0000000000..62a7517f5a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/start/StartAppViewModel.kt @@ -0,0 +1,70 @@ +/* + * 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.start + +import com.airbnb.mvrx.MavericksViewModelFactory +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.di.ActiveSessionSetter +import im.vector.app.core.di.MavericksAssistedViewModelFactory +import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.platform.VectorViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +class StartAppViewModel @AssistedInject constructor( + @Assisted val initialState: StartAppViewState, + private val activeSessionSetter: ActiveSessionSetter, +) : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: StartAppViewState): StartAppViewModel + } + + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + + fun shouldStartApp(): Boolean { + return activeSessionSetter.shouldSetActionSession() + } + + override fun handle(action: StartAppAction) { + when (action) { + StartAppAction.StartApp -> handleStartApp() + } + } + + private fun handleStartApp() { + handleLongProcessing() + viewModelScope.launch(Dispatchers.IO) { + // This can take time because of DB migration(s), so do it in a background task. + activeSessionSetter.tryToSetActiveSession(startSync = true) + _viewEvents.post(StartAppViewEvent.AppStarted) + } + } + + private fun handleLongProcessing() { + viewModelScope.launch(Dispatchers.Default) { + delay(1.seconds.inWholeMilliseconds) + setState { copy(mayBeLongToProcess = true) } + _viewEvents.post(StartAppViewEvent.StartForegroundService) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/start/StartAppViewState.kt b/vector/src/main/java/im/vector/app/features/start/StartAppViewState.kt new file mode 100644 index 0000000000..3ff933f054 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/start/StartAppViewState.kt @@ -0,0 +1,23 @@ +/* + * 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.start + +import com.airbnb.mvrx.MavericksState + +data class StartAppViewState( + val mayBeLongToProcess: Boolean = false +) : MavericksState diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt index b72ea68b7f..d5d8e95aa6 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetAction.kt @@ -26,4 +26,5 @@ sealed class WidgetAction : VectorViewModelAction { object DeleteWidget : WidgetAction() object RevokeWidget : WidgetAction() object OnTermsReviewed : WidgetAction() + object CloseWidget : WidgetAction() } 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 954f622801..91aa3a4e6a 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 @@ -17,8 +17,20 @@ package im.vector.app.features.widgets import android.app.Activity +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_IMMUTABLE +import android.app.PictureInPictureParams +import android.app.RemoteAction +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter +import android.graphics.drawable.Icon +import android.os.Build +import android.util.Rational +import androidx.annotation.RequiresApi +import androidx.core.app.PictureInPictureModeChangedInfo +import androidx.core.util.Consumer import androidx.core.view.isVisible import com.airbnb.mvrx.Mavericks import com.airbnb.mvrx.viewModel @@ -27,11 +39,14 @@ import im.vector.app.R import im.vector.app.core.extensions.addFragment import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivityWidgetBinding +import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.widgets.permissions.RoomWidgetPermissionBottomSheet import im.vector.app.features.widgets.permissions.RoomWidgetPermissionViewEvents import im.vector.app.features.widgets.permissions.RoomWidgetPermissionViewModel +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.events.model.Content import java.io.Serializable +import javax.inject.Inject @AndroidEntryPoint class WidgetActivity : VectorBaseActivity() { @@ -40,6 +55,10 @@ class WidgetActivity : VectorBaseActivity() { private const val WIDGET_FRAGMENT_TAG = "WIDGET_FRAGMENT_TAG" private const val WIDGET_PERMISSION_FRAGMENT_TAG = "WIDGET_PERMISSION_FRAGMENT_TAG" private const val EXTRA_RESULT = "EXTRA_RESULT" + private const val REQUEST_CODE_HANGUP = 1 + private const val ACTION_MEDIA_CONTROL = "MEDIA_CONTROL" + private const val EXTRA_CONTROL_TYPE = "EXTRA_CONTROL_TYPE" + private const val CONTROL_TYPE_HANGUP = 2 fun newIntent(context: Context, args: WidgetArgs): Intent { return Intent(context, WidgetActivity::class.java).apply { @@ -62,6 +81,8 @@ class WidgetActivity : VectorBaseActivity() { private val viewModel: WidgetViewModel by viewModel() private val permissionViewModel: RoomWidgetPermissionViewModel by viewModel() + @Inject lateinit var vectorPreferences: VectorPreferences + override fun getBinding() = ActivityWidgetBinding.inflate(layoutInflater) override fun getTitleRes() = R.string.room_widget_activity_title @@ -82,29 +103,37 @@ class WidgetActivity : VectorBaseActivity() { } } - permissionViewModel.observeViewEvents { - when (it) { - is RoomWidgetPermissionViewEvents.Close -> finish() + // Trust element call widget by default + if (widgetArgs.kind == WidgetKind.ELEMENT_CALL && vectorPreferences.labsEnableElementCallPermissionShortcuts()) { + if (supportFragmentManager.findFragmentByTag(WIDGET_FRAGMENT_TAG) == null) { + addOnPictureInPictureModeChangedListener(pictureInPictureModeChangedInfoConsumer) + addFragment(views.fragmentContainer, WidgetFragment::class.java, widgetArgs, WIDGET_FRAGMENT_TAG) + } + } else { + permissionViewModel.observeViewEvents { + when (it) { + is RoomWidgetPermissionViewEvents.Close -> finish() + } } - } - viewModel.onEach(WidgetViewState::status) { ws -> - when (ws) { - WidgetStatus.UNKNOWN -> { - } - WidgetStatus.WIDGET_NOT_ALLOWED -> { - val dFrag = supportFragmentManager.findFragmentByTag(WIDGET_PERMISSION_FRAGMENT_TAG) as? RoomWidgetPermissionBottomSheet - if (dFrag != null && dFrag.dialog?.isShowing == true && !dFrag.isRemoving) { - return@onEach - } else { - RoomWidgetPermissionBottomSheet - .newInstance(widgetArgs) - .show(supportFragmentManager, WIDGET_PERMISSION_FRAGMENT_TAG) + viewModel.onEach(WidgetViewState::status) { ws -> + when (ws) { + WidgetStatus.UNKNOWN -> { } - } - WidgetStatus.WIDGET_ALLOWED -> { - if (supportFragmentManager.findFragmentByTag(WIDGET_FRAGMENT_TAG) == null) { - addFragment(views.fragmentContainer, WidgetFragment::class.java, widgetArgs, WIDGET_FRAGMENT_TAG) + WidgetStatus.WIDGET_NOT_ALLOWED -> { + val dFrag = supportFragmentManager.findFragmentByTag(WIDGET_PERMISSION_FRAGMENT_TAG) as? RoomWidgetPermissionBottomSheet + if (dFrag != null && dFrag.dialog?.isShowing == true && !dFrag.isRemoving) { + return@onEach + } else { + RoomWidgetPermissionBottomSheet + .newInstance(widgetArgs) + .show(supportFragmentManager, WIDGET_PERMISSION_FRAGMENT_TAG) + } + } + WidgetStatus.WIDGET_ALLOWED -> { + if (supportFragmentManager.findFragmentByTag(WIDGET_FRAGMENT_TAG) == null) { + addFragment(views.fragmentContainer, WidgetFragment::class.java, widgetArgs, WIDGET_FRAGMENT_TAG) + } } } } @@ -119,6 +148,64 @@ class WidgetActivity : VectorBaseActivity() { } } + override fun onUserLeaveHint() { + super.onUserLeaveHint() + val widgetArgs: WidgetArgs? = intent?.extras?.getParcelable(Mavericks.KEY_ARG) + if (widgetArgs?.kind?.supportsPictureInPictureMode().orFalse()) { + enterPictureInPicture() + } + } + + override fun onDestroy() { + removeOnPictureInPictureModeChangedListener(pictureInPictureModeChangedInfoConsumer) + super.onDestroy() + } + + private fun enterPictureInPicture() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createElementCallPipParams()?.let { + enterPictureInPictureMode(it) + } + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createElementCallPipParams(): PictureInPictureParams? { + val actions = mutableListOf() + val intent = Intent(ACTION_MEDIA_CONTROL).putExtra(EXTRA_CONTROL_TYPE, CONTROL_TYPE_HANGUP) + val pendingIntent = PendingIntent.getBroadcast(this, REQUEST_CODE_HANGUP, intent, FLAG_IMMUTABLE) + val icon = Icon.createWithResource(this, R.drawable.ic_call_hangup) + actions.add(RemoteAction(icon, getString(R.string.call_notification_hangup), getString(R.string.call_notification_hangup), pendingIntent)) + + val aspectRatio = Rational(resources.getDimensionPixelSize(R.dimen.call_pip_width), resources.getDimensionPixelSize(R.dimen.call_pip_height)) + return PictureInPictureParams.Builder() + .setAspectRatio(aspectRatio) + .setActions(actions) + .build() + } + + private var hangupBroadcastReceiver: BroadcastReceiver? = null + + private val pictureInPictureModeChangedInfoConsumer = Consumer { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return@Consumer + + if (isInPictureInPictureMode) { + hangupBroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == ACTION_MEDIA_CONTROL) { + val controlType = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0) + if (controlType == CONTROL_TYPE_HANGUP) { + viewModel.handle(WidgetAction.CloseWidget) + } + } + } + } + registerReceiver(hangupBroadcastReceiver, IntentFilter(ACTION_MEDIA_CONTROL)) + } else { + unregisterReceiver(hangupBroadcastReceiver) + } + } + private fun handleClose(event: WidgetViewEvents.Close) { if (event.content != null) { val intent = createResultIntent(event.content) diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetArgsBuilder.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetArgsBuilder.kt index 777bd9cc7e..83ea100cb6 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetArgsBuilder.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetArgsBuilder.kt @@ -78,6 +78,13 @@ class WidgetArgsBuilder @Inject constructor( ) } + fun buildElementCallWidgetArgs(roomId: String, widget: Widget): WidgetArgs { + return buildRoomWidgetArgs(roomId, widget) + .copy( + kind = WidgetKind.ELEMENT_CALL + ) + } + @Suppress("UNCHECKED_CAST") private fun Map.filterNotNull(): Map { return filterValues { it != null } as Map 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 5501031e92..ed1bace70c 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 @@ -43,8 +43,10 @@ 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.CheckWebViewPermissionsUseCase import im.vector.app.core.utils.openUrlInExternalBrowser import im.vector.app.databinding.FragmentRoomWidgetBinding +import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.webview.WebEventListener import im.vector.app.features.widgets.webview.WebviewPermissionUtils import im.vector.app.features.widgets.webview.clearAfterWidget @@ -65,7 +67,9 @@ data class WidgetArgs( ) : Parcelable class WidgetFragment @Inject constructor( - private val permissionUtils: WebviewPermissionUtils + private val permissionUtils: WebviewPermissionUtils, + private val checkWebViewPermissionsUseCase: CheckWebViewPermissionsUseCase, + private val vectorPreferences: VectorPreferences, ) : VectorBaseFragment(), WebEventListener, @@ -81,7 +85,7 @@ class WidgetFragment @Inject constructor( override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - views.widgetWebView.setupForWidget(this) + views.widgetWebView.setupForWidget(requireActivity(), checkWebViewPermissionsUseCase, this) if (fragmentArgs.kind.isAdmin()) { viewModel.getPostAPIMediator().setWebView(views.widgetWebView) } @@ -131,9 +135,11 @@ class WidgetFragment @Inject constructor( override fun onPause() { super.onPause() - views.widgetWebView.let { - it.pauseTimers() - it.onPause() + if (fragmentArgs.kind != WidgetKind.ELEMENT_CALL) { + views.widgetWebView.let { + it.pauseTimers() + it.onPause() + } } } @@ -298,7 +304,8 @@ class WidgetFragment @Inject constructor( request = request, context = requireContext(), activity = requireActivity(), - activityResultLauncher = permissionResultLauncher + activityResultLauncher = permissionResultLauncher, + autoApprove = fragmentArgs.kind == WidgetKind.ELEMENT_CALL && vectorPreferences.labsEnableElementCallPermissionShortcuts() ) } diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt index b3f4712815..ecd6ca2fd6 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewModel.kt @@ -147,9 +147,14 @@ class WidgetViewModel @AssistedInject constructor( WidgetAction.DeleteWidget -> handleDeleteWidget() WidgetAction.RevokeWidget -> handleRevokeWidget() WidgetAction.OnTermsReviewed -> loadFormattedUrl(forceFetchToken = false) + WidgetAction.CloseWidget -> handleCloseWidget() } } + private fun handleCloseWidget() { + _viewEvents.post(WidgetViewEvents.Close()) + } + private fun handleRevokeWidget() { viewModelScope.launch { val widgetId = initialState.widgetId ?: return@launch diff --git a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewState.kt b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewState.kt index 2d98f734dd..cd2ed23980 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/WidgetViewState.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/WidgetViewState.kt @@ -33,11 +33,16 @@ enum class WidgetStatus { enum class WidgetKind(@StringRes val nameRes: Int, val screenId: String?) { ROOM(R.string.room_widget_activity_title, null), STICKER_PICKER(R.string.title_activity_choose_sticker, WidgetType.StickerPicker.preferred), - INTEGRATION_MANAGER(0, null); + INTEGRATION_MANAGER(0, null), + ELEMENT_CALL(0, null); fun isAdmin(): Boolean { return this == STICKER_PICKER || this == INTEGRATION_MANAGER } + + fun supportsPictureInPictureMode(): Boolean { + return this == ELEMENT_CALL + } } data class WidgetViewState( diff --git a/vector/src/main/java/im/vector/app/features/widgets/webview/WebviewPermissionUtils.kt b/vector/src/main/java/im/vector/app/features/widgets/webview/WebviewPermissionUtils.kt index fa7b842ab9..44af4ec335 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/webview/WebviewPermissionUtils.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/webview/WebviewPermissionUtils.kt @@ -41,11 +41,22 @@ class WebviewPermissionUtils @Inject constructor( request: PermissionRequest, context: Context, activity: FragmentActivity, - activityResultLauncher: ActivityResultLauncher> + activityResultLauncher: ActivityResultLauncher>, + autoApprove: Boolean = false ) { + if (autoApprove) { + onPermissionsSelected( + permissions = request.resources.toList(), + request = request, + activity = activity, + activityResultLauncher = activityResultLauncher) + return + } + val allowedPermissions = request.resources.map { it to false }.toMutableList() + MaterialAlertDialogBuilder(context) .setTitle(title) .setMultiChoiceItems( @@ -54,21 +65,10 @@ class WebviewPermissionUtils @Inject constructor( allowedPermissions[which] = allowedPermissions[which].first to isChecked } .setPositiveButton(R.string.room_widget_resource_grant_permission) { _, _ -> - permissionRequest = request - selectedPermissions = allowedPermissions.mapNotNull { perm -> + val permissions = allowedPermissions.mapNotNull { perm -> perm.first.takeIf { perm.second } } - - val requiredAndroidPermissions = selectedPermissions.mapNotNull { permission -> - webPermissionToAndroidPermission(permission) - } - - // When checkPermissions returns false, some of the required Android permissions will - // have to be requested and the flow completes asynchronously via onPermissionResult - if (checkPermissions(requiredAndroidPermissions, activity, activityResultLauncher)) { - request.grant(selectedPermissions.toTypedArray()) - reset() - } + onPermissionsSelected(permissions, request, activity, activityResultLauncher) } .setNegativeButton(R.string.room_widget_resource_decline_permission) { _, _ -> request.deny() @@ -76,6 +76,27 @@ class WebviewPermissionUtils @Inject constructor( .show() } + private fun onPermissionsSelected( + permissions: List, + request: PermissionRequest, + activity: FragmentActivity, + activityResultLauncher: ActivityResultLauncher>, + ) { + permissionRequest = request + selectedPermissions = permissions + + val requiredAndroidPermissions = selectedPermissions.mapNotNull { permission -> + webPermissionToAndroidPermission(permission) + } + + // When checkPermissions returns false, some of the required Android permissions will + // have to be requested and the flow completes asynchronously via onPermissionResult + if (checkPermissions(requiredAndroidPermissions, activity, activityResultLauncher)) { + request.grant(selectedPermissions.toTypedArray()) + reset() + } + } + fun onPermissionResult(result: Map) { if (permissionRequest == null) { fatalError( diff --git a/vector/src/main/java/im/vector/app/features/widgets/webview/WidgetWebView.kt b/vector/src/main/java/im/vector/app/features/widgets/webview/WidgetWebView.kt index 0207987ca3..ac9930866f 100644 --- a/vector/src/main/java/im/vector/app/features/widgets/webview/WidgetWebView.kt +++ b/vector/src/main/java/im/vector/app/features/widgets/webview/WidgetWebView.kt @@ -17,18 +17,23 @@ package im.vector.app.features.widgets.webview import android.annotation.SuppressLint +import android.app.Activity import android.view.ViewGroup import android.webkit.CookieManager import android.webkit.PermissionRequest import android.webkit.WebChromeClient import android.webkit.WebView import im.vector.app.R +import im.vector.app.core.utils.CheckWebViewPermissionsUseCase import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.webview.VectorWebViewClient import im.vector.app.features.webview.WebEventListener @SuppressLint("NewApi") -fun WebView.setupForWidget(eventListener: WebEventListener) { +fun WebView.setupForWidget(activity: Activity, + checkWebViewPermissionsUseCase: CheckWebViewPermissionsUseCase, + eventListener: WebEventListener, +) { // xml value seems ignored setBackgroundColor(ThemeUtils.getColor(context, R.attr.colorSurface)) @@ -56,10 +61,16 @@ fun WebView.setupForWidget(eventListener: WebEventListener) { settings.displayZoomControls = false + settings.mediaPlaybackRequiresUserGesture = false + // Permission requests webChromeClient = object : WebChromeClient() { override fun onPermissionRequest(request: PermissionRequest) { - eventListener.onPermissionRequest(request) + if (checkWebViewPermissionsUseCase.execute(activity, request)) { + request.grant(request.resources) + } else { + eventListener.onPermissionRequest(request) + } } } webViewClient = VectorWebViewClient(eventListener) diff --git a/vector/src/main/res/drawable/ic_robot.xml b/vector/src/main/res/drawable/ic_robot.xml new file mode 100644 index 0000000000..cdc358f7eb --- /dev/null +++ b/vector/src/main/res/drawable/ic_robot.xml @@ -0,0 +1,10 @@ + + + diff --git a/vector/src/main/res/layout/activity_main.xml b/vector/src/main/res/layout/activity_main.xml index c7bca50acb..ba5925f000 100644 --- a/vector/src/main/res/layout/activity_main.xml +++ b/vector/src/main/res/layout/activity_main.xml @@ -1,5 +1,4 @@ - - + + diff --git a/vector/src/main/res/layout/fragment_ftue_combined_login.xml b/vector/src/main/res/layout/fragment_ftue_combined_login.xml index b23ea9d7cc..9533ab29fc 100644 --- a/vector/src/main/res/layout/fragment_ftue_combined_login.xml +++ b/vector/src/main/res/layout/fragment_ftue_combined_login.xml @@ -66,7 +66,7 @@ android:layout_height="wrap_content" android:layout_marginStart="12dp" android:layout_marginTop="4dp" - android:text="@string/ftue_auth_create_account_choose_server_header" + android:text="@string/ftue_auth_sign_in_choose_server_header" android:textColor="?vctr_content_secondary" app:layout_constraintBottom_toTopOf="@id/selectedServerName" app:layout_constraintEnd_toStartOf="@id/editServerButton" diff --git a/vector/src/main/res/layout/fragment_ftue_login_captcha.xml b/vector/src/main/res/layout/fragment_ftue_login_captcha.xml index becb745305..2f6970c785 100644 --- a/vector/src/main/res/layout/fragment_ftue_login_captcha.xml +++ b/vector/src/main/res/layout/fragment_ftue_login_captcha.xml @@ -38,7 +38,7 @@ android:background="@drawable/circle" android:backgroundTint="?colorSecondary" android:contentDescription="@null" - android:src="@drawable/ic_user_fg" + android:src="@drawable/ic_robot" app:layout_constraintBottom_toTopOf="@id/captchaHeaderTitle" app:layout_constraintEnd_toEndOf="@id/captchaGutterEnd" app:layout_constraintHeight_percent="0.10" diff --git a/vector/src/main/res/layout/fragment_ftue_server_selection_combined.xml b/vector/src/main/res/layout/fragment_ftue_server_selection_combined.xml index afe7a06183..f1944e25ad 100644 --- a/vector/src/main/res/layout/fragment_ftue_server_selection_combined.xml +++ b/vector/src/main/res/layout/fragment_ftue_server_selection_combined.xml @@ -1,6 +1,7 @@ + app:layout_constraintTop_toBottomOf="@id/chooseServerHeaderTitle" + tools:text="@string/ftue_auth_choose_server_subtitle" /> + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_gravity="center" /> + + diff --git a/vector/src/main/res/layout/item_timeline_event_live_location_inactive_stub.xml b/vector/src/main/res/layout/item_timeline_event_live_location_inactive_stub.xml index d5a0cefb28..53b740bb4e 100644 --- a/vector/src/main/res/layout/item_timeline_event_live_location_inactive_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_live_location_inactive_stub.xml @@ -15,16 +15,15 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - + app:locLiveEndedBkgWithAlpha="true" + app:locLiveEndedIconMarginStart="8dp" /> - - - - diff --git a/vector/src/main/res/layout/item_timeline_event_location_stub.xml b/vector/src/main/res/layout/item_timeline_event_location_stub.xml index a696140669..3875a25773 100644 --- a/vector/src/main/res/layout/item_timeline_event_location_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_location_stub.xml @@ -45,8 +45,8 @@ app:layout_constraintTop_toBottomOf="@id/staticMapPinImageView" tools:visibility="visible" /> - + + + + + + + + diff --git a/vector/src/main/res/layout/view_location_live_message_banner.xml b/vector/src/main/res/layout/view_location_live_running_banner.xml similarity index 63% rename from vector/src/main/res/layout/view_location_live_message_banner.xml rename to vector/src/main/res/layout/view_location_live_running_banner.xml index 5c8f3a8970..5bf8c8258d 100644 --- a/vector/src/main/res/layout/view_location_live_message_banner.xml +++ b/vector/src/main/res/layout/view_location_live_running_banner.xml @@ -7,7 +7,7 @@ tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">