diff --git a/CHANGES.md b/CHANGES.md index d3bc0cc414..3824fead36 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,12 @@ +Changes in Element v1.3.6 (2021-10-26) +====================================== + +Bugfixes 🐛 +---------- + - Correctly handle url of type https://mobile.element.io/?hs_url=…&is_url=… + Skip the choose server screen when such URL are open when Element ([#2684](https://github.com/vector-im/element-android/issues/2684)) + + Changes in Element v1.3.5 (2021-10-25) ====================================== diff --git a/changelog.d/1491.bugfix b/changelog.d/1491.bugfix new file mode 100644 index 0000000000..0ff6bd2c11 --- /dev/null +++ b/changelog.d/1491.bugfix @@ -0,0 +1 @@ +Stops showing a dedicated redacted event notification, the message notifications will update accordingly \ No newline at end of file diff --git a/changelog.d/3395.bugfix b/changelog.d/3395.bugfix new file mode 100644 index 0000000000..9482e1bc7e --- /dev/null +++ b/changelog.d/3395.bugfix @@ -0,0 +1 @@ +Fixes marking individual notifications as read causing other notifications to be dismissed \ No newline at end of file diff --git a/changelog.d/4152.bugfix b/changelog.d/4152.bugfix new file mode 100644 index 0000000000..1ff45609b5 --- /dev/null +++ b/changelog.d/4152.bugfix @@ -0,0 +1 @@ +Tentatively fixing the doubled notifications by updating the group summary at specific points in the notification rendering cycle \ No newline at end of file diff --git a/changelog.d/4255.bugfix b/changelog.d/4255.bugfix new file mode 100644 index 0000000000..8fc820d70f --- /dev/null +++ b/changelog.d/4255.bugfix @@ -0,0 +1 @@ +Fixes being unable to join rooms by name \ No newline at end of file diff --git a/changelog.d/4266.removal b/changelog.d/4266.removal new file mode 100644 index 0000000000..5ac757bc8a --- /dev/null +++ b/changelog.d/4266.removal @@ -0,0 +1 @@ +Add API `LoginWizard.loginCustom(data: JsonDict): Session` to be able to login to a homeserver using arbitrary request content \ No newline at end of file diff --git a/changelog.d/4334.removal b/changelog.d/4334.removal new file mode 100644 index 0000000000..1ed04d3cdf --- /dev/null +++ b/changelog.d/4334.removal @@ -0,0 +1 @@ +Add optional deviceId to the login API \ No newline at end of file diff --git a/changelog.d/582.feature b/changelog.d/582.feature new file mode 100644 index 0000000000..5f82e1b82c --- /dev/null +++ b/changelog.d/582.feature @@ -0,0 +1 @@ +Adding the room name to the invitation notification (if the room summary is available) \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40103060.txt b/fastlane/metadata/android/en-US/changelogs/40103060.txt new file mode 100644 index 0000000000..7afd03a5c8 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40103060.txt @@ -0,0 +1,2 @@ +Main changes in this version: Add Presence support, for Direct Message room (note: presence is disabled on matrix.org). Add again Android Auto support. +Full changelog: https://github.com/vector-im/element-android/releases/tag/v1.3.6 \ No newline at end of file diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index e3d0f273b8..deea87e8a3 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -31,7 +31,7 @@ android { // that the app's state is completely cleared between tests. testInstrumentationRunnerArguments clearPackageData: 'true' - buildConfigField "String", "SDK_VERSION", "\"1.3.6\"" + buildConfigField "String", "SDK_VERSION", "\"1.3.7\"" buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\"" resValue "string", "git_sdk_revision", "\"${gitRevision()}\"" @@ -156,7 +156,7 @@ dependencies { implementation libs.apache.commonsImaging // Phone number https://github.com/google/libphonenumber - implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.35' + implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.36' testImplementation libs.tests.junit testImplementation 'org.robolectric:robolectric:4.6.1' diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt index 5e35917243..9cb784c9c0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt @@ -105,9 +105,15 @@ interface AuthenticationService { /** * Authenticate with a matrixId and a password * Usually call this after a successful call to getWellKnownData() + * @param homeServerConnectionConfig the information about the homeserver and other configuration + * @param matrixId the matrixId of the user + * @param password the password of the account + * @param initialDeviceName the initial device name + * @param deviceId the device id, optional. If not provided or null, the server will generate one. */ suspend fun directAuthentication(homeServerConnectionConfig: HomeServerConnectionConfig, matrixId: String, password: String, - initialDeviceName: String): Session + initialDeviceName: String, + deviceId: String? = null): Session } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt index a2a9373837..247d58ce79 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/login/LoginWizard.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.api.auth.login import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.util.JsonDict /** * Set of methods to be able to login to an existing account on a homeserver. @@ -34,12 +35,14 @@ interface LoginWizard { * * @param login the login field. Can be a user name, or a msisdn (email or phone number) associated to the account * @param password the password of the account - * @param deviceName the initial device name + * @param initialDeviceName the initial device name + * @param deviceId the device id, optional. If not provided or null, the server will generate one. * @return a [Session] if the login is successful */ suspend fun login(login: String, password: String, - deviceName: String): Session + initialDeviceName: String, + deviceId: String? = null): Session /** * Exchange a login token to an access token. @@ -49,6 +52,12 @@ interface LoginWizard { */ suspend fun loginWithToken(loginToken: String): Session + /** + * Login to the homeserver by sending a custom JsonDict. + * The data should contain at least one entry "type" with a String value. + */ + suspend fun loginCustom(data: JsonDict): Session + /** * Ask the homeserver to reset the user password. The password will not be reset until * [resetPasswordMailConfirmed] is successfully called. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt index 169f90dbca..aad5fce33e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt @@ -22,6 +22,8 @@ import org.json.JSONObject import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.session.crypto.MXCryptoError +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomMemberContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent @@ -310,3 +312,6 @@ fun Event.isEdition(): Boolean { fun Event.getPresenceContent(): PresenceContent? { return content.toModel() } + +fun Event.isInvitation(): Boolean = type == EventType.STATE_ROOM_MEMBER && + content?.toModel()?.membership == Membership.INVITE diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthAPI.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthAPI.kt index 50d9e5a06c..554e21ce55 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthAPI.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/AuthAPI.kt @@ -121,6 +121,10 @@ internal interface AuthAPI { @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "login") suspend fun login(@Body loginParams: TokenLoginParams): Credentials + @Headers("CONNECT_TIMEOUT:60000", "READ_TIMEOUT:60000", "WRITE_TIMEOUT:60000") + @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "login") + suspend fun login(@Body loginParams: JsonDict): Credentials + /** * Ask the homeserver to reset the password associated with the provided email. */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt index 641a8f1bb6..8784d85c10 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt @@ -388,8 +388,15 @@ internal class DefaultAuthenticationService @Inject constructor( override suspend fun directAuthentication(homeServerConnectionConfig: HomeServerConnectionConfig, matrixId: String, password: String, - initialDeviceName: String): Session { - return directLoginTask.execute(DirectLoginTask.Params(homeServerConnectionConfig, matrixId, password, initialDeviceName)) + initialDeviceName: String, + deviceId: String?): Session { + return directLoginTask.execute(DirectLoginTask.Params( + homeServerConnectionConfig = homeServerConnectionConfig, + userId = matrixId, + password = password, + deviceName = initialDeviceName, + deviceId = deviceId + )) } private fun buildAuthAPI(homeServerConnectionConfig: HomeServerConnectionConfig): AuthAPI { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/PasswordLoginParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/PasswordLoginParams.kt index d4b14f1ca9..5be480f633 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/PasswordLoginParams.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/data/PasswordLoginParams.kt @@ -49,51 +49,54 @@ internal data class PasswordLoginParams( fun userIdentifier(user: String, password: String, - deviceDisplayName: String? = null, - deviceId: String? = null): PasswordLoginParams { + deviceDisplayName: String?, + deviceId: String?): PasswordLoginParams { return PasswordLoginParams( - mapOf( + identifier = mapOf( IDENTIFIER_KEY_TYPE to IDENTIFIER_KEY_TYPE_USER, IDENTIFIER_KEY_USER to user ), - password, - LoginFlowTypes.PASSWORD, - deviceDisplayName, - deviceId) + password = password, + type = LoginFlowTypes.PASSWORD, + deviceDisplayName = deviceDisplayName, + deviceId = deviceId + ) } fun thirdPartyIdentifier(medium: String, address: String, password: String, - deviceDisplayName: String? = null, - deviceId: String? = null): PasswordLoginParams { + deviceDisplayName: String?, + deviceId: String?): PasswordLoginParams { return PasswordLoginParams( - mapOf( + identifier = mapOf( IDENTIFIER_KEY_TYPE to IDENTIFIER_KEY_TYPE_THIRD_PARTY, IDENTIFIER_KEY_MEDIUM to medium, IDENTIFIER_KEY_ADDRESS to address ), - password, - LoginFlowTypes.PASSWORD, - deviceDisplayName, - deviceId) + password = password, + type = LoginFlowTypes.PASSWORD, + deviceDisplayName = deviceDisplayName, + deviceId = deviceId + ) } fun phoneIdentifier(country: String, phone: String, password: String, - deviceDisplayName: String? = null, - deviceId: String? = null): PasswordLoginParams { + deviceDisplayName: String?, + deviceId: String?): PasswordLoginParams { return PasswordLoginParams( - mapOf( + identifier = mapOf( IDENTIFIER_KEY_TYPE to IDENTIFIER_KEY_TYPE_PHONE, IDENTIFIER_KEY_COUNTRY to country, IDENTIFIER_KEY_PHONE to phone ), - password, - LoginFlowTypes.PASSWORD, - deviceDisplayName, - deviceId) + password = password, + type = LoginFlowTypes.PASSWORD, + deviceDisplayName = deviceDisplayName, + deviceId = deviceId + ) } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt index 854caf8a62..b72cff3cf1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DefaultLoginWizard.kt @@ -21,6 +21,7 @@ import org.matrix.android.sdk.api.auth.login.LoginProfileInfo import org.matrix.android.sdk.api.auth.login.LoginWizard import org.matrix.android.sdk.api.auth.registration.RegisterThreePid import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.internal.auth.AuthAPI import org.matrix.android.sdk.internal.auth.PendingSessionStore import org.matrix.android.sdk.internal.auth.SessionCreator @@ -52,11 +53,23 @@ internal class DefaultLoginWizard( override suspend fun login(login: String, password: String, - deviceName: String): Session { + initialDeviceName: String, + deviceId: String?): Session { val loginParams = if (Patterns.EMAIL_ADDRESS.matcher(login).matches()) { - PasswordLoginParams.thirdPartyIdentifier(ThreePidMedium.EMAIL, login, password, deviceName) + PasswordLoginParams.thirdPartyIdentifier( + medium = ThreePidMedium.EMAIL, + address = login, + password = password, + deviceDisplayName = initialDeviceName, + deviceId = deviceId + ) } else { - PasswordLoginParams.userIdentifier(login, password, deviceName) + PasswordLoginParams.userIdentifier( + user = login, + password = password, + deviceDisplayName = initialDeviceName, + deviceId = deviceId + ) } val credentials = executeRequest(null) { authAPI.login(loginParams) @@ -79,6 +92,14 @@ internal class DefaultLoginWizard( return sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig) } + override suspend fun loginCustom(data: JsonDict): Session { + val credentials = executeRequest(null) { + authAPI.login(data) + } + + return sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig) + } + override suspend fun resetPassword(email: String, newPassword: String) { val param = RegisterAddThreePidTask.Params( RegisterThreePid.Email(email), diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DirectLoginTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DirectLoginTask.kt index 8f61afe374..28706c7e80 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DirectLoginTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/login/DirectLoginTask.kt @@ -37,7 +37,8 @@ internal interface DirectLoginTask : Task { val homeServerConnectionConfig: HomeServerConnectionConfig, val userId: String, val password: String, - val deviceName: String + val deviceName: String, + val deviceId: String? ) } @@ -55,7 +56,12 @@ internal class DefaultDirectLoginTask @Inject constructor( val authAPI = retrofitFactory.create(client, homeServerUrl) .create(AuthAPI::class.java) - val loginParams = PasswordLoginParams.userIdentifier(params.userId, params.password, params.deviceName) + val loginParams = PasswordLoginParams.userIdentifier( + user = params.userId, + password = params.password, + deviceDisplayName = params.deviceName, + deviceId = params.deviceId + ) val credentials = try { executeRequest(null) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendEventTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendEventTask.kt index e1e297767b..e40db6af67 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendEventTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendEventTask.kt @@ -34,7 +34,7 @@ internal interface SendEventTask : Task { internal class DefaultSendEventTask @Inject constructor( private val localEchoRepository: LocalEchoRepository, - private val encryptEventTask: DefaultEncryptEventTask, + private val encryptEventTask: EncryptEventTask, private val loadRoomMembersTask: LoadRoomMembersTask, private val roomAPI: RoomAPI, private val globalErrorReceiver: GlobalErrorReceiver) : SendEventTask { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendVerificationMessageTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendVerificationMessageTask.kt index 7fa48c3da1..c4a6ba27d6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendVerificationMessageTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendVerificationMessageTask.kt @@ -34,7 +34,7 @@ internal interface SendVerificationMessageTask : Task, private val accountService: Lazy, private val eventService: Lazy, - private val defaultIdentityService: DefaultIdentityService, + private val identityService: IdentityService, private val integrationManagerService: IntegrationManagerService, private val thirdPartyService: Lazy, private val callSignalingService: Lazy, @@ -275,7 +275,7 @@ internal class DefaultSession @Inject constructor( override fun cryptoService(): CryptoService = cryptoService.get() - override fun identityService() = defaultIdentityService + override fun identityService() = identityService override fun fileService(): FileService = defaultFileService.get() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt index bdebb0addf..1b0ccbb489 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt @@ -35,12 +35,12 @@ import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.session.content.ContentUrlResolver import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities +import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService import org.matrix.android.sdk.internal.di.Authenticated import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.ProgressRequestBody import org.matrix.android.sdk.internal.network.awaitResponse import org.matrix.android.sdk.internal.network.toFailure -import org.matrix.android.sdk.internal.session.homeserver.DefaultHomeServerCapabilitiesService import org.matrix.android.sdk.internal.util.TemporaryFileCreator import java.io.File import java.io.FileNotFoundException @@ -50,7 +50,7 @@ import javax.inject.Inject internal class FileUploader @Inject constructor( @Authenticated private val okHttpClient: OkHttpClient, private val globalErrorReceiver: GlobalErrorReceiver, - private val homeServerCapabilitiesService: DefaultHomeServerCapabilitiesService, + private val homeServerCapabilitiesService: HomeServerCapabilitiesService, private val context: Context, private val temporaryFileCreator: TemporaryFileCreator, contentUrlResolver: ContentUrlResolver, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/DefaultIdentityService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/DefaultIdentityService.kt index 37d9a4e74f..c8a9c0f09a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/DefaultIdentityService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/DefaultIdentityService.kt @@ -80,7 +80,7 @@ internal class DefaultIdentityService @Inject constructor( private val identityApiProvider: IdentityApiProvider, private val accountDataDataSource: UserAccountDataDataSource, private val homeServerCapabilitiesService: HomeServerCapabilitiesService, - private val sign3pidInvitationTask: DefaultSign3pidInvitationTask, + private val sign3pidInvitationTask: Sign3pidInvitationTask, private val sessionParams: SessionParams ) : IdentityService, SessionLifecycleObserver { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/IdentityModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/IdentityModule.kt index 19e602d7a7..65794e6b14 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/IdentityModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/IdentityModule.kt @@ -21,6 +21,7 @@ import dagger.Module import dagger.Provides import io.realm.RealmConfiguration import okhttp3.OkHttpClient +import org.matrix.android.sdk.api.session.identity.IdentityService import org.matrix.android.sdk.internal.database.RealmKeysUtils import org.matrix.android.sdk.internal.di.AuthenticatedIdentity import org.matrix.android.sdk.internal.di.IdentityDatabase @@ -75,6 +76,9 @@ internal abstract class IdentityModule { } } + @Binds + abstract fun bindIdentityService(service: DefaultIdentityService): IdentityService + @Binds @AuthenticatedIdentity abstract fun bindAccessTokenProvider(provider: IdentityAccessTokenProvider): AccessTokenProvider diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt index 1321f8dd62..3c74888eda 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.notification import org.matrix.android.sdk.api.pushrules.rest.PushRule import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.isInvitation import org.matrix.android.sdk.api.session.sync.model.RoomsSyncResponse import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.task.Task @@ -48,14 +49,18 @@ internal class DefaultProcessEventForPushTask @Inject constructor( } val newJoinEvents = params.syncResponse.join .mapNotNull { (key, value) -> - value.timeline?.events?.map { it.copy(roomId = key) } + value.timeline?.events?.mapNotNull { + it.takeIf { !it.isInvitation() }?.copy(roomId = key) + } } .flatten() + val inviteEvents = params.syncResponse.invite .mapNotNull { (key, value) -> value.inviteState?.events?.map { it.copy(roomId = key) } } .flatten() + val allEvents = (newJoinEvents + inviteEvents).filter { event -> when (event.type) { EventType.MESSAGE, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/signout/SignInAgainTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/signout/SignInAgainTask.kt index 563e85aefc..42fdf30501 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/signout/SignInAgainTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/signout/SignInAgainTask.kt @@ -42,11 +42,12 @@ internal class DefaultSignInAgainTask @Inject constructor( signOutAPI.loginAgain( PasswordLoginParams.userIdentifier( // Reuse the same userId - sessionParams.userId, - params.password, + user = sessionParams.userId, + password = params.password, // The spec says the initial device name will be ignored // https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-login // but https://github.com/matrix-org/synapse/issues/6525 + deviceDisplayName = null, // Reuse the same deviceId deviceId = sessionParams.deviceId ) diff --git a/tools/check/forbidden_strings_in_code.txt b/tools/check/forbidden_strings_in_code.txt index 8f8625fe1c..29077c3a76 100644 --- a/tools/check/forbidden_strings_in_code.txt +++ b/tools/check/forbidden_strings_in_code.txt @@ -160,7 +160,7 @@ Formatter\.formatShortFileSize===1 # android\.text\.TextUtils ### This is not a rule, but a warning: the number of "enum class" has changed. For Json classes, it is mandatory that they have `@JsonClass(generateAdapter = false)`. If the enum is not used as a Json class, change the value in file forbidden_strings_in_code.txt -enum class===106 +enum class===107 ### Do not import temporary legacy classes import org.matrix.android.sdk.internal.legacy.riot===3 diff --git a/vector/build.gradle b/vector/build.gradle index 2b616335db..c32e70754a 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -15,7 +15,7 @@ kapt { // Note: 2 digits max for each value ext.versionMajor = 1 ext.versionMinor = 3 -ext.versionPatch = 6 +ext.versionPatch = 7 static def getGitTimestamp() { def cmd = 'git show -s --format=%ct' @@ -371,7 +371,7 @@ dependencies { implementation 'com.facebook.stetho:stetho:1.6.0' // Phone number https://github.com/google/libphonenumber - implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.35' + implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.36' // FlowBinding implementation libs.github.flowBinding diff --git a/vector/src/debug/java/im/vector/app/features/debug/DebugPermissionActivity.kt b/vector/src/debug/java/im/vector/app/features/debug/DebugPermissionActivity.kt index 048c64bc3a..a35bb40f8f 100644 --- a/vector/src/debug/java/im/vector/app/features/debug/DebugPermissionActivity.kt +++ b/vector/src/debug/java/im/vector/app/features/debug/DebugPermissionActivity.kt @@ -22,6 +22,7 @@ import android.os.Build import android.widget.Toast import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.utils.checkPermissions @@ -31,6 +32,7 @@ import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.databinding.ActivityDebugPermissionBinding import timber.log.Timber +@AndroidEntryPoint class DebugPermissionActivity : VectorBaseActivity() { override fun getBinding() = ActivityDebugPermissionBinding.inflate(layoutInflater) diff --git a/vector/src/gplay/java/im/vector/app/gplay/push/fcm/VectorFirebaseMessagingService.kt b/vector/src/gplay/java/im/vector/app/gplay/push/fcm/VectorFirebaseMessagingService.kt index b1f0b43705..63d50d4f97 100755 --- a/vector/src/gplay/java/im/vector/app/gplay/push/fcm/VectorFirebaseMessagingService.kt +++ b/vector/src/gplay/java/im/vector/app/gplay/push/fcm/VectorFirebaseMessagingService.kt @@ -29,16 +29,13 @@ import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import dagger.hilt.android.AndroidEntryPoint import im.vector.app.BuildConfig -import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.network.WifiDetector import im.vector.app.core.pushers.PushersManager import im.vector.app.features.badge.BadgeProxy import im.vector.app.features.notifications.NotifiableEventResolver -import im.vector.app.features.notifications.NotifiableMessageEvent import im.vector.app.features.notifications.NotificationDrawerManager import im.vector.app.features.notifications.NotificationUtils -import im.vector.app.features.notifications.SimpleNotifiableEvent import im.vector.app.features.settings.VectorDataStore import im.vector.app.features.settings.VectorPreferences import im.vector.app.push.fcm.FcmHelper @@ -48,9 +45,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.logger.LoggerTag -import org.matrix.android.sdk.api.pushrules.Action import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.events.model.Event import timber.log.Timber import javax.inject.Inject @@ -201,12 +196,11 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() { Timber.tag(loggerTag.value).d("Fast lane: start request") val event = tryOrNull { session.getEvent(roomId, eventId) } ?: return@launch - val resolvedEvent = notifiableEventResolver.resolveInMemoryEvent(session, event) + val resolvedEvent = notifiableEventResolver.resolveInMemoryEvent(session, event, canBeReplaced = true) resolvedEvent ?.also { Timber.tag(loggerTag.value).d("Fast lane: notify drawer") } ?.let { - it.isPushGatewayEvent = true notificationDrawerManager.onNotifiableEventReceived(it) notificationDrawerManager.refreshNotificationDrawer() } @@ -227,87 +221,4 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() { } return false } - - private fun handleNotificationWithoutSyncingMode(data: Map, session: Session?) { - if (session == null) { - Timber.tag(loggerTag.value).e("## handleNotificationWithoutSyncingMode cannot find session") - return - } - - // The Matrix event ID of the event being notified about. - // This is required if the notification is about a particular Matrix event. - // It may be omitted for notifications that only contain updated badge counts. - // This ID can and should be used to detect duplicate notification requests. - val eventId = data["event_id"] ?: return // Just ignore - - val eventType = data["type"] - if (eventType == null) { - // Just add a generic unknown event - val simpleNotifiableEvent = SimpleNotifiableEvent( - session.myUserId, - eventId, - null, - true, // It's an issue in this case, all event will bing even if expected to be silent. - title = getString(R.string.notification_unknown_new_event), - description = "", - type = null, - timestamp = System.currentTimeMillis(), - soundName = Action.ACTION_OBJECT_VALUE_VALUE_DEFAULT, - isPushGatewayEvent = true - ) - notificationDrawerManager.onNotifiableEventReceived(simpleNotifiableEvent) - notificationDrawerManager.refreshNotificationDrawer() - } else { - val event = parseEvent(data) ?: return - - val notifiableEvent = notifiableEventResolver.resolveEvent(event, session) - - if (notifiableEvent == null) { - Timber.tag(loggerTag.value).e("Unsupported notifiable event $eventId") - if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { - Timber.tag(loggerTag.value).e("--> $event") - } - } else { - if (notifiableEvent is NotifiableMessageEvent) { - if (notifiableEvent.senderName.isNullOrEmpty()) { - notifiableEvent.senderName = data["sender_display_name"] ?: data["sender"] ?: "" - } - if (notifiableEvent.roomName.isNullOrEmpty()) { - notifiableEvent.roomName = findRoomNameBestEffort(data, session) ?: "" - } - } - - notifiableEvent.isPushGatewayEvent = true - notifiableEvent.matrixID = session.myUserId - notificationDrawerManager.onNotifiableEventReceived(notifiableEvent) - notificationDrawerManager.refreshNotificationDrawer() - } - } - } - - private fun findRoomNameBestEffort(data: Map, session: Session?): String? { - var roomName: String? = data["room_name"] - val roomId = data["room_id"] - if (null == roomName && null != roomId) { - // Try to get the room name from our store - roomName = session?.getRoom(roomId)?.roomSummary()?.displayName - } - return roomName - } - - /** - * Try to create an event from the FCM data - * - * @param data the FCM data - * @return the event or null if required data are missing - */ - private fun parseEvent(data: Map?): Event? { - return Event( - eventId = data?.get("event_id") ?: return null, - senderId = data["sender"], - roomId = data["room_id"] ?: return null, - type = data["type"] ?: return null, - originServerTs = System.currentTimeMillis() - ) - } } diff --git a/vector/src/main/java/im/vector/app/features/login/LoginAction.kt b/vector/src/main/java/im/vector/app/features/login/LoginAction.kt index f6e1ccc9bc..70ca49a10e 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginAction.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginAction.kt @@ -23,6 +23,8 @@ import org.matrix.android.sdk.api.auth.registration.RegisterThreePid import org.matrix.android.sdk.internal.network.ssl.Fingerprint sealed class LoginAction : VectorViewModelAction { + data class OnGetStarted(val resetLoginConfig: Boolean) : LoginAction() + data class UpdateServerType(val serverType: ServerType) : LoginAction() data class UpdateHomeServer(val homeServerUrl: String) : LoginAction() data class UpdateSignMode(val signMode: SignMode) : LoginAction() 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 bcde8fd37e..a357caf8fa 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 @@ -94,7 +94,6 @@ open class LoginActivity : VectorBaseActivity(), ToolbarCo // Get config extra val loginConfig = intent.getParcelableExtra(EXTRA_CONFIG) if (isFirstCreation()) { - // TODO Check this loginViewModel.handle(LoginAction.InitWith(loginConfig)) } } diff --git a/vector/src/main/java/im/vector/app/features/login/LoginServerSelectionFragment.kt b/vector/src/main/java/im/vector/app/features/login/LoginServerSelectionFragment.kt index 2d40ea818a..89d8985a81 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginServerSelectionFragment.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginServerSelectionFragment.kt @@ -87,10 +87,5 @@ class LoginServerSelectionFragment @Inject constructor() : AbstractLoginFragment override fun updateWithState(state: LoginViewState) { updateSelectedChoice(state) - - if (state.loginMode != LoginMode.Unknown) { - // LoginFlow for matrix.org has been retrieved - loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved)) - } } } diff --git a/vector/src/main/java/im/vector/app/features/login/LoginServerUrlFormFragment.kt b/vector/src/main/java/im/vector/app/features/login/LoginServerUrlFormFragment.kt index cafffc9405..65ad7af346 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginServerUrlFormFragment.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginServerUrlFormFragment.kt @@ -159,10 +159,5 @@ class LoginServerUrlFormFragment @Inject constructor() : AbstractLoginFragment + loginViewModel.handle(LoginAction.OnGetStarted(resetLoginConfig = true)) + } + .setNegativeButton(R.string.cancel, null) + .show() + } else { + super.onError(throwable) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/login/LoginViewModel.kt b/vector/src/main/java/im/vector/app/features/login/LoginViewModel.kt index c08c36d552..bfa924c155 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginViewModel.kt @@ -18,7 +18,6 @@ package im.vector.app.features.login import android.content.Context import android.net.Uri -import androidx.lifecycle.viewModelScope import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Loading import com.airbnb.mvrx.MavericksViewModelFactory @@ -116,6 +115,7 @@ class LoginViewModel @AssistedInject constructor( override fun handle(action: LoginAction) { when (action) { + is LoginAction.OnGetStarted -> handleOnGetStarted(action) is LoginAction.UpdateServerType -> handleUpdateServerType(action) is LoginAction.UpdateSignMode -> handleUpdateSignMode(action) is LoginAction.InitWith -> handleInitWith(action) @@ -134,6 +134,27 @@ class LoginViewModel @AssistedInject constructor( }.exhaustive } + private fun handleOnGetStarted(action: LoginAction.OnGetStarted) { + if (action.resetLoginConfig) { + loginConfig = null + } + + val configUrl = loginConfig?.homeServerUrl?.takeIf { it.isNotEmpty() } + if (configUrl != null) { + // Use config from uri + val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(configUrl) + if (homeServerConnectionConfig == null) { + // Url is invalid, in this case, just use the regular flow + Timber.w("Url from config url was invalid: $configUrl") + _viewEvents.post(LoginViewEvents.OpenServerSelection) + } else { + getLoginFlow(homeServerConnectionConfig, ServerType.Other) + } + } else { + _viewEvents.post(LoginViewEvents.OpenServerSelection) + } + } + private fun handleUserAcceptCertificate(action: LoginAction.UserAcceptCertificate) { // It happens when we get the login flow, or during direct authentication. // So alter the homeserver config and retrieve again the login flow @@ -732,7 +753,8 @@ class LoginViewModel @AssistedInject constructor( } } - private fun getLoginFlow(homeServerConnectionConfig: HomeServerConnectionConfig) { + private fun getLoginFlow(homeServerConnectionConfig: HomeServerConnectionConfig, + serverTypeOverride: ServerType? = null) { currentHomeServerConnectionConfig = homeServerConnectionConfig currentJob = viewModelScope.launch { @@ -743,7 +765,11 @@ class LoginViewModel @AssistedInject constructor( asyncHomeServerLoginFlowRequest = Loading(), // If user has entered https://matrix.org, ensure that server type is ServerType.MatrixOrg // It is also useful to set the value again in the case of a certificate error on matrix.org - serverType = if (homeServerConnectionConfig.homeServerUri.toString() == matrixOrgUrl) ServerType.MatrixOrg else serverType + serverType = if (homeServerConnectionConfig.homeServerUri.toString() == matrixOrgUrl) { + ServerType.MatrixOrg + } else { + serverTypeOverride ?: serverType + } ) } @@ -776,7 +802,6 @@ class LoginViewModel @AssistedInject constructor( else -> LoginMode.Unsupported } - // FIXME We should post a view event here normally? setState { copy( asyncHomeServerLoginFlowRequest = Uninitialized, @@ -791,6 +816,7 @@ class LoginViewModel @AssistedInject constructor( // Notify the UI _viewEvents.post(LoginViewEvents.OutdatedHomeserver) } + _viewEvents.post(LoginViewEvents.OnLoginFlowRetrieved) } } diff --git a/vector/src/main/java/im/vector/app/features/login/LoginViewState.kt b/vector/src/main/java/im/vector/app/features/login/LoginViewState.kt index 46b5052a38..23689225b5 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginViewState.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginViewState.kt @@ -53,7 +53,7 @@ data class LoginViewState( val loginMode: LoginMode = LoginMode.Unknown, // Supported types for the login. We cannot use a sealed class for LoginType because it is not serializable @PersistState -val loginModeSupportedTypes: List = emptyList(), + val loginModeSupportedTypes: List = emptyList(), val knownCustomHomeServersUrls: List = emptyList() ) : MavericksState { diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginViewModel2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginViewModel2.kt index ee33b8c222..b73988126b 100644 --- a/vector/src/main/java/im/vector/app/features/login2/LoginViewModel2.kt +++ b/vector/src/main/java/im/vector/app/features/login2/LoginViewModel2.kt @@ -547,7 +547,7 @@ class LoginViewModel2 @AssistedInject constructor( safeLoginWizard.login( login = login, password = password, - deviceName = stringProvider.getString(R.string.login_default_session_public_name) + initialDeviceName = stringProvider.getString(R.string.login_default_session_public_name) ) } catch (failure: Throwable) { _viewEvents.post(LoginViewEvents2.Failure(failure)) diff --git a/vector/src/main/java/im/vector/app/features/notifications/InviteNotifiableEvent.kt b/vector/src/main/java/im/vector/app/features/notifications/InviteNotifiableEvent.kt index 61fd5c677a..832f97bc4e 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/InviteNotifiableEvent.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/InviteNotifiableEvent.kt @@ -15,22 +15,18 @@ */ package im.vector.app.features.notifications -import androidx.core.app.NotificationCompat - data class InviteNotifiableEvent( - override var matrixID: String?, + val matrixID: String?, override val eventId: String, override val editedEventId: String?, - var roomId: String, - override var noisy: Boolean, - override val title: String, - override val description: String, - override val type: String?, - override val timestamp: Long, - override var soundName: String?, - override var isPushGatewayEvent: Boolean = false) : NotifiableEvent { - - override var hasBeenDisplayed: Boolean = false - override var isRedacted: Boolean = false - override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC -} + override val canBeReplaced: Boolean, + val roomId: String, + val roomName: String?, + val noisy: Boolean, + val title: String, + val description: String, + val type: String?, + val timestamp: Long, + val soundName: String?, + override val isRedacted: Boolean = false +) : NotifiableEvent diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEvent.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEvent.kt index a4f099b905..52d8119cbb 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEvent.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEvent.kt @@ -20,24 +20,11 @@ import java.io.Serializable /** * Parent interface for all events which can be displayed as a Notification */ -interface NotifiableEvent : Serializable { - var matrixID: String? +sealed interface NotifiableEvent : Serializable { val eventId: String val editedEventId: String? - var noisy: Boolean - val title: String - val description: String? - val type: String? - val timestamp: Long - - // NotificationCompat.VISIBILITY_PUBLIC , VISIBILITY_PRIVATE , VISIBILITY_SECRET - var lockScreenVisibility: Int - - // Compat: Only for android <7, for newer version the sound is defined in the channel - var soundName: String? - var hasBeenDisplayed: Boolean - var isRedacted: Boolean // Used to know if event should be replaced with the one coming from eventstream - var isPushGatewayEvent: Boolean + val canBeReplaced: Boolean + val isRedacted: Boolean } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt new file mode 100644 index 0000000000..3d10d74fe3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +import im.vector.app.features.invite.AutoAcceptInvites +import im.vector.app.features.notifications.ProcessedEvent.Type.KEEP +import im.vector.app.features.notifications.ProcessedEvent.Type.REMOVE +import org.matrix.android.sdk.api.session.events.model.EventType +import javax.inject.Inject + +private typealias ProcessedEvents = List> + +class NotifiableEventProcessor @Inject constructor( + private val outdatedDetector: OutdatedEventDetector, + private val autoAcceptInvites: AutoAcceptInvites +) { + + fun process(queuedEvents: List, currentRoomId: String?, renderedEvents: ProcessedEvents): ProcessedEvents { + val processedEvents = queuedEvents.map { + val type = when (it) { + is InviteNotifiableEvent -> if (autoAcceptInvites.hideInvites) REMOVE else KEEP + is NotifiableMessageEvent -> if (shouldIgnoreMessageEventInRoom(currentRoomId, it.roomId) || outdatedDetector.isMessageOutdated(it)) { + REMOVE + } else KEEP + is SimpleNotifiableEvent -> when (it.type) { + EventType.REDACTION -> REMOVE + else -> KEEP + } + } + ProcessedEvent(type, it) + } + + val removedEventsDiff = renderedEvents.filter { renderedEvent -> + queuedEvents.none { it.eventId == renderedEvent.event.eventId } + }.map { ProcessedEvent(REMOVE, it.event) } + + return removedEventsDiff + processedEvents + } + + private fun shouldIgnoreMessageEventInRoom(currentRoomId: String?, roomId: String?): Boolean { + return currentRoomId != null && roomId == currentRoomId + } +} diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt index 63c296f418..d2db73af3d 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt @@ -15,7 +15,6 @@ */ package im.vector.app.features.notifications -import androidx.core.app.NotificationCompat import im.vector.app.BuildConfig import im.vector.app.R import im.vector.app.core.resources.StringProvider @@ -54,21 +53,19 @@ class NotifiableEventResolver @Inject constructor( // private val eventDisplay = RiotEventDisplay(context) - fun resolveEvent(event: Event/*, roomState: RoomState?, bingRule: PushRule?*/, session: Session): NotifiableEvent? { + fun resolveEvent(event: Event/*, roomState: RoomState?, bingRule: PushRule?*/, session: Session, isNoisy: Boolean): NotifiableEvent? { val roomID = event.roomId ?: return null val eventId = event.eventId ?: return null if (event.getClearType() == EventType.STATE_ROOM_MEMBER) { - return resolveStateRoomEvent(event, session) + return resolveStateRoomEvent(event, session, canBeReplaced = false, isNoisy = isNoisy) } val timelineEvent = session.getRoom(roomID)?.getTimeLineEvent(eventId) ?: return null when (event.getClearType()) { EventType.MESSAGE -> { - return resolveMessageEvent(timelineEvent, session) + return resolveMessageEvent(timelineEvent, session, canBeReplaced = false, isNoisy = isNoisy) } EventType.ENCRYPTED -> { - val messageEvent = resolveMessageEvent(timelineEvent, session) - messageEvent?.lockScreenVisibility = NotificationCompat.VISIBILITY_PRIVATE - return messageEvent + return resolveMessageEvent(timelineEvent, session, canBeReplaced = false, isNoisy = isNoisy) } else -> { // If the event can be displayed, display it as is @@ -85,12 +82,14 @@ class NotifiableEventResolver @Inject constructor( description = bodyPreview, title = stringProvider.getString(R.string.notification_unknown_new_event), soundName = null, - type = event.type) + type = event.type, + canBeReplaced = false + ) } } } - fun resolveInMemoryEvent(session: Session, event: Event): NotifiableEvent? { + fun resolveInMemoryEvent(session: Session, event: Event, canBeReplaced: Boolean): NotifiableEvent? { if (event.getClearType() != EventType.MESSAGE) return null // Ignore message edition @@ -114,24 +113,14 @@ class NotifiableEventResolver @Inject constructor( avatarUrl = user.avatarUrl ) ) - - val notifiableEvent = resolveMessageEvent(timelineEvent, session) - - if (notifiableEvent == null) { - Timber.d("## Failed to resolve event") - // TODO - null - } else { - notifiableEvent.noisy = !notificationAction.soundName.isNullOrBlank() - notifiableEvent - } + resolveMessageEvent(timelineEvent, session, canBeReplaced = canBeReplaced, isNoisy = !notificationAction.soundName.isNullOrBlank()) } else { Timber.d("Matched push rule is set to not notify") null } } - private fun resolveMessageEvent(event: TimelineEvent, session: Session): NotifiableEvent? { + private fun resolveMessageEvent(event: TimelineEvent, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableEvent { // The event only contains an eventId, and roomId (type is m.room.*) , we need to get the displayable content (names, avatar, text, etc...) val room = session.getRoom(event.root.roomId!! /*roomID cannot be null*/) @@ -142,19 +131,19 @@ class NotifiableEventResolver @Inject constructor( val roomName = stringProvider.getString(R.string.notification_unknown_room_name) val senderDisplayName = event.senderInfo.disambiguatedDisplayName - val notifiableEvent = NotifiableMessageEvent( + return NotifiableMessageEvent( eventId = event.root.eventId!!, editedEventId = event.getEditedEventId(), + canBeReplaced = canBeReplaced, timestamp = event.root.originServerTs ?: 0, - noisy = false, // will be updated + noisy = isNoisy, senderName = senderDisplayName, senderId = event.root.senderId, body = body.toString(), roomId = event.root.roomId!!, - roomName = roomName) - - notifiableEvent.matrixID = session.myUserId - return notifiableEvent + roomName = roomName, + matrixID = session.myUserId + ) } else { if (event.root.isEncrypted() && event.root.mxDecryptionResult == null) { // TODO use a global event decryptor? attache to session and that listen to new sessionId? @@ -175,57 +164,56 @@ class NotifiableEventResolver @Inject constructor( val roomName = room.roomSummary()?.displayName ?: "" val senderDisplayName = event.senderInfo.disambiguatedDisplayName - val notifiableEvent = NotifiableMessageEvent( + return NotifiableMessageEvent( eventId = event.root.eventId!!, editedEventId = event.getEditedEventId(), + canBeReplaced = canBeReplaced, timestamp = event.root.originServerTs ?: 0, - noisy = false, // will be updated + noisy = isNoisy, senderName = senderDisplayName, senderId = event.root.senderId, body = body, roomId = event.root.roomId!!, roomName = roomName, - roomIsDirect = room.roomSummary()?.isDirect ?: false) - - notifiableEvent.matrixID = session.myUserId - notifiableEvent.soundName = null - - // Get the avatars URL - notifiableEvent.roomAvatarPath = session.contentUrlResolver() - .resolveThumbnail(room.roomSummary()?.avatarUrl, - 250, - 250, - ContentUrlResolver.ThumbnailMethod.SCALE) - - notifiableEvent.senderAvatarPath = session.contentUrlResolver() - .resolveThumbnail(event.senderInfo.avatarUrl, - 250, - 250, - ContentUrlResolver.ThumbnailMethod.SCALE) - - return notifiableEvent + roomIsDirect = room.roomSummary()?.isDirect ?: false, + roomAvatarPath = session.contentUrlResolver() + .resolveThumbnail(room.roomSummary()?.avatarUrl, + 250, + 250, + ContentUrlResolver.ThumbnailMethod.SCALE), + senderAvatarPath = session.contentUrlResolver() + .resolveThumbnail(event.senderInfo.avatarUrl, + 250, + 250, + ContentUrlResolver.ThumbnailMethod.SCALE), + matrixID = session.myUserId, + soundName = null + ) } } - private fun resolveStateRoomEvent(event: Event, session: Session): NotifiableEvent? { + private fun resolveStateRoomEvent(event: Event, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableEvent? { val content = event.content?.toModel() ?: return null val roomId = event.roomId ?: return null val dName = event.senderId?.let { session.getRoomMember(it, roomId)?.displayName } if (Membership.INVITE == content.membership) { - val body = noticeEventFormatter.format(event, dName, isDm = session.getRoomSummary(roomId)?.isDirect.orFalse()) + val roomSummary = session.getRoomSummary(roomId) + val body = noticeEventFormatter.format(event, dName, isDm = roomSummary?.isDirect.orFalse()) ?: stringProvider.getString(R.string.notification_new_invitation) return InviteNotifiableEvent( session.myUserId, eventId = event.eventId!!, editedEventId = null, + canBeReplaced = canBeReplaced, roomId = roomId, + roomName = roomSummary?.displayName, timestamp = event.originServerTs ?: 0, - noisy = false, // will be set later + noisy = isNoisy, title = stringProvider.getString(R.string.notification_new_invitation), description = body.toString(), soundName = null, // will be set later - type = event.getClearType(), - isPushGatewayEvent = false) + type = event.getClearType() + ) } else { Timber.e("## unsupported notifiable event for eventId [${event.eventId}]") if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableMessageEvent.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableMessageEvent.kt index fb9ca8d23c..161c9f74a6 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableMessageEvent.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableMessageEvent.kt @@ -15,43 +15,31 @@ */ package im.vector.app.features.notifications -import androidx.core.app.NotificationCompat import org.matrix.android.sdk.api.session.events.model.EventType data class NotifiableMessageEvent( override val eventId: String, override val editedEventId: String?, - override var noisy: Boolean, - override val timestamp: Long, - var senderName: String?, - var senderId: String?, - var body: String?, - var roomId: String, - var roomName: String?, - var roomIsDirect: Boolean = false + override val canBeReplaced: Boolean, + val noisy: Boolean, + val timestamp: Long, + val senderName: String?, + val senderId: String?, + val body: String?, + val roomId: String, + val roomName: String?, + val roomIsDirect: Boolean = false, + val roomAvatarPath: String? = null, + val senderAvatarPath: String? = null, + val matrixID: String? = null, + val soundName: String? = null, + // This is used for >N notification, as the result of a smart reply + val outGoingMessage: Boolean = false, + val outGoingMessageFailed: Boolean = false, + override val isRedacted: Boolean = false ) : NotifiableEvent { - override var matrixID: String? = null - override var soundName: String? = null - override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC - override var hasBeenDisplayed: Boolean = false - override var isRedacted: Boolean = false - - var roomAvatarPath: String? = null - var senderAvatarPath: String? = null - - override var isPushGatewayEvent: Boolean = false - - override val type: String - get() = EventType.MESSAGE - - override val description: String? - get() = body ?: "" - - override val title: String - get() = senderName ?: "" - - // This is used for >N notification, as the result of a smart reply - var outGoingMessage = false - var outGoingMessageFailed = false + val type: String = EventType.MESSAGE + val description: String = body ?: "" + val title: String = senderName ?: "" } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt index 6583db6f69..33e43cd7e4 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt @@ -130,19 +130,20 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { val notifiableMessageEvent = NotifiableMessageEvent( // Generate a Fake event id - UUID.randomUUID().toString(), - null, - false, - System.currentTimeMillis(), - session.getRoomMember(session.myUserId, room.roomId)?.displayName + eventId = UUID.randomUUID().toString(), + editedEventId = null, + noisy = false, + timestamp = System.currentTimeMillis(), + senderName = session.getRoomMember(session.myUserId, room.roomId)?.displayName ?: context?.getString(R.string.notification_sender_me), - session.myUserId, - message, - room.roomId, - room.roomSummary()?.displayName ?: room.roomId, - room.roomSummary()?.isDirect == true + senderId = session.myUserId, + body = message, + roomId = room.roomId, + roomName = room.roomSummary()?.displayName ?: room.roomId, + roomIsDirect = room.roomSummary()?.isDirect == true, + outGoingMessage = true, + canBeReplaced = false ) - notifiableMessageEvent.outGoingMessage = true notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent) notificationDrawerManager.refreshNotificationDrawer() diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationDisplayer.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationDisplayer.kt new file mode 100644 index 0000000000..680ff32a52 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationDisplayer.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +import android.app.Notification +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import timber.log.Timber +import javax.inject.Inject + +class NotificationDisplayer @Inject constructor(context: Context) { + + private val notificationManager = NotificationManagerCompat.from(context) + + fun showNotificationMessage(tag: String?, id: Int, notification: Notification) { + notificationManager.notify(tag, id, notification) + } + + fun cancelNotificationMessage(tag: String?, id: Int) { + notificationManager.cancel(tag, id) + } + + fun cancelAllNotifications() { + // Keep this try catch (reported by GA) + try { + notificationManager.cancelAll() + } catch (e: Exception) { + Timber.e(e, "## cancelAllNotifications() failed") + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt index e4b2ead93d..c052de650e 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt @@ -16,26 +16,15 @@ package im.vector.app.features.notifications import android.content.Context -import android.graphics.Bitmap -import android.os.Build import android.os.Handler import android.os.HandlerThread import androidx.annotation.WorkerThread -import androidx.core.app.NotificationCompat -import androidx.core.app.Person -import androidx.core.content.pm.ShortcutInfoCompat -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.graphics.drawable.IconCompat import im.vector.app.ActiveSessionDataSource import im.vector.app.BuildConfig import im.vector.app.R -import im.vector.app.core.resources.StringProvider import im.vector.app.core.utils.FirstThrottler import im.vector.app.features.displayname.getBestName -import im.vector.app.features.home.room.detail.RoomDetailActivity -import im.vector.app.features.invite.AutoAcceptInvites import im.vector.app.features.settings.VectorPreferences -import me.gujun.android.span.span import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.content.ContentUrlResolver import org.matrix.android.sdk.api.util.toMatrixItem @@ -52,14 +41,11 @@ import javax.inject.Singleton */ @Singleton class NotificationDrawerManager @Inject constructor(private val context: Context, - private val notificationUtils: NotificationUtils, + private val notificationDisplayer: NotificationDisplayer, private val vectorPreferences: VectorPreferences, - private val stringProvider: StringProvider, private val activeSessionDataSource: ActiveSessionDataSource, - private val iconLoader: IconLoader, - private val bitmapLoader: BitmapLoader, - private val outdatedDetector: OutdatedEventDetector?, - private val autoAcceptInvites: AutoAcceptInvites) { + private val notifiableEventProcessor: NotifiableEventProcessor, + private val notificationRenderer: NotificationRenderer) { private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY) private var backgroundHandler: Handler @@ -69,13 +55,23 @@ class NotificationDrawerManager @Inject constructor(private val context: Context backgroundHandler = Handler(handlerThread.looper) } - // The first time the notification drawer is refreshed, we force re-render of all notifications - private var firstTime = true - - private val eventList = loadEventInfo() + /** + * The notifiable events to render + * this is our source of truth for notifications, any changes to this list will be rendered as notifications + * when events are removed the previously rendered notifications will be cancelled + * when adding or updating, the notifications will be notified + * + * Events are unique by their properties, we should be careful not to insert multiple events with the same event-id + */ + private val queuedEvents = loadEventInfo() + /** + * The last known rendered notifiable events + * we keep track of them in order to know which events have been removed from the eventList + * allowing us to cancel any notifications previous displayed by now removed events + */ + private var renderedEvents = emptyList>() private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size) - private var currentRoomId: String? = null // TODO Multi-session: this will have to be improved @@ -107,12 +103,12 @@ class NotificationDrawerManager @Inject constructor(private val context: Context if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { Timber.d("onNotifiableEventReceived(): $notifiableEvent") } else { - Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.isPushGatewayEvent}") + Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}") } - synchronized(eventList) { - val existing = eventList.firstOrNull { it.eventId == notifiableEvent.eventId } + synchronized(queuedEvents) { + val existing = queuedEvents.firstOrNull { it.eventId == notifiableEvent.eventId } if (existing != null) { - if (existing.isPushGatewayEvent) { + if (existing.canBeReplaced) { // Use the event coming from the event stream as it may contains more info than // the fcm one (like type/content/clear text) (e.g when an encrypted message from // FCM should be update with clear text after a sync) @@ -121,9 +117,8 @@ class NotificationDrawerManager @Inject constructor(private val context: Context // Use setOnlyAlertOnce to ensure update notification does not interfere with sound // from first notify invocation as outlined in: // https://developer.android.com/training/notify-user/build-notification#Updating - notifiableEvent.hasBeenDisplayed = false - eventList.remove(existing) - eventList.add(notifiableEvent) + queuedEvents.remove(existing) + queuedEvents.add(notifiableEvent) } else { // keep the existing one, do not replace } @@ -131,7 +126,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context // Check if this is an edit if (notifiableEvent.editedEventId != null) { // This is an edition - val eventBeforeEdition = eventList.firstOrNull { + val eventBeforeEdition = queuedEvents.firstOrNull { // Edition of an event it.eventId == notifiableEvent.editedEventId || // or edition of an edition @@ -140,9 +135,9 @@ class NotificationDrawerManager @Inject constructor(private val context: Context if (eventBeforeEdition != null) { // Replace the existing notification with the new content - eventList.remove(eventBeforeEdition) + queuedEvents.remove(eventBeforeEdition) - eventList.add(notifiableEvent) + queuedEvents.add(notifiableEvent) } else { // Ignore an edit of a not displayed event in the notification drawer } @@ -153,7 +148,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context Timber.d("onNotifiableEventReceived(): skipping event, already seen") } else { seenEventIds.put(notifiableEvent.eventId) - eventList.add(notifiableEvent) + queuedEvents.add(notifiableEvent) } } } @@ -161,10 +156,13 @@ class NotificationDrawerManager @Inject constructor(private val context: Context } fun onEventRedacted(eventId: String) { - synchronized(eventList) { - eventList.find { it.eventId == eventId }?.apply { - isRedacted = true - hasBeenDisplayed = false + synchronized(queuedEvents) { + queuedEvents.replace(eventId) { + when (it) { + is InviteNotifiableEvent -> it.copy(isRedacted = true) + is NotifiableMessageEvent -> it.copy(isRedacted = true) + is SimpleNotifiableEvent -> it.copy(isRedacted = true) + } } } } @@ -173,8 +171,8 @@ class NotificationDrawerManager @Inject constructor(private val context: Context * Clear all known events and refresh the notification drawer */ fun clearAllEvents() { - synchronized(eventList) { - eventList.clear() + synchronized(queuedEvents) { + queuedEvents.clear() } refreshNotificationDrawer() } @@ -183,14 +181,8 @@ class NotificationDrawerManager @Inject constructor(private val context: Context fun clearMessageEventOfRoom(roomId: String?) { Timber.v("clearMessageEventOfRoom $roomId") if (roomId != null) { - var shouldUpdate = false - synchronized(eventList) { - shouldUpdate = eventList.removeAll { e -> - e is NotifiableMessageEvent && e.roomId == roomId - } - } + val shouldUpdate = removeAll { it is NotifiableMessageEvent && it.roomId == roomId } if (shouldUpdate) { - notificationUtils.cancelNotificationMessage(roomId, ROOM_MESSAGES_NOTIFICATION_ID) refreshNotificationDrawer() } } @@ -202,7 +194,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context */ fun setCurrentRoom(roomId: String?) { var hasChanged: Boolean - synchronized(eventList) { + synchronized(queuedEvents) { hasChanged = roomId != currentRoomId currentRoomId = roomId } @@ -212,12 +204,16 @@ class NotificationDrawerManager @Inject constructor(private val context: Context } fun clearMemberShipNotificationForRoom(roomId: String) { - synchronized(eventList) { - eventList.removeAll { e -> - e is InviteNotifiableEvent && e.roomId == roomId - } + val shouldUpdate = removeAll { it is InviteNotifiableEvent && it.roomId == roomId } + if (shouldUpdate) { + refreshNotificationDrawerBg() + } + } + + private fun removeAll(predicate: (NotifiableEvent) -> Boolean): Boolean { + return synchronized(queuedEvents) { + queuedEvents.removeAll(predicate) } - notificationUtils.cancelNotificationMessage(roomId, ROOM_INVITATION_NOTIFICATION_ID) } private var firstThrottler = FirstThrottler(200) @@ -244,359 +240,36 @@ class NotificationDrawerManager @Inject constructor(private val context: Context private fun refreshNotificationDrawerBg() { Timber.v("refreshNotificationDrawerBg()") - val session = currentSession ?: return - - val user = session.getUser(session.myUserId) - // myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash - val myUserDisplayName = user?.toMatrixItem()?.getBestName() ?: session.myUserId - val myUserAvatarUrl = session.contentUrlResolver().resolveThumbnail(user?.avatarUrl, avatarSize, avatarSize, ContentUrlResolver.ThumbnailMethod.SCALE) - synchronized(eventList) { - Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER ") - // TMP code - var hasNewEvent = false - var summaryIsNoisy = false - val summaryInboxStyle = NotificationCompat.InboxStyle() - - // group events by room to create a single MessagingStyle notif - val roomIdToEventMap: MutableMap> = LinkedHashMap() - val simpleEvents: MutableList = ArrayList() - val invitationEvents: MutableList = ArrayList() - - val eventIterator = eventList.listIterator() - while (eventIterator.hasNext()) { - when (val event = eventIterator.next()) { - is NotifiableMessageEvent -> { - val roomId = event.roomId - val roomEvents = roomIdToEventMap.getOrPut(roomId) { ArrayList() } - - if (shouldIgnoreMessageEventInRoom(roomId) || outdatedDetector?.isMessageOutdated(event) == true) { - // forget this event - eventIterator.remove() - } else { - roomEvents.add(event) - } - } - is InviteNotifiableEvent -> { - if (autoAcceptInvites.hideInvites) { - // Forget this event - eventIterator.remove() - } else { - invitationEvents.add(event) - } - } - is SimpleNotifiableEvent -> simpleEvents.add(event) - else -> Timber.w("Type not handled") - } - } - - Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER ${roomIdToEventMap.size} room groups") - - var globalLastMessageTimestamp = 0L - - val newSettings = vectorPreferences.useCompleteNotificationFormat() - if (newSettings != useCompleteNotificationFormat) { - // Settings has changed, remove all current notifications - notificationUtils.cancelAllNotifications() - useCompleteNotificationFormat = newSettings - } - - var simpleNotificationRoomCounter = 0 - var simpleNotificationMessageCounter = 0 - - // events have been grouped by roomId - for ((roomId, events) in roomIdToEventMap) { - // Build the notification for the room - if (events.isEmpty() || events.all { it.isRedacted }) { - // Just clear this notification - Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER $roomId has no more events") - notificationUtils.cancelNotificationMessage(roomId, ROOM_MESSAGES_NOTIFICATION_ID) - continue - } - - simpleNotificationRoomCounter++ - val roomName = events[0].roomName ?: events[0].senderName ?: "" - - val roomEventGroupInfo = RoomEventGroupInfo( - roomId = roomId, - isDirect = events[0].roomIsDirect, - roomDisplayName = roomName) - - val style = NotificationCompat.MessagingStyle(Person.Builder() - .setName(myUserDisplayName) - .setIcon(iconLoader.getUserIcon(myUserAvatarUrl)) - .setKey(events[0].matrixID) - .build()) - - style.isGroupConversation = !roomEventGroupInfo.isDirect - - if (!roomEventGroupInfo.isDirect) { - style.conversationTitle = roomEventGroupInfo.roomDisplayName - } - - val largeBitmap = getRoomBitmap(events) - - for (event in events) { - // if all events in this room have already been displayed there is no need to update it - if (!event.hasBeenDisplayed && !event.isRedacted) { - roomEventGroupInfo.shouldBing = roomEventGroupInfo.shouldBing || event.noisy - roomEventGroupInfo.customSound = event.soundName - } - roomEventGroupInfo.hasNewEvent = roomEventGroupInfo.hasNewEvent || !event.hasBeenDisplayed - - val senderPerson = if (event.outGoingMessage) { - null - } else { - Person.Builder() - .setName(event.senderName) - .setIcon(iconLoader.getUserIcon(event.senderAvatarPath)) - .setKey(event.senderId) - .build() - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val openRoomIntent = RoomDetailActivity.shortcutIntent(context, roomId) - - val shortcut = ShortcutInfoCompat.Builder(context, roomId) - .setLongLived(true) - .setIntent(openRoomIntent) - .setShortLabel(roomName) - .setIcon(largeBitmap?.let { IconCompat.createWithAdaptiveBitmap(it) } ?: iconLoader.getUserIcon(event.senderAvatarPath)) - .build() - - ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) - } - - if (event.outGoingMessage && event.outGoingMessageFailed) { - style.addMessage(stringProvider.getString(R.string.notification_inline_reply_failed), event.timestamp, senderPerson) - roomEventGroupInfo.hasSmartReplyError = true - } else { - if (!event.isRedacted) { - simpleNotificationMessageCounter++ - style.addMessage(event.body, event.timestamp, senderPerson) - } - } - event.hasBeenDisplayed = true // we can consider it as displayed - - // It is possible that this event was previously shown as an 'anonymous' simple notif. - // And now it will be merged in a single MessageStyle notif, so we can clean to be sure - notificationUtils.cancelNotificationMessage(event.eventId, ROOM_EVENT_NOTIFICATION_ID) - } - - try { - if (events.size == 1) { - val event = events[0] - if (roomEventGroupInfo.isDirect) { - val line = span { - span { - textStyle = "bold" - +String.format("%s: ", event.senderName) - } - +(event.description ?: "") - } - summaryInboxStyle.addLine(line) - } else { - val line = span { - span { - textStyle = "bold" - +String.format("%s: %s ", roomName, event.senderName) - } - +(event.description ?: "") - } - summaryInboxStyle.addLine(line) - } - } else { - val summaryLine = stringProvider.getQuantityString( - R.plurals.notification_compat_summary_line_for_room, events.size, roomName, events.size) - summaryInboxStyle.addLine(summaryLine) - } - } catch (e: Throwable) { - // String not found or bad format - Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER failed to resolve string") - summaryInboxStyle.addLine(roomName) - } - - if (firstTime || roomEventGroupInfo.hasNewEvent) { - // Should update displayed notification - Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER $roomId need refresh") - val lastMessageTimestamp = events.last().timestamp - - if (globalLastMessageTimestamp < lastMessageTimestamp) { - globalLastMessageTimestamp = lastMessageTimestamp - } - - val tickerText = if (roomEventGroupInfo.isDirect) { - stringProvider.getString(R.string.notification_ticker_text_dm, events.last().senderName, events.last().description) - } else { - stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderName, events.last().description) - } - - if (useCompleteNotificationFormat) { - val notification = notificationUtils.buildMessagesListNotification( - style, - roomEventGroupInfo, - largeBitmap, - lastMessageTimestamp, - myUserDisplayName, - tickerText) - - // is there an id for this room? - notificationUtils.showNotificationMessage(roomId, ROOM_MESSAGES_NOTIFICATION_ID, notification) - } - - hasNewEvent = true - summaryIsNoisy = summaryIsNoisy || roomEventGroupInfo.shouldBing - } else { - Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER $roomId is up to date") - } - } - - // Handle invitation events - for (event in invitationEvents) { - // We build a invitation notification - if (firstTime || !event.hasBeenDisplayed) { - if (useCompleteNotificationFormat) { - val notification = notificationUtils.buildRoomInvitationNotification(event, session.myUserId) - notificationUtils.showNotificationMessage(event.roomId, ROOM_INVITATION_NOTIFICATION_ID, notification) - } - event.hasBeenDisplayed = true // we can consider it as displayed - hasNewEvent = true - summaryIsNoisy = summaryIsNoisy || event.noisy - summaryInboxStyle.addLine(event.description) - } - } - - // Handle simple events - for (event in simpleEvents) { - // We build a simple notification - if (firstTime || !event.hasBeenDisplayed) { - if (useCompleteNotificationFormat) { - val notification = notificationUtils.buildSimpleEventNotification(event, session.myUserId) - notificationUtils.showNotificationMessage(event.eventId, ROOM_EVENT_NOTIFICATION_ID, notification) - } - event.hasBeenDisplayed = true // we can consider it as displayed - hasNewEvent = true - summaryIsNoisy = summaryIsNoisy || event.noisy - summaryInboxStyle.addLine(event.description) - } - } - - // ======== Build summary notification ========= - // On Android 7.0 (API level 24) and higher, the system automatically builds a summary for - // your group using snippets of text from each notification. The user can expand this - // notification to see each separate notification. - // To support older versions, which cannot show a nested group of notifications, - // you must create an extra notification that acts as the summary. - // This appears as the only notification and the system hides all the others. - // So this summary should include a snippet from all the other notifications, - // which the user can tap to open your app. - // The behavior of the group summary may vary on some device types such as wearables. - // To ensure the best experience on all devices and versions, always include a group summary when you create a group - // https://developer.android.com/training/notify-user/group - - if (eventList.isEmpty() || eventList.all { it.isRedacted }) { - notificationUtils.cancelNotificationMessage(null, SUMMARY_NOTIFICATION_ID) - } else if (hasNewEvent) { - // FIXME roomIdToEventMap.size is not correct, this is the number of rooms - val nbEvents = roomIdToEventMap.size + simpleEvents.size - val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents) - summaryInboxStyle.setBigContentTitle(sumTitle) - // TODO get latest event? - .setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents)) - - if (useCompleteNotificationFormat) { - val notification = notificationUtils.buildSummaryListNotification( - summaryInboxStyle, - sumTitle, - noisy = hasNewEvent && summaryIsNoisy, - lastMessageTimestamp = globalLastMessageTimestamp) - - notificationUtils.showNotificationMessage(null, SUMMARY_NOTIFICATION_ID, notification) - } else { - // Add the simple events as message (?) - simpleNotificationMessageCounter += simpleEvents.size - val numberOfInvitations = invitationEvents.size - - val privacyTitle = if (numberOfInvitations > 0) { - val invitationsStr = stringProvider.getQuantityString(R.plurals.notification_invitations, numberOfInvitations, numberOfInvitations) - if (simpleNotificationMessageCounter > 0) { - // Invitation and message - val messageStr = stringProvider.getQuantityString(R.plurals.room_new_messages_notification, - simpleNotificationMessageCounter, simpleNotificationMessageCounter) - if (simpleNotificationRoomCounter > 1) { - // In several rooms - val roomStr = stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages_in_room_rooms, - simpleNotificationRoomCounter, simpleNotificationRoomCounter) - stringProvider.getString( - R.string.notification_unread_notified_messages_in_room_and_invitation, - messageStr, - roomStr, - invitationsStr - ) - } else { - // In one room - stringProvider.getString( - R.string.notification_unread_notified_messages_and_invitation, - messageStr, - invitationsStr - ) - } - } else { - // Only invitation - invitationsStr - } - } else { - // No invitation, only messages - val messageStr = stringProvider.getQuantityString(R.plurals.room_new_messages_notification, - simpleNotificationMessageCounter, simpleNotificationMessageCounter) - if (simpleNotificationRoomCounter > 1) { - // In several rooms - val roomStr = stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages_in_room_rooms, - simpleNotificationRoomCounter, simpleNotificationRoomCounter) - stringProvider.getString(R.string.notification_unread_notified_messages_in_room, messageStr, roomStr) - } else { - // In one room - messageStr - } - } - val notification = notificationUtils.buildSummaryListNotification( - style = null, - compatSummary = privacyTitle, - noisy = hasNewEvent && summaryIsNoisy, - lastMessageTimestamp = globalLastMessageTimestamp) - - notificationUtils.showNotificationMessage(null, SUMMARY_NOTIFICATION_ID, notification) - } - - if (hasNewEvent && summaryIsNoisy) { - try { - // turn the screen on for 3 seconds - /* - TODO - if (Matrix.getInstance(VectorApp.getInstance())!!.pushManager.isScreenTurnedOn) { - val pm = VectorApp.getInstance().getSystemService()!! - val wl = pm.newWakeLock(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or PowerManager.ACQUIRE_CAUSES_WAKEUP, - NotificationDrawerManager::class.java.name) - wl.acquire(3000) - wl.release() - } - */ - } catch (e: Throwable) { - Timber.e(e, "## Failed to turn screen on") - } - } - } - // notice that we can get bit out of sync with actual display but not a big issue - firstTime = false + val newSettings = vectorPreferences.useCompleteNotificationFormat() + if (newSettings != useCompleteNotificationFormat) { + // Settings has changed, remove all current notifications + notificationDisplayer.cancelAllNotifications() + useCompleteNotificationFormat = newSettings } - } - private fun getRoomBitmap(events: List): Bitmap? { - if (events.isEmpty()) return null + val eventsToRender = synchronized(queuedEvents) { + notifiableEventProcessor.process(queuedEvents, currentRoomId, renderedEvents).also { + queuedEvents.clear() + queuedEvents.addAll(it.onlyKeptEvents()) + } + } - // Use the last event (most recent?) - val roomAvatarPath = events.last().roomAvatarPath ?: events.last().senderAvatarPath - - return bitmapLoader.getRoomBitmap(roomAvatarPath) + if (renderedEvents == eventsToRender) { + Timber.d("Skipping notification update due to event list not changing") + } else { + renderedEvents = eventsToRender + val session = currentSession ?: return + val user = session.getUser(session.myUserId) + // myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash + val myUserDisplayName = user?.toMatrixItem()?.getBestName() ?: session.myUserId + val myUserAvatarUrl = session.contentUrlResolver().resolveThumbnail( + contentUrl = user?.avatarUrl, + width = avatarSize, + height = avatarSize, + method = ContentUrlResolver.ThumbnailMethod.SCALE + ) + notificationRenderer.render(session.myUserId, myUserDisplayName, myUserAvatarUrl, useCompleteNotificationFormat, eventsToRender) + } } fun shouldIgnoreMessageEventInRoom(roomId: String?): Boolean { @@ -604,8 +277,8 @@ class NotificationDrawerManager @Inject constructor(private val context: Context } fun persistInfo() { - synchronized(eventList) { - if (eventList.isEmpty()) { + synchronized(queuedEvents) { + if (queuedEvents.isEmpty()) { deleteCachedRoomNotifications() return } @@ -613,7 +286,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME) if (!file.exists()) file.createNewFile() FileOutputStream(file).use { - currentSession?.securelyStoreObject(eventList, KEY_ALIAS_SECRET_STORAGE, it) + currentSession?.securelyStoreObject(queuedEvents, KEY_ALIAS_SECRET_STORAGE, it) } } catch (e: Throwable) { Timber.e(e, "## Failed to save cached notification info") @@ -645,15 +318,11 @@ class NotificationDrawerManager @Inject constructor(private val context: Context } } - fun displayDiagnosticNotification() { - notificationUtils.displayDiagnosticNotification() - } - companion object { - private const val SUMMARY_NOTIFICATION_ID = 0 - private const val ROOM_MESSAGES_NOTIFICATION_ID = 1 - private const val ROOM_EVENT_NOTIFICATION_ID = 2 - private const val ROOM_INVITATION_NOTIFICATION_ID = 3 + const val SUMMARY_NOTIFICATION_ID = 0 + const val ROOM_MESSAGES_NOTIFICATION_ID = 1 + const val ROOM_EVENT_NOTIFICATION_ID = 2 + const val ROOM_INVITATION_NOTIFICATION_ID = 3 // TODO Mutliaccount private const val ROOMS_NOTIFICATIONS_FILE_NAME = "im.vector.notifications.cache" @@ -661,3 +330,11 @@ class NotificationDrawerManager @Inject constructor(private val context: Context private const val KEY_ALIAS_SECRET_STORAGE = "notificationMgr" } } + +private fun MutableList.replace(eventId: String, block: (NotifiableEvent) -> NotifiableEvent) { + val indexToReplace = indexOfFirst { it.eventId == eventId } + if (indexToReplace == -1) { + return + } + set(indexToReplace, block(get(indexToReplace))) +} diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationFactory.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationFactory.kt new file mode 100644 index 0000000000..adc4e44bcc --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationFactory.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +import android.app.Notification +import androidx.core.content.pm.ShortcutInfoCompat +import javax.inject.Inject + +private typealias ProcessedMessageEvents = List> + +class NotificationFactory @Inject constructor( + private val notificationUtils: NotificationUtils, + private val roomGroupMessageCreator: RoomGroupMessageCreator, + private val summaryGroupMessageCreator: SummaryGroupMessageCreator +) { + + fun Map.toNotifications(myUserDisplayName: String, myUserAvatarUrl: String?): List { + return map { (roomId, events) -> + when { + events.hasNoEventsToDisplay() -> RoomNotification.Removed(roomId) + else -> { + val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted } + roomGroupMessageCreator.createRoomMessage(messageEvents, roomId, myUserDisplayName, myUserAvatarUrl) + } + } + } + } + + private fun ProcessedMessageEvents.hasNoEventsToDisplay() = isEmpty() || all { + it.type == ProcessedEvent.Type.REMOVE || it.event.canNotBeDisplayed() + } + + private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted + + @JvmName("toNotificationsInviteNotifiableEvent") + fun List>.toNotifications(myUserId: String): List { + return map { (processed, event) -> + when (processed) { + ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.roomId) + ProcessedEvent.Type.KEEP -> OneShotNotification.Append( + notificationUtils.buildRoomInvitationNotification(event, myUserId), + OneShotNotification.Append.Meta( + key = event.roomId, + summaryLine = event.description, + isNoisy = event.noisy, + timestamp = event.timestamp + ) + ) + } + } + } + + @JvmName("toNotificationsSimpleNotifiableEvent") + fun List>.toNotifications(myUserId: String): List { + return map { (processed, event) -> + when (processed) { + ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId) + ProcessedEvent.Type.KEEP -> OneShotNotification.Append( + notificationUtils.buildSimpleEventNotification(event, myUserId), + OneShotNotification.Append.Meta( + key = event.eventId, + summaryLine = event.description, + isNoisy = event.noisy, + timestamp = event.timestamp + ) + ) + } + } + } + + fun createSummaryNotification(roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + useCompleteNotificationFormat: Boolean): SummaryNotification { + val roomMeta = roomNotifications.filterIsInstance().map { it.meta } + val invitationMeta = invitationNotifications.filterIsInstance().map { it.meta } + val simpleMeta = simpleNotifications.filterIsInstance().map { it.meta } + return when { + roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed + else -> SummaryNotification.Update( + summaryGroupMessageCreator.createSummaryNotification( + roomNotifications = roomMeta, + invitationNotifications = invitationMeta, + simpleNotifications = simpleMeta, + useCompleteNotificationFormat = useCompleteNotificationFormat + )) + } + } +} + +sealed interface RoomNotification { + data class Removed(val roomId: String) : RoomNotification + data class Message(val notification: Notification, val shortcutInfo: ShortcutInfoCompat?, val meta: Meta) : RoomNotification { + data class Meta( + val summaryLine: CharSequence, + val messageCount: Int, + val latestTimestamp: Long, + val roomId: String, + val shouldBing: Boolean + ) + } +} + +sealed interface OneShotNotification { + data class Removed(val key: String) : OneShotNotification + data class Append(val notification: Notification, val meta: Meta) : OneShotNotification { + data class Meta( + val key: String, + val summaryLine: CharSequence, + val isNoisy: Boolean, + val timestamp: Long, + ) + } +} + +sealed interface SummaryNotification { + object Removed : SummaryNotification + data class Update(val notification: Notification) : SummaryNotification +} diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationRenderer.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationRenderer.kt new file mode 100644 index 0000000000..5afff89402 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationRenderer.kt @@ -0,0 +1,135 @@ +/* + * 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.notifications + +import android.content.Context +import androidx.annotation.WorkerThread +import androidx.core.content.pm.ShortcutManagerCompat +import im.vector.app.features.notifications.NotificationDrawerManager.Companion.ROOM_EVENT_NOTIFICATION_ID +import im.vector.app.features.notifications.NotificationDrawerManager.Companion.ROOM_INVITATION_NOTIFICATION_ID +import im.vector.app.features.notifications.NotificationDrawerManager.Companion.ROOM_MESSAGES_NOTIFICATION_ID +import im.vector.app.features.notifications.NotificationDrawerManager.Companion.SUMMARY_NOTIFICATION_ID +import timber.log.Timber +import javax.inject.Inject + +class NotificationRenderer @Inject constructor(private val notificationDisplayer: NotificationDisplayer, + private val notificationFactory: NotificationFactory, + private val appContext: Context) { + + @WorkerThread + fun render(myUserId: String, + myUserDisplayName: String, + myUserAvatarUrl: String?, + useCompleteNotificationFormat: Boolean, + eventsToProcess: List>) { + val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType() + with(notificationFactory) { + val roomNotifications = roomEvents.toNotifications(myUserDisplayName, myUserAvatarUrl) + val invitationNotifications = invitationEvents.toNotifications(myUserId) + val simpleNotifications = simpleEvents.toNotifications(myUserId) + val summaryNotification = createSummaryNotification( + roomNotifications = roomNotifications, + invitationNotifications = invitationNotifications, + simpleNotifications = simpleNotifications, + useCompleteNotificationFormat = useCompleteNotificationFormat + ) + + // Remove summary first to avoid briefly displaying it after dismissing the last notification + when (summaryNotification) { + SummaryNotification.Removed -> { + Timber.d("Removing summary notification") + notificationDisplayer.cancelNotificationMessage(null, SUMMARY_NOTIFICATION_ID) + } + } + + roomNotifications.forEach { wrapper -> + when (wrapper) { + is RoomNotification.Removed -> { + Timber.d("Removing room messages notification ${wrapper.roomId}") + notificationDisplayer.cancelNotificationMessage(wrapper.roomId, ROOM_MESSAGES_NOTIFICATION_ID) + } + is RoomNotification.Message -> if (useCompleteNotificationFormat) { + Timber.d("Updating room messages notification ${wrapper.meta.roomId}") + wrapper.shortcutInfo?.let { + ShortcutManagerCompat.pushDynamicShortcut(appContext, it) + } + notificationDisplayer.showNotificationMessage(wrapper.meta.roomId, ROOM_MESSAGES_NOTIFICATION_ID, wrapper.notification) + } + } + } + + invitationNotifications.forEach { wrapper -> + when (wrapper) { + is OneShotNotification.Removed -> { + Timber.d("Removing invitation notification ${wrapper.key}") + notificationDisplayer.cancelNotificationMessage(wrapper.key, ROOM_INVITATION_NOTIFICATION_ID) + } + is OneShotNotification.Append -> if (useCompleteNotificationFormat) { + Timber.d("Updating invitation notification ${wrapper.meta.key}") + notificationDisplayer.showNotificationMessage(wrapper.meta.key, ROOM_INVITATION_NOTIFICATION_ID, wrapper.notification) + } + } + } + + simpleNotifications.forEach { wrapper -> + when (wrapper) { + is OneShotNotification.Removed -> { + Timber.d("Removing simple notification ${wrapper.key}") + notificationDisplayer.cancelNotificationMessage(wrapper.key, ROOM_EVENT_NOTIFICATION_ID) + } + is OneShotNotification.Append -> if (useCompleteNotificationFormat) { + Timber.d("Updating simple notification ${wrapper.meta.key}") + notificationDisplayer.showNotificationMessage(wrapper.meta.key, ROOM_EVENT_NOTIFICATION_ID, wrapper.notification) + } + } + } + + // Update summary last to avoid briefly displaying it before other notifications + when (summaryNotification) { + is SummaryNotification.Update -> { + Timber.d("Updating summary notification") + notificationDisplayer.showNotificationMessage(null, SUMMARY_NOTIFICATION_ID, summaryNotification.notification) + } + } + } + } +} + +private fun List>.groupByType(): GroupedNotificationEvents { + val roomIdToEventMap: MutableMap>> = LinkedHashMap() + val simpleEvents: MutableList> = ArrayList() + val invitationEvents: MutableList> = ArrayList() + forEach { + when (val event = it.event) { + is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType()) + is NotifiableMessageEvent -> { + val roomEvents = roomIdToEventMap.getOrPut(event.roomId) { ArrayList() } + roomEvents.add(it.castedToEventType()) + } + is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType()) + } + } + return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents) +} + +@Suppress("UNCHECKED_CAST") +private fun ProcessedEvent.castedToEventType(): ProcessedEvent = this as ProcessedEvent + +data class GroupedNotificationEvents( + val roomEvents: Map>>, + val simpleEvents: List>, + val invitationEvents: List> +) diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt index f3b34e1269..491302a225 100755 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt @@ -642,7 +642,7 @@ class NotificationUtils @Inject constructor(private val context: Context, return NotificationCompat.Builder(context, channelID) .setOnlyAlertOnce(true) - .setContentTitle(stringProvider.getString(R.string.app_name)) + .setContentTitle(inviteNotifiableEvent.roomName ?: stringProvider.getString(R.string.app_name)) .setContentText(inviteNotifiableEvent.description) .setGroup(stringProvider.getString(R.string.app_name)) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) diff --git a/vector/src/main/java/im/vector/app/features/notifications/ProcessedEvent.kt b/vector/src/main/java/im/vector/app/features/notifications/ProcessedEvent.kt new file mode 100644 index 0000000000..8bd9819ca9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/ProcessedEvent.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +data class ProcessedEvent( + val type: Type, + val event: T +) { + + enum class Type { + KEEP, + REMOVE + } +} + +fun List>.onlyKeptEvents() = mapNotNull { processedEvent -> + processedEvent.event.takeIf { processedEvent.type == ProcessedEvent.Type.KEEP } +} diff --git a/vector/src/main/java/im/vector/app/features/notifications/PushRuleTriggerListener.kt b/vector/src/main/java/im/vector/app/features/notifications/PushRuleTriggerListener.kt index 791803fa49..abbbd47f95 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/PushRuleTriggerListener.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/PushRuleTriggerListener.kt @@ -40,12 +40,11 @@ class PushRuleTriggerListener @Inject constructor( val notificationAction = actions.toNotificationAction() if (notificationAction.shouldNotify) { - val notifiableEvent = resolver.resolveEvent(event, safeSession) + val notifiableEvent = resolver.resolveEvent(event, safeSession, isNoisy = !notificationAction.soundName.isNullOrBlank()) if (notifiableEvent == null) { Timber.v("## Failed to resolve event") // TODO } else { - notifiableEvent.noisy = !notificationAction.soundName.isNullOrBlank() Timber.v("New event to notify") notificationDrawerManager.onNotifiableEventReceived(notifiableEvent) } diff --git a/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt b/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt new file mode 100644 index 0000000000..bdd7d026f9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.Person +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.graphics.drawable.IconCompat +import im.vector.app.R +import im.vector.app.core.resources.StringProvider +import im.vector.app.features.home.room.detail.RoomDetailActivity +import me.gujun.android.span.Span +import me.gujun.android.span.span +import timber.log.Timber +import javax.inject.Inject + +class RoomGroupMessageCreator @Inject constructor( + private val iconLoader: IconLoader, + private val bitmapLoader: BitmapLoader, + private val stringProvider: StringProvider, + private val notificationUtils: NotificationUtils, + private val appContext: Context +) { + + fun createRoomMessage(events: List, roomId: String, userDisplayName: String, userAvatarUrl: String?): RoomNotification.Message { + val firstKnownRoomEvent = events[0] + val roomName = firstKnownRoomEvent.roomName ?: firstKnownRoomEvent.senderName ?: "" + val roomIsGroup = !firstKnownRoomEvent.roomIsDirect + val style = NotificationCompat.MessagingStyle(Person.Builder() + .setName(userDisplayName) + .setIcon(iconLoader.getUserIcon(userAvatarUrl)) + .setKey(firstKnownRoomEvent.matrixID) + .build() + ).also { + it.conversationTitle = roomName.takeIf { roomIsGroup } + it.isGroupConversation = roomIsGroup + it.addMessagesFromEvents(events) + } + + val tickerText = if (roomIsGroup) { + stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderName, events.last().description) + } else { + stringProvider.getString(R.string.notification_ticker_text_dm, events.last().senderName, events.last().description) + } + + val largeBitmap = getRoomBitmap(events) + val shortcutInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val openRoomIntent = RoomDetailActivity.shortcutIntent(appContext, roomId) + ShortcutInfoCompat.Builder(appContext, roomId) + .setLongLived(true) + .setIntent(openRoomIntent) + .setShortLabel(roomName) + .setIcon(largeBitmap?.let { IconCompat.createWithAdaptiveBitmap(it) } ?: iconLoader.getUserIcon(events.last().senderAvatarPath)) + .build() + } else { + null + } + + val lastMessageTimestamp = events.last().timestamp + val smartReplyErrors = events.filter { it.isSmartReplyError() } + val messageCount = (events.size - smartReplyErrors.size) + val meta = RoomNotification.Message.Meta( + summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, roomIsDirect = !roomIsGroup), + messageCount = messageCount, + latestTimestamp = lastMessageTimestamp, + roomId = roomId, + shouldBing = events.any { it.noisy } + ) + return RoomNotification.Message( + notificationUtils.buildMessagesListNotification( + style, + RoomEventGroupInfo(roomId, roomName, isDirect = !roomIsGroup).also { + it.hasSmartReplyError = smartReplyErrors.isNotEmpty() + it.shouldBing = meta.shouldBing + it.customSound = events.last().soundName + }, + largeIcon = largeBitmap, + lastMessageTimestamp, + userDisplayName, + tickerText + ), + shortcutInfo, + meta + ) + } + + private fun NotificationCompat.MessagingStyle.addMessagesFromEvents(events: List) { + events.forEach { event -> + val senderPerson = if (event.outGoingMessage) { + null + } else { + Person.Builder() + .setName(event.senderName) + .setIcon(iconLoader.getUserIcon(event.senderAvatarPath)) + .setKey(event.senderId) + .build() + } + when { + event.isSmartReplyError() -> addMessage(stringProvider.getString(R.string.notification_inline_reply_failed), event.timestamp, senderPerson) + else -> addMessage(event.body, event.timestamp, senderPerson) + } + } + } + + private fun createRoomMessagesGroupSummaryLine(events: List, roomName: String, roomIsDirect: Boolean): CharSequence { + return try { + when (events.size) { + 1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect) + else -> { + stringProvider.getQuantityString( + R.plurals.notification_compat_summary_line_for_room, + events.size, + roomName, + events.size + ) + } + } + } catch (e: Throwable) { + // String not found or bad format + Timber.v("%%%%%%%% REFRESH NOTIFICATION DRAWER failed to resolve string") + roomName + } + } + + private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): Span { + return if (roomIsDirect) { + span { + span { + textStyle = "bold" + +String.format("%s: ", event.senderName) + } + +(event.description) + } + } else { + span { + span { + textStyle = "bold" + +String.format("%s: %s ", roomName, event.senderName) + } + +(event.description) + } + } + } + + private fun getRoomBitmap(events: List): Bitmap? { + // Use the last event (most recent?) + return events.lastOrNull() + ?.roomAvatarPath + ?.let { bitmapLoader.getRoomBitmap(it) } + } +} + +private fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed diff --git a/vector/src/main/java/im/vector/app/features/notifications/SimpleNotifiableEvent.kt b/vector/src/main/java/im/vector/app/features/notifications/SimpleNotifiableEvent.kt index 2f74737ba2..8c72372204 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/SimpleNotifiableEvent.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/SimpleNotifiableEvent.kt @@ -15,21 +15,16 @@ */ package im.vector.app.features.notifications -import androidx.core.app.NotificationCompat - data class SimpleNotifiableEvent( - override var matrixID: String?, + val matrixID: String?, override val eventId: String, override val editedEventId: String?, - override var noisy: Boolean, - override val title: String, - override val description: String, - override val type: String?, - override val timestamp: Long, - override var soundName: String?, - override var isPushGatewayEvent: Boolean = false) : NotifiableEvent { - - override var hasBeenDisplayed: Boolean = false - override var isRedacted: Boolean = false - override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC -} + val noisy: Boolean, + val title: String, + val description: String, + val type: String?, + val timestamp: Long, + val soundName: String?, + override var canBeReplaced: Boolean, + override val isRedacted: Boolean = false +) : NotifiableEvent diff --git a/vector/src/main/java/im/vector/app/features/notifications/SummaryGroupMessageCreator.kt b/vector/src/main/java/im/vector/app/features/notifications/SummaryGroupMessageCreator.kt new file mode 100644 index 0000000000..91163434c2 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/notifications/SummaryGroupMessageCreator.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +import android.app.Notification +import androidx.core.app.NotificationCompat +import im.vector.app.R +import im.vector.app.core.resources.StringProvider +import javax.inject.Inject + +/** + * ======== Build summary notification ========= + * On Android 7.0 (API level 24) and higher, the system automatically builds a summary for + * your group using snippets of text from each notification. The user can expand this + * notification to see each separate notification. + * To support older versions, which cannot show a nested group of notifications, + * you must create an extra notification that acts as the summary. + * This appears as the only notification and the system hides all the others. + * So this summary should include a snippet from all the other notifications, + * which the user can tap to open your app. + * The behavior of the group summary may vary on some device types such as wearables. + * To ensure the best experience on all devices and versions, always include a group summary when you create a group + * https://developer.android.com/training/notify-user/group + */ +class SummaryGroupMessageCreator @Inject constructor( + private val stringProvider: StringProvider, + private val notificationUtils: NotificationUtils +) { + + fun createSummaryNotification(roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + useCompleteNotificationFormat: Boolean): Notification { + val summaryInboxStyle = NotificationCompat.InboxStyle().also { style -> + roomNotifications.forEach { style.addLine(it.summaryLine) } + invitationNotifications.forEach { style.addLine(it.summaryLine) } + simpleNotifications.forEach { style.addLine(it.summaryLine) } + } + + val summaryIsNoisy = roomNotifications.any { it.shouldBing } || + invitationNotifications.any { it.isNoisy } || + simpleNotifications.any { it.isNoisy } + + val messageCount = roomNotifications.fold(initial = 0) { acc, current -> acc + current.messageCount } + + val lastMessageTimestamp = roomNotifications.lastOrNull()?.latestTimestamp + ?: invitationNotifications.lastOrNull()?.timestamp + ?: simpleNotifications.last().timestamp + + // FIXME roomIdToEventMap.size is not correct, this is the number of rooms + val nbEvents = roomNotifications.size + simpleNotifications.size + val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents) + summaryInboxStyle.setBigContentTitle(sumTitle) + // TODO get latest event? + .setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents)) + return if (useCompleteNotificationFormat) { + notificationUtils.buildSummaryListNotification( + summaryInboxStyle, + sumTitle, + noisy = summaryIsNoisy, + lastMessageTimestamp = lastMessageTimestamp + ) + } else { + processSimpleGroupSummary( + summaryIsNoisy, + messageCount, + simpleNotifications.size, + invitationNotifications.size, + roomNotifications.size, + lastMessageTimestamp + ) + } + } + + private fun processSimpleGroupSummary(summaryIsNoisy: Boolean, + messageEventsCount: Int, + simpleEventsCount: Int, + invitationEventsCount: Int, + roomCount: Int, + lastMessageTimestamp: Long): Notification { + // Add the simple events as message (?) + val messageNotificationCount = messageEventsCount + simpleEventsCount + + val privacyTitle = if (invitationEventsCount > 0) { + val invitationsStr = stringProvider.getQuantityString(R.plurals.notification_invitations, invitationEventsCount, invitationEventsCount) + if (messageNotificationCount > 0) { + // Invitation and message + val messageStr = stringProvider.getQuantityString(R.plurals.room_new_messages_notification, + messageNotificationCount, messageNotificationCount) + if (roomCount > 1) { + // In several rooms + val roomStr = stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages_in_room_rooms, + roomCount, roomCount) + stringProvider.getString( + R.string.notification_unread_notified_messages_in_room_and_invitation, + messageStr, + roomStr, + invitationsStr + ) + } else { + // In one room + stringProvider.getString( + R.string.notification_unread_notified_messages_and_invitation, + messageStr, + invitationsStr + ) + } + } else { + // Only invitation + invitationsStr + } + } else { + // No invitation, only messages + val messageStr = stringProvider.getQuantityString(R.plurals.room_new_messages_notification, + messageNotificationCount, messageNotificationCount) + if (roomCount > 1) { + // In several rooms + val roomStr = stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages_in_room_rooms, roomCount, roomCount) + stringProvider.getString(R.string.notification_unread_notified_messages_in_room, messageStr, roomStr) + } else { + // In one room + messageStr + } + } + return notificationUtils.buildSummaryListNotification( + style = null, + compatSummary = privacyTitle, + noisy = summaryIsNoisy, + lastMessageTimestamp = lastMessageTimestamp + ) + } +} diff --git a/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandler.kt b/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandler.kt index 90bcee3d04..d4eed6a41e 100644 --- a/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandler.kt +++ b/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandler.kt @@ -79,15 +79,14 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti return when (permalinkData) { is PermalinkData.RoomLink -> { val roomId = permalinkData.getRoomId() - if (navigationInterceptor?.navToRoom(roomId, permalinkData.eventId, rawLink) != true) { - openRoom( - context = context, - roomId = roomId, - permalinkData = permalinkData, - rawLink = rawLink, - buildTask = buildTask - ) - } + openRoom( + navigationInterceptor, + context = context, + roomId = roomId, + permalinkData = permalinkData, + rawLink = rawLink, + buildTask = buildTask + ) true } is PermalinkData.GroupLink -> { @@ -146,6 +145,7 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti * Open room either joined, or not */ private fun openRoom( + navigationInterceptor: NavigationInterceptor?, context: Context, roomId: String?, permalinkData: PermalinkData.RoomLink, @@ -167,7 +167,7 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti membership?.isActive().orFalse() -> { if (!isSpace && membership == Membership.JOIN) { // If it's a room you're in, let's just open it, you can tap back if needed - navigator.openRoom(context, roomId, eventId, buildTask) + navigationInterceptor.openJoinedRoomScreen(buildTask, roomId, eventId, rawLink, context) } else { // maybe open space preview navigator.openSpacePreview(context, roomId)? if already joined? navigator.openMatrixToBottomSheet(context, rawLink.toString()) @@ -180,6 +180,12 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti } } + private fun NavigationInterceptor?.openJoinedRoomScreen(buildTask: Boolean, roomId: String, eventId: String?, rawLink: Uri, context: Context) { + if (this?.navToRoom(roomId, eventId, rawLink) != true) { + navigator.openRoom(context, roomId, eventId, buildTask) + } + } + companion object { const val MATRIX_TO_CUSTOM_SCHEME_URL_BASE = "element://" const val ROOM_LINK_PREFIX = "${MATRIX_TO_CUSTOM_SCHEME_URL_BASE}room/" diff --git a/vector/src/main/java/im/vector/app/features/pin/PinActivity.kt b/vector/src/main/java/im/vector/app/features/pin/PinActivity.kt index 430be6bc1f..0978f0d5b5 100644 --- a/vector/src/main/java/im/vector/app/features/pin/PinActivity.kt +++ b/vector/src/main/java/im/vector/app/features/pin/PinActivity.kt @@ -20,12 +20,14 @@ import android.content.Context import android.content.Intent import com.airbnb.mvrx.Mavericks import com.google.android.material.appbar.MaterialToolbar +import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.extensions.addFragment import im.vector.app.core.platform.ToolbarConfigurable import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivitySimpleBinding +@AndroidEntryPoint class PinActivity : VectorBaseActivity(), ToolbarConfigurable, UnlockedActivity { companion object { diff --git a/vector/src/main/java/im/vector/app/features/roomdirectory/RoomDirectoryActivity.kt b/vector/src/main/java/im/vector/app/features/roomdirectory/RoomDirectoryActivity.kt index e59cfafa42..82dc31c4d6 100644 --- a/vector/src/main/java/im/vector/app/features/roomdirectory/RoomDirectoryActivity.kt +++ b/vector/src/main/java/im/vector/app/features/roomdirectory/RoomDirectoryActivity.kt @@ -29,6 +29,8 @@ import im.vector.app.core.extensions.addFragmentToBackstack import im.vector.app.core.extensions.popBackstack import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivitySimpleBinding +import im.vector.app.features.matrixto.MatrixToBottomSheet +import im.vector.app.features.navigation.Navigator import im.vector.app.features.roomdirectory.createroom.CreateRoomArgs import im.vector.app.features.roomdirectory.createroom.CreateRoomFragment import im.vector.app.features.roomdirectory.picker.RoomDirectoryPickerFragment @@ -37,7 +39,7 @@ import kotlinx.coroutines.flow.onEach import javax.inject.Inject @AndroidEntryPoint -class RoomDirectoryActivity : VectorBaseActivity() { +class RoomDirectoryActivity : VectorBaseActivity(), MatrixToBottomSheet.InteractionListener { @Inject lateinit var roomDirectoryViewModelFactory: RoomDirectoryViewModel.Factory private val roomDirectoryViewModel: RoomDirectoryViewModel by viewModel() @@ -84,6 +86,14 @@ class RoomDirectoryActivity : VectorBaseActivity() { } } + override fun mxToBottomSheetNavigateToRoom(roomId: String) { + navigator.openRoom(this, roomId) + } + + override fun mxToBottomSheetSwitchToSpace(spaceId: String) { + navigator.switchToSpace(this, spaceId, Navigator.PostSwitchSpaceAction.None) + } + companion object { private const val INITIAL_FILTER = "INITIAL_FILTER" diff --git a/vector/src/main/java/im/vector/app/features/roomdirectory/roompreview/RoomPreviewActivity.kt b/vector/src/main/java/im/vector/app/features/roomdirectory/roompreview/RoomPreviewActivity.kt index 0121d5d795..86bfdb79cd 100644 --- a/vector/src/main/java/im/vector/app/features/roomdirectory/roompreview/RoomPreviewActivity.kt +++ b/vector/src/main/java/im/vector/app/features/roomdirectory/roompreview/RoomPreviewActivity.kt @@ -20,6 +20,7 @@ import android.content.Context import android.content.Intent import android.os.Parcelable import com.google.android.material.appbar.MaterialToolbar +import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.extensions.addFragment import im.vector.app.core.platform.ToolbarConfigurable @@ -51,6 +52,7 @@ data class RoomPreviewData( get() = MatrixItem.RoomItem(roomId, roomName ?: roomAlias, avatarUrl) } +@AndroidEntryPoint class RoomPreviewActivity : VectorBaseActivity(), ToolbarConfigurable { companion object { diff --git a/vector/src/main/java/im/vector/app/features/signout/hard/SignedOutActivity.kt b/vector/src/main/java/im/vector/app/features/signout/hard/SignedOutActivity.kt index 6f05a73f13..ee7557b402 100644 --- a/vector/src/main/java/im/vector/app/features/signout/hard/SignedOutActivity.kt +++ b/vector/src/main/java/im/vector/app/features/signout/hard/SignedOutActivity.kt @@ -19,6 +19,7 @@ package im.vector.app.features.signout.hard import android.content.Context import android.content.Intent import android.os.Bundle +import dagger.hilt.android.AndroidEntryPoint import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivitySignedOutBinding import im.vector.app.features.MainActivity @@ -29,6 +30,7 @@ import timber.log.Timber /** * In this screen, the user is viewing a message informing that he has been logged out */ +@AndroidEntryPoint class SignedOutActivity : VectorBaseActivity() { override fun getBinding() = ActivitySignedOutBinding.inflate(layoutInflater) diff --git a/vector/src/main/java/im/vector/app/features/spaces/SpacePreviewActivity.kt b/vector/src/main/java/im/vector/app/features/spaces/SpacePreviewActivity.kt index fcbbf6a752..b616cdcce7 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/SpacePreviewActivity.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/SpacePreviewActivity.kt @@ -21,6 +21,7 @@ import android.content.Intent import android.os.Bundle import androidx.lifecycle.lifecycleScope import com.airbnb.mvrx.Mavericks +import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.extensions.commitTransaction import im.vector.app.core.platform.VectorBaseActivity @@ -30,6 +31,7 @@ import im.vector.app.features.spaces.preview.SpacePreviewFragment import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +@AndroidEntryPoint class SpacePreviewActivity : VectorBaseActivity() { lateinit var sharedActionViewModel: SpacePreviewSharedActionViewModel diff --git a/vector/src/main/java/im/vector/app/features/spaces/people/SpacePeopleActivity.kt b/vector/src/main/java/im/vector/app/features/spaces/people/SpacePeopleActivity.kt index bc778a1b34..a269273cd5 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/people/SpacePeopleActivity.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/people/SpacePeopleActivity.kt @@ -23,6 +23,7 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.airbnb.mvrx.Mavericks +import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.extensions.commitTransaction import im.vector.app.core.extensions.hideKeyboard @@ -33,6 +34,7 @@ import im.vector.app.features.spaces.share.ShareSpaceBottomSheet import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +@AndroidEntryPoint class SpacePeopleActivity : VectorBaseActivity() { override fun getBinding() = ActivitySimpleLoadingBinding.inflate(layoutInflater) diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 274753ee3f..0e8197dbae 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -657,6 +657,8 @@ This URL is not reachable, please check it This is not a valid Matrix server address Cannot reach a homeserver at this URL, please check it + Cannot reach a homeserver at the URL %s. Please check your link or choose a homeserver manually. + Choose homeserver "SSL Error: the peer's identity has not been verified." "SSL Error." Your device is using an outdated TLS security protocol, vulnerable to attack, for your security you will not be able to connect diff --git a/vector/src/test/java/im/vector/app/features/notifications/NotifiableEventProcessorTest.kt b/vector/src/test/java/im/vector/app/features/notifications/NotifiableEventProcessorTest.kt new file mode 100644 index 0000000000..229ab39d1d --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/notifications/NotifiableEventProcessorTest.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +import im.vector.app.features.notifications.ProcessedEvent.Type +import im.vector.app.test.fakes.FakeAutoAcceptInvites +import im.vector.app.test.fakes.FakeOutdatedEventDetector +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import org.matrix.android.sdk.api.session.events.model.EventType + +private val NOT_VIEWING_A_ROOM: String? = null + +class NotifiableEventProcessorTest { + + private val outdatedDetector = FakeOutdatedEventDetector() + private val autoAcceptInvites = FakeAutoAcceptInvites() + + private val eventProcessor = NotifiableEventProcessor(outdatedDetector.instance, autoAcceptInvites) + + @Test + fun `given simple events when processing then keep simple events`() { + val events = listOf( + aSimpleNotifiableEvent(eventId = "event-1"), + aSimpleNotifiableEvent(eventId = "event-2") + ) + + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.KEEP to events[0], + Type.KEEP to events[1] + ) + } + + @Test + fun `given redacted simple event when processing then remove redaction event`() { + val events = listOf(aSimpleNotifiableEvent(eventId = "event-1", type = EventType.REDACTION)) + + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.REMOVE to events[0] + ) + } + + @Test + fun `given invites are auto accepted when processing then remove invitations`() { + autoAcceptInvites._isEnabled = true + val events = listOf( + anInviteNotifiableEvent(roomId = "room-1"), + anInviteNotifiableEvent(roomId = "room-2") + ) + + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.REMOVE to events[0], + Type.REMOVE to events[1] + ) + } + + @Test + fun `given invites are not auto accepted when processing then keep invitation events`() { + autoAcceptInvites._isEnabled = false + val events = listOf( + anInviteNotifiableEvent(roomId = "room-1"), + anInviteNotifiableEvent(roomId = "room-2") + ) + + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.KEEP to events[0], + Type.KEEP to events[1] + ) + } + + @Test + fun `given out of date message event when processing then removes message event`() { + val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1")) + outdatedDetector.givenEventIsOutOfDate(events[0]) + + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.REMOVE to events[0], + ) + } + + @Test + fun `given in date message event when processing then keep message event`() { + val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1")) + outdatedDetector.givenEventIsInDate(events[0]) + + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.KEEP to events[0], + ) + } + + @Test + fun `given viewing the same room as message event when processing then removes message`() { + val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1")) + + val result = eventProcessor.process(events, currentRoomId = "room-1", renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.REMOVE to events[0], + ) + } + + @Test + fun `given events are different to rendered events when processing then removes difference`() { + val events = listOf(aSimpleNotifiableEvent(eventId = "event-1")) + val renderedEvents = listOf>( + ProcessedEvent(Type.KEEP, events[0]), + ProcessedEvent(Type.KEEP, anInviteNotifiableEvent(roomId = "event-2")) + ) + + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = renderedEvents) + + result shouldBeEqualTo listOfProcessedEvents( + Type.REMOVE to renderedEvents[1].event, + Type.KEEP to renderedEvents[0].event + ) + } + + private fun listOfProcessedEvents(vararg event: Pair) = event.map { + ProcessedEvent(it.first, it.second) + } +} + +fun aSimpleNotifiableEvent(eventId: String, type: String? = null) = SimpleNotifiableEvent( + matrixID = null, + eventId = eventId, + editedEventId = null, + noisy = false, + title = "title", + description = "description", + type = type, + timestamp = 0, + soundName = null, + canBeReplaced = false, + isRedacted = false +) + +fun anInviteNotifiableEvent(roomId: String) = InviteNotifiableEvent( + matrixID = null, + eventId = "event-id", + roomId = roomId, + roomName = "a room name", + editedEventId = null, + noisy = false, + title = "title", + description = "description", + type = null, + timestamp = 0, + soundName = null, + canBeReplaced = false, + isRedacted = false +) + +fun aNotifiableMessageEvent(eventId: String, roomId: String) = NotifiableMessageEvent( + eventId = eventId, + editedEventId = null, + noisy = false, + timestamp = 0, + senderName = "sender-name", + senderId = "sending-id", + body = "message-body", + roomId = roomId, + roomName = "room-name", + roomIsDirect = false, + canBeReplaced = false, + isRedacted = false +) diff --git a/vector/src/test/java/im/vector/app/features/notifications/NotificationFactoryTest.kt b/vector/src/test/java/im/vector/app/features/notifications/NotificationFactoryTest.kt new file mode 100644 index 0000000000..d720881bac --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/notifications/NotificationFactoryTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +import im.vector.app.features.notifications.ProcessedEvent.Type +import im.vector.app.test.fakes.FakeNotificationUtils +import im.vector.app.test.fakes.FakeRoomGroupMessageCreator +import im.vector.app.test.fakes.FakeSummaryGroupMessageCreator +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test + +private const val MY_USER_ID = "user-id" +private const val A_ROOM_ID = "room-id" +private const val AN_EVENT_ID = "event-id" + +private val MY_AVATAR_URL: String? = null +private val AN_INVITATION_EVENT = anInviteNotifiableEvent(roomId = A_ROOM_ID) +private val A_SIMPLE_EVENT = aSimpleNotifiableEvent(eventId = AN_EVENT_ID) +private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID) + +class NotificationFactoryTest { + + private val notificationUtils = FakeNotificationUtils() + private val roomGroupMessageCreator = FakeRoomGroupMessageCreator() + private val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator() + + private val notificationFactory = NotificationFactory( + notificationUtils.instance, + roomGroupMessageCreator.instance, + summaryGroupMessageCreator.instance + ) + + @Test + fun `given a room invitation when mapping to notification then is Append`() = testWith(notificationFactory) { + val expectedNotification = notificationUtils.givenBuildRoomInvitationNotificationFor(AN_INVITATION_EVENT, MY_USER_ID) + val roomInvitation = listOf(ProcessedEvent(Type.KEEP, AN_INVITATION_EVENT)) + + val result = roomInvitation.toNotifications(MY_USER_ID) + + result shouldBeEqualTo listOf(OneShotNotification.Append( + notification = expectedNotification, + meta = OneShotNotification.Append.Meta( + key = A_ROOM_ID, + summaryLine = AN_INVITATION_EVENT.description, + isNoisy = AN_INVITATION_EVENT.noisy, + timestamp = AN_INVITATION_EVENT.timestamp + )) + ) + } + + @Test + fun `given a missing event in room invitation when mapping to notification then is Removed`() = testWith(notificationFactory) { + val missingEventRoomInvitation = listOf(ProcessedEvent(Type.REMOVE, AN_INVITATION_EVENT)) + + val result = missingEventRoomInvitation.toNotifications(MY_USER_ID) + + result shouldBeEqualTo listOf(OneShotNotification.Removed( + key = A_ROOM_ID + )) + } + + @Test + fun `given a simple event when mapping to notification then is Append`() = testWith(notificationFactory) { + val expectedNotification = notificationUtils.givenBuildSimpleInvitationNotificationFor(A_SIMPLE_EVENT, MY_USER_ID) + val roomInvitation = listOf(ProcessedEvent(Type.KEEP, A_SIMPLE_EVENT)) + + val result = roomInvitation.toNotifications(MY_USER_ID) + + result shouldBeEqualTo listOf(OneShotNotification.Append( + notification = expectedNotification, + meta = OneShotNotification.Append.Meta( + key = AN_EVENT_ID, + summaryLine = A_SIMPLE_EVENT.description, + isNoisy = A_SIMPLE_EVENT.noisy, + timestamp = AN_INVITATION_EVENT.timestamp + )) + ) + } + + @Test + fun `given a missing simple event when mapping to notification then is Removed`() = testWith(notificationFactory) { + val missingEventRoomInvitation = listOf(ProcessedEvent(Type.REMOVE, A_SIMPLE_EVENT)) + + val result = missingEventRoomInvitation.toNotifications(MY_USER_ID) + + result shouldBeEqualTo listOf(OneShotNotification.Removed( + key = AN_EVENT_ID + )) + } + + @Test + fun `given room with message when mapping to notification then delegates to room group message creator`() = testWith(notificationFactory) { + val events = listOf(A_MESSAGE_EVENT) + val expectedNotification = roomGroupMessageCreator.givenCreatesRoomMessageFor(events, A_ROOM_ID, MY_USER_ID, MY_AVATAR_URL) + val roomWithMessage = mapOf(A_ROOM_ID to listOf(ProcessedEvent(Type.KEEP, A_MESSAGE_EVENT))) + + val result = roomWithMessage.toNotifications(MY_USER_ID, MY_AVATAR_URL) + + result shouldBeEqualTo listOf(expectedNotification) + } + + @Test + fun `given a room with no events to display when mapping to notification then is Empty`() = testWith(notificationFactory) { + val events = listOf(ProcessedEvent(Type.REMOVE, A_MESSAGE_EVENT)) + val emptyRoom = mapOf(A_ROOM_ID to events) + + val result = emptyRoom.toNotifications(MY_USER_ID, MY_AVATAR_URL) + + result shouldBeEqualTo listOf(RoomNotification.Removed( + roomId = A_ROOM_ID + )) + } + + @Test + fun `given a room with only redacted events when mapping to notification then is Empty`() = testWith(notificationFactory) { + val redactedRoom = mapOf(A_ROOM_ID to listOf(ProcessedEvent(Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true)))) + + val result = redactedRoom.toNotifications(MY_USER_ID, MY_AVATAR_URL) + + result shouldBeEqualTo listOf(RoomNotification.Removed( + roomId = A_ROOM_ID + )) + } + + @Test + fun `given a room with redacted and non redacted message events when mapping to notification then redacted events are removed`() = testWith(notificationFactory) { + val roomWithRedactedMessage = mapOf(A_ROOM_ID to listOf( + ProcessedEvent(Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true)), + ProcessedEvent(Type.KEEP, A_MESSAGE_EVENT.copy(eventId = "not-redacted")) + )) + val withRedactedRemoved = listOf(A_MESSAGE_EVENT.copy(eventId = "not-redacted")) + val expectedNotification = roomGroupMessageCreator.givenCreatesRoomMessageFor(withRedactedRemoved, A_ROOM_ID, MY_USER_ID, MY_AVATAR_URL) + + val result = roomWithRedactedMessage.toNotifications(MY_USER_ID, MY_AVATAR_URL) + + result shouldBeEqualTo listOf(expectedNotification) + } +} + +fun testWith(receiver: T, block: T.() -> Unit) { + receiver.block() +} diff --git a/vector/src/test/java/im/vector/app/features/notifications/NotificationRendererTest.kt b/vector/src/test/java/im/vector/app/features/notifications/NotificationRendererTest.kt new file mode 100644 index 0000000000..f726ff1b54 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/notifications/NotificationRendererTest.kt @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.notifications + +import android.app.Notification +import im.vector.app.test.fakes.FakeContext +import im.vector.app.test.fakes.FakeNotificationDisplayer +import im.vector.app.test.fakes.FakeNotificationFactory +import io.mockk.mockk +import org.junit.Test + +private const val MY_USER_ID = "my-user-id" +private const val MY_USER_DISPLAY_NAME = "display-name" +private const val MY_USER_AVATAR_URL = "avatar-url" +private const val AN_EVENT_ID = "event-id" +private const val A_ROOM_ID = "room-id" +private const val USE_COMPLETE_NOTIFICATION_FORMAT = true + +private val AN_EVENT_LIST = listOf>() +private val A_PROCESSED_EVENTS = GroupedNotificationEvents(emptyMap(), emptyList(), emptyList()) +private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(mockk()) +private val A_REMOVE_SUMMARY_NOTIFICATION = SummaryNotification.Removed +private val A_NOTIFICATION = mockk() +private val MESSAGE_META = RoomNotification.Message.Meta( + summaryLine = "ignored", messageCount = 1, latestTimestamp = -1, roomId = A_ROOM_ID, shouldBing = false +) +private val ONE_SHOT_META = OneShotNotification.Append.Meta(key = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1) + +class NotificationRendererTest { + + private val context = FakeContext() + private val notificationDisplayer = FakeNotificationDisplayer() + private val notificationFactory = FakeNotificationFactory() + + private val notificationRenderer = NotificationRenderer( + notificationDisplayer = notificationDisplayer.instance, + notificationFactory = notificationFactory.instance, + appContext = context.instance + ) + + @Test + fun `given no notifications when rendering then cancels summary notification`() { + givenNoNotifications() + + renderEventsAsNotifications() + + notificationDisplayer.verifySummaryCancelled() + notificationDisplayer.verifyNoOtherInteractions() + } + + @Test + fun `given last room message group notification is removed when rendering then remove the summary and then remove message notification`() { + givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID) + cancelNotificationMessage(tag = A_ROOM_ID, NotificationDrawerManager.ROOM_MESSAGES_NOTIFICATION_ID) + } + } + + @Test + fun `given a room message group notification is removed when rendering then remove the message notification and update summary`() { + givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = A_ROOM_ID, NotificationDrawerManager.ROOM_MESSAGES_NOTIFICATION_ID) + showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given a room message group notification is added when rendering then show the message notification and update summary`() { + givenNotifications(roomNotifications = listOf(RoomNotification.Message( + A_NOTIFICATION, + shortcutInfo = null, + MESSAGE_META + ))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + showNotificationMessage(tag = A_ROOM_ID, NotificationDrawerManager.ROOM_MESSAGES_NOTIFICATION_ID, A_NOTIFICATION) + showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given last simple notification is removed when rendering then remove the summary and then remove simple notification`() { + givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID) + cancelNotificationMessage(tag = AN_EVENT_ID, NotificationDrawerManager.ROOM_EVENT_NOTIFICATION_ID) + } + } + + @Test + fun `given a simple notification is removed when rendering then remove the simple notification and update summary`() { + givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = AN_EVENT_ID, NotificationDrawerManager.ROOM_EVENT_NOTIFICATION_ID) + showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given a simple notification is added when rendering then show the simple notification and update summary`() { + givenNotifications(simpleNotifications = listOf(OneShotNotification.Append( + A_NOTIFICATION, + ONE_SHOT_META.copy(key = AN_EVENT_ID) + ))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + showNotificationMessage(tag = AN_EVENT_ID, NotificationDrawerManager.ROOM_EVENT_NOTIFICATION_ID, A_NOTIFICATION) + showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given last invitation notification is removed when rendering then remove the summary and then remove invitation notification`() { + givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID) + cancelNotificationMessage(tag = A_ROOM_ID, NotificationDrawerManager.ROOM_INVITATION_NOTIFICATION_ID) + } + } + + @Test + fun `given an invitation notification is removed when rendering then remove the invitation notification and update summary`() { + givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + cancelNotificationMessage(tag = A_ROOM_ID, NotificationDrawerManager.ROOM_INVITATION_NOTIFICATION_ID) + showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION.notification) + } + } + + @Test + fun `given an invitation notification is added when rendering then show the invitation notification and update summary`() { + givenNotifications(simpleNotifications = listOf(OneShotNotification.Append( + A_NOTIFICATION, + ONE_SHOT_META.copy(key = A_ROOM_ID) + ))) + + renderEventsAsNotifications() + + notificationDisplayer.verifyInOrder { + showNotificationMessage(tag = A_ROOM_ID, NotificationDrawerManager.ROOM_EVENT_NOTIFICATION_ID, A_NOTIFICATION) + showNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID, A_SUMMARY_NOTIFICATION.notification) + } + } + + private fun renderEventsAsNotifications() { + notificationRenderer.render( + myUserId = MY_USER_ID, + myUserDisplayName = MY_USER_DISPLAY_NAME, + myUserAvatarUrl = MY_USER_AVATAR_URL, + useCompleteNotificationFormat = USE_COMPLETE_NOTIFICATION_FORMAT, + eventsToProcess = AN_EVENT_LIST + ) + } + + private fun givenNoNotifications() { + givenNotifications(emptyList(), emptyList(), emptyList(), USE_COMPLETE_NOTIFICATION_FORMAT, A_REMOVE_SUMMARY_NOTIFICATION) + } + + private fun givenNotifications(roomNotifications: List = emptyList(), + invitationNotifications: List = emptyList(), + simpleNotifications: List = emptyList(), + useCompleteNotificationFormat: Boolean = USE_COMPLETE_NOTIFICATION_FORMAT, + summaryNotification: SummaryNotification = A_SUMMARY_NOTIFICATION) { + notificationFactory.givenNotificationsFor( + groupedEvents = A_PROCESSED_EVENTS, + myUserId = MY_USER_ID, + myUserDisplayName = MY_USER_DISPLAY_NAME, + myUserAvatarUrl = MY_USER_AVATAR_URL, + useCompleteNotificationFormat = useCompleteNotificationFormat, + roomNotifications = roomNotifications, + invitationNotifications = invitationNotifications, + simpleNotifications = simpleNotifications, + summaryNotification = summaryNotification + ) + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeAutoAcceptInvites.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeAutoAcceptInvites.kt new file mode 100644 index 0000000000..778c2f113d --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeAutoAcceptInvites.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import im.vector.app.features.invite.AutoAcceptInvites + +class FakeAutoAcceptInvites : AutoAcceptInvites { + + var _isEnabled: Boolean = false + + override val isEnabled: Boolean + get() = _isEnabled +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationDisplayer.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationDisplayer.kt new file mode 100644 index 0000000000..2856b0f49c --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationDisplayer.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import im.vector.app.features.notifications.NotificationDisplayer +import im.vector.app.features.notifications.NotificationDrawerManager +import io.mockk.confirmVerified +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder + +class FakeNotificationDisplayer { + + val instance = mockk(relaxed = true) + + fun verifySummaryCancelled() { + verify { instance.cancelNotificationMessage(tag = null, NotificationDrawerManager.SUMMARY_NOTIFICATION_ID) } + } + + fun verifyNoOtherInteractions() { + confirmVerified(instance) + } + + fun verifyInOrder(verifyBlock: NotificationDisplayer.() -> Unit) { + verifyOrder { verifyBlock(instance) } + verifyNoOtherInteractions() + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationFactory.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationFactory.kt new file mode 100644 index 0000000000..a6e7d1a078 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationFactory.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import im.vector.app.features.notifications.GroupedNotificationEvents +import im.vector.app.features.notifications.NotificationFactory +import im.vector.app.features.notifications.OneShotNotification +import im.vector.app.features.notifications.RoomNotification +import im.vector.app.features.notifications.SummaryNotification +import io.mockk.every +import io.mockk.mockk + +class FakeNotificationFactory { + + val instance = mockk() + + fun givenNotificationsFor(groupedEvents: GroupedNotificationEvents, + myUserId: String, + myUserDisplayName: String, + myUserAvatarUrl: String?, + useCompleteNotificationFormat: Boolean, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + summaryNotification: SummaryNotification) { + with(instance) { + every { groupedEvents.roomEvents.toNotifications(myUserDisplayName, myUserAvatarUrl) } returns roomNotifications + every { groupedEvents.invitationEvents.toNotifications(myUserId) } returns invitationNotifications + every { groupedEvents.simpleEvents.toNotifications(myUserId) } returns simpleNotifications + + every { + createSummaryNotification( + roomNotifications, + invitationNotifications, + simpleNotifications, + useCompleteNotificationFormat + ) + } returns summaryNotification + } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationUtils.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationUtils.kt new file mode 100644 index 0000000000..39f2ad59ff --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeNotificationUtils.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import android.app.Notification +import im.vector.app.features.notifications.InviteNotifiableEvent +import im.vector.app.features.notifications.NotificationUtils +import im.vector.app.features.notifications.SimpleNotifiableEvent +import io.mockk.every +import io.mockk.mockk + +class FakeNotificationUtils { + + val instance = mockk() + + fun givenBuildRoomInvitationNotificationFor(event: InviteNotifiableEvent, myUserId: String): Notification { + val mockNotification = mockk() + every { instance.buildRoomInvitationNotification(event, myUserId) } returns mockNotification + return mockNotification + } + + fun givenBuildSimpleInvitationNotificationFor(event: SimpleNotifiableEvent, myUserId: String): Notification { + val mockNotification = mockk() + every { instance.buildSimpleEventNotification(event, myUserId) } returns mockNotification + return mockNotification + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeOutdatedEventDetector.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeOutdatedEventDetector.kt new file mode 100644 index 0000000000..0e1d617ca2 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeOutdatedEventDetector.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import im.vector.app.features.notifications.NotifiableEvent +import im.vector.app.features.notifications.OutdatedEventDetector +import io.mockk.every +import io.mockk.mockk + +class FakeOutdatedEventDetector { + val instance = mockk() + + fun givenEventIsOutOfDate(notifiableEvent: NotifiableEvent) { + every { instance.isMessageOutdated(notifiableEvent) } returns true + } + + fun givenEventIsInDate(notifiableEvent: NotifiableEvent) { + every { instance.isMessageOutdated(notifiableEvent) } returns false + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeRoomGroupMessageCreator.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeRoomGroupMessageCreator.kt new file mode 100644 index 0000000000..c164b9a661 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeRoomGroupMessageCreator.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import im.vector.app.features.notifications.NotifiableMessageEvent +import im.vector.app.features.notifications.RoomGroupMessageCreator +import im.vector.app.features.notifications.RoomNotification +import io.mockk.every +import io.mockk.mockk + +class FakeRoomGroupMessageCreator { + + val instance = mockk() + + fun givenCreatesRoomMessageFor(events: List, + roomId: String, + userDisplayName: String, + userAvatarUrl: String?): RoomNotification.Message { + val mockMessage = mockk() + every { instance.createRoomMessage(events, roomId, userDisplayName, userAvatarUrl) } returns mockMessage + return mockMessage + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeSummaryGroupMessageCreator.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeSummaryGroupMessageCreator.kt new file mode 100644 index 0000000000..eef77298a0 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeSummaryGroupMessageCreator.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import im.vector.app.features.notifications.SummaryGroupMessageCreator +import io.mockk.mockk + +class FakeSummaryGroupMessageCreator { + + val instance = mockk() +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt new file mode 100644 index 0000000000..eb8f9ac413 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import im.vector.app.features.settings.VectorPreferences +import io.mockk.every +import io.mockk.mockk + +class FakeVectorPreferences { + + val instance = mockk() + + fun givenUseCompleteNotificationFormat(value: Boolean) { + every { instance.useCompleteNotificationFormat() } returns value + } +}