diff --git a/.github/workflows/sanity_test.yml b/.github/workflows/sanity_test.yml index 93e4686fe7..483926fa1f 100644 --- a/.github/workflows/sanity_test.yml +++ b/.github/workflows/sanity_test.yml @@ -69,9 +69,10 @@ jobs: touch emulator.log chmod 777 emulator.log adb logcat >> emulator.log & - ./gradlew $CI_GRADLE_ARG_PROPERTIES -PallWarningsAsErrors=false connectedGplayDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=im.vector.app.ui.UiAllScreensSanityTest || adb pull storage/emulated/0/Pictures/failure_screenshots + ./gradlew $CI_GRADLE_ARG_PROPERTIES -PallWarningsAsErrors=false connectedGplayDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=im.vector.app.ui.UiAllScreensSanityTest || (adb pull storage/emulated/0/Pictures/failure_screenshots && exit 1 ) - name: Upload Test Report Log uses: actions/upload-artifact@v2 + if: always() with: name: sanity-error-results path: | diff --git a/.idea/dictionaries/bmarty.xml b/.idea/dictionaries/bmarty.xml index f99842f067..ed572b573f 100644 --- a/.idea/dictionaries/bmarty.xml +++ b/.idea/dictionaries/bmarty.xml @@ -28,6 +28,7 @@ previewable previewables pstn + rageshake riotx signin signout diff --git a/.idea/icon.png b/.idea/icon.png new file mode 100644 index 0000000000..6f7872211b Binary files /dev/null and b/.idea/icon.png differ diff --git a/changelog.d/2782.misc b/changelog.d/2782.misc new file mode 100644 index 0000000000..dc20050369 --- /dev/null +++ b/changelog.d/2782.misc @@ -0,0 +1 @@ +Collapse successive ACLs events in room timeline diff --git a/changelog.d/3771.feature b/changelog.d/3771.feature new file mode 100644 index 0000000000..c480bb649d --- /dev/null +++ b/changelog.d/3771.feature @@ -0,0 +1 @@ +Open the room when user accepts an invite from the room list \ No newline at end of file diff --git a/changelog.d/4643.misc b/changelog.d/4643.misc new file mode 100644 index 0000000000..3d86baa1a2 --- /dev/null +++ b/changelog.d/4643.misc @@ -0,0 +1 @@ +Home screen: Replacing search icon by filter icon in the top right menu diff --git a/changelog.d/5104.misc b/changelog.d/5104.misc new file mode 100644 index 0000000000..673614955b --- /dev/null +++ b/changelog.d/5104.misc @@ -0,0 +1 @@ +Make Space creation screens more consistent diff --git a/changelog.d/5123.feature b/changelog.d/5123.feature new file mode 100644 index 0000000000..cb1a7adf08 --- /dev/null +++ b/changelog.d/5123.feature @@ -0,0 +1 @@ +Add completion for @room to notify everyone in a room diff --git a/changelog.d/5136.misc b/changelog.d/5136.misc new file mode 100644 index 0000000000..43404acc31 --- /dev/null +++ b/changelog.d/5136.misc @@ -0,0 +1 @@ +Defensive coding to ensure encryption when room was once e2e \ No newline at end of file diff --git a/changelog.d/5185.sdk b/changelog.d/5185.sdk new file mode 100644 index 0000000000..9eda2e7c9b --- /dev/null +++ b/changelog.d/5185.sdk @@ -0,0 +1 @@ +Deprecates Matrix.initialize and Matrix.getInstance in favour of the client providing its own singleton instance via Matrix.createInstance \ No newline at end of file diff --git a/changelog.d/5201.bugfix b/changelog.d/5201.bugfix new file mode 100644 index 0000000000..f77ddcce84 --- /dev/null +++ b/changelog.d/5201.bugfix @@ -0,0 +1 @@ +Fix for call transfer with consult failing to make outgoing consultation call. \ No newline at end of file diff --git a/changelog.d/5225.misc b/changelog.d/5225.misc new file mode 100644 index 0000000000..799a3a4d81 --- /dev/null +++ b/changelog.d/5225.misc @@ -0,0 +1 @@ +Replacing color "vctr_unread_room_badge" by "vctr_content_secondary" diff --git a/changelog.d/5234.bugfix b/changelog.d/5234.bugfix new file mode 100644 index 0000000000..2b5d4dee37 --- /dev/null +++ b/changelog.d/5234.bugfix @@ -0,0 +1 @@ +Analytics: Fixes missing use case identity values from within the onboarding flow \ No newline at end of file diff --git a/changelog.d/5243.bugfix b/changelog.d/5243.bugfix new file mode 100644 index 0000000000..eb323c1ca4 --- /dev/null +++ b/changelog.d/5243.bugfix @@ -0,0 +1 @@ +Increments database schema to take advantage of homeserver capabilities entity migration (fixes crash in pre-release builds) \ No newline at end of file diff --git a/changelog.d/5254.misc b/changelog.d/5254.misc new file mode 100644 index 0000000000..2ae642e9b7 --- /dev/null +++ b/changelog.d/5254.misc @@ -0,0 +1 @@ +Change preferred jitsi domain from `jitsi.riot.im` to `meet.element.io` \ No newline at end of file diff --git a/changelog.d/5290.feature b/changelog.d/5290.feature new file mode 100644 index 0000000000..6f7e9aea7f --- /dev/null +++ b/changelog.d/5290.feature @@ -0,0 +1 @@ +Support creating disclosed polls \ No newline at end of file diff --git a/library/ui-styles/src/main/res/values/colors.xml b/library/ui-styles/src/main/res/values/colors.xml index 48ac48a8ca..770b001893 100644 --- a/library/ui-styles/src/main/res/values/colors.xml +++ b/library/ui-styles/src/main/res/values/colors.xml @@ -57,11 +57,6 @@ - - @color/palette_gray_200 - @color/palette_gray_250 - @color/palette_gray_250 - @android:color/white #FF181B21 diff --git a/library/ui-styles/src/main/res/values/text_appearances.xml b/library/ui-styles/src/main/res/values/text_appearances.xml index 4ad3fd493e..8e30dd00d6 100644 --- a/library/ui-styles/src/main/res/values/text_appearances.xml +++ b/library/ui-styles/src/main/res/values/text_appearances.xml @@ -59,6 +59,10 @@ sans-serif-medium + + - \ No newline at end of file + diff --git a/library/ui-styles/src/main/res/values/theme_black.xml b/library/ui-styles/src/main/res/values/theme_black.xml index c472a4fae5..44d4206d43 100644 --- a/library/ui-styles/src/main/res/values/theme_black.xml +++ b/library/ui-styles/src/main/res/values/theme_black.xml @@ -7,7 +7,6 @@ - @color/vctr_unread_room_badge_black @color/vctr_fab_label_bg_black @color/vctr_fab_label_stroke_black @color/vctr_fab_label_color_black diff --git a/library/ui-styles/src/main/res/values/theme_dark.xml b/library/ui-styles/src/main/res/values/theme_dark.xml index b1d95c5439..100a07f41d 100644 --- a/library/ui-styles/src/main/res/values/theme_dark.xml +++ b/library/ui-styles/src/main/res/values/theme_dark.xml @@ -16,7 +16,6 @@ @color/element_system_dark - @color/vctr_unread_room_badge_dark @color/vctr_fab_label_bg_dark @color/vctr_fab_label_stroke_dark @color/vctr_fab_label_color_dark diff --git a/library/ui-styles/src/main/res/values/theme_light.xml b/library/ui-styles/src/main/res/values/theme_light.xml index dba39c97ca..39e78ee5b1 100644 --- a/library/ui-styles/src/main/res/values/theme_light.xml +++ b/library/ui-styles/src/main/res/values/theme_light.xml @@ -16,7 +16,6 @@ @color/element_system_light - @color/vctr_unread_room_badge_light @color/vctr_fab_label_bg_light @color/vctr_fab_label_stroke_light @color/vctr_fab_label_color_light diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt index 901ba75d16..5fedff53f0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt @@ -99,12 +99,31 @@ class Matrix private constructor(context: Context, matrixConfiguration: MatrixCo private lateinit var instance: Matrix private val isInit = AtomicBoolean(false) + /** + * Creates a new instance of Matrix, it's recommended to manage this instance as a singleton. + * To make use of the built in singleton use Matrix.initialize() and/or Matrix.getInstance(context) instead + **/ + fun createInstance(context: Context, matrixConfiguration: MatrixConfiguration): Matrix { + return Matrix(context.applicationContext, matrixConfiguration) + } + + /** + * Initializes a singleton instance of Matrix for the given MatrixConfiguration + * This instance will be returned by Matrix.getInstance(context) + */ + @Deprecated("Use Matrix.createInstance and manage the instance manually") fun initialize(context: Context, matrixConfiguration: MatrixConfiguration) { if (isInit.compareAndSet(false, true)) { instance = Matrix(context.applicationContext, matrixConfiguration) } } + /** + * Either provides an already initialized singleton Matrix instance or queries the application context for a MatrixConfiguration.Provider + * to lazily create and store the instance. + */ + @Suppress("deprecation") // suppressing warning as this method is unused but is still provided for SDK clients + @Deprecated("Use Matrix.createInstance and manage the instance manually") fun getInstance(context: Context): Matrix { if (isInit.compareAndSet(false, true)) { val appContext = context.applicationContext @@ -113,7 +132,8 @@ class Matrix private constructor(context: Context, matrixConfiguration: MatrixCo instance = Matrix(appContext, matrixConfiguration) } else { throw IllegalStateException("Matrix is not initialized properly." + - " You should call Matrix.initialize or let your application implements MatrixConfiguration.Provider.") + " If you want to manage your own Matrix instance use Matrix.createInstance" + + " otherwise you should call Matrix.initialize or let your application implement MatrixConfiguration.Provider.") } } return instance diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt index 306ed45500..c87f21d7ac 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt @@ -66,6 +66,7 @@ data class MatrixConfiguration( /** * Can be implemented by your Application class. */ + @Deprecated("Use Matrix.createInstance and manage the instance manually instead of Matrix.getInstance") interface Provider { fun providesMatrixConfiguration(): MatrixConfiguration } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/pushrules/PushRuleService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/pushrules/PushRuleService.kt index 88268f0f86..76885d8545 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/pushrules/PushRuleService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/pushrules/PushRuleService.kt @@ -50,6 +50,9 @@ interface PushRuleService { // fun fulfilledBingRule(event: Event, rules: List): PushRule? + fun resolveSenderNotificationPermissionCondition(event: Event, + condition: SenderNotificationPermissionCondition): Boolean + interface PushRuleListener { fun onEvents(pushEvents: PushEvents) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt index 17d7d96a38..650b8cc26d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt @@ -36,7 +36,19 @@ sealed class MatrixItem( data class UserItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName?.removeSuffix(ircPattern), avatarUrl) { + MatrixItem(id, displayName?.removeSuffix(IRC_PATTERN), avatarUrl) { + init { + if (BuildConfig.DEBUG) checkId() + } + + override fun updateAvatar(newAvatar: String?) = copy(avatarUrl = newAvatar) + } + + data class EveryoneInRoomItem(override val id: String, + override val displayName: String = NOTIFY_EVERYONE, + override val avatarUrl: String? = null, + val roomDisplayName: String? = null) : + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -47,7 +59,7 @@ sealed class MatrixItem( data class EventItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName, avatarUrl) { + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -58,7 +70,7 @@ sealed class MatrixItem( data class RoomItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName, avatarUrl) { + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -69,7 +81,7 @@ sealed class MatrixItem( data class SpaceItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName, avatarUrl) { + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -80,7 +92,7 @@ sealed class MatrixItem( data class RoomAliasItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName, avatarUrl) { + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -91,7 +103,7 @@ sealed class MatrixItem( data class GroupItem(override val id: String, override val displayName: String? = null, override val avatarUrl: String? = null) : - MatrixItem(id, displayName, avatarUrl) { + MatrixItem(id, displayName, avatarUrl) { init { if (BuildConfig.DEBUG) checkId() } @@ -110,16 +122,22 @@ sealed class MatrixItem( /** * Return the prefix as defined in the matrix spec (and not extracted from the id) */ - fun getIdPrefix() = when (this) { - is UserItem -> '@' - is EventItem -> '$' + private fun getIdPrefix() = when (this) { + is UserItem -> '@' + is EventItem -> '$' is SpaceItem, - is RoomItem -> '!' - is RoomAliasItem -> '#' - is GroupItem -> '+' + is RoomItem, + is EveryoneInRoomItem -> '!' + is RoomAliasItem -> '#' + is GroupItem -> '+' } fun firstLetterOfDisplayName(): String { + val displayName = when (this) { + // use the room display name for the notify everyone item + is EveryoneInRoomItem -> roomDisplayName + else -> displayName + } return (displayName?.takeIf { it.isNotBlank() } ?: id) .let { dn -> var startIndex = 0 @@ -152,7 +170,8 @@ sealed class MatrixItem( } companion object { - private const val ircPattern = " (IRC)" + private const val IRC_PATTERN = " (IRC)" + const val NOTIFY_EVERYONE = "@room" } } @@ -172,6 +191,8 @@ fun RoomSummary.toMatrixItem() = if (roomType == RoomType.SPACE) { fun RoomSummary.toRoomAliasMatrixItem() = MatrixItem.RoomAliasItem(canonicalAlias ?: roomId, displayName, avatarUrl) +fun RoomSummary.toEveryoneInRoomMatrixItem() = MatrixItem.EveryoneInRoomItem(id = roomId, avatarUrl = avatarUrl, roomDisplayName = displayName) + // If no name is available, use room alias as Riot-Web does fun PublicRoom.toMatrixItem() = MatrixItem.RoomItem(roomId, name ?: getPrimaryAlias() ?: "", avatarUrl) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoSessionInfoProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoSessionInfoProvider.kt index 82eced4371..2a58d731e5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoSessionInfoProvider.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/CryptoSessionInfoProvider.kt @@ -35,6 +35,8 @@ internal class CryptoSessionInfoProvider @Inject constructor( ) { fun isRoomEncrypted(roomId: String): Boolean { + // We look at the presence at any m.room.encryption state event no matter if it's + // the latest one or if it is well formed val encryptionEvent = monarchy.fetchCopied { realm -> EventEntity.whereType(realm, roomId = roomId, type = EventType.STATE_ROOM_ENCRYPTION) .isEmpty(EventEntityFields.STATE_KEY) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt index 82fb565377..96ea5c03fa 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/IMXCryptoStore.kt @@ -240,6 +240,14 @@ internal interface IMXCryptoStore { */ fun getRoomAlgorithm(roomId: String): String? + /** + * This is a bit different than isRoomEncrypted + * A room is encrypted when there is a m.room.encryption state event in the room (malformed/invalid or not) + * But the crypto layer has additional guaranty to ensure that encryption would never been reverted + * It's defensive coding out of precaution (if ever state is reset) + */ + fun roomWasOnceEncrypted(roomId: String): Boolean + fun shouldEncryptForInvitedMembers(roomId: String): Boolean fun setShouldEncryptForInvitedMembers(roomId: String, shouldEncryptForInvitedMembers: Boolean) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt index 33578ba06a..a07827c033 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStore.kt @@ -631,7 +631,15 @@ internal class RealmCryptoStore @Inject constructor( override fun storeRoomAlgorithm(roomId: String, algorithm: String?) { doRealmTransaction(realmConfiguration) { - CryptoRoomEntity.getOrCreate(it, roomId).algorithm = algorithm + CryptoRoomEntity.getOrCreate(it, roomId).let { entity -> + entity.algorithm = algorithm + // store anyway the new algorithm, but mark the room + // as having been encrypted once whatever, this can never + // go back to false + if (algorithm == MXCRYPTO_ALGORITHM_MEGOLM) { + entity.wasEncryptedOnce = true + } + } } } @@ -641,6 +649,12 @@ internal class RealmCryptoStore @Inject constructor( } } + override fun roomWasOnceEncrypted(roomId: String): Boolean { + return doWithRealm(realmConfiguration) { + CryptoRoomEntity.getById(it, roomId)?.wasEncryptedOnce ?: false + } + } + override fun shouldEncryptForInvitedMembers(roomId: String): Boolean { return doWithRealm(realmConfiguration) { CryptoRoomEntity.getById(it, roomId)?.shouldEncryptForInvitedMembers diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt index 685b2d2967..cac6499486 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/RealmCryptoStoreMigration.kt @@ -32,6 +32,7 @@ import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo012 import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo013 import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo014 +import org.matrix.android.sdk.internal.crypto.store.db.migration.MigrateCryptoTo015 import timber.log.Timber import javax.inject.Inject @@ -46,7 +47,7 @@ internal class RealmCryptoStoreMigration @Inject constructor() : RealmMigration // 0, 1, 2: legacy Riot-Android // 3: migrate to RiotX schema // 4, 5, 6, 7, 8, 9: migrations from RiotX (which was previously 1, 2, 3, 4, 5, 6) - val schemaVersion = 14L + val schemaVersion = 15L override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { Timber.d("Migrating Realm Crypto from $oldVersion to $newVersion") @@ -65,5 +66,6 @@ internal class RealmCryptoStoreMigration @Inject constructor() : RealmMigration if (oldVersion < 12) MigrateCryptoTo012(realm).perform() if (oldVersion < 13) MigrateCryptoTo013(realm).perform() if (oldVersion < 14) MigrateCryptoTo014(realm).perform() + if (oldVersion < 15) MigrateCryptoTo015(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo015.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo015.kt new file mode 100644 index 0000000000..465c18555a --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/migration/MigrateCryptoTo015.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.crypto.store.db.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM +import org.matrix.android.sdk.internal.crypto.store.db.model.CryptoRoomEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +// Version 15L adds wasEncryptedOnce field to CryptoRoomEntity +class MigrateCryptoTo015(realm: DynamicRealm) : RealmMigrator(realm, 15) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("CryptoRoomEntity") + ?.addField(CryptoRoomEntityFields.WAS_ENCRYPTED_ONCE, Boolean::class.java) + ?.setNullable(CryptoRoomEntityFields.WAS_ENCRYPTED_ONCE, true) + ?.transform { + val currentAlgorithm = it.getString(CryptoRoomEntityFields.ALGORITHM) + it.set(CryptoRoomEntityFields.WAS_ENCRYPTED_ONCE, currentAlgorithm == MXCRYPTO_ALGORITHM_MEGOLM) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/CryptoRoomEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/CryptoRoomEntity.kt index 711b698464..6167314b5a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/CryptoRoomEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/store/db/model/CryptoRoomEntity.kt @@ -27,7 +27,10 @@ internal open class CryptoRoomEntity( // Store the current outbound session for this room, // to avoid re-create and re-share at each startup (if rotation not needed..) // This is specific to megolm but not sure how to model it better - var outboundSessionInfo: OutboundGroupSessionInfoEntity? = null + var outboundSessionInfo: OutboundGroupSessionInfoEntity? = null, + // a security to ensure that a room will never revert to not encrypted + // even if a new state event with empty encryption, or state is reset somehow + var wasEncryptedOnce: Boolean? = false ) : RealmObject() { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/DefaultPushRuleService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/DefaultPushRuleService.kt index 3e821b8956..cdc7350f8b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/DefaultPushRuleService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/DefaultPushRuleService.kt @@ -19,11 +19,13 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import com.zhuinden.monarchy.Monarchy import org.matrix.android.sdk.api.pushrules.Action +import org.matrix.android.sdk.api.pushrules.ConditionResolver import org.matrix.android.sdk.api.pushrules.PushEvents import org.matrix.android.sdk.api.pushrules.PushRuleService import org.matrix.android.sdk.api.pushrules.RuleKind import org.matrix.android.sdk.api.pushrules.RuleScope import org.matrix.android.sdk.api.pushrules.RuleSetKey +import org.matrix.android.sdk.api.pushrules.SenderNotificationPermissionCondition import org.matrix.android.sdk.api.pushrules.getActions import org.matrix.android.sdk.api.pushrules.rest.PushRule import org.matrix.android.sdk.api.pushrules.rest.RuleSet @@ -53,6 +55,7 @@ internal class DefaultPushRuleService @Inject constructor( private val removePushRuleTask: RemovePushRuleTask, private val pushRuleFinder: PushRuleFinder, private val taskExecutor: TaskExecutor, + private val conditionResolver: ConditionResolver, @SessionDatabase private val monarchy: Monarchy ) : PushRuleService { @@ -143,6 +146,10 @@ internal class DefaultPushRuleService @Inject constructor( return pushRuleFinder.fulfilledBingRule(event, rules)?.getActions().orEmpty() } + override fun resolveSenderNotificationPermissionCondition(event: Event, condition: SenderNotificationPermissionCondition): Boolean { + return conditionResolver.resolveSenderNotificationPermissionCondition(event, condition) + } + override fun getKeywords(): LiveData> { // Keywords are all content rules that don't start with '.' val liveData = monarchy.findAllMappedWithChanges( diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt index f21ee4346c..d5d2059969 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt @@ -30,7 +30,6 @@ import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.NoOpCancellable import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.toOptional -import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity @@ -47,7 +46,6 @@ internal class DefaultRelationService @AssistedInject constructor( private val eventEditor: EventEditor, private val eventSenderProcessor: EventSenderProcessor, private val eventFactory: LocalEchoEventFactory, - private val cryptoSessionInfoProvider: CryptoSessionInfoProvider, private val findReactionEventForUndoTask: FindReactionEventForUndoTask, private val fetchEditHistoryTask: FetchEditHistoryTask, private val timelineEventMapper: TimelineEventMapper, @@ -144,7 +142,7 @@ internal class DefaultRelationService @AssistedInject constructor( ?.also { saveLocalEcho(it) } ?: return null - return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + return eventSenderProcessor.postEvent(event) } override fun getEventAnnotationsSummary(eventId: String): EventAnnotationsSummary? { @@ -200,7 +198,7 @@ internal class DefaultRelationService @AssistedInject constructor( saveLocalEcho(it) } } - return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + return eventSenderProcessor.postEvent(event) } /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt index 4551f390e7..b54cd71e50 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt @@ -23,7 +23,6 @@ import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.NoOpCancellable -import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider import org.matrix.android.sdk.internal.database.mapper.toEntity import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository @@ -33,7 +32,6 @@ import javax.inject.Inject internal class EventEditor @Inject constructor(private val eventSenderProcessor: EventSenderProcessor, private val eventFactory: LocalEchoEventFactory, - private val cryptoSessionInfoProvider: CryptoSessionInfoProvider, private val localEchoRepository: LocalEchoRepository) { fun editTextMessage(targetEvent: TimelineEvent, @@ -51,7 +49,7 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor: } else if (targetEvent.root.sendState.isSent()) { val event = eventFactory .createReplaceTextEvent(roomId, targetEvent.eventId, newBodyText, newBodyAutoMarkdown, msgType, compatibilityBodyText) - return sendReplaceEvent(roomId, event) + return sendReplaceEvent(event) } else { // Should we throw? Timber.w("Can't edit a sending event") @@ -72,7 +70,7 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor: } else if (targetEvent.root.sendState.isSent()) { val event = eventFactory .createPollReplaceEvent(roomId, pollType, targetEvent.eventId, question, options) - return sendReplaceEvent(roomId, event) + return sendReplaceEvent(event) } else { Timber.w("Can't edit a sending event") return NoOpCancellable @@ -82,12 +80,12 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor: private fun sendFailedEvent(targetEvent: TimelineEvent, editedEvent: Event): Cancelable { val roomId = targetEvent.roomId updateFailedEchoWithEvent(roomId, targetEvent.eventId, editedEvent) - return eventSenderProcessor.postEvent(editedEvent, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + return eventSenderProcessor.postEvent(editedEvent) } - private fun sendReplaceEvent(roomId: String, editedEvent: Event): Cancelable { + private fun sendReplaceEvent(editedEvent: Event): Cancelable { localEchoRepository.createLocalEcho(editedEvent) - return eventSenderProcessor.postEvent(editedEvent, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + return eventSenderProcessor.postEvent(editedEvent) } fun editReply(replyToEdit: TimelineEvent, @@ -107,7 +105,7 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor: eventId = replyToEdit.eventId ) ?: return NoOpCancellable updateFailedEchoWithEvent(roomId, replyToEdit.eventId, editedEvent) - return eventSenderProcessor.postEvent(editedEvent, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + return eventSenderProcessor.postEvent(editedEvent) } else if (replyToEdit.root.sendState.isSent()) { val event = eventFactory.createReplaceTextOfReply( roomId, @@ -119,7 +117,7 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor: compatibilityBodyText ) .also { localEchoRepository.createLocalEcho(it) } - return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + return eventSenderProcessor.postEvent(event) } else { // Should we throw? Timber.w("Can't edit a sending event") diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt index 8c0ea0ec4c..28c17f38b6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt @@ -46,7 +46,7 @@ import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.CancelableBag import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.util.NoOpCancellable -import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider +import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore import org.matrix.android.sdk.internal.di.SessionId import org.matrix.android.sdk.internal.di.WorkManagerProvider import org.matrix.android.sdk.internal.session.content.UploadContentWorker @@ -66,7 +66,7 @@ internal class DefaultSendService @AssistedInject constructor( private val workManagerProvider: WorkManagerProvider, @SessionId private val sessionId: String, private val localEchoEventFactory: LocalEchoEventFactory, - private val cryptoSessionInfoProvider: CryptoSessionInfoProvider, + private val cryptoStore: IMXCryptoStore, private val taskExecutor: TaskExecutor, private val localEchoRepository: LocalEchoRepository, private val eventSenderProcessor: EventSenderProcessor, @@ -303,7 +303,7 @@ internal class DefaultSendService @AssistedInject constructor( private fun internalSendMedia(allLocalEchoes: List, attachment: ContentAttachmentData, compressBeforeSending: Boolean): Cancelable { val cancelableBag = CancelableBag() - allLocalEchoes.groupBy { cryptoSessionInfoProvider.isRoomEncrypted(it.roomId!!) } + allLocalEchoes.groupBy { cryptoStore.roomWasOnceEncrypted(it.roomId!!) } .apply { keys.forEach { isRoomEncrypted -> // Should never be empty @@ -334,7 +334,7 @@ internal class DefaultSendService @AssistedInject constructor( } private fun sendEvent(event: Event): Cancelable { - return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(event.roomId!!)) + return eventSenderProcessor.postEvent(event) } private fun createLocalEcho(event: Event) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt index 33cb0db243..ccbfbfcded 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/pills/TextPillsUtils.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.internal.session.room.send.pills import android.text.SpannableString import org.matrix.android.sdk.api.session.room.send.MatrixItemSpan +import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.internal.session.displayname.DisplayNameResolver import java.util.Collections import javax.inject.Inject @@ -51,6 +52,8 @@ internal class TextPillsUtils @Inject constructor( val pills = spannableString ?.getSpans(0, text.length, MatrixItemSpan::class.java) ?.map { MentionLinkSpec(it, spannableString.getSpanStart(it), spannableString.getSpanEnd(it)) } + // we use the raw text for @room notification instead of a link + ?.filterNot { it.span.matrixItem is MatrixItem.EveryoneInRoomItem } ?.toMutableList() ?.takeIf { it.isNotEmpty() } ?: return null diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessorCoroutine.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessorCoroutine.kt index eb69161614..5b4efa5df6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessorCoroutine.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/queue/EventSenderProcessorCoroutine.kt @@ -26,9 +26,9 @@ import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.getRetryDelay import org.matrix.android.sdk.api.failure.isLimitExceededError import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.crypto.CryptoService import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.util.Cancelable +import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore import org.matrix.android.sdk.internal.session.SessionScope import org.matrix.android.sdk.internal.task.CoroutineSequencer import org.matrix.android.sdk.internal.task.SemaphoreCoroutineSequencer @@ -54,7 +54,7 @@ private const val MAX_RETRY_COUNT = 3 */ @SessionScope internal class EventSenderProcessorCoroutine @Inject constructor( - private val cryptoService: CryptoService, + private val cryptoStore: IMXCryptoStore, private val sessionParams: SessionParams, private val queuedTaskFactory: QueuedTaskFactory, private val taskExecutor: TaskExecutor, @@ -92,7 +92,8 @@ internal class EventSenderProcessorCoroutine @Inject constructor( } override fun postEvent(event: Event): Cancelable { - return postEvent(event, event.roomId?.let { cryptoService.isRoomEncrypted(it) } ?: false) + val shouldEncrypt = event.roomId?.let { cryptoStore.roomWasOnceEncrypted(it) } ?: false + return postEvent(event, shouldEncrypt) } override fun postEvent(event: Event, encrypt: Boolean): Cancelable { 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 a7887d77f8..1c1d59fb3d 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 @@ -119,9 +119,8 @@ internal class RoomSummaryUpdater @Inject constructor( roomSummaryEntity.roomType = roomType Timber.v("## Space: Updating summary room [$roomId] roomType: [$roomType]") - // Don't use current state for this one as we are only interested in having MXCRYPTO_ALGORITHM_MEGOLM event in the room val encryptionEvent = CurrentStateEventEntity.getOrNull(realm, roomId, type = EventType.STATE_ROOM_ENCRYPTION, stateKey = "")?.root - Timber.v("## CRYPTO: currentEncryptionEvent is $encryptionEvent") + Timber.d("## CRYPTO: currentEncryptionEvent is $encryptionEvent") val latestPreviewableEvent = RoomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncService.kt index 0ecf91f6fa..97ae9b3a68 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncService.kt @@ -192,12 +192,14 @@ abstract class SyncService : Service() { } } + abstract fun provideMatrix(): Matrix + private fun initialize(intent: Intent?): Boolean { if (intent == null) { Timber.d("## Sync: initialize intent is null") return false } - val matrix = Matrix.getInstance(applicationContext) + val matrix = provideMatrix() val safeSessionId = intent.getStringExtra(EXTRA_SESSION_ID) ?: return false syncTimeoutSeconds = intent.getIntExtra(EXTRA_TIMEOUT_SECONDS, getDefaultSyncTimeoutSeconds()) syncDelaySeconds = intent.getIntExtra(EXTRA_DELAY_SECONDS, getDefaultSyncDelaySeconds()) diff --git a/vector/src/androidTest/java/im/vector/app/SecurityBootstrapTest.kt b/vector/src/androidTest/java/im/vector/app/SecurityBootstrapTest.kt index fb7b9dcb41..69fe63fb7b 100644 --- a/vector/src/androidTest/java/im/vector/app/SecurityBootstrapTest.kt +++ b/vector/src/androidTest/java/im/vector/app/SecurityBootstrapTest.kt @@ -38,7 +38,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest -import androidx.test.platform.app.InstrumentationRegistry +import im.vector.app.core.utils.getMatrixInstance import im.vector.app.features.MainActivity import im.vector.app.features.crypto.recover.SetupMode import im.vector.app.features.home.HomeActivity @@ -47,7 +47,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.api.session.Session @RunWith(AndroidJUnit4::class) @@ -61,8 +60,7 @@ class SecurityBootstrapTest : VerificationTestBase() { @Before fun createSessionWithCrossSigning() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val matrix = Matrix.getInstance(context) + val matrix = getMatrixInstance() val userName = "foobar_${System.currentTimeMillis()}" existingSession = createAccountAndSync(matrix, userName, password, true) stubAllExternalIntents() diff --git a/vector/src/androidTest/java/im/vector/app/VerifySessionInteractiveTest.kt b/vector/src/androidTest/java/im/vector/app/VerifySessionInteractiveTest.kt index 982a421425..c82b543a08 100644 --- a/vector/src/androidTest/java/im/vector/app/VerifySessionInteractiveTest.kt +++ b/vector/src/androidTest/java/im/vector/app/VerifySessionInteractiveTest.kt @@ -33,7 +33,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest -import androidx.test.platform.app.InstrumentationRegistry +import im.vector.app.core.utils.getMatrixInstance import im.vector.app.features.MainActivity import im.vector.app.features.home.HomeActivity import org.hamcrest.CoreMatchers.not @@ -41,7 +41,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.api.auth.UIABaseAuth import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor import org.matrix.android.sdk.api.auth.UserPasswordAuth @@ -66,8 +65,7 @@ class VerifySessionInteractiveTest : VerificationTestBase() { @Before fun createSessionWithCrossSigning() { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val matrix = Matrix.getInstance(context) + val matrix = getMatrixInstance() val userName = "foobar_${System.currentTimeMillis()}" existingSession = createAccountAndSync(matrix, userName, password, true) doSync { diff --git a/vector/src/androidTest/java/im/vector/app/VerifySessionPassphraseTest.kt b/vector/src/androidTest/java/im/vector/app/VerifySessionPassphraseTest.kt index c51ff29669..80d8315a0e 100644 --- a/vector/src/androidTest/java/im/vector/app/VerifySessionPassphraseTest.kt +++ b/vector/src/androidTest/java/im/vector/app/VerifySessionPassphraseTest.kt @@ -34,6 +34,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry import im.vector.app.core.resources.StringProvider +import im.vector.app.core.utils.getMatrixInstance import im.vector.app.features.MainActivity import im.vector.app.features.crypto.quads.SharedSecureStorageActivity import im.vector.app.features.crypto.recover.BootstrapCrossSigningTask @@ -45,7 +46,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.api.auth.UIABaseAuth import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor import org.matrix.android.sdk.api.auth.UserPasswordAuth @@ -67,7 +67,7 @@ class VerifySessionPassphraseTest : VerificationTestBase() { @Before fun createSessionWithCrossSigningAnd4S() { val context = InstrumentationRegistry.getInstrumentation().targetContext - val matrix = Matrix.getInstance(context) + val matrix = getMatrixInstance() val userName = "foobar_${System.currentTimeMillis()}" existingSession = createAccountAndSync(matrix, userName, password, true) doSync { @@ -90,7 +90,7 @@ class VerifySessionPassphraseTest : VerificationTestBase() { runBlocking { task.execute(Params( - userInteractiveAuthInterceptor = object : UserInteractiveAuthInterceptor { + userInteractiveAuthInterceptor = object : UserInteractiveAuthInterceptor { override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation) { promise.resume( UserPasswordAuth( diff --git a/vector/src/androidTest/java/im/vector/app/core/utils/TestMatrixHelper.kt b/vector/src/androidTest/java/im/vector/app/core/utils/TestMatrixHelper.kt new file mode 100644 index 0000000000..322f5fa23d --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/core/utils/TestMatrixHelper.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.utils + +import androidx.test.platform.app.InstrumentationRegistry +import im.vector.app.features.room.VectorRoomDisplayNameFallbackProvider +import org.matrix.android.sdk.api.Matrix +import org.matrix.android.sdk.api.MatrixConfiguration + +fun getMatrixInstance(): Matrix { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val configuration = MatrixConfiguration( + roomDisplayNameFallbackProvider = VectorRoomDisplayNameFallbackProvider(context) + ) + return Matrix.createInstance(context, configuration) +} diff --git a/vector/src/androidTest/java/im/vector/app/espresso/tools/ScreenshotFailureRule.kt b/vector/src/androidTest/java/im/vector/app/espresso/tools/ScreenshotFailureRule.kt index 2e329ebb6b..2939dcf4e0 100644 --- a/vector/src/androidTest/java/im/vector/app/espresso/tools/ScreenshotFailureRule.kt +++ b/vector/src/androidTest/java/im/vector/app/espresso/tools/ScreenshotFailureRule.kt @@ -40,7 +40,7 @@ private val deviceLanguage = Locale.getDefault().language class ScreenshotFailureRule : TestWatcher() { override fun failed(e: Throwable?, description: Description) { - val screenShotName = "$deviceLanguage-${description.methodName}-${SimpleDateFormat("EEE-MMMM-dd-HH:mm:ss").format(Date())}" + val screenShotName = "$deviceLanguage-${description.methodName}-${SimpleDateFormat("EEE-MMMM-dd-HHmmss").format(Date())}" val bitmap = getInstrumentation().uiAutomation.takeScreenshot() storeFailureScreenshot(bitmap, screenShotName) } diff --git a/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt b/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt index d625cf0390..417d28d625 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt @@ -16,10 +16,12 @@ package im.vector.app.ui +import android.Manifest import androidx.test.espresso.IdlingPolicies import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest +import androidx.test.rule.GrantPermissionRule import im.vector.app.R import im.vector.app.espresso.tools.ScreenshotFailureRule import im.vector.app.features.MainActivity @@ -43,6 +45,7 @@ class UiAllScreensSanityTest { @get:Rule val testRule = RuleChain .outerRule(ActivityScenarioRule(MainActivity::class.java)) + .around(GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE)) .around(ScreenshotFailureRule()) private val elementRobot = ElementRobot() @@ -94,6 +97,30 @@ class UiAllScreensSanityTest { } } + elementRobot.space { + createSpace { + crawl() + } + val spaceName = UUID.randomUUID().toString() + createSpace { + createPublicSpace(spaceName) + } + + spaceMenu(spaceName) { + spaceMembers() + spaceSettings { + crawl() + } + exploreRooms() + + invitePeople().also { openMenu(spaceName) } + addRoom().also { openMenu(spaceName) } + addSpace().also { openMenu(spaceName) } + + leaveSpace() + } + } + elementRobot.withDeveloperMode { settings { advancedSettings { crawlDeveloperOptions() } 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 a5962d16fe..f0ce23b7db 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 @@ -35,6 +35,7 @@ import im.vector.app.features.home.HomeActivity import im.vector.app.features.onboarding.OnboardingActivity import im.vector.app.initialSyncIdlingResource import im.vector.app.ui.robot.settings.SettingsRobot +import im.vector.app.ui.robot.space.SpaceRobot import im.vector.app.withIdlingResource import timber.log.Timber @@ -147,6 +148,10 @@ class ElementRobot { waitUntilViewVisible(withId(R.id.bottomSheetFragmentContainer)) }.onFailure { Timber.w(it, "Verification popup missing") } } + + fun space(block: SpaceRobot.() -> Unit) { + block(SpaceRobot()) + } } private fun Boolean.toWarningType() = if (this) "shown" else "skipped" diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceCreateRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceCreateRobot.kt new file mode 100644 index 0000000000..68e5fa5059 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceCreateRobot.kt @@ -0,0 +1,97 @@ +/* + * 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.ui.robot.space + +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.Espresso.pressBack +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn +import im.vector.app.R +import im.vector.app.espresso.tools.waitUntilActivityVisible +import im.vector.app.espresso.tools.waitUntilDialogVisible +import im.vector.app.espresso.tools.waitUntilViewVisible +import im.vector.app.features.home.HomeActivity +import im.vector.app.features.spaces.manage.SpaceManageActivity +import java.util.UUID + +class SpaceCreateRobot { + + fun crawl() { + // public + clickOn(R.id.publicButton) + waitUntilViewVisible(withId(R.id.recyclerView)) + onView(ViewMatchers.withHint(R.string.create_room_name_hint)).perform(ViewActions.replaceText(UUID.randomUUID().toString())) + clickOn(R.id.nextButton) + waitUntilViewVisible(withId(R.id.recyclerView)) + pressBack() + pressBack() + + // private + clickOn(R.id.privateButton) + waitUntilViewVisible(withId(R.id.recyclerView)) + clickOn(R.id.nextButton) + + waitUntilViewVisible(withId(R.id.teammatesButton)) + // me and teammates + clickOn(R.id.teammatesButton) + waitUntilViewVisible(withId(R.id.recyclerView)) + clickOn(R.id.nextButton) + pressBack() + pressBack() + + // just me + waitUntilViewVisible(withId(R.id.justMeButton)) + clickOn(R.id.justMeButton) + waitUntilActivityVisible { + waitUntilViewVisible(withId(R.id.roomList)) + } + + onView(withId(R.id.roomList)) + .perform( + RecyclerViewActions.actionOnItem( + ViewMatchers.hasDescendant(withText(R.string.room_displayname_empty_room)), + click() + ).atPosition(0) + ) + clickOn(R.id.spaceAddRoomSaveItem) + waitUntilActivityVisible { + waitUntilViewVisible(withId(R.id.roomListContainer)) + } + } + + fun createPublicSpace(spaceName: String) { + clickOn(R.id.publicButton) + waitUntilViewVisible(withId(R.id.recyclerView)) + onView(ViewMatchers.withHint(R.string.create_room_name_hint)).perform(ViewActions.replaceText(spaceName)) + clickOn(R.id.nextButton) + waitUntilViewVisible(withId(R.id.recyclerView)) + clickOn(R.id.nextButton) + waitUntilDialogVisible(withId(R.id.inviteByMxidButton)) + // close invite dialog + pressBack() + waitUntilViewVisible(withId(R.id.timelineRecyclerView)) + // close room + pressBack() + waitUntilViewVisible(withId(R.id.roomListContainer)) + } +} diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt new file mode 100644 index 0000000000..431df396d0 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt @@ -0,0 +1,114 @@ +/* + * 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.ui.robot.space + +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers +import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn +import com.adevinta.android.barista.internal.viewaction.ClickChildAction +import im.vector.app.R +import im.vector.app.espresso.tools.waitUntilActivityVisible +import im.vector.app.espresso.tools.waitUntilDialogVisible +import im.vector.app.espresso.tools.waitUntilViewVisible +import im.vector.app.features.invite.InviteUsersToRoomActivity +import im.vector.app.features.roomprofile.RoomProfileActivity +import im.vector.app.features.spaces.SpaceExploreActivity +import im.vector.app.features.spaces.leave.SpaceLeaveAdvancedActivity +import im.vector.app.features.spaces.manage.SpaceManageActivity +import org.hamcrest.Matchers + +class SpaceMenuRobot { + + fun openMenu(spaceName: String) { + waitUntilViewVisible(ViewMatchers.withId(R.id.groupListView)) + onView(ViewMatchers.withId(R.id.groupListView)) + .perform( + RecyclerViewActions.actionOnItem( + ViewMatchers.hasDescendant(Matchers.allOf(ViewMatchers.withId(R.id.groupNameView), ViewMatchers.withText(spaceName))), + ClickChildAction.clickChildWithId(R.id.groupTmpLeave) + ).atPosition(0) + ) + waitUntilDialogVisible(ViewMatchers.withId(R.id.spaceNameView)) + } + + fun invitePeople() = apply { + clickOn(R.id.invitePeople) + waitUntilDialogVisible(ViewMatchers.withId(R.id.inviteByMxidButton)) + clickOn(R.id.inviteByMxidButton) + waitUntilActivityVisible { + waitUntilViewVisible(ViewMatchers.withId(R.id.userListRecyclerView)) + } + // close keyboard + Espresso.pressBack() + // close invite view + Espresso.pressBack() + } + + fun spaceMembers() { + clickOn(R.id.showMemberList) + waitUntilActivityVisible { + waitUntilViewVisible(ViewMatchers.withId(R.id.roomSettingsRecyclerView)) + } + Espresso.pressBack() + } + + fun spaceSettings(block: SpaceSettingsRobot.() -> Unit) { + clickOn(R.id.spaceSettings) + waitUntilActivityVisible { + waitUntilViewVisible(ViewMatchers.withId(R.id.roomSettingsRecyclerView)) + } + block(SpaceSettingsRobot()) + } + + fun exploreRooms() { + clickOn(R.id.exploreRooms) + waitUntilActivityVisible { + waitUntilViewVisible(ViewMatchers.withId(R.id.spaceDirectoryList)) + } + Espresso.pressBack() + } + + fun addRoom() = apply { + clickOn(R.id.addRooms) + waitUntilActivityVisible { + waitUntilViewVisible(ViewMatchers.withId(R.id.roomList)) + } + Espresso.pressBack() + } + + fun addSpace() = apply { + clickOn(R.id.addSpaces) + waitUntilActivityVisible { + waitUntilViewVisible(ViewMatchers.withId(R.id.roomList)) + } + Espresso.pressBack() + } + + fun leaveSpace() { + clickOn(R.id.leaveSpace) + waitUntilDialogVisible(ViewMatchers.withId(R.id.leaveButton)) + clickOn(R.id.leave_selected) + waitUntilActivityVisible { + waitUntilViewVisible(ViewMatchers.withId(R.id.roomList)) + } + clickOn(R.id.spaceLeaveButton) + waitUntilViewVisible(ViewMatchers.withId(R.id.groupListView)) + } +} diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceRobot.kt new file mode 100644 index 0000000000..ffb3c24051 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceRobot.kt @@ -0,0 +1,38 @@ +/* + * 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.ui.robot.space + +import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn +import com.adevinta.android.barista.interaction.BaristaDrawerInteractions.openDrawer +import im.vector.app.R + +class SpaceRobot { + + fun createSpace(block: SpaceCreateRobot.() -> Unit) { + openDrawer() + clickOn(R.string.add_space) + block(SpaceCreateRobot()) + } + + fun spaceMenu(spaceName: String, block: SpaceMenuRobot.() -> Unit) { + openDrawer() + with(SpaceMenuRobot()) { + openMenu(spaceName) + block() + } + } +} diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceSettingsRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceSettingsRobot.kt new file mode 100644 index 0000000000..dcd003da98 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/space/SpaceSettingsRobot.kt @@ -0,0 +1,68 @@ +/* + * 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.ui.robot.space + +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers +import im.vector.app.R +import im.vector.app.espresso.tools.waitUntilActivityVisible +import im.vector.app.espresso.tools.waitUntilViewVisible +import im.vector.app.features.roomprofile.settings.joinrule.RoomJoinRuleActivity + +class SpaceSettingsRobot { + fun crawl() { + Espresso.onView(ViewMatchers.withId(R.id.roomSettingsRecyclerView)) + .perform( + RecyclerViewActions.actionOnItem( + ViewMatchers.hasDescendant(ViewMatchers.withText(R.string.room_settings_space_access_title)), + ViewActions.click() + ) + ) + + waitUntilActivityVisible { + waitUntilViewVisible(ViewMatchers.withId(R.id.genericRecyclerView)) + } + + Espresso.pressBack() + + Espresso.onView(ViewMatchers.withId(R.id.roomSettingsRecyclerView)) + .perform( + RecyclerViewActions.actionOnItem( + ViewMatchers.hasDescendant(ViewMatchers.withText(R.string.space_settings_manage_rooms)), + ViewActions.click() + ) + ) + + waitUntilViewVisible(ViewMatchers.withId(R.id.roomList)) + Espresso.pressBack() + + Espresso.onView(ViewMatchers.withId(R.id.roomSettingsRecyclerView)) + .perform( + RecyclerViewActions.actionOnItem( + ViewMatchers.hasDescendant(ViewMatchers.withText(R.string.space_settings_permissions_title)), + ViewActions.click() + ) + ) + + waitUntilViewVisible(ViewMatchers.withId(R.id.roomSettingsRecyclerView)) + Espresso.pressBack() + Espresso.pressBack() + } +} diff --git a/vector/src/main/java/im/vector/app/VectorApplication.kt b/vector/src/main/java/im/vector/app/VectorApplication.kt index e64188765e..a3f4ffcfcd 100644 --- a/vector/src/main/java/im/vector/app/VectorApplication.kt +++ b/vector/src/main/java/im/vector/app/VectorApplication.kt @@ -55,7 +55,6 @@ import im.vector.app.features.pin.PinLocker import im.vector.app.features.popup.PopupAlertManager import im.vector.app.features.rageshake.VectorFileLogger import im.vector.app.features.rageshake.VectorUncaughtExceptionHandler -import im.vector.app.features.room.VectorRoomDisplayNameFallbackProvider import im.vector.app.features.settings.VectorLocale import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.themes.ThemeUtils @@ -63,7 +62,6 @@ import im.vector.app.features.version.VersionProvider import im.vector.app.push.fcm.FcmHelper import org.jitsi.meet.sdk.log.JitsiMeetDefaultLogHandler import org.matrix.android.sdk.api.Matrix -import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.legacy.LegacySessionImporter import timber.log.Timber @@ -77,7 +75,6 @@ import androidx.work.Configuration as WorkConfiguration @HiltAndroidApp class VectorApplication : Application(), - MatrixConfiguration.Provider, WorkConfiguration.Provider { lateinit var appContext: Context @@ -100,6 +97,7 @@ class VectorApplication : @Inject lateinit var autoRageShaker: AutoRageShaker @Inject lateinit var vectorFileLogger: VectorFileLogger @Inject lateinit var vectorAnalytics: VectorAnalytics + @Inject lateinit var matrix: Matrix // font thread handler private var fontThreadHandler: Handler? = null @@ -220,16 +218,9 @@ class VectorApplication : } } - override fun providesMatrixConfiguration(): MatrixConfiguration { - return MatrixConfiguration( - applicationFlavor = BuildConfig.FLAVOR_DESCRIPTION, - roomDisplayNameFallbackProvider = VectorRoomDisplayNameFallbackProvider(this) - ) - } - override fun getWorkManagerConfiguration(): WorkConfiguration { return WorkConfiguration.Builder() - .setWorkerFactory(Matrix.getInstance(this.appContext).workerFactory()) + .setWorkerFactory(matrix.workerFactory()) .setExecutor(Executors.newCachedThreadPool()) .build() } diff --git a/vector/src/main/java/im/vector/app/core/di/NamedGlobalScope.kt b/vector/src/main/java/im/vector/app/core/di/NamedGlobalScope.kt new file mode 100644 index 0000000000..cc1ac829a1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/di/NamedGlobalScope.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.core.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class NamedGlobalScope diff --git a/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt b/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt index 0e19cd4388..a5575ef536 100644 --- a/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt @@ -26,13 +26,16 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import im.vector.app.BuildConfig import im.vector.app.EmojiCompatWrapper import im.vector.app.EmojiSpanify +import im.vector.app.config.analyticsConfig import im.vector.app.core.dispatchers.CoroutineDispatchers import im.vector.app.core.error.DefaultErrorFormatter import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.time.Clock import im.vector.app.core.time.DefaultClock +import im.vector.app.features.analytics.AnalyticsConfig import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.analytics.VectorAnalytics import im.vector.app.features.analytics.impl.DefaultVectorAnalytics @@ -42,12 +45,15 @@ import im.vector.app.features.navigation.DefaultNavigator import im.vector.app.features.navigation.Navigator import im.vector.app.features.pin.PinCodeStore import im.vector.app.features.pin.SharedPrefPinCodeStore +import im.vector.app.features.room.VectorRoomDisplayNameFallbackProvider import im.vector.app.features.ui.SharedPreferencesUiStateRepository import im.vector.app.features.ui.UiStateRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.SupervisorJob import org.matrix.android.sdk.api.Matrix +import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.auth.HomeServerHistoryService import org.matrix.android.sdk.api.legacy.LegacySessionImporter @@ -107,8 +113,17 @@ object VectorStaticModule { } @Provides - fun providesMatrix(context: Context): Matrix { - return Matrix.getInstance(context) + fun providesMatrixConfiguration(vectorRoomDisplayNameFallbackProvider: VectorRoomDisplayNameFallbackProvider): MatrixConfiguration { + return MatrixConfiguration( + applicationFlavor = BuildConfig.FLAVOR_DESCRIPTION, + roomDisplayNameFallbackProvider = vectorRoomDisplayNameFallbackProvider + ) + } + + @Provides + @Singleton + fun providesMatrix(context: Context, configuration: MatrixConfiguration): Matrix { + return Matrix.createInstance(context, configuration) } @Provides @@ -147,4 +162,16 @@ object VectorStaticModule { fun providesCoroutineDispatchers(): CoroutineDispatchers { return CoroutineDispatchers(io = Dispatchers.IO, computation = Dispatchers.Default) } + + @Suppress("EXPERIMENTAL_API_USAGE") + @Provides + @NamedGlobalScope + fun providesGlobalScope(): CoroutineScope { + return GlobalScope + } + + @Provides + fun providesAnalyticsConfig(): AnalyticsConfig { + return analyticsConfig + } } diff --git a/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt b/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt index 8164df9c55..5767acd44b 100644 --- a/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt +++ b/vector/src/main/java/im/vector/app/core/platform/VectorBaseActivity.kt @@ -67,7 +67,7 @@ import im.vector.app.core.utils.toast import im.vector.app.features.MainActivity import im.vector.app.features.MainActivityArgs import im.vector.app.features.analytics.AnalyticsTracker -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.screen.ScreenEvent import im.vector.app.features.configuration.VectorConfiguration import im.vector.app.features.consent.ConsentNotGivenHelper @@ -97,7 +97,7 @@ abstract class VectorBaseActivity : AppCompatActivity(), Maver * Analytics * ========================================================================================== */ - protected var analyticsScreenName: Screen.ScreenName? = null + protected var analyticsScreenName: MobileScreen.ScreenName? = null private var screenEvent: ScreenEvent? = null protected lateinit var analyticsTracker: AnalyticsTracker diff --git a/vector/src/main/java/im/vector/app/core/platform/VectorBaseBottomSheetDialogFragment.kt b/vector/src/main/java/im/vector/app/core/platform/VectorBaseBottomSheetDialogFragment.kt index 7e6a429274..869a12e871 100644 --- a/vector/src/main/java/im/vector/app/core/platform/VectorBaseBottomSheetDialogFragment.kt +++ b/vector/src/main/java/im/vector/app/core/platform/VectorBaseBottomSheetDialogFragment.kt @@ -38,7 +38,7 @@ import im.vector.app.core.extensions.singletonEntryPoint import im.vector.app.core.extensions.toMvRxBundle import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.analytics.AnalyticsTracker -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.screen.ScreenEvent import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -53,7 +53,7 @@ abstract class VectorBaseBottomSheetDialogFragment : BottomShe * Analytics * ========================================================================================== */ - protected var analyticsScreenName: Screen.ScreenName? = null + protected var analyticsScreenName: MobileScreen.ScreenName? = null private var screenEvent: ScreenEvent? = null protected lateinit var analyticsTracker: AnalyticsTracker diff --git a/vector/src/main/java/im/vector/app/core/platform/VectorBaseFragment.kt b/vector/src/main/java/im/vector/app/core/platform/VectorBaseFragment.kt index 8a1b9051cc..6bd62707f2 100644 --- a/vector/src/main/java/im/vector/app/core/platform/VectorBaseFragment.kt +++ b/vector/src/main/java/im/vector/app/core/platform/VectorBaseFragment.kt @@ -44,7 +44,7 @@ import im.vector.app.core.extensions.singletonEntryPoint import im.vector.app.core.extensions.toMvRxBundle import im.vector.app.core.utils.ToolbarConfig import im.vector.app.features.analytics.AnalyticsTracker -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.screen.ScreenEvent import im.vector.app.features.navigation.Navigator import im.vector.lib.ui.styles.dialogs.MaterialProgressDialog @@ -58,7 +58,7 @@ abstract class VectorBaseFragment : Fragment(), MavericksView * Analytics * ========================================================================================== */ - protected var analyticsScreenName: Screen.ScreenName? = null + protected var analyticsScreenName: MobileScreen.ScreenName? = null private var screenEvent: ScreenEvent? = null protected lateinit var analyticsTracker: AnalyticsTracker diff --git a/vector/src/main/java/im/vector/app/core/services/VectorSyncService.kt b/vector/src/main/java/im/vector/app/core/services/VectorSyncService.kt index a6f6d8d82f..8621c28d57 100644 --- a/vector/src/main/java/im/vector/app/core/services/VectorSyncService.kt +++ b/vector/src/main/java/im/vector/app/core/services/VectorSyncService.kt @@ -35,6 +35,7 @@ import im.vector.app.R import im.vector.app.core.platform.PendingIntentCompat import im.vector.app.features.notifications.NotificationUtils import im.vector.app.features.settings.BackgroundSyncMode +import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.internal.session.sync.job.SyncService import timber.log.Timber import javax.inject.Inject @@ -75,6 +76,9 @@ class VectorSyncService : SyncService() { } @Inject lateinit var notificationUtils: NotificationUtils + @Inject lateinit var matrix: Matrix + + override fun provideMatrix() = matrix override fun getDefaultSyncDelaySeconds() = BackgroundSyncMode.DEFAULT_SYNC_DELAY_SECONDS diff --git a/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt b/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt index 6dbf412d83..7b653ef44b 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt @@ -16,19 +16,18 @@ package im.vector.app.features.analytics.impl -import android.content.Context import com.posthog.android.Options import com.posthog.android.PostHog import com.posthog.android.Properties -import im.vector.app.BuildConfig -import im.vector.app.config.analyticsConfig +import im.vector.app.core.di.NamedGlobalScope +import im.vector.app.features.analytics.AnalyticsConfig import im.vector.app.features.analytics.VectorAnalytics import im.vector.app.features.analytics.itf.VectorAnalyticsEvent import im.vector.app.features.analytics.itf.VectorAnalyticsScreen import im.vector.app.features.analytics.log.analyticsTag import im.vector.app.features.analytics.plan.UserProperties import im.vector.app.features.analytics.store.AnalyticsStore -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -41,15 +40,30 @@ private val IGNORED_OPTIONS: Options? = null @Singleton class DefaultVectorAnalytics @Inject constructor( - private val context: Context, - private val analyticsStore: AnalyticsStore + postHogFactory: PostHogFactory, + analyticsConfig: AnalyticsConfig, + private val analyticsStore: AnalyticsStore, + private val lateInitUserPropertiesFactory: LateInitUserPropertiesFactory, + @NamedGlobalScope private val globalScope: CoroutineScope ) : VectorAnalytics { - private var posthog: PostHog? = null + + private val posthog: PostHog? = when { + analyticsConfig.isEnabled -> postHogFactory.createPosthog() + else -> { + Timber.tag(analyticsTag.value).w("Analytics is disabled") + null + } + } // Cache for the store values private var userConsent: Boolean? = null private var analyticsId: String? = null + override fun init() { + observeUserConsent() + observeAnalyticsId() + } + override fun getUserConsent(): Flow { return analyticsStore.userConsentFlow } @@ -82,13 +96,6 @@ class DefaultVectorAnalytics @Inject constructor( setAnalyticsId("") } - override fun init() { - observeUserConsent() - observeAnalyticsId() - createAnalyticsClient() - } - - @Suppress("EXPERIMENTAL_API_USAGE") private fun observeAnalyticsId() { getAnalyticsId() .onEach { id -> @@ -96,21 +103,20 @@ class DefaultVectorAnalytics @Inject constructor( analyticsId = id identifyPostHog() } - .launchIn(GlobalScope) + .launchIn(globalScope) } - private fun identifyPostHog() { + private suspend fun identifyPostHog() { val id = analyticsId ?: return if (id.isEmpty()) { Timber.tag(analyticsTag.value).d("reset") posthog?.reset() } else { Timber.tag(analyticsTag.value).d("identify") - posthog?.identify(id) + posthog?.identify(id, lateInitUserPropertiesFactory.createUserProperties()?.getProperties()?.toPostHogUserProperties(), IGNORED_OPTIONS) } } - @Suppress("EXPERIMENTAL_API_USAGE") private fun observeUserConsent() { getUserConsent() .onEach { consent -> @@ -118,49 +124,13 @@ class DefaultVectorAnalytics @Inject constructor( userConsent = consent optOutPostHog() } - .launchIn(GlobalScope) + .launchIn(globalScope) } private fun optOutPostHog() { userConsent?.let { posthog?.optOut(!it) } } - private fun createAnalyticsClient() { - Timber.tag(analyticsTag.value).d("createAnalyticsClient()") - - if (analyticsConfig.isEnabled.not()) { - Timber.tag(analyticsTag.value).w("Analytics is disabled") - return - } - - posthog = PostHog.Builder(context, analyticsConfig.postHogApiKey, analyticsConfig.postHogHost) - // Record certain application events automatically! (off/false by default) - // .captureApplicationLifecycleEvents() - // Record screen views automatically! (off/false by default) - // .recordScreenViews() - // Capture deep links as part of the screen call. (off by default) - // .captureDeepLinks() - // Maximum number of events to keep in queue before flushing (default 20) - // .flushQueueSize(20) - // Max delay before flushing the queue (30 seconds) - // .flushInterval(30, TimeUnit.SECONDS) - // Enable or disable collection of ANDROID_ID (true) - .collectDeviceId(false) - .logLevel(getLogLevel()) - .build() - - optOutPostHog() - identifyPostHog() - } - - private fun getLogLevel(): PostHog.LogLevel { - return if (BuildConfig.DEBUG) { - PostHog.LogLevel.DEBUG - } else { - PostHog.LogLevel.INFO - } - } - override fun capture(event: VectorAnalyticsEvent) { Timber.tag(analyticsTag.value).d("capture($event)") posthog diff --git a/vector/src/main/java/im/vector/app/features/analytics/impl/LateInitUserPropertiesFactory.kt b/vector/src/main/java/im/vector/app/features/analytics/impl/LateInitUserPropertiesFactory.kt new file mode 100644 index 0000000000..d961ceaadc --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/analytics/impl/LateInitUserPropertiesFactory.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.analytics.impl + +import android.content.Context +import im.vector.app.ActiveSessionDataSource +import im.vector.app.core.extensions.vectorStore +import im.vector.app.features.analytics.extensions.toTrackingValue +import im.vector.app.features.analytics.plan.UserProperties +import javax.inject.Inject + +class LateInitUserPropertiesFactory @Inject constructor( + private val activeSessionDataSource: ActiveSessionDataSource, + private val context: Context, +) { + suspend fun createUserProperties(): UserProperties? { + val useCase = activeSessionDataSource.currentValue?.orNull()?.vectorStore(context)?.readUseCase() + return useCase?.let { + UserProperties(ftueUseCaseSelection = it.toTrackingValue()) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/analytics/impl/PostHogFactory.kt b/vector/src/main/java/im/vector/app/features/analytics/impl/PostHogFactory.kt new file mode 100644 index 0000000000..029732f76c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/analytics/impl/PostHogFactory.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.analytics.impl + +import android.content.Context +import com.posthog.android.PostHog +import im.vector.app.BuildConfig +import im.vector.app.config.analyticsConfig +import javax.inject.Inject + +class PostHogFactory @Inject constructor(private val context: Context) { + + fun createPosthog(): PostHog { + return PostHog.Builder(context, analyticsConfig.postHogApiKey, analyticsConfig.postHogHost) + // Record certain application events automatically! (off/false by default) + // .captureApplicationLifecycleEvents() + // Record screen views automatically! (off/false by default) + // .recordScreenViews() + // Capture deep links as part of the screen call. (off by default) + // .captureDeepLinks() + // Maximum number of events to keep in queue before flushing (default 20) + // .flushQueueSize(20) + // Max delay before flushing the queue (30 seconds) + // .flushInterval(30, TimeUnit.SECONDS) + // Enable or disable collection of ANDROID_ID (true) + .collectDeviceId(false) + .logLevel(getLogLevel()) + .build() + } + + private fun getLogLevel(): PostHog.LogLevel { + return if (BuildConfig.DEBUG) { + PostHog.LogLevel.DEBUG + } else { + PostHog.LogLevel.INFO + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/Screen.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/MobileScreen.kt similarity index 67% rename from vector/src/main/java/im/vector/app/features/analytics/plan/Screen.kt rename to vector/src/main/java/im/vector/app/features/analytics/plan/MobileScreen.kt index 710ae8f6f2..758a0540bf 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/plan/Screen.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/plan/MobileScreen.kt @@ -22,9 +22,9 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsScreen // https://github.com/matrix-org/matrix-analytics-events/ /** - * Triggered when the user changed screen + * Triggered when the user changed screen on Element Android/iOS */ -data class Screen( +data class MobileScreen( /** * How long the screen was displayed for in milliseconds. */ @@ -33,6 +33,11 @@ data class Screen( ) : VectorAnalyticsScreen { enum class ScreenName { + /** + * The screen that displays the user's breadcrumbs. + */ + Breadcrumbs, + /** * The screen shown to create a new (non-direct) room. */ @@ -43,6 +48,16 @@ data class Screen( */ DeactivateAccount, + /** + * The tab on mobile that displays the dialpad. + */ + Dialpad, + + /** + * The Favourites tab on mobile that lists your favourite people/rooms. + */ + Favourites, + /** * The form for the forgot password use case */ @@ -54,11 +69,15 @@ data class Screen( Group, /** - * The Home tab on iOS | possibly the same on Android? | Home page on - * Web + * The Home tab on iOS | possibly the same on Android? */ Home, + /** + * The screen shown to share a link to download the app. + */ + InviteFriends, + /** * The screen that displays the login flow (when the user already has an * account). @@ -66,100 +85,14 @@ data class Screen( Login, /** - * The screen that displays the user's breadcrumbs. + * Legacy: The screen that shows all groups/communities you have joined. */ - MobileBreadcrumbs, - - /** - * The tab on mobile that displays the dialpad. - */ - MobileDialpad, - - /** - * The Favourites tab on mobile that lists your favourite people/rooms. - */ - MobileFavourites, - - /** - * The screen shown to share a link to download the app. - */ - MobileInviteFriends, + MyGroups, /** * The People tab on mobile that lists all the DM rooms you have joined. */ - MobilePeople, - - /** - * The Rooms tab on mobile that lists all the (non-direct) rooms you've - * joined. - */ - MobileRooms, - - /** - * The Files tab shown in the global search screen on Mobile. - */ - MobileSearchFiles, - - /** - * The Messages tab shown in the global search screen on Mobile. - */ - MobileSearchMessages, - - /** - * The People tab shown in the global search screen on Mobile. - */ - MobileSearchPeople, - - /** - * The Rooms tab shown in the global search screen on Mobile. - */ - MobileSearchRooms, - - /** - * The global settings screen shown in the app. - */ - MobileSettings, - - /** - * The settings screen to change the default notification options. - */ - MobileSettingsDefaultNotifications, - - /** - * The settings screen to manage notification mentions and keywords. - */ - MobileSettingsMentionsAndKeywords, - - /** - * The global security settings screen. - */ - MobileSettingsSecurity, - - /** - * The sidebar shown on mobile with spaces, settings etc. - */ - MobileSidebar, - - /** - * Screen that displays the list of members of a space - */ - MobileSpaceMembers, - - /** - * The bottom sheet that list all space options - */ - MobileSpaceMenu, - - /** - * The screen shown to select which room directory you'd like to use. - */ - MobileSwitchDirectory, - - /** - * Legacy: The screen that shows all groups/communities you have joined. - */ - MyGroups, + People, /** * The screen that displays the registration flow (when the user wants @@ -216,107 +149,87 @@ data class Screen( */ RoomUploads, + /** + * The Rooms tab on mobile that lists all the (non-direct) rooms you've + * joined. + */ + Rooms, + + /** + * The Files tab shown in the global search screen on Mobile. + */ + SearchFiles, + + /** + * The Messages tab shown in the global search screen on Mobile. + */ + SearchMessages, + + /** + * The People tab shown in the global search screen on Mobile. + */ + SearchPeople, + + /** + * The Rooms tab shown in the global search screen on Mobile. + */ + SearchRooms, + + /** + * The global settings screen shown in the app. + */ + Settings, + + /** + * The settings screen to change the default notification options. + */ + SettingsDefaultNotifications, + + /** + * The settings screen to manage notification mentions and keywords. + */ + SettingsMentionsAndKeywords, + + /** + * The global security settings screen. + */ + SettingsSecurity, + + /** + * The sidebar shown on mobile with spaces, settings etc. + */ + Sidebar, + /** * Screen that displays the list of rooms and spaces of a space */ SpaceExploreRooms, + /** + * Screen that displays the list of members of a space + */ + SpaceMembers, + + /** + * The bottom sheet that list all space options + */ + SpaceMenu, + /** * The screen shown to create a new direct room. */ StartChat, + /** + * The screen shown to select which room directory you'd like to use. + */ + SwitchDirectory, + /** * A screen that shows information about a room member. */ User, - /** - * Element Web showing flow to trust this new device with cross-signing. - */ - WebCompleteSecurity, - - /** - * Element Web showing flow to setup SSSS / cross-signing on this - * account. - */ - WebE2ESetup, - - /** - * Element Web loading spinner. - */ - WebLoading, - - /** - * Element Web device has been soft logged out by the server. - */ - WebSoftLogout, - - /** - * Legacy: Element Web User Settings Flair Tab. - */ - WebUserSettingFlair, - - /** - * Element Web User Settings Mjolnir (labs) Tab. - */ - WebUserSettingMjolnir, - - /** - * Element Web User Settings Appearance Tab. - */ - WebUserSettingsAppearance, - - /** - * Element Web User Settings General Tab. - */ - WebUserSettingsGeneral, - - /** - * Element Web User Settings Help & About Tab. - */ - WebUserSettingsHelpAbout, - - /** - * Element Web User Settings Ignored Users Tab. - */ - WebUserSettingsIgnoredUsers, - - /** - * Element Web User Settings Keyboard Tab. - */ - WebUserSettingsKeyboard, - - /** - * Element Web User Settings Labs Tab. - */ - WebUserSettingsLabs, - - /** - * Element Web User Settings Notifications Tab. - */ - WebUserSettingsNotifications, - - /** - * Element Web User Settings Preferences Tab. - */ - WebUserSettingsPreferences, - - /** - * Element Web User Settings Security & Privacy Tab. - */ - WebUserSettingsSecurityPrivacy, - - /** - * Element Web User Settings Sidebar Tab. - */ - WebUserSettingsSidebar, - - /** - * Element Web User Settings Voice & Video Tab. - */ - WebUserSettingsVoiceVideo, - /** * The splash screen. */ diff --git a/vector/src/main/java/im/vector/app/features/analytics/screen/ScreenEvent.kt b/vector/src/main/java/im/vector/app/features/analytics/screen/ScreenEvent.kt index 8e0513f25a..1ad4a1fa32 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/screen/ScreenEvent.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/screen/ScreenEvent.kt @@ -18,13 +18,13 @@ package im.vector.app.features.analytics.screen import android.os.SystemClock import im.vector.app.features.analytics.AnalyticsTracker -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import timber.log.Timber /** * Track a screen display. Unique usage. */ -class ScreenEvent(val screenName: Screen.ScreenName) { +class ScreenEvent(val screenName: MobileScreen.ScreenName) { private val startTime = SystemClock.elapsedRealtime() // Protection to avoid multiple sending @@ -34,14 +34,14 @@ class ScreenEvent(val screenName: Screen.ScreenName) { * @param screenNameOverride can be used to override the screen name passed in constructor parameter */ fun send(analyticsTracker: AnalyticsTracker, - screenNameOverride: Screen.ScreenName? = null) { + screenNameOverride: MobileScreen.ScreenName? = null) { if (isSent) { Timber.w("Event $screenName Already sent!") return } isSent = true analyticsTracker.screen( - Screen( + MobileScreen( screenName = screenNameOverride ?: screenName, durationMs = (SystemClock.elapsedRealtime() - startTime).toInt() ) diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/AutocompleteHeaderItem.kt b/vector/src/main/java/im/vector/app/features/autocomplete/AutocompleteHeaderItem.kt new file mode 100644 index 0000000000..f287104415 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/autocomplete/AutocompleteHeaderItem.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2019 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.autocomplete + +import android.widget.TextView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel + +@EpoxyModelClass(layout = R.layout.item_autocomplete_header_item) +abstract class AutocompleteHeaderItem : VectorEpoxyModel() { + + @EpoxyAttribute var title: String? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.titleView.text = title + } + + class Holder : VectorEpoxyHolder() { + val titleView by bind(R.id.headerItemAutocompleteTitle) + } +} diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberController.kt b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberController.kt index 9b4bd78504..2034cee90a 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberController.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberController.kt @@ -16,31 +16,81 @@ package im.vector.app.features.autocomplete.member +import android.content.Context import com.airbnb.epoxy.TypedEpoxyController +import im.vector.app.R import im.vector.app.features.autocomplete.AutocompleteClickListener +import im.vector.app.features.autocomplete.autocompleteHeaderItem import im.vector.app.features.autocomplete.autocompleteMatrixItem import im.vector.app.features.home.AvatarRenderer -import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary +import org.matrix.android.sdk.api.util.toEveryoneInRoomMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject -class AutocompleteMemberController @Inject constructor() : TypedEpoxyController>() { +class AutocompleteMemberController @Inject constructor(private val context: Context) : + TypedEpoxyController>() { - var listener: AutocompleteClickListener? = null + /* ========================================================================================== + * Fields + * ========================================================================================== */ + + var listener: AutocompleteClickListener? = null + + /* ========================================================================================== + * Dependencies + * ========================================================================================== */ @Inject lateinit var avatarRenderer: AvatarRenderer - override fun buildModels(data: List?) { + /* ========================================================================================== + * Specialization + * ========================================================================================== */ + + override fun buildModels(data: List?) { if (data.isNullOrEmpty()) { return } + data.forEach { item -> + when (item) { + is AutocompleteMemberItem.Header -> buildHeaderItem(item) + is AutocompleteMemberItem.RoomMember -> buildRoomMemberItem(item) + is AutocompleteMemberItem.Everyone -> buildEveryoneItem(item) + } + } + } + + /* ========================================================================================== + * Helper methods + * ========================================================================================== */ + + private fun buildHeaderItem(header: AutocompleteMemberItem.Header) { + autocompleteHeaderItem { + id(header.id) + title(header.title) + } + } + + private fun buildRoomMemberItem(roomMember: AutocompleteMemberItem.RoomMember) { val host = this - data.forEach { user -> - autocompleteMatrixItem { + autocompleteMatrixItem { + roomMember.roomMemberSummary.let { user -> id(user.userId) matrixItem(user.toMatrixItem()) avatarRenderer(host.avatarRenderer) - clickListener { host.listener?.onItemClick(user) } + clickListener { host.listener?.onItemClick(roomMember) } + } + } + } + + private fun buildEveryoneItem(everyone: AutocompleteMemberItem.Everyone) { + val host = this + autocompleteMatrixItem { + everyone.roomSummary.let { room -> + id(room.roomId) + matrixItem(room.toEveryoneInRoomMatrixItem()) + subName(host.context.getString(R.string.room_message_notify_everyone)) + avatarRenderer(host.avatarRenderer) + clickListener { host.listener?.onItemClick(everyone) } } } } diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberItem.kt b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberItem.kt new file mode 100644 index 0000000000..77c5069938 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberItem.kt @@ -0,0 +1,26 @@ +/* + * 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.autocomplete.member + +import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary +import org.matrix.android.sdk.api.session.room.model.RoomSummary + +sealed class AutocompleteMemberItem { + data class Header(val id: String, val title: String) : AutocompleteMemberItem() + data class RoomMember(val roomMemberSummary: RoomMemberSummary) : AutocompleteMemberItem() + data class Everyone(val roomSummary: RoomSummary) : AutocompleteMemberItem() +} diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt index 4976cb39b9..ce3b9c6a7e 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt @@ -21,26 +21,44 @@ import androidx.recyclerview.widget.RecyclerView import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import im.vector.app.R import im.vector.app.features.autocomplete.AutocompleteClickListener import im.vector.app.features.autocomplete.RecyclerViewPresenter +import org.matrix.android.sdk.api.pushrules.SenderNotificationPermissionCondition import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.room.members.RoomMemberQueryParams import org.matrix.android.sdk.api.session.room.members.roomMemberQueryParams import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary +import org.matrix.android.sdk.api.util.MatrixItem class AutocompleteMemberPresenter @AssistedInject constructor(context: Context, @Assisted val roomId: String, - session: Session, + private val session: Session, private val controller: AutocompleteMemberController -) : RecyclerViewPresenter(context), AutocompleteClickListener { +) : RecyclerViewPresenter(context), AutocompleteClickListener { + + /* ========================================================================================== + * Fields + * ========================================================================================== */ private val room by lazy { session.getRoom(roomId)!! } + /* ========================================================================================== + * Init + * ========================================================================================== */ + init { controller.listener = this } + /* ========================================================================================== + * Public api + * ========================================================================================== */ + fun clear() { controller.listener = null } @@ -50,29 +68,100 @@ class AutocompleteMemberPresenter @AssistedInject constructor(context: Context, fun create(roomId: String): AutocompleteMemberPresenter } + /* ========================================================================================== + * Specialization + * ========================================================================================== */ + override fun instantiateAdapter(): RecyclerView.Adapter<*> { return controller.adapter } - override fun onItemClick(t: RoomMemberSummary) { + override fun onItemClick(t: AutocompleteMemberItem) { dispatchClick(t) } override fun onQuery(query: CharSequence?) { - val queryParams = roomMemberQueryParams { - displayName = if (query.isNullOrBlank()) { - QueryStringValue.IsNotEmpty - } else { - QueryStringValue.Contains(query.toString(), QueryStringValue.Case.INSENSITIVE) + val queryParams = createQueryParams(query) + val membersHeader = createMembersHeader() + val members = createMemberItems(queryParams) + val everyone = createEveryoneItem(query) + // add headers only when user can notify everyone + val canAddHeaders = canNotifyEveryone() + + val items = mutableListOf().apply { + if (members.isNotEmpty()) { + if (canAddHeaders) { + add(membersHeader) + } + addAll(members) + } + everyone?.let { + val everyoneHeader = createEveryoneHeader() + add(everyoneHeader) + add(it) } - memberships = listOf(Membership.JOIN) - excludeSelf = true } - val members = room.getRoomMembers(queryParams) - .asSequence() - .sortedBy { it.displayName } - .disambiguate() - controller.setData(members.toList()) + + controller.setData(items) + } + + /* ========================================================================================== + * Helper methods + * ========================================================================================== */ + + private fun createQueryParams(query: CharSequence?) = roomMemberQueryParams { + displayName = if (query.isNullOrBlank()) { + QueryStringValue.IsNotEmpty + } else { + QueryStringValue.Contains(query.toString(), QueryStringValue.Case.INSENSITIVE) + } + memberships = listOf(Membership.JOIN) + excludeSelf = true + } + + private fun createMembersHeader() = + AutocompleteMemberItem.Header( + ID_HEADER_MEMBERS, + context.getString(R.string.room_message_autocomplete_users) + ) + + private fun createMemberItems(queryParams: RoomMemberQueryParams) = + room.getRoomMembers(queryParams) + .asSequence() + .sortedBy { it.displayName } + .disambiguate() + .map { AutocompleteMemberItem.RoomMember(it) } + .toList() + + private fun createEveryoneHeader() = + AutocompleteMemberItem.Header( + ID_HEADER_EVERYONE, + context.getString(R.string.room_message_autocomplete_notification) + ) + + private fun createEveryoneItem(query: CharSequence?) = + room.roomSummary() + ?.takeIf { canNotifyEveryone() } + ?.takeIf { query.isNullOrBlank() || MatrixItem.NOTIFY_EVERYONE.startsWith("@$query") } + ?.let { + AutocompleteMemberItem.Everyone(it) + } + + private fun canNotifyEveryone() = session.resolveSenderNotificationPermissionCondition( + Event( + senderId = session.myUserId, + roomId = roomId + ), + SenderNotificationPermissionCondition(PowerLevelsContent.NOTIFICATIONS_ROOM_KEY) + ) + + /* ========================================================================================== + * Const + * ========================================================================================== */ + + companion object { + private const val ID_HEADER_MEMBERS = "ID_HEADER_MEMBERS" + private const val ID_HEADER_EVERYONE = "ID_HEADER_EVERYONE" } } 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 caab4c85e1..23c7b79914 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 @@ -54,6 +54,7 @@ import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.databinding.ActivityCallBinding import im.vector.app.features.call.dialpad.CallDialPadBottomSheet import im.vector.app.features.call.dialpad.DialPadFragment +import im.vector.app.features.call.transfer.CallTransferActivity import im.vector.app.features.call.utils.EglUtils import im.vector.app.features.call.webrtc.WebRtcCall import im.vector.app.features.call.webrtc.WebRtcCallManager @@ -165,6 +166,7 @@ class VectorCallActivity : VectorBaseActivity(), CallContro ?.let { callViewModel.handle(VectorCallViewActions.SwitchCall(it)) } + this.intent = intent } override fun getMenuRes() = R.menu.vector_call @@ -522,14 +524,21 @@ class VectorCallActivity : VectorBaseActivity(), CallContro val callId = withState(callViewModel) { it.callId } navigator.openCallTransfer(this, callTransferActivityResultLauncher, callId) } + is VectorCallViewEvents.FailToTransfer -> showSnackbar(getString(R.string.call_transfer_failure)) null -> { } } } private val callTransferActivityResultLauncher = registerStartForActivityResult { activityResult -> - if (activityResult.resultCode == Activity.RESULT_CANCELED) { - callViewModel.handle(VectorCallViewActions.CallTransferSelectionCancelled) + when (activityResult.resultCode) { + Activity.RESULT_CANCELED -> { + callViewModel.handle(VectorCallViewActions.CallTransferSelectionCancelled) + } + Activity.RESULT_OK -> { + CallTransferActivity.getCallTransferResult(activityResult.data) + ?.let { callViewModel.handle(VectorCallViewActions.CallTransferSelectionResult(it)) } + } } } diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt index fb39660282..d1ed961814 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewActions.kt @@ -18,6 +18,7 @@ package im.vector.app.features.call import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.features.call.audio.CallAudioManager +import im.vector.app.features.call.transfer.CallTransferResult sealed class VectorCallViewActions : VectorViewModelAction { object EndCall : VectorCallViewActions() @@ -37,5 +38,6 @@ sealed class VectorCallViewActions : VectorViewModelAction { object ToggleHDSD : VectorCallViewActions() object InitiateCallTransfer : VectorCallViewActions() object CallTransferSelectionCancelled : VectorCallViewActions() + data class CallTransferSelectionResult(val callTransferResult: CallTransferResult) : VectorCallViewActions() object TransferCall : VectorCallViewActions() } diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt index 5a0a2f127c..7c29d7eea3 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewEvents.kt @@ -29,6 +29,7 @@ sealed class VectorCallViewEvents : VectorViewEvents { ) : VectorCallViewEvents() object ShowDialPad : VectorCallViewEvents() object ShowCallTransferScreen : VectorCallViewEvents() + object FailToTransfer : VectorCallViewEvents() // data class CallAnswered(val content: CallAnswerContent) : VectorCallViewEvents() // data class CallHangup(val content: CallHangupContent) : VectorCallViewEvents() // object CallAccepted : VectorCallViewEvents() diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt index 4aca0ea499..a26eec04f3 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallViewModel.kt @@ -29,13 +29,17 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorViewModel import im.vector.app.features.call.audio.CallAudioManager +import im.vector.app.features.call.dialpad.DialPadLookup +import im.vector.app.features.call.transfer.CallTransferResult import im.vector.app.features.call.webrtc.WebRtcCall import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.call.webrtc.getOpponentAsMatrixItem +import im.vector.app.features.createdirect.DirectRoomHelper import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.matrix.android.sdk.api.MatrixPatterns +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.call.CallState import org.matrix.android.sdk.api.session.call.MxCall @@ -47,7 +51,9 @@ class VectorCallViewModel @AssistedInject constructor( @Assisted initialState: VectorCallViewState, val session: Session, val callManager: WebRtcCallManager, - val proximityManager: CallProximityManager + val proximityManager: CallProximityManager, + private val dialPadLookup: DialPadLookup, + private val directRoomHelper: DirectRoomHelper, ) : VectorViewModel(initialState) { private var call: WebRtcCall? = null @@ -327,6 +333,9 @@ class VectorCallViewModel @AssistedInject constructor( VectorCallViewActions.CallTransferSelectionCancelled -> { call?.updateRemoteOnHold(false) } + is VectorCallViewActions.CallTransferSelectionResult -> { + handleCallTransferSelectionResult(action.callTransferResult) + } VectorCallViewActions.TransferCall -> { handleCallTransfer() } @@ -345,6 +354,53 @@ class VectorCallViewModel @AssistedInject constructor( } } + private fun handleCallTransferSelectionResult(result: CallTransferResult) { + when (result) { + is CallTransferResult.ConnectWithUserId -> connectWithUserId(result) + is CallTransferResult.ConnectWithPhoneNumber -> connectWithPhoneNumber(result) + }.exhaustive + } + + private fun connectWithUserId(result: CallTransferResult.ConnectWithUserId) { + viewModelScope.launch { + try { + if (result.consultFirst) { + val dmRoomId = directRoomHelper.ensureDMExists(result.selectedUserId) + callManager.startOutgoingCall( + nativeRoomId = dmRoomId, + otherUserId = result.selectedUserId, + isVideoCall = call?.mxCall?.isVideoCall.orFalse(), + transferee = call + ) + } else { + call?.transferToUser(result.selectedUserId, null) + } + } catch (failure: Throwable) { + _viewEvents.post(VectorCallViewEvents.FailToTransfer) + } + } + } + + private fun connectWithPhoneNumber(action: CallTransferResult.ConnectWithPhoneNumber) { + viewModelScope.launch { + try { + val result = dialPadLookup.lookupPhoneNumber(action.phoneNumber) + if (action.consultFirst) { + callManager.startOutgoingCall( + nativeRoomId = result.roomId, + otherUserId = result.userId, + isVideoCall = call?.mxCall?.isVideoCall.orFalse(), + transferee = call + ) + } else { + call?.transferToUser(result.userId, result.roomId) + } + } catch (failure: Throwable) { + _viewEvents.post(VectorCallViewEvents.FailToTransfer) + } + } + } + @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { override fun create(initialState: VectorCallViewState): VectorCallViewModel diff --git a/vector/src/main/java/im/vector/app/features/call/dialpad/DialPadFragment.kt b/vector/src/main/java/im/vector/app/features/call/dialpad/DialPadFragment.kt index 5fc866a4dd..b33ce25f55 100644 --- a/vector/src/main/java/im/vector/app/features/call/dialpad/DialPadFragment.kt +++ b/vector/src/main/java/im/vector/app/features/call/dialpad/DialPadFragment.kt @@ -40,7 +40,7 @@ import com.android.dialer.dialpadview.DigitsEditText import im.vector.app.R import im.vector.app.core.extensions.singletonEntryPoint import im.vector.app.features.analytics.AnalyticsTracker -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.screen.ScreenEvent import im.vector.app.features.themes.ThemeUtils @@ -69,7 +69,7 @@ class DialPadFragment : Fragment(), TextWatcher { private var screenEvent: ScreenEvent? = null override fun onResume() { super.onResume() - screenEvent = ScreenEvent(Screen.ScreenName.MobileDialpad) + screenEvent = ScreenEvent(MobileScreen.ScreenName.Dialpad) } override fun onPause() { diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferActivity.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferActivity.kt index 0e63316bbe..d8eede6a55 100644 --- a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferActivity.kt +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferActivity.kt @@ -16,7 +16,6 @@ package im.vector.app.features.call.transfer -import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle @@ -27,6 +26,7 @@ import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.error.ErrorFormatter +import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivityCallTransferBinding import kotlinx.parcelize.Parcelize @@ -56,10 +56,8 @@ class CallTransferActivity : VectorBaseActivity() { callTransferViewModel.observeViewEvents { when (it) { - is CallTransferViewEvents.Complete -> handleComplete() - CallTransferViewEvents.Loading -> showWaitingView() - is CallTransferViewEvents.FailToTransfer -> showSnackbar(getString(R.string.call_transfer_failure)) - } + is CallTransferViewEvents.Complete -> handleComplete() + }.exhaustive } sectionsPagerAdapter = CallTransferPagerAdapter(this) @@ -82,29 +80,41 @@ class CallTransferActivity : VectorBaseActivity() { when (views.callTransferTabLayout.selectedTabPosition) { CallTransferPagerAdapter.USER_LIST_INDEX -> { val selectedUser = sectionsPagerAdapter.userListFragment?.getCurrentState()?.getSelectedMatrixId()?.firstOrNull() ?: return@debouncedClicks - val action = CallTransferAction.ConnectWithUserId(views.callTransferConsultCheckBox.isChecked, selectedUser) - callTransferViewModel.handle(action) + val result = CallTransferResult.ConnectWithUserId(views.callTransferConsultCheckBox.isChecked, selectedUser) + handleComplete(result) } CallTransferPagerAdapter.DIAL_PAD_INDEX -> { val phoneNumber = sectionsPagerAdapter.dialPadFragment?.getRawInput() ?: return@debouncedClicks - val action = CallTransferAction.ConnectWithPhoneNumber(views.callTransferConsultCheckBox.isChecked, phoneNumber) - callTransferViewModel.handle(action) + val result = CallTransferResult.ConnectWithPhoneNumber(views.callTransferConsultCheckBox.isChecked, phoneNumber) + handleComplete(result) } } } } - private fun handleComplete() { - setResult(Activity.RESULT_OK) + private fun handleComplete(callTransferResult: CallTransferResult? = null) { + if (callTransferResult != null) { + val intent = Intent().apply { + putExtra(EXTRA_TRANSFER_RESULT, callTransferResult) + } + setResult(RESULT_OK, intent) + } else { + setResult(RESULT_OK) + } finish() } companion object { + private const val EXTRA_TRANSFER_RESULT = "EXTRA_TRANSFER_RESULT" fun newIntent(context: Context, callId: String): Intent { return Intent(context, CallTransferActivity::class.java).also { it.putExtra(Mavericks.KEY_ARG, CallTransferArgs(callId)) } } + + fun getCallTransferResult(intent: Intent?): CallTransferResult? { + return intent?.extras?.getParcelable(EXTRA_TRANSFER_RESULT) + } } } diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferAction.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferResult.kt similarity index 64% rename from vector/src/main/java/im/vector/app/features/call/transfer/CallTransferAction.kt rename to vector/src/main/java/im/vector/app/features/call/transfer/CallTransferResult.kt index bd694ad14e..0629e91d35 100644 --- a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferAction.kt +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferResult.kt @@ -16,9 +16,10 @@ package im.vector.app.features.call.transfer -import im.vector.app.core.platform.VectorViewModelAction +import android.os.Parcelable +import kotlinx.parcelize.Parcelize -sealed class CallTransferAction : VectorViewModelAction { - data class ConnectWithUserId(val consultFirst: Boolean, val selectedUserId: String) : CallTransferAction() - data class ConnectWithPhoneNumber(val consultFirst: Boolean, val phoneNumber: String) : CallTransferAction() +sealed class CallTransferResult : Parcelable { + @Parcelize data class ConnectWithUserId(val consultFirst: Boolean, val selectedUserId: String) : CallTransferResult() + @Parcelize data class ConnectWithPhoneNumber(val consultFirst: Boolean, val phoneNumber: String) : CallTransferResult() } diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewEvents.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewEvents.kt index a8451e4fb5..4202506d23 100644 --- a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewEvents.kt @@ -20,6 +20,4 @@ import im.vector.app.core.platform.VectorViewEvents sealed class CallTransferViewEvents : VectorViewEvents { object Complete : CallTransferViewEvents() - object Loading : CallTransferViewEvents() - object FailToTransfer : CallTransferViewEvents() } diff --git a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewModel.kt b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewModel.kt index de6a5de539..1765b58a02 100644 --- a/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/call/transfer/CallTransferViewModel.kt @@ -22,22 +22,16 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory -import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.platform.EmptyAction import im.vector.app.core.platform.VectorViewModel -import im.vector.app.features.call.dialpad.DialPadLookup import im.vector.app.features.call.webrtc.WebRtcCall import im.vector.app.features.call.webrtc.WebRtcCallManager -import im.vector.app.features.createdirect.DirectRoomHelper -import kotlinx.coroutines.launch -import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.call.CallState import org.matrix.android.sdk.api.session.call.MxCall class CallTransferViewModel @AssistedInject constructor(@Assisted initialState: CallTransferViewState, - private val dialPadLookup: DialPadLookup, - private val directRoomHelper: DirectRoomHelper, private val callManager: WebRtcCallManager) : - VectorViewModel(initialState) { + VectorViewModel(initialState) { @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { @@ -68,53 +62,5 @@ class CallTransferViewModel @AssistedInject constructor(@Assisted initialState: call?.removeListener(callListener) } - override fun handle(action: CallTransferAction) { - when (action) { - is CallTransferAction.ConnectWithUserId -> connectWithUserId(action) - is CallTransferAction.ConnectWithPhoneNumber -> connectWithPhoneNumber(action) - }.exhaustive - } - - private fun connectWithUserId(action: CallTransferAction.ConnectWithUserId) { - viewModelScope.launch { - try { - if (action.consultFirst) { - val dmRoomId = directRoomHelper.ensureDMExists(action.selectedUserId) - callManager.startOutgoingCall( - nativeRoomId = dmRoomId, - otherUserId = action.selectedUserId, - isVideoCall = call?.mxCall?.isVideoCall.orFalse(), - transferee = call - ) - } else { - call?.transferToUser(action.selectedUserId, null) - } - _viewEvents.post(CallTransferViewEvents.Complete) - } catch (failure: Throwable) { - _viewEvents.post(CallTransferViewEvents.FailToTransfer) - } - } - } - - private fun connectWithPhoneNumber(action: CallTransferAction.ConnectWithPhoneNumber) { - viewModelScope.launch { - try { - _viewEvents.post(CallTransferViewEvents.Loading) - val result = dialPadLookup.lookupPhoneNumber(action.phoneNumber) - if (action.consultFirst) { - callManager.startOutgoingCall( - nativeRoomId = result.roomId, - otherUserId = result.userId, - isVideoCall = call?.mxCall?.isVideoCall.orFalse(), - transferee = call - ) - } else { - call?.transferToUser(result.userId, result.roomId) - } - _viewEvents.post(CallTransferViewEvents.Complete) - } catch (failure: Throwable) { - _viewEvents.post(CallTransferViewEvents.FailToTransfer) - } - } - } + override fun handle(action: EmptyAction) { } } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt index 2d93bab6a3..a6b34eda25 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt @@ -43,7 +43,7 @@ import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO import im.vector.app.core.utils.checkPermissions import im.vector.app.core.utils.onPermissionDeniedSnackbar import im.vector.app.core.utils.registerForPermissionsResult -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.contactsbook.ContactsBookFragment import im.vector.app.features.qrcode.QrCodeScannerEvents import im.vector.app.features.qrcode.QrCodeScannerFragment @@ -71,7 +71,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.StartChat + analyticsScreenName = MobileScreen.ScreenName.StartChat views.toolbar.visibility = View.GONE sharedActionViewModel = viewModelProvider.get(UserListSharedActionViewModel::class.java) 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 6b6be63480..f6b32973a0 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 @@ -48,7 +48,7 @@ import im.vector.app.databinding.ActivityHomeBinding import im.vector.app.features.MainActivity import im.vector.app.features.MainActivityArgs import im.vector.app.features.analytics.accountdata.AnalyticsAccountDataViewModel -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.screen.ScreenEvent import im.vector.app.features.disclaimer.showDisclaimerDialog import im.vector.app.features.matrixto.MatrixToBottomSheet @@ -165,7 +165,7 @@ class HomeActivity : private val drawerListener = object : DrawerLayout.SimpleDrawerListener() { private var drawerScreenEvent: ScreenEvent? = null override fun onDrawerOpened(drawerView: View) { - drawerScreenEvent = ScreenEvent(Screen.ScreenName.MobileSidebar) + drawerScreenEvent = ScreenEvent(MobileScreen.ScreenName.Sidebar) } override fun onDrawerClosed(drawerView: View) { @@ -184,7 +184,7 @@ class HomeActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.Home + analyticsScreenName = MobileScreen.ScreenName.Home supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, false) FcmHelper.ensureFcmTokenIsRetrieved(this, pushManager, vectorPreferences.areNotificationEnabledForDevice()) sharedActionViewModel = viewModelProvider.get(HomeSharedActionViewModel::class.java) 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 a07409d063..ea03b833ac 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 @@ -457,7 +457,7 @@ class HomeDetailFragment @Inject constructor( backgroundColor = if (highlight) { ThemeUtils.getColor(requireContext(), R.attr.colorError) } else { - ThemeUtils.getColor(requireContext(), R.attr.vctr_unread_room_badge) + ThemeUtils.getColor(requireContext(), R.attr.vctr_content_secondary) } } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt b/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt index 9af06ef801..1aee0257f4 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDrawerFragment.kt @@ -30,7 +30,7 @@ import im.vector.app.core.extensions.replaceChildFragment import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.utils.startSharePlainTextIntent import im.vector.app.databinding.FragmentHomeDrawerBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorSettingsActivity import im.vector.app.features.spaces.SpaceListFragment @@ -98,7 +98,7 @@ class HomeDrawerFragment @Inject constructor( views.homeDrawerInviteFriendButton.debouncedClicks { session.permalinkService().createPermalink(sharedActionViewModel.session.myUserId)?.let { permalink -> - analyticsTracker.screen(Screen(screenName = Screen.ScreenName.MobileInviteFriends)) + analyticsTracker.screen(MobileScreen(screenName = MobileScreen.ScreenName.InviteFriends)) val text = getString(R.string.invite_friends_text, permalink) startSharePlainTextIntent( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt index 9f85d4015b..be5f9c0bb4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt @@ -33,6 +33,7 @@ import im.vector.app.features.autocomplete.command.AutocompleteCommandPresenter import im.vector.app.features.autocomplete.command.CommandAutocompletePolicy import im.vector.app.features.autocomplete.emoji.AutocompleteEmojiPresenter import im.vector.app.features.autocomplete.group.AutocompleteGroupPresenter +import im.vector.app.features.autocomplete.member.AutocompleteMemberItem import im.vector.app.features.autocomplete.member.AutocompleteMemberPresenter import im.vector.app.features.autocomplete.room.AutocompleteRoomPresenter import im.vector.app.features.command.Command @@ -41,9 +42,9 @@ import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.html.PillImageSpan import im.vector.app.features.themes.ThemeUtils import org.matrix.android.sdk.api.session.group.model.GroupSummary -import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.util.MatrixItem +import org.matrix.android.sdk.api.util.toEveryoneInRoomMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toRoomAliasMatrixItem @@ -106,7 +107,7 @@ class AutoCompleter @AssistedInject constructor( Autocomplete.on(editText) .with(commandAutocompletePolicy) .with(autocompleteCommandPresenter) - .with(ELEVATION) + .with(ELEVATION_DP) .with(backgroundDrawable) .with(object : AutocompleteCallback { override fun onPopupItemClicked(editable: Editable, item: Command): Boolean { @@ -125,15 +126,24 @@ class AutoCompleter @AssistedInject constructor( private fun setupMembers(backgroundDrawable: ColorDrawable, editText: EditText) { autocompleteMemberPresenter = autocompleteMemberPresenterFactory.create(roomId) - Autocomplete.on(editText) - .with(CharPolicy('@', true)) + Autocomplete.on(editText) + .with(CharPolicy(TRIGGER_AUTO_COMPLETE_MEMBERS, true)) .with(autocompleteMemberPresenter) - .with(ELEVATION) + .with(ELEVATION_DP) .with(backgroundDrawable) - .with(object : AutocompleteCallback { - override fun onPopupItemClicked(editable: Editable, item: RoomMemberSummary): Boolean { - insertMatrixItem(editText, editable, "@", item.toMatrixItem()) - return true + .with(object : AutocompleteCallback { + override fun onPopupItemClicked(editable: Editable, item: AutocompleteMemberItem): Boolean { + return when (item) { + is AutocompleteMemberItem.Header -> false // do nothing header is not clickable + is AutocompleteMemberItem.RoomMember -> { + insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, item.roomMemberSummary.toMatrixItem()) + true + } + is AutocompleteMemberItem.Everyone -> { + insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_MEMBERS, item.roomSummary.toEveryoneInRoomMatrixItem()) + true + } + } } override fun onPopupVisibilityChanged(shown: Boolean) { @@ -144,13 +154,13 @@ class AutoCompleter @AssistedInject constructor( private fun setupRooms(backgroundDrawable: ColorDrawable, editText: EditText) { Autocomplete.on(editText) - .with(CharPolicy('#', true)) + .with(CharPolicy(TRIGGER_AUTO_COMPLETE_ROOMS, true)) .with(autocompleteRoomPresenter) - .with(ELEVATION) + .with(ELEVATION_DP) .with(backgroundDrawable) .with(object : AutocompleteCallback { override fun onPopupItemClicked(editable: Editable, item: RoomSummary): Boolean { - insertMatrixItem(editText, editable, "#", item.toRoomAliasMatrixItem()) + insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_ROOMS, item.toRoomAliasMatrixItem()) return true } @@ -162,13 +172,13 @@ class AutoCompleter @AssistedInject constructor( private fun setupGroups(backgroundDrawable: ColorDrawable, editText: EditText) { Autocomplete.on(editText) - .with(CharPolicy('+', true)) + .with(CharPolicy(TRIGGER_AUTO_COMPLETE_GROUPS, true)) .with(autocompleteGroupPresenter) - .with(ELEVATION) + .with(ELEVATION_DP) .with(backgroundDrawable) .with(object : AutocompleteCallback { override fun onPopupItemClicked(editable: Editable, item: GroupSummary): Boolean { - insertMatrixItem(editText, editable, "+", item.toMatrixItem()) + insertMatrixItem(editText, editable, TRIGGER_AUTO_COMPLETE_GROUPS, item.toMatrixItem()) return true } @@ -180,9 +190,9 @@ class AutoCompleter @AssistedInject constructor( private fun setupEmojis(backgroundDrawable: Drawable, editText: EditText) { Autocomplete.on(editText) - .with(CharPolicy(':', false)) + .with(CharPolicy(TRIGGER_AUTO_COMPLETE_EMOJIS, false)) .with(autocompleteEmojiPresenter) - .with(ELEVATION) + .with(ELEVATION_DP) .with(backgroundDrawable) .with(object : AutocompleteCallback { override fun onPopupItemClicked(editable: Editable, item: String): Boolean { @@ -210,7 +220,7 @@ class AutoCompleter @AssistedInject constructor( .build() } - private fun insertMatrixItem(editText: EditText, editable: Editable, firstChar: String, matrixItem: MatrixItem) { + private fun insertMatrixItem(editText: EditText, editable: Editable, firstChar: Char, matrixItem: MatrixItem) { // Detect last firstChar and remove it var startIndex = editable.lastIndexOf(firstChar) if (startIndex == -1) { @@ -228,7 +238,7 @@ class AutoCompleter @AssistedInject constructor( // Adding trailing space " " or ": " if the user started mention someone val displayNameSuffix = - if (firstChar == "@" && startIndex == 0) { + if (matrixItem is MatrixItem.UserItem) { ": " } else { " " @@ -249,6 +259,10 @@ class AutoCompleter @AssistedInject constructor( } companion object { - private const val ELEVATION = 6f + private const val ELEVATION_DP = 6f + private const val TRIGGER_AUTO_COMPLETE_MEMBERS = '@' + private const val TRIGGER_AUTO_COMPLETE_ROOMS = '#' + private const val TRIGGER_AUTO_COMPLETE_GROUPS = '+' + private const val TRIGGER_AUTO_COMPLETE_EMOJIS = ':' } } 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 ae24052aa2..f5bf086e96 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,7 +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.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.screen.ScreenEvent import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsFragment import im.vector.app.features.home.room.detail.arguments.TimelineArgs @@ -160,7 +160,7 @@ class RoomDetailActivity : private val drawerListener = object : DrawerLayout.SimpleDrawerListener() { private var drawerScreenEvent: ScreenEvent? = null override fun onDrawerOpened(drawerView: View) { - drawerScreenEvent = ScreenEvent(Screen.ScreenName.MobileBreadcrumbs) + drawerScreenEvent = ScreenEvent(MobileScreen.ScreenName.Breadcrumbs) } override fun onDrawerClosed(drawerView: View) { 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 86240a5ffe..d08a27324c 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 @@ -29,7 +29,7 @@ import java.io.File * Transient events for RoomDetail */ sealed class RoomDetailViewEvents : VectorViewEvents { - data class Failure(val throwable: Throwable) : RoomDetailViewEvents() + data class Failure(val throwable: Throwable, val showInDialog: Boolean = false) : RoomDetailViewEvents() data class OnNewTimelineEvents(val eventIds: List) : RoomDetailViewEvents() data class ActionSuccess(val action: RoomDetailAction) : 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 22d5fc2a77..71a299e11b 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 @@ -49,6 +49,7 @@ data class JitsiState( data class RoomDetailViewState( val roomId: String, val eventId: String?, + val isInviteAlreadyAccepted: Boolean, val myRoomMember: Async = Uninitialized, val asyncInviter: Async = Uninitialized, val asyncRoomSummary: Async = Uninitialized, @@ -77,6 +78,7 @@ data class RoomDetailViewState( constructor(args: TimelineArgs) : this( roomId = args.roomId, eventId = args.eventId, + isInviteAlreadyAccepted = args.isInviteAlreadyAccepted, // Also highlight the target event, if any highlightedEventId = args.eventId, switchToParentSpace = args.switchToParentSpace, 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 92319fabdc..b6cbd538f3 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 @@ -120,7 +120,7 @@ import im.vector.app.core.utils.toast import im.vector.app.databinding.DialogReportContentBinding import im.vector.app.databinding.FragmentTimelineBinding import im.vector.app.features.analytics.plan.Composer -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.attachments.AttachmentTypeSelectorView import im.vector.app.features.attachments.AttachmentsHelper import im.vector.app.features.attachments.ContactAttachment @@ -342,7 +342,7 @@ class TimelineFragment @Inject constructor( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.Room + analyticsScreenName = MobileScreen.ScreenName.Room setFragmentResultListener(MigrateRoomBottomSheet.REQUEST_KEY) { _, bundle -> bundle.getString(MigrateRoomBottomSheet.BUNDLE_KEY_REPLACEMENT_ROOM)?.let { replacementRoomId -> timelineViewModel.handle(RoomDetailAction.RoomUpgradeSuccess(replacementRoomId)) @@ -448,7 +448,7 @@ class TimelineFragment @Inject constructor( timelineViewModel.observeViewEvents { when (it) { - is RoomDetailViewEvents.Failure -> showErrorInSnackbar(it.throwable) + is RoomDetailViewEvents.Failure -> displayErrorMessage(it) is RoomDetailViewEvents.OnNewTimelineEvents -> scrollOnNewMessageCallback.addNewTimelineEventIds(it.eventIds) is RoomDetailViewEvents.ActionSuccess -> displayRoomDetailActionSuccess(it) is RoomDetailViewEvents.ActionFailure -> displayRoomDetailActionFailure(it) @@ -623,6 +623,10 @@ class TimelineFragment @Inject constructor( ) } + private fun displayErrorMessage(error: RoomDetailViewEvents.Failure) { + if (error.showInDialog) displayErrorDialog(error.throwable) else showErrorInSnackbar(error.throwable) + } + private fun requestNativeWidgetPermission(it: RoomDetailViewEvents.RequestNativeWidgetPermission) { val tag = RoomWidgetPermissionBottomSheet::class.java.name val dFrag = childFragmentManager.findFragmentByTag(tag) as? RoomWidgetPermissionBottomSheet @@ -2374,12 +2378,10 @@ class TimelineFragment @Inject constructor( // VectorInviteView.Callback override fun onAcceptInvite() { - notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(timelineArgs.roomId) } timelineViewModel.handle(RoomDetailAction.AcceptInvite) } override fun onRejectInvite() { - notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(timelineArgs.roomId) } timelineViewModel.handle(RoomDetailAction.RejectInvite) } 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 a831332407..628dccfade 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 @@ -53,6 +53,7 @@ import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandle import im.vector.app.features.home.room.detail.timeline.factory.TimelineFactory import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever import im.vector.app.features.home.room.typing.TypingHelper +import im.vector.app.features.notifications.NotificationDrawerManager import im.vector.app.features.powerlevel.PowerLevelsFlowFactory import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorDataStore @@ -123,6 +124,7 @@ class TimelineViewModel @AssistedInject constructor( private val analyticsTracker: AnalyticsTracker, private val activeConferenceHolder: JitsiActiveConferenceHolder, private val decryptionFailureTracker: DecryptionFailureTracker, + private val notificationDrawerManager: NotificationDrawerManager, timelineFactory: TimelineFactory, appStateHandler: AppStateHandler ) : VectorViewModel(initialState), @@ -193,6 +195,11 @@ class TimelineViewModel @AssistedInject constructor( prepareForEncryption() } + // If the user had already accepted the invitation in the room list + if (initialState.isInviteAlreadyAccepted) { + handleAcceptInvite() + } + if (initialState.switchToParentSpace) { // We are coming from a notification, try to switch to the most relevant space // so that when hitting back the room will appear in the list @@ -803,16 +810,24 @@ class TimelineViewModel @AssistedInject constructor( } private fun handleRejectInvite() { + notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(initialState.roomId) } viewModelScope.launch { - tryOrNull { session.leaveRoom(room.roomId) } + try { + session.leaveRoom(room.roomId) + } catch (throwable: Throwable) { + _viewEvents.post(RoomDetailViewEvents.Failure(throwable, showInDialog = true)) + } } } private fun handleAcceptInvite() { + notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(initialState.roomId) } viewModelScope.launch { - tryOrNull { + try { session.joinRoom(room.roomId) analyticsTracker.capture(room.roomSummary().toAnalyticsJoinedRoom()) + } catch (throwable: Throwable) { + _viewEvents.post(RoomDetailViewEvents.Failure(throwable, showInDialog = true)) } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/arguments/TimelineArgs.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/arguments/TimelineArgs.kt index f22fe1b7df..a21567acb1 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/arguments/TimelineArgs.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/arguments/TimelineArgs.kt @@ -28,5 +28,6 @@ data class TimelineArgs( val sharedData: SharedData? = null, val openShareSpaceForId: String? = null, val threadTimelineArgs: ThreadTimelineArgs? = null, - val switchToParentSpace: Boolean = false + val switchToParentSpace: Boolean = false, + val isInviteAlreadyAccepted: Boolean = false ) : Parcelable diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt index 99a026a0cf..76ed024370 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.detail.timeline.factory +import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.extensions.prevOrNull import im.vector.app.features.home.AvatarRenderer @@ -26,10 +27,10 @@ import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisi import im.vector.app.features.home.room.detail.timeline.helper.canBeMerged import im.vector.app.features.home.room.detail.timeline.helper.isRoomConfiguration import im.vector.app.features.home.room.detail.timeline.item.BasedMergedItem -import im.vector.app.features.home.room.detail.timeline.item.MergedMembershipEventsItem -import im.vector.app.features.home.room.detail.timeline.item.MergedMembershipEventsItem_ import im.vector.app.features.home.room.detail.timeline.item.MergedRoomCreationItem import im.vector.app.features.home.room.detail.timeline.item.MergedRoomCreationItem_ +import im.vector.app.features.home.room.detail.timeline.item.MergedSimilarEventsItem +import im.vector.app.features.home.room.detail.timeline.item.MergedSimilarEventsItem_ import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.query.QueryStringValue @@ -82,7 +83,7 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde event: TimelineEvent, eventIdToHighlight: String?, requestModelBuild: () -> Unit, - callback: TimelineEventController.Callback?): MergedMembershipEventsItem_? { + callback: TimelineEventController.Callback?): MergedSimilarEventsItem_? { val mergedEvents = timelineEventVisibilityHelper.prevSameTypeEvents( items, currentPosition, @@ -122,23 +123,31 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde collapsedEventIds.removeAll(mergedEventIds) } val mergeId = mergedEventIds.joinToString(separator = "_") { it.toString() } - val attributes = MergedMembershipEventsItem.Attributes( - isCollapsed = isCollapsed, - mergeData = mergedData, - avatarRenderer = avatarRenderer, - onCollapsedStateChanged = { - mergeItemCollapseStates[event.localId] = it - requestModelBuild() - } - ) - MergedMembershipEventsItem_() - .id(mergeId) - .leftGuideline(avatarSizeProvider.leftGuideline) - .highlighted(isCollapsed && highlighted) - .attributes(attributes) - .also { - it.setOnVisibilityStateChanged(MergedTimelineEventVisibilityStateChangedListener(callback, mergedEvents)) - } + val summaryTitleResId = when (event.root.getClearType()) { + EventType.STATE_ROOM_MEMBER -> R.plurals.membership_changes + EventType.STATE_ROOM_SERVER_ACL -> R.plurals.notice_room_server_acl_changes + else -> null + } + summaryTitleResId?.let { summaryTitle -> + val attributes = MergedSimilarEventsItem.Attributes( + summaryTitleResId = summaryTitle, + isCollapsed = isCollapsed, + mergeData = mergedData, + avatarRenderer = avatarRenderer, + onCollapsedStateChanged = { + mergeItemCollapseStates[event.localId] = it + requestModelBuild() + } + ) + MergedSimilarEventsItem_() + .id(mergeId) + .leftGuideline(avatarSizeProvider.leftGuideline) + .highlighted(isCollapsed && highlighted) + .attributes(attributes) + .also { + it.setOnVisibilityStateChanged(MergedTimelineEventVisibilityStateChangedListener(callback, mergedEvents)) + } + } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index 0c836748c8..aa1758dd6c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -61,6 +61,7 @@ import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem_ import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem_ +import im.vector.app.features.home.room.detail.timeline.render.EventTextRenderer import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod import im.vector.app.features.home.room.detail.timeline.tools.linkify import im.vector.app.features.html.EventHtmlRenderer @@ -112,6 +113,7 @@ class MessageItemFactory @Inject constructor( private val timelineMediaSizeProvider: TimelineMediaSizeProvider, private val htmlRenderer: Lazy, private val htmlCompressor: VectorHtmlCompressor, + private val textRendererFactory: EventTextRenderer.Factory, private val stringProvider: StringProvider, private val imageContentRenderer: ImageContentRenderer, private val messageInformationDataFactory: MessageInformationDataFactory, @@ -138,6 +140,10 @@ class MessageItemFactory @Inject constructor( pillsPostProcessorFactory.create(roomId) } + private val textRenderer by lazy { + textRendererFactory.create(roomId) + } + fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? { val event = params.event val highlight = params.isHighlighted @@ -549,8 +555,9 @@ class MessageItemFactory @Inject constructor( highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes): MessageTextItem? { - val bindingOptions = spanUtils.getBindingOptions(body) - val linkifiedBody = body.linkify(callback) + val renderedBody = textRenderer.render(body) + val bindingOptions = spanUtils.getBindingOptions(renderedBody) + val linkifiedBody = renderedBody.linkify(callback) return MessageTextItem_() .message( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt index bcccbc9f7c..53a9fbbaea 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt @@ -56,7 +56,8 @@ object TimelineDisplayableEvents { } fun TimelineEvent.canBeMerged(): Boolean { - return root.getClearType() == EventType.STATE_ROOM_MEMBER + return root.getClearType() == EventType.STATE_ROOM_MEMBER || + root.getClearType() == EventType.STATE_ROOM_SERVER_ACL } fun TimelineEvent.isRoomConfiguration(roomCreatorUserId: String?): Boolean { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedMembershipEventsItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedSimilarEventsItem.kt similarity index 91% rename from vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedMembershipEventsItem.kt rename to vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedSimilarEventsItem.kt index e19dc33fff..f012ca6cdc 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedMembershipEventsItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MergedSimilarEventsItem.kt @@ -20,6 +20,7 @@ import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView +import androidx.annotation.PluralsRes import androidx.core.view.children import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass @@ -27,7 +28,7 @@ import im.vector.app.R import im.vector.app.features.home.AvatarRenderer @EpoxyModelClass(layout = R.layout.item_timeline_event_base_noinfo) -abstract class MergedMembershipEventsItem : BasedMergedItem() { +abstract class MergedSimilarEventsItem : BasedMergedItem() { override fun getViewStubId() = STUB_ID @@ -37,7 +38,7 @@ abstract class MergedMembershipEventsItem : BasedMergedItem, override val avatarRenderer: AvatarRenderer, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/EventTextRenderer.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/EventTextRenderer.kt new file mode 100644 index 0000000000..d50a6fb297 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/EventTextRenderer.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.render + +import android.content.Context +import android.text.Spannable +import android.text.SpannableStringBuilder +import android.text.Spanned +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.glide.GlideApp +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.html.PillImageSpan +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.util.MatrixItem + +class EventTextRenderer @AssistedInject constructor(@Assisted private val roomId: String?, + private val context: Context, + private val avatarRenderer: AvatarRenderer, + private val sessionHolder: ActiveSessionHolder) { + + /* ========================================================================================== + * Public api + * ========================================================================================== */ + + @AssistedFactory + interface Factory { + fun create(roomId: String?): EventTextRenderer + } + + /** + * @param text the text you want to render + */ + fun render(text: CharSequence): CharSequence { + return if (roomId != null && text.contains(MatrixItem.NOTIFY_EVERYONE)) { + SpannableStringBuilder(text).apply { + addNotifyEveryoneSpans(this, roomId) + } + } else { + text + } + } + + /* ========================================================================================== + * Helper methods + * ========================================================================================== */ + + private fun addNotifyEveryoneSpans(text: Spannable, roomId: String) { + val room: RoomSummary? = sessionHolder.getSafeActiveSession()?.getRoomSummary(roomId) + val matrixItem = MatrixItem.EveryoneInRoomItem( + id = roomId, + avatarUrl = room?.avatarUrl, + roomDisplayName = room?.displayName + ) + + // search for notify everyone text + var foundIndex = text.indexOf(MatrixItem.NOTIFY_EVERYONE, 0) + while (foundIndex >= 0) { + val endSpan = foundIndex + MatrixItem.NOTIFY_EVERYONE.length + addPillSpan(text, createPillImageSpan(matrixItem), foundIndex, endSpan) + foundIndex = text.indexOf(MatrixItem.NOTIFY_EVERYONE, endSpan) + } + } + + private fun createPillImageSpan(matrixItem: MatrixItem) = + PillImageSpan(GlideApp.with(context), avatarRenderer, context, matrixItem) + + private fun addPillSpan( + renderedText: Spannable, + pillSpan: PillImageSpan, + startSpan: Int, + endSpan: Int + ) { + renderedText.setSpan(pillSpan, startSpan, endSpan, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/filtered/FilteredRoomsActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/filtered/FilteredRoomsActivity.kt index 0e16b4b0df..cf7c4a0e80 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/filtered/FilteredRoomsActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/filtered/FilteredRoomsActivity.kt @@ -24,7 +24,7 @@ import dagger.hilt.android.AndroidEntryPoint import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivityFilteredRoomsBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.home.RoomListDisplayMode import im.vector.app.features.home.room.list.RoomListFragment import im.vector.app.features.home.room.list.RoomListParams @@ -43,7 +43,7 @@ class FilteredRoomsActivity : VectorBaseActivity() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.RoomFilter + analyticsScreenName = MobileScreen.ScreenName.RoomFilter setupToolbar(views.filteredRoomsToolbar) .allowBack() if (isFirstCreation()) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt index b6481c9cbb..28849204c4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListFragment.kt @@ -42,7 +42,7 @@ 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.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.home.RoomListDisplayMode import im.vector.app.features.home.room.filtered.FilteredRoomFooterItem import im.vector.app.features.home.room.list.actions.RoomListQuickActionsBottomSheet @@ -104,8 +104,8 @@ class RoomListFragment @Inject constructor( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) analyticsScreenName = when (roomListParams.displayMode) { - RoomListDisplayMode.PEOPLE -> Screen.ScreenName.MobilePeople - RoomListDisplayMode.ROOMS -> Screen.ScreenName.MobileRooms + RoomListDisplayMode.PEOPLE -> MobileScreen.ScreenName.People + RoomListDisplayMode.ROOMS -> MobileScreen.ScreenName.Rooms else -> null } } @@ -121,7 +121,7 @@ class RoomListFragment @Inject constructor( when (it) { is RoomListViewEvents.Loading -> showLoading(it.message) is RoomListViewEvents.Failure -> showFailure(it.throwable) - is RoomListViewEvents.SelectRoom -> handleSelectRoom(it) + is RoomListViewEvents.SelectRoom -> handleSelectRoom(it, it.isInviteAlreadyAccepted) is RoomListViewEvents.Done -> Unit is RoomListViewEvents.NavigateToMxToBottomSheet -> handleShowMxToLink(it.link) }.exhaustive @@ -184,8 +184,8 @@ class RoomListFragment @Inject constructor( super.onDestroyView() } - private fun handleSelectRoom(event: RoomListViewEvents.SelectRoom) { - navigator.openRoom(requireActivity(), event.roomSummary.roomId) + private fun handleSelectRoom(event: RoomListViewEvents.SelectRoom, isInviteAlreadyAccepted: Boolean) { + navigator.openRoom(context = requireActivity(), roomId = event.roomSummary.roomId, isInviteAlreadyAccepted = isInviteAlreadyAccepted) } private fun setupCreateRoomButton() { diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewEvents.kt index df2ff58da6..15e16c464f 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewEvents.kt @@ -27,7 +27,7 @@ sealed class RoomListViewEvents : VectorViewEvents { data class Loading(val message: CharSequence? = null) : RoomListViewEvents() data class Failure(val throwable: Throwable) : RoomListViewEvents() - data class SelectRoom(val roomSummary: RoomSummary) : RoomListViewEvents() + data class SelectRoom(val roomSummary: RoomSummary, val isInviteAlreadyAccepted: Boolean = false) : RoomListViewEvents() object Done : RoomListViewEvents() data class NavigateToMxToBottomSheet(val link: String) : RoomListViewEvents() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt index a5977501d2..4a81a8b526 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomListViewModel.kt @@ -33,7 +33,6 @@ import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.StringProvider import im.vector.app.features.analytics.AnalyticsTracker -import im.vector.app.features.analytics.extensions.toAnalyticsJoinedRoom import im.vector.app.features.displayname.getBestName import im.vector.app.features.invite.AutoAcceptInvites import im.vector.app.features.settings.VectorPreferences @@ -174,7 +173,7 @@ class RoomListViewModel @AssistedInject constructor( // PRIVATE METHODS ***************************************************************************** private fun handleSelectRoom(action: RoomListAction.SelectRoom) = withState { - _viewEvents.post(RoomListViewEvents.SelectRoom(action.roomSummary)) + _viewEvents.post(RoomListViewEvents.SelectRoom(action.roomSummary, false)) } private fun handleToggleSection(roomSection: RoomsSection) { @@ -208,6 +207,7 @@ class RoomListViewModel @AssistedInject constructor( Timber.w("Try to join an already joining room. Should not happen") return@withState } + _viewEvents.post(RoomListViewEvents.SelectRoom(action.roomSummary, true)) // quick echo setState { @@ -221,18 +221,6 @@ class RoomListViewModel @AssistedInject constructor( } ) } - - viewModelScope.launch { - try { - session.joinRoom(roomId) - analyticsTracker.capture(action.roomSummary.toAnalyticsJoinedRoom()) - // We do not update the joiningRoomsIds here, because, the room is not joined yet regarding the sync data. - // Instead, we wait for the room to be joined - } catch (failure: Throwable) { - // Notify the user - _viewEvents.post(RoomListViewEvents.Failure(failure)) - } - } } private fun handleRejectInvitation(action: RoomListAction.RejectInvitation) = withState { state -> diff --git a/vector/src/main/java/im/vector/app/features/html/PillImageSpan.kt b/vector/src/main/java/im/vector/app/features/html/PillImageSpan.kt index ff2e2a9cdb..ae285b074c 100644 --- a/vector/src/main/java/im/vector/app/features/html/PillImageSpan.kt +++ b/vector/src/main/java/im/vector/app/features/html/PillImageSpan.kt @@ -19,6 +19,7 @@ package im.vector.app.features.html import android.content.Context +import android.content.res.ColorStateList import android.graphics.Canvas import android.graphics.Paint import android.graphics.drawable.Drawable @@ -32,6 +33,7 @@ import im.vector.app.R import im.vector.app.core.glide.GlideRequests import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.themes.ThemeUtils import org.matrix.android.sdk.api.session.room.send.MatrixItemSpan import org.matrix.android.sdk.api.util.MatrixItem import java.lang.ref.WeakReference @@ -117,6 +119,11 @@ class PillImageSpan(private val glideRequests: GlideRequests, setChipMinHeightResource(R.dimen.pill_min_height) setChipIconSizeResource(R.dimen.pill_avatar_size) chipIcon = icon + if (matrixItem is MatrixItem.EveryoneInRoomItem) { + chipBackgroundColor = ColorStateList.valueOf(ThemeUtils.getColor(context, R.attr.colorError)) + // setTextColor API does not exist right now for ChipDrawable, use textAppearance + setTextAppearanceResource(R.style.TextAppearance_Vector_Body_OnError) + } setBounds(0, 0, intrinsicWidth, intrinsicHeight) } } diff --git a/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt b/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt index f8a2ee5137..506f5e773c 100644 --- a/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt +++ b/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt @@ -36,57 +36,87 @@ class PillsPostProcessor @AssistedInject constructor(@Assisted private val roomI private val context: Context, private val avatarRenderer: AvatarRenderer, private val sessionHolder: ActiveSessionHolder) : - EventHtmlRenderer.PostProcessor { + EventHtmlRenderer.PostProcessor { + + /* ========================================================================================== + * Public api + * ========================================================================================== */ @AssistedFactory interface Factory { fun create(roomId: String?): PillsPostProcessor } + /* ========================================================================================== + * Specialization + * ========================================================================================== */ + override fun afterRender(renderedText: Spannable) { addPillSpans(renderedText, roomId) } + /* ========================================================================================== + * Helper methods + * ========================================================================================== */ + private fun addPillSpans(renderedText: Spannable, roomId: String?) { + addLinkSpans(renderedText, roomId) + } + + private fun addPillSpan( + renderedText: Spannable, + pillSpan: PillImageSpan, + startSpan: Int, + endSpan: Int + ) { + renderedText.setSpan(pillSpan, startSpan, endSpan, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + private fun addLinkSpans(renderedText: Spannable, roomId: String?) { // We let markdown handle links and then we add PillImageSpan if needed. val linkSpans = renderedText.getSpans(0, renderedText.length, LinkSpan::class.java) linkSpans.forEach { linkSpan -> val pillSpan = linkSpan.createPillSpan(roomId) ?: return@forEach val startSpan = renderedText.getSpanStart(linkSpan) val endSpan = renderedText.getSpanEnd(linkSpan) - renderedText.setSpan(pillSpan, startSpan, endSpan, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + addPillSpan(renderedText, pillSpan, startSpan, endSpan) } } + private fun createPillImageSpan(matrixItem: MatrixItem) = + PillImageSpan(GlideApp.with(context), avatarRenderer, context, matrixItem) + private fun LinkSpan.createPillSpan(roomId: String?): PillImageSpan? { - val permalinkData = PermalinkParser.parse(url) - val matrixItem = when (permalinkData) { - is PermalinkData.UserLink -> { - if (roomId == null) { - sessionHolder.getSafeActiveSession()?.getUser(permalinkData.userId)?.toMatrixItem() - } else { - sessionHolder.getSafeActiveSession()?.getRoomMember(permalinkData.userId, roomId)?.toMatrixItem() - } - } - is PermalinkData.RoomLink -> { - if (permalinkData.eventId == null) { - val room: RoomSummary? = sessionHolder.getSafeActiveSession()?.getRoomSummary(permalinkData.roomIdOrAlias) - if (permalinkData.isRoomAlias) { - MatrixItem.RoomAliasItem(permalinkData.roomIdOrAlias, room?.displayName, room?.avatarUrl) - } else { - MatrixItem.RoomItem(permalinkData.roomIdOrAlias, room?.displayName, room?.avatarUrl) - } - } else { - // Exclude event link (used in reply events, we do not want to pill the "in reply to") - null - } - } - is PermalinkData.GroupLink -> { - val group = sessionHolder.getSafeActiveSession()?.getGroupSummary(permalinkData.groupId) - MatrixItem.GroupItem(permalinkData.groupId, group?.displayName, group?.avatarUrl) - } + val matrixItem = when (val permalinkData = PermalinkParser.parse(url)) { + is PermalinkData.UserLink -> permalinkData.toMatrixItem(roomId) + is PermalinkData.RoomLink -> permalinkData.toMatrixItem() + is PermalinkData.GroupLink -> permalinkData.toMatrixItem() else -> null } ?: return null - return PillImageSpan(GlideApp.with(context), avatarRenderer, context, matrixItem) + return createPillImageSpan(matrixItem) + } + + private fun PermalinkData.UserLink.toMatrixItem(roomId: String?): MatrixItem? = + if (roomId == null) { + sessionHolder.getSafeActiveSession()?.getUser(userId)?.toMatrixItem() + } else { + sessionHolder.getSafeActiveSession()?.getRoomMember(userId, roomId)?.toMatrixItem() + } + + private fun PermalinkData.RoomLink.toMatrixItem(): MatrixItem? = + if (eventId == null) { + val room: RoomSummary? = sessionHolder.getSafeActiveSession()?.getRoomSummary(roomIdOrAlias) + when { + isRoomAlias -> MatrixItem.RoomAliasItem(roomIdOrAlias, room?.displayName, room?.avatarUrl) + else -> MatrixItem.RoomItem(roomIdOrAlias, room?.displayName, room?.avatarUrl) + } + } else { + // Exclude event link (used in reply events, we do not want to pill the "in reply to") + null + } + + private fun PermalinkData.GroupLink.toMatrixItem(): MatrixItem? { + val group = sessionHolder.getSafeActiveSession()?.getGroupSummary(groupId) + return MatrixItem.GroupItem(groupId, group?.displayName, group?.avatarUrl) } } 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 edc77d73f6..bf596fc6aa 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 @@ -38,7 +38,7 @@ import im.vector.app.core.extensions.addFragmentToBackstack import im.vector.app.core.extensions.exhaustive import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivityLoginBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.home.HomeActivity import im.vector.app.features.login.terms.LoginTermsFragment import im.vector.app.features.login.terms.LoginTermsFragmentArgument @@ -81,7 +81,7 @@ open class LoginActivity : VectorBaseActivity(), UnlockedA override fun getCoordinatorLayout() = views.coordinatorLayout override fun initUiAndData() { - analyticsScreenName = Screen.ScreenName.Login + analyticsScreenName = MobileScreen.ScreenName.Login if (isFirstCreation()) { addFirstFragment() @@ -203,7 +203,7 @@ open class LoginActivity : VectorBaseActivity(), UnlockedA if (loginViewState.isUserLogged()) { if (loginViewState.signMode == SignMode.SignUp) { // change the screen name - analyticsScreenName = Screen.ScreenName.Register + analyticsScreenName = MobileScreen.ScreenName.Register } val intent = HomeActivity.newIntent( this, diff --git a/vector/src/main/java/im/vector/app/features/login/LoginResetPasswordFragment.kt b/vector/src/main/java/im/vector/app/features/login/LoginResetPasswordFragment.kt index 0328d09427..d121245532 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginResetPasswordFragment.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginResetPasswordFragment.kt @@ -31,7 +31,7 @@ import im.vector.app.core.extensions.hidePassword import im.vector.app.core.extensions.isEmail import im.vector.app.core.extensions.toReducedUrl import im.vector.app.databinding.FragmentLoginResetPasswordBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -48,7 +48,7 @@ class LoginResetPasswordFragment @Inject constructor() : AbstractLoginFragment(), Matri override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.RoomDirectory + analyticsScreenName = MobileScreen.ScreenName.RoomDirectory sharedActionViewModel = viewModelProvider.get(RoomDirectorySharedActionViewModel::class.java) if (isFirstCreation()) { diff --git a/vector/src/main/java/im/vector/app/features/roomdirectory/createroom/CreateRoomActivity.kt b/vector/src/main/java/im/vector/app/features/roomdirectory/createroom/CreateRoomActivity.kt index 339c819a65..e4c350b88e 100644 --- a/vector/src/main/java/im/vector/app/features/roomdirectory/createroom/CreateRoomActivity.kt +++ b/vector/src/main/java/im/vector/app/features/roomdirectory/createroom/CreateRoomActivity.kt @@ -26,7 +26,7 @@ import dagger.hilt.android.AndroidEntryPoint import im.vector.app.core.extensions.addFragment import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivitySimpleBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.roomdirectory.RoomDirectorySharedAction import im.vector.app.features.roomdirectory.RoomDirectorySharedActionViewModel import kotlinx.coroutines.flow.launchIn @@ -57,7 +57,7 @@ class CreateRoomActivity : VectorBaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.CreateRoom + analyticsScreenName = MobileScreen.ScreenName.CreateRoom sharedActionViewModel = viewModelProvider.get(RoomDirectorySharedActionViewModel::class.java) sharedActionViewModel .stream() diff --git a/vector/src/main/java/im/vector/app/features/roomdirectory/picker/RoomDirectoryPickerFragment.kt b/vector/src/main/java/im/vector/app/features/roomdirectory/picker/RoomDirectoryPickerFragment.kt index 48610dda7b..cb71f93a0e 100644 --- a/vector/src/main/java/im/vector/app/features/roomdirectory/picker/RoomDirectoryPickerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomdirectory/picker/RoomDirectoryPickerFragment.kt @@ -29,7 +29,7 @@ import im.vector.app.core.extensions.configureWith import im.vector.app.core.platform.OnBackPressed import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.databinding.FragmentRoomDirectoryPickerBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.roomdirectory.RoomDirectoryAction import im.vector.app.features.roomdirectory.RoomDirectoryData import im.vector.app.features.roomdirectory.RoomDirectoryServer @@ -54,7 +54,7 @@ class RoomDirectoryPickerFragment @Inject constructor(private val roomDirectoryP override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.MobileSwitchDirectory + analyticsScreenName = MobileScreen.ScreenName.SwitchDirectory } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileFragment.kt b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileFragment.kt index c68bfca973..fcebe9adbb 100644 --- a/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roommemberprofile/RoomMemberProfileFragment.kt @@ -47,7 +47,7 @@ import im.vector.app.databinding.DialogBaseEditTextBinding import im.vector.app.databinding.DialogShareQrCodeBinding import im.vector.app.databinding.FragmentMatrixProfileBinding import im.vector.app.databinding.ViewStubRoomMemberProfileHeaderBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.AvatarRenderer @@ -91,7 +91,7 @@ class RoomMemberProfileFragment @Inject constructor( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.User + analyticsScreenName = MobileScreen.ScreenName.User } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt index 8acf53088d..251b99e318 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileFragment.kt @@ -44,7 +44,7 @@ import im.vector.app.core.utils.copyToClipboard import im.vector.app.core.utils.startSharePlainTextIntent import im.vector.app.databinding.FragmentMatrixProfileBinding import im.vector.app.databinding.ViewStubRoomProfileHeaderBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.RoomDetailPendingAction import im.vector.app.features.home.room.detail.RoomDetailPendingActionStore @@ -89,7 +89,7 @@ class RoomProfileFragment @Inject constructor( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.RoomSettings + analyticsScreenName = MobileScreen.ScreenName.RoomSettings setFragmentResultListener(MigrateRoomBottomSheet.REQUEST_KEY) { _, bundle -> bundle.getString(MigrateRoomBottomSheet.BUNDLE_KEY_REPLACEMENT_ROOM)?.let { replacementRoomId -> roomDetailPendingActionStore.data = RoomDetailPendingAction.OpenRoom(replacementRoomId, closeCurrentRoom = true) diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsFragment.kt index 3c1a763072..a0adf42d5b 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsFragment.kt @@ -34,7 +34,7 @@ import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.utils.saveMedia import im.vector.app.core.utils.shareMedia import im.vector.app.databinding.FragmentRoomUploadsBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.notifications.NotificationUtils import im.vector.app.features.roomprofile.RoomProfileArgs @@ -57,7 +57,7 @@ class RoomUploadsFragment @Inject constructor( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.RoomUploads + analyticsScreenName = MobileScreen.ScreenName.RoomUploads } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsBaseFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsBaseFragment.kt index 7cefd20269..4185fde663 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsBaseFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsBaseFragment.kt @@ -30,7 +30,7 @@ import im.vector.app.core.extensions.singletonEntryPoint import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.utils.toast import im.vector.app.features.analytics.AnalyticsTracker -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.screen.ScreenEvent import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -43,7 +43,7 @@ abstract class VectorSettingsBaseFragment : PreferenceFragmentCompat(), Maverick * Analytics * ========================================================================================== */ - protected var analyticsScreenName: Screen.ScreenName? = null + protected var analyticsScreenName: MobileScreen.ScreenName? = null private var screenEvent: ScreenEvent? = null protected lateinit var analyticsTracker: AnalyticsTracker diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsRootFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsRootFragment.kt index cd76efac58..51011e29a2 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsRootFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsRootFragment.kt @@ -19,7 +19,7 @@ package im.vector.app.features.settings import android.os.Bundle import im.vector.app.R import im.vector.app.core.preference.VectorPreference -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import javax.inject.Inject class VectorSettingsRootFragment @Inject constructor() : VectorSettingsBaseFragment() { @@ -29,7 +29,7 @@ class VectorSettingsRootFragment @Inject constructor() : VectorSettingsBaseFragm override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.MobileSettings + analyticsScreenName = MobileScreen.ScreenName.Settings } override fun bindPref() { diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsSecurityPrivacyFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsSecurityPrivacyFragment.kt index e4e287e83a..ef87d908ea 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsSecurityPrivacyFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsSecurityPrivacyFragment.kt @@ -51,7 +51,7 @@ import im.vector.app.core.utils.copyToClipboard import im.vector.app.core.utils.openFileSelection import im.vector.app.core.utils.toast import im.vector.app.databinding.DialogImportE2eKeysBinding -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.ui.consent.AnalyticsConsentViewActions import im.vector.app.features.analytics.ui.consent.AnalyticsConsentViewModel import im.vector.app.features.analytics.ui.consent.AnalyticsConsentViewState @@ -94,7 +94,7 @@ class VectorSettingsSecurityPrivacyFragment @Inject constructor( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analyticsScreenName = Screen.ScreenName.MobileSettingsSecurity + analyticsScreenName = MobileScreen.ScreenName.SettingsSecurity } // cryptography diff --git a/vector/src/main/java/im/vector/app/features/settings/account/deactivation/DeactivateAccountFragment.kt b/vector/src/main/java/im/vector/app/features/settings/account/deactivation/DeactivateAccountFragment.kt index 867526c009..631c375e62 100644 --- a/vector/src/main/java/im/vector/app/features/settings/account/deactivation/DeactivateAccountFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/account/deactivation/DeactivateAccountFragment.kt @@ -31,7 +31,7 @@ import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.databinding.FragmentDeactivateAccountBinding import im.vector.app.features.MainActivity import im.vector.app.features.MainActivityArgs -import im.vector.app.features.analytics.plan.Screen +import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.auth.ReAuthActivity import im.vector.app.features.settings.VectorSettingsActivity import org.matrix.android.sdk.api.auth.data.LoginFlowTypes @@ -66,7 +66,7 @@ class DeactivateAccountFragment @Inject constructor() : VectorBaseFragment - - \ No newline at end of file + + diff --git a/vector/src/main/res/layout/fragment_space_create_choose_private_model.xml b/vector/src/main/res/layout/fragment_space_create_choose_private_model.xml index e1e555eace..e3e5fe7e45 100644 --- a/vector/src/main/res/layout/fragment_space_create_choose_private_model.xml +++ b/vector/src/main/res/layout/fragment_space_create_choose_private_model.xml @@ -13,7 +13,7 @@ + + \ No newline at end of file diff --git a/vector/src/main/res/layout/fragment_space_create_choose_type.xml b/vector/src/main/res/layout/fragment_space_create_choose_type.xml index b675ec9b30..dd448bc967 100644 --- a/vector/src/main/res/layout/fragment_space_create_choose_type.xml +++ b/vector/src/main/res/layout/fragment_space_create_choose_type.xml @@ -12,26 +12,14 @@ - - + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toBottomOf="@id/headerText" /> + + + + + diff --git a/vector/src/main/res/menu/home.xml b/vector/src/main/res/menu/home.xml index 93947a5a7f..f15c31b4e9 100644 --- a/vector/src/main/res/menu/home.xml +++ b/vector/src/main/res/menu/home.xml @@ -31,9 +31,9 @@ - \ No newline at end of file + diff --git a/vector/src/main/res/values/config.xml b/vector/src/main/res/values/config.xml index 8f23c2e1be..78b92cbfa4 100755 --- a/vector/src/main/res/values/config.xml +++ b/vector/src/main/res/values/config.xml @@ -5,7 +5,8 @@ https://matrix.org - https://piwik.riot.im + + https://riot.im/bugreports/submit riot-android element-auto-uisi @@ -21,7 +22,7 @@ im.vector.app.android - jitsi.riot.im + meet.element.io matrix.org diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index c155b6bb75..edf9ef189b 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -81,6 +81,10 @@ %s changed the server ACLs for this room. You changed the server ACLs for this room. + + %d server ACLs change + %d server ACLs changes + • Servers matching %s are now banned. • Servers matching %s were removed from the ban list. • Servers matching %s are now allowed. @@ -3520,12 +3524,13 @@ Add Space Your public space Your private space + Spaces are a new way to group rooms and people What type of space do you want to create? You can change this later To join an existing space, you need an invite. Who are you working with? - Make sure the right people have access to %s. You can change this later. + Make sure the right people have access to %s. Just me A private space to organise your rooms Me and teammates @@ -3782,4 +3787,7 @@ Show less "%1$d more" + Notify the whole room + Users + Room notification diff --git a/vector/src/test/java/im/vector/app/features/analytics/impl/DefaultVectorAnalyticsTest.kt b/vector/src/test/java/im/vector/app/features/analytics/impl/DefaultVectorAnalyticsTest.kt new file mode 100644 index 0000000000..b17c1a8bba --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/analytics/impl/DefaultVectorAnalyticsTest.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.analytics.impl + +import com.posthog.android.Properties +import im.vector.app.features.analytics.itf.VectorAnalyticsEvent +import im.vector.app.features.analytics.itf.VectorAnalyticsScreen +import im.vector.app.test.fakes.FakeAnalyticsStore +import im.vector.app.test.fakes.FakeLateInitUserPropertiesFactory +import im.vector.app.test.fakes.FakePostHog +import im.vector.app.test.fakes.FakePostHogFactory +import im.vector.app.test.fixtures.AnalyticsConfigFixture.anAnalyticsConfig +import im.vector.app.test.fixtures.aUserProperties +import im.vector.app.test.fixtures.aVectorAnalyticsEvent +import im.vector.app.test.fixtures.aVectorAnalyticsScreen +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Before +import org.junit.Test + +private const val AN_ANALYTICS_ID = "analytics-id" +private val A_SCREEN_EVENT = aVectorAnalyticsScreen() +private val AN_EVENT = aVectorAnalyticsEvent() +private val A_LATE_INIT_USER_PROPERTIES = aUserProperties() + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultVectorAnalyticsTest { + + private val fakePostHog = FakePostHog() + private val fakeAnalyticsStore = FakeAnalyticsStore() + private val fakeLateInitUserPropertiesFactory = FakeLateInitUserPropertiesFactory() + + private val defaultVectorAnalytics = DefaultVectorAnalytics( + postHogFactory = FakePostHogFactory(fakePostHog.instance).instance, + analyticsStore = fakeAnalyticsStore.instance, + globalScope = CoroutineScope(Dispatchers.Unconfined), + analyticsConfig = anAnalyticsConfig(isEnabled = true), + lateInitUserPropertiesFactory = fakeLateInitUserPropertiesFactory.instance + ) + + @Before + fun setUp() { + defaultVectorAnalytics.init() + } + + @Test + fun `when setting user consent then updates analytics store`() = runBlockingTest { + defaultVectorAnalytics.setUserConsent(true) + + fakeAnalyticsStore.verifyConsentUpdated(updatedValue = true) + } + + @Test + fun `when consenting to analytics then updates posthog opt out to false`() = runBlockingTest { + fakeAnalyticsStore.givenUserContent(consent = true) + + fakePostHog.verifyOptOutStatus(optedOut = false) + } + + @Test + fun `when revoking consent to analytics then updates posthog opt out to true`() = runBlockingTest { + fakeAnalyticsStore.givenUserContent(consent = false) + + fakePostHog.verifyOptOutStatus(optedOut = true) + } + + @Test + fun `when setting the analytics id then updates analytics store`() = runBlockingTest { + defaultVectorAnalytics.setAnalyticsId(AN_ANALYTICS_ID) + + fakeAnalyticsStore.verifyAnalyticsIdUpdated(updatedValue = AN_ANALYTICS_ID) + } + + @Test + fun `given lateinit user properties when valid analytics id updates then identify with lateinit properties`() = runBlockingTest { + fakeLateInitUserPropertiesFactory.givenCreatesProperties(A_LATE_INIT_USER_PROPERTIES) + + fakeAnalyticsStore.givenAnalyticsId(AN_ANALYTICS_ID) + + fakePostHog.verifyIdentifies(AN_ANALYTICS_ID, A_LATE_INIT_USER_PROPERTIES) + } + + @Test + fun `when signing out then resets posthog`() = runBlockingTest { + fakeAnalyticsStore.allowSettingAnalyticsIdToCallBackingFlow() + + defaultVectorAnalytics.onSignOut() + + fakePostHog.verifyReset() + } + + @Test + fun `given user consent when tracking screen events then submits to posthog`() = runBlockingTest { + fakeAnalyticsStore.givenUserContent(consent = true) + + defaultVectorAnalytics.screen(A_SCREEN_EVENT) + + fakePostHog.verifyScreenTracked(A_SCREEN_EVENT.getName(), A_SCREEN_EVENT.toPostHogProperties()) + } + + @Test + fun `given user has not consented when tracking screen events then does not track`() = runBlockingTest { + fakeAnalyticsStore.givenUserContent(consent = false) + + defaultVectorAnalytics.screen(A_SCREEN_EVENT) + + fakePostHog.verifyNoScreenTracking() + } + + @Test + fun `given user consent when tracking events then submits to posthog`() = runBlockingTest { + fakeAnalyticsStore.givenUserContent(consent = true) + + defaultVectorAnalytics.capture(AN_EVENT) + + fakePostHog.verifyEventTracked(AN_EVENT.getName(), AN_EVENT.toPostHogProperties()) + } + + @Test + fun `given user has not consented when tracking events then does not track`() = runBlockingTest { + fakeAnalyticsStore.givenUserContent(consent = false) + + defaultVectorAnalytics.capture(AN_EVENT) + + fakePostHog.verifyNoEventTracking() + } +} + +private fun VectorAnalyticsScreen.toPostHogProperties(): Properties? { + return getProperties()?.let { properties -> + Properties().also { it.putAll(properties) } + } +} + +private fun VectorAnalyticsEvent.toPostHogProperties(): Properties? { + return getProperties()?.let { properties -> + Properties().also { it.putAll(properties) } + } +} diff --git a/vector/src/test/java/im/vector/app/features/analytics/impl/LateInitUserPropertiesFactoryTest.kt b/vector/src/test/java/im/vector/app/features/analytics/impl/LateInitUserPropertiesFactoryTest.kt new file mode 100644 index 0000000000..c2fa50f789 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/analytics/impl/LateInitUserPropertiesFactoryTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.analytics.impl + +import im.vector.app.features.analytics.plan.UserProperties +import im.vector.app.features.onboarding.FtueUseCase +import im.vector.app.test.fakes.FakeActiveSessionDataSource +import im.vector.app.test.fakes.FakeContext +import im.vector.app.test.fakes.FakeSession +import im.vector.app.test.fakes.FakeVectorStore +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +@ExperimentalCoroutinesApi +class LateInitUserPropertiesFactoryTest { + + private val fakeActiveSessionDataSource = FakeActiveSessionDataSource() + private val fakeVectorStore = FakeVectorStore() + private val fakeContext = FakeContext() + private val fakeSession = FakeSession().also { + it.givenVectorStore(fakeVectorStore.instance) + } + + private val lateInitUserProperties = LateInitUserPropertiesFactory( + fakeActiveSessionDataSource.instance, + fakeContext.instance + ) + + @Test + fun `given no active session when creating properties then returns null`() = runBlockingTest { + val result = lateInitUserProperties.createUserProperties() + + result shouldBeEqualTo null + } + + @Test + fun `given no use case set on an active session when creating properties then returns null`() = runBlockingTest { + fakeVectorStore.givenUseCase(null) + fakeSession.givenVectorStore(fakeVectorStore.instance) + fakeActiveSessionDataSource.setActiveSession(fakeSession) + + val result = lateInitUserProperties.createUserProperties() + + result shouldBeEqualTo null + } + + @Test + fun `given use case set on an active session when creating properties then includes the use case`() = runBlockingTest { + fakeVectorStore.givenUseCase(FtueUseCase.TEAMS) + fakeActiveSessionDataSource.setActiveSession(fakeSession) + val result = lateInitUserProperties.createUserProperties() + + result shouldBeEqualTo UserProperties( + ftueUseCaseSelection = UserProperties.FtueUseCaseSelection.WorkMessaging + ) + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeActiveSessionDataSource.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeActiveSessionDataSource.kt new file mode 100644 index 0000000000..4dab6daf3b --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeActiveSessionDataSource.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import arrow.core.Option +import im.vector.app.ActiveSessionDataSource +import org.matrix.android.sdk.api.session.Session + +class FakeActiveSessionDataSource { + + val instance = ActiveSessionDataSource() + + fun setActiveSession(session: Session) { + instance.post(Option.just(session)) + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeAnalyticsStore.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeAnalyticsStore.kt new file mode 100644 index 0000000000..6304da8d37 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeAnalyticsStore.kt @@ -0,0 +1,58 @@ +/* + * 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.test.fakes + +import im.vector.app.features.analytics.store.AnalyticsStore +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.runBlocking + +class FakeAnalyticsStore { + + private val _consentFlow = MutableSharedFlow() + private val _idFlow = MutableSharedFlow() + + val instance = mockk(relaxed = true) { + every { userConsentFlow } returns _consentFlow + every { analyticsIdFlow } returns _idFlow + } + + fun allowSettingAnalyticsIdToCallBackingFlow() { + coEvery { instance.setAnalyticsId(any()) } answers { + runBlocking { _idFlow.emit(firstArg()) } + } + } + + fun verifyConsentUpdated(updatedValue: Boolean) { + coVerify { instance.setUserConsent(updatedValue) } + } + + suspend fun givenUserContent(consent: Boolean) { + _consentFlow.emit(consent) + } + + fun verifyAnalyticsIdUpdated(updatedValue: String) { + coVerify { instance.setAnalyticsId(updatedValue) } + } + + suspend fun givenAnalyticsId(id: String) { + _idFlow.emit(id) + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeLateInitUserPropertiesFactory.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeLateInitUserPropertiesFactory.kt new file mode 100644 index 0000000000..9b442ece73 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeLateInitUserPropertiesFactory.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.test.fakes + +import im.vector.app.features.analytics.impl.LateInitUserPropertiesFactory +import im.vector.app.features.analytics.plan.UserProperties +import io.mockk.coEvery +import io.mockk.mockk + +class FakeLateInitUserPropertiesFactory { + + val instance = mockk() + + fun givenCreatesProperties(userProperties: UserProperties?) { + coEvery { instance.createUserProperties() } returns userProperties + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakePostHog.kt b/vector/src/test/java/im/vector/app/test/fakes/FakePostHog.kt new file mode 100644 index 0000000000..e14f809e66 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakePostHog.kt @@ -0,0 +1,81 @@ +/* + * 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.test.fakes + +import android.os.Looper +import com.posthog.android.PostHog +import com.posthog.android.Properties +import im.vector.app.features.analytics.plan.UserProperties +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify + +class FakePostHog { + + init { + // workaround to avoid PostHog.HANDLER failing + mockkStatic(Looper::class) + val looper = mockk { + every { thread } returns Thread.currentThread() + } + every { Looper.getMainLooper() } returns looper + } + + val instance = mockk(relaxed = true) + + fun verifyOptOutStatus(optedOut: Boolean) { + verify { instance.optOut(optedOut) } + } + + fun verifyIdentifies(analyticsId: String, userProperties: UserProperties?) { + verify { + val postHogProperties = userProperties?.getProperties() + ?.let { rawProperties -> Properties().also { it.putAll(rawProperties) } } + ?.takeIf { it.isNotEmpty() } + instance.identify(analyticsId, postHogProperties, null) + } + } + + fun verifyReset() { + verify { instance.reset() } + } + + fun verifyScreenTracked(name: String, properties: Properties?) { + verify { instance.screen(name, properties) } + } + + fun verifyNoScreenTracking() { + verify(exactly = 0) { + instance.screen(any()) + instance.screen(any(), any()) + instance.screen(any(), any(), any()) + } + } + + fun verifyEventTracked(name: String, properties: Properties?) { + verify { instance.capture(name, properties) } + } + + fun verifyNoEventTracking() { + verify(exactly = 0) { + instance.capture(any()) + instance.capture(any(), any()) + instance.capture(any(), any(), any()) + } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakePostHogFactory.kt b/vector/src/test/java/im/vector/app/test/fakes/FakePostHogFactory.kt new file mode 100644 index 0000000000..1d18c97d32 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakePostHogFactory.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.test.fakes + +import com.posthog.android.PostHog +import im.vector.app.features.analytics.impl.PostHogFactory +import io.mockk.every +import io.mockk.mockk + +class FakePostHogFactory(postHog: PostHog) { + val instance = mockk().also { + every { it.createPosthog() } returns postHog + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt index 91403b3b2c..a23c43b986 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSession.kt @@ -16,8 +16,12 @@ package im.vector.app.test.fakes +import im.vector.app.core.extensions.vectorStore +import im.vector.app.features.session.VectorSessionStore import im.vector.app.test.testCoroutineDispatchers +import io.mockk.coEvery import io.mockk.mockk +import io.mockk.mockkStatic import org.matrix.android.sdk.api.session.Session class FakeSession( @@ -25,7 +29,19 @@ class FakeSession( val fakeSharedSecretStorageService: FakeSharedSecretStorageService = FakeSharedSecretStorageService() ) : Session by mockk(relaxed = true) { + init { + mockkStatic("im.vector.app.core.extensions.SessionKt") + } + override fun cryptoService() = fakeCryptoService override val sharedSecretStorageService = fakeSharedSecretStorageService override val coroutineDispatchers = testCoroutineDispatchers + + fun givenVectorStore(vectorSessionStore: VectorSessionStore) { + coEvery { + this@FakeSession.vectorStore(any()) + } coAnswers { + vectorSessionStore + } + } } diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorStore.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorStore.kt new file mode 100644 index 0000000000..22a4a5f6cf --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorStore.kt @@ -0,0 +1,34 @@ +/* + * 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.test.fakes + +import im.vector.app.features.onboarding.FtueUseCase +import im.vector.app.features.session.VectorSessionStore +import io.mockk.coEvery +import io.mockk.mockk + +class FakeVectorStore { + val instance = mockk() + + fun givenUseCase(useCase: FtueUseCase?) { + coEvery { + instance.readUseCase() + } coAnswers { + useCase + } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fixtures/AnalyticsConfigFixture.kt b/vector/src/test/java/im/vector/app/test/fixtures/AnalyticsConfigFixture.kt new file mode 100644 index 0000000000..5fbcdd98d1 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fixtures/AnalyticsConfigFixture.kt @@ -0,0 +1,33 @@ +/* + * 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.test.fixtures + +import im.vector.app.features.analytics.AnalyticsConfig + +object AnalyticsConfigFixture { + fun anAnalyticsConfig( + isEnabled: Boolean = false, + postHogHost: String = "http://posthog.url", + postHogApiKey: String = "api-key", + policyLink: String = "http://policy.link" + ) = object : AnalyticsConfig { + override val isEnabled: Boolean = isEnabled + override val postHogHost = postHogHost + override val postHogApiKey = postHogApiKey + override val policyLink = policyLink + } +} diff --git a/vector/src/test/java/im/vector/app/test/fixtures/UserPropertiesFixture.kt b/vector/src/test/java/im/vector/app/test/fixtures/UserPropertiesFixture.kt new file mode 100644 index 0000000000..5a911e2bc9 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fixtures/UserPropertiesFixture.kt @@ -0,0 +1,26 @@ +/* + * 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.test.fixtures + +import im.vector.app.features.analytics.plan.UserProperties +import im.vector.app.features.analytics.plan.UserProperties.FtueUseCaseSelection + +fun aUserProperties( + ftueUseCase: FtueUseCaseSelection? = FtueUseCaseSelection.Skip +) = UserProperties( + ftueUseCaseSelection = ftueUseCase +) diff --git a/vector/src/test/java/im/vector/app/test/fixtures/VectorAnalyticsFixture.kt b/vector/src/test/java/im/vector/app/test/fixtures/VectorAnalyticsFixture.kt new file mode 100644 index 0000000000..95590b0a44 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fixtures/VectorAnalyticsFixture.kt @@ -0,0 +1,36 @@ +/* + * 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.test.fixtures + +import im.vector.app.features.analytics.itf.VectorAnalyticsEvent +import im.vector.app.features.analytics.itf.VectorAnalyticsScreen + +fun aVectorAnalyticsScreen( + name: String = "a-screen-name", + properties: Map? = null +) = object : VectorAnalyticsScreen { + override fun getName() = name + override fun getProperties() = properties +} + +fun aVectorAnalyticsEvent( + name: String = "an-event-name", + properties: Map? = null +) = object : VectorAnalyticsEvent { + override fun getName() = name + override fun getProperties() = properties +}