Merge branch 'develop' into feature/fga/rx_flow_migration

This commit is contained in:
ganfra 2021-10-27 16:05:43 +02:00
commit 635ca8e276
74 changed files with 1999 additions and 728 deletions

View file

@ -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) Changes in Element v1.3.5 (2021-10-25)
====================================== ======================================

1
changelog.d/1491.bugfix Normal file
View file

@ -0,0 +1 @@
Stops showing a dedicated redacted event notification, the message notifications will update accordingly

1
changelog.d/3395.bugfix Normal file
View file

@ -0,0 +1 @@
Fixes marking individual notifications as read causing other notifications to be dismissed

1
changelog.d/4152.bugfix Normal file
View file

@ -0,0 +1 @@
Tentatively fixing the doubled notifications by updating the group summary at specific points in the notification rendering cycle

1
changelog.d/4255.bugfix Normal file
View file

@ -0,0 +1 @@
Fixes being unable to join rooms by name

1
changelog.d/4266.removal Normal file
View file

@ -0,0 +1 @@
Add API `LoginWizard.loginCustom(data: JsonDict): Session` to be able to login to a homeserver using arbitrary request content

1
changelog.d/4334.removal Normal file
View file

@ -0,0 +1 @@
Add optional deviceId to the login API

1
changelog.d/582.feature Normal file
View file

@ -0,0 +1 @@
Adding the room name to the invitation notification (if the room summary is available)

View file

@ -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

View file

@ -31,7 +31,7 @@ android {
// that the app's state is completely cleared between tests. // that the app's state is completely cleared between tests.
testInstrumentationRunnerArguments clearPackageData: 'true' testInstrumentationRunnerArguments clearPackageData: 'true'
buildConfigField "String", "SDK_VERSION", "\"1.3.6\"" buildConfigField "String", "SDK_VERSION", "\"1.3.7\""
buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\"" buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\""
resValue "string", "git_sdk_revision", "\"${gitRevision()}\"" resValue "string", "git_sdk_revision", "\"${gitRevision()}\""
@ -156,7 +156,7 @@ dependencies {
implementation libs.apache.commonsImaging implementation libs.apache.commonsImaging
// Phone number https://github.com/google/libphonenumber // 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 libs.tests.junit
testImplementation 'org.robolectric:robolectric:4.6.1' testImplementation 'org.robolectric:robolectric:4.6.1'

View file

@ -105,9 +105,15 @@ interface AuthenticationService {
/** /**
* Authenticate with a matrixId and a password * Authenticate with a matrixId and a password
* Usually call this after a successful call to getWellKnownData() * 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, suspend fun directAuthentication(homeServerConnectionConfig: HomeServerConnectionConfig,
matrixId: String, matrixId: String,
password: String, password: String,
initialDeviceName: String): Session initialDeviceName: String,
deviceId: String? = null): Session
} }

View file

@ -17,6 +17,7 @@
package org.matrix.android.sdk.api.auth.login package org.matrix.android.sdk.api.auth.login
import org.matrix.android.sdk.api.session.Session 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. * 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 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 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 * @return a [Session] if the login is successful
*/ */
suspend fun login(login: String, suspend fun login(login: String,
password: String, password: String,
deviceName: String): Session initialDeviceName: String,
deviceId: String? = null): Session
/** /**
* Exchange a login token to an access token. * Exchange a login token to an access token.
@ -49,6 +52,12 @@ interface LoginWizard {
*/ */
suspend fun loginWithToken(loginToken: String): Session 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 * Ask the homeserver to reset the user password. The password will not be reset until
* [resetPasswordMailConfirmed] is successfully called. * [resetPasswordMailConfirmed] is successfully called.

View file

@ -22,6 +22,8 @@ import org.json.JSONObject
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.failure.MatrixError
import org.matrix.android.sdk.api.session.crypto.MXCryptoError 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.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
@ -310,3 +312,6 @@ fun Event.isEdition(): Boolean {
fun Event.getPresenceContent(): PresenceContent? { fun Event.getPresenceContent(): PresenceContent? {
return content.toModel<PresenceContent>() return content.toModel<PresenceContent>()
} }
fun Event.isInvitation(): Boolean = type == EventType.STATE_ROOM_MEMBER &&
content?.toModel<RoomMemberContent>()?.membership == Membership.INVITE

View file

@ -121,6 +121,10 @@ internal interface AuthAPI {
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "login") @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "login")
suspend fun login(@Body loginParams: TokenLoginParams): Credentials 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. * Ask the homeserver to reset the password associated with the provided email.
*/ */

View file

@ -388,8 +388,15 @@ internal class DefaultAuthenticationService @Inject constructor(
override suspend fun directAuthentication(homeServerConnectionConfig: HomeServerConnectionConfig, override suspend fun directAuthentication(homeServerConnectionConfig: HomeServerConnectionConfig,
matrixId: String, matrixId: String,
password: String, password: String,
initialDeviceName: String): Session { initialDeviceName: String,
return directLoginTask.execute(DirectLoginTask.Params(homeServerConnectionConfig, matrixId, password, initialDeviceName)) deviceId: String?): Session {
return directLoginTask.execute(DirectLoginTask.Params(
homeServerConnectionConfig = homeServerConnectionConfig,
userId = matrixId,
password = password,
deviceName = initialDeviceName,
deviceId = deviceId
))
} }
private fun buildAuthAPI(homeServerConnectionConfig: HomeServerConnectionConfig): AuthAPI { private fun buildAuthAPI(homeServerConnectionConfig: HomeServerConnectionConfig): AuthAPI {

View file

@ -49,51 +49,54 @@ internal data class PasswordLoginParams(
fun userIdentifier(user: String, fun userIdentifier(user: String,
password: String, password: String,
deviceDisplayName: String? = null, deviceDisplayName: String?,
deviceId: String? = null): PasswordLoginParams { deviceId: String?): PasswordLoginParams {
return PasswordLoginParams( return PasswordLoginParams(
mapOf( identifier = mapOf(
IDENTIFIER_KEY_TYPE to IDENTIFIER_KEY_TYPE_USER, IDENTIFIER_KEY_TYPE to IDENTIFIER_KEY_TYPE_USER,
IDENTIFIER_KEY_USER to user IDENTIFIER_KEY_USER to user
), ),
password, password = password,
LoginFlowTypes.PASSWORD, type = LoginFlowTypes.PASSWORD,
deviceDisplayName, deviceDisplayName = deviceDisplayName,
deviceId) deviceId = deviceId
)
} }
fun thirdPartyIdentifier(medium: String, fun thirdPartyIdentifier(medium: String,
address: String, address: String,
password: String, password: String,
deviceDisplayName: String? = null, deviceDisplayName: String?,
deviceId: String? = null): PasswordLoginParams { deviceId: String?): PasswordLoginParams {
return PasswordLoginParams( return PasswordLoginParams(
mapOf( identifier = mapOf(
IDENTIFIER_KEY_TYPE to IDENTIFIER_KEY_TYPE_THIRD_PARTY, IDENTIFIER_KEY_TYPE to IDENTIFIER_KEY_TYPE_THIRD_PARTY,
IDENTIFIER_KEY_MEDIUM to medium, IDENTIFIER_KEY_MEDIUM to medium,
IDENTIFIER_KEY_ADDRESS to address IDENTIFIER_KEY_ADDRESS to address
), ),
password, password = password,
LoginFlowTypes.PASSWORD, type = LoginFlowTypes.PASSWORD,
deviceDisplayName, deviceDisplayName = deviceDisplayName,
deviceId) deviceId = deviceId
)
} }
fun phoneIdentifier(country: String, fun phoneIdentifier(country: String,
phone: String, phone: String,
password: String, password: String,
deviceDisplayName: String? = null, deviceDisplayName: String?,
deviceId: String? = null): PasswordLoginParams { deviceId: String?): PasswordLoginParams {
return PasswordLoginParams( return PasswordLoginParams(
mapOf( identifier = mapOf(
IDENTIFIER_KEY_TYPE to IDENTIFIER_KEY_TYPE_PHONE, IDENTIFIER_KEY_TYPE to IDENTIFIER_KEY_TYPE_PHONE,
IDENTIFIER_KEY_COUNTRY to country, IDENTIFIER_KEY_COUNTRY to country,
IDENTIFIER_KEY_PHONE to phone IDENTIFIER_KEY_PHONE to phone
), ),
password, password = password,
LoginFlowTypes.PASSWORD, type = LoginFlowTypes.PASSWORD,
deviceDisplayName, deviceDisplayName = deviceDisplayName,
deviceId) deviceId = deviceId
)
} }
} }
} }

View file

@ -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.login.LoginWizard
import org.matrix.android.sdk.api.auth.registration.RegisterThreePid import org.matrix.android.sdk.api.auth.registration.RegisterThreePid
import org.matrix.android.sdk.api.session.Session 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.AuthAPI
import org.matrix.android.sdk.internal.auth.PendingSessionStore import org.matrix.android.sdk.internal.auth.PendingSessionStore
import org.matrix.android.sdk.internal.auth.SessionCreator import org.matrix.android.sdk.internal.auth.SessionCreator
@ -52,11 +53,23 @@ internal class DefaultLoginWizard(
override suspend fun login(login: String, override suspend fun login(login: String,
password: String, password: String,
deviceName: String): Session { initialDeviceName: String,
deviceId: String?): Session {
val loginParams = if (Patterns.EMAIL_ADDRESS.matcher(login).matches()) { 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 { } else {
PasswordLoginParams.userIdentifier(login, password, deviceName) PasswordLoginParams.userIdentifier(
user = login,
password = password,
deviceDisplayName = initialDeviceName,
deviceId = deviceId
)
} }
val credentials = executeRequest(null) { val credentials = executeRequest(null) {
authAPI.login(loginParams) authAPI.login(loginParams)
@ -79,6 +92,14 @@ internal class DefaultLoginWizard(
return sessionCreator.createSession(credentials, pendingSessionData.homeServerConnectionConfig) 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) { override suspend fun resetPassword(email: String, newPassword: String) {
val param = RegisterAddThreePidTask.Params( val param = RegisterAddThreePidTask.Params(
RegisterThreePid.Email(email), RegisterThreePid.Email(email),

View file

@ -37,7 +37,8 @@ internal interface DirectLoginTask : Task<DirectLoginTask.Params, Session> {
val homeServerConnectionConfig: HomeServerConnectionConfig, val homeServerConnectionConfig: HomeServerConnectionConfig,
val userId: String, val userId: String,
val password: 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) val authAPI = retrofitFactory.create(client, homeServerUrl)
.create(AuthAPI::class.java) .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 { val credentials = try {
executeRequest(null) { executeRequest(null) {

View file

@ -34,7 +34,7 @@ internal interface SendEventTask : Task<SendEventTask.Params, String> {
internal class DefaultSendEventTask @Inject constructor( internal class DefaultSendEventTask @Inject constructor(
private val localEchoRepository: LocalEchoRepository, private val localEchoRepository: LocalEchoRepository,
private val encryptEventTask: DefaultEncryptEventTask, private val encryptEventTask: EncryptEventTask,
private val loadRoomMembersTask: LoadRoomMembersTask, private val loadRoomMembersTask: LoadRoomMembersTask,
private val roomAPI: RoomAPI, private val roomAPI: RoomAPI,
private val globalErrorReceiver: GlobalErrorReceiver) : SendEventTask { private val globalErrorReceiver: GlobalErrorReceiver) : SendEventTask {

View file

@ -34,7 +34,7 @@ internal interface SendVerificationMessageTask : Task<SendVerificationMessageTas
internal class DefaultSendVerificationMessageTask @Inject constructor( internal class DefaultSendVerificationMessageTask @Inject constructor(
private val localEchoRepository: LocalEchoRepository, private val localEchoRepository: LocalEchoRepository,
private val encryptEventTask: DefaultEncryptEventTask, private val encryptEventTask: EncryptEventTask,
private val roomAPI: RoomAPI, private val roomAPI: RoomAPI,
private val cryptoSessionInfoProvider: CryptoSessionInfoProvider, private val cryptoSessionInfoProvider: CryptoSessionInfoProvider,
private val globalErrorReceiver: GlobalErrorReceiver) : SendVerificationMessageTask { private val globalErrorReceiver: GlobalErrorReceiver) : SendVerificationMessageTask {

View file

@ -21,13 +21,13 @@ import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo
import org.matrix.android.sdk.api.session.room.model.SpaceParentInfo import org.matrix.android.sdk.api.session.room.model.SpaceParentInfo
import org.matrix.android.sdk.api.session.room.model.tag.RoomTag import org.matrix.android.sdk.api.session.room.model.tag.RoomTag
import org.matrix.android.sdk.api.session.typing.TypingUsersTracker
import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity
import org.matrix.android.sdk.internal.database.model.presence.toUserPresence import org.matrix.android.sdk.internal.database.model.presence.toUserPresence
import org.matrix.android.sdk.internal.session.typing.DefaultTypingUsersTracker
import javax.inject.Inject import javax.inject.Inject
internal class RoomSummaryMapper @Inject constructor(private val timelineEventMapper: TimelineEventMapper, internal class RoomSummaryMapper @Inject constructor(private val timelineEventMapper: TimelineEventMapper,
private val typingUsersTracker: DefaultTypingUsersTracker) { private val typingUsersTracker: TypingUsersTracker) {
fun map(roomSummaryEntity: RoomSummaryEntity): RoomSummary { fun map(roomSummaryEntity: RoomSummaryEntity): RoomSummary {
val tags = roomSummaryEntity.tags().map { val tags = roomSummaryEntity.tags().map {

View file

@ -41,6 +41,7 @@ import org.matrix.android.sdk.api.session.file.ContentDownloadStateTracker
import org.matrix.android.sdk.api.session.file.FileService import org.matrix.android.sdk.api.session.file.FileService
import org.matrix.android.sdk.api.session.group.GroupService import org.matrix.android.sdk.api.session.group.GroupService
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
import org.matrix.android.sdk.api.session.identity.IdentityService
import org.matrix.android.sdk.api.session.initsync.SyncStatusService import org.matrix.android.sdk.api.session.initsync.SyncStatusService
import org.matrix.android.sdk.api.session.integrationmanager.IntegrationManagerService import org.matrix.android.sdk.api.session.integrationmanager.IntegrationManagerService
import org.matrix.android.sdk.api.session.media.MediaService import org.matrix.android.sdk.api.session.media.MediaService
@ -72,7 +73,6 @@ import org.matrix.android.sdk.internal.di.SessionId
import org.matrix.android.sdk.internal.di.UnauthenticatedWithCertificate import org.matrix.android.sdk.internal.di.UnauthenticatedWithCertificate
import org.matrix.android.sdk.internal.di.WorkManagerProvider import org.matrix.android.sdk.internal.di.WorkManagerProvider
import org.matrix.android.sdk.internal.network.GlobalErrorHandler import org.matrix.android.sdk.internal.network.GlobalErrorHandler
import org.matrix.android.sdk.internal.session.identity.DefaultIdentityService
import org.matrix.android.sdk.internal.session.sync.SyncTokenStore import org.matrix.android.sdk.internal.session.sync.SyncTokenStore
import org.matrix.android.sdk.internal.session.sync.job.SyncThread import org.matrix.android.sdk.internal.session.sync.job.SyncThread
import org.matrix.android.sdk.internal.session.sync.job.SyncWorker import org.matrix.android.sdk.internal.session.sync.job.SyncWorker
@ -124,7 +124,7 @@ internal class DefaultSession @Inject constructor(
private val _sharedSecretStorageService: Lazy<SharedSecretStorageService>, private val _sharedSecretStorageService: Lazy<SharedSecretStorageService>,
private val accountService: Lazy<AccountService>, private val accountService: Lazy<AccountService>,
private val eventService: Lazy<EventService>, private val eventService: Lazy<EventService>,
private val defaultIdentityService: DefaultIdentityService, private val identityService: IdentityService,
private val integrationManagerService: IntegrationManagerService, private val integrationManagerService: IntegrationManagerService,
private val thirdPartyService: Lazy<ThirdPartyService>, private val thirdPartyService: Lazy<ThirdPartyService>,
private val callSignalingService: Lazy<CallSignalingService>, private val callSignalingService: Lazy<CallSignalingService>,
@ -275,7 +275,7 @@ internal class DefaultSession @Inject constructor(
override fun cryptoService(): CryptoService = cryptoService.get() override fun cryptoService(): CryptoService = cryptoService.get()
override fun identityService() = defaultIdentityService override fun identityService() = identityService
override fun fileService(): FileService = defaultFileService.get() override fun fileService(): FileService = defaultFileService.get()

View file

@ -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.failure.MatrixError
import org.matrix.android.sdk.api.session.content.ContentUrlResolver 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.HomeServerCapabilities
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
import org.matrix.android.sdk.internal.di.Authenticated import org.matrix.android.sdk.internal.di.Authenticated
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.ProgressRequestBody import org.matrix.android.sdk.internal.network.ProgressRequestBody
import org.matrix.android.sdk.internal.network.awaitResponse import org.matrix.android.sdk.internal.network.awaitResponse
import org.matrix.android.sdk.internal.network.toFailure 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 org.matrix.android.sdk.internal.util.TemporaryFileCreator
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
@ -50,7 +50,7 @@ import javax.inject.Inject
internal class FileUploader @Inject constructor( internal class FileUploader @Inject constructor(
@Authenticated private val okHttpClient: OkHttpClient, @Authenticated private val okHttpClient: OkHttpClient,
private val globalErrorReceiver: GlobalErrorReceiver, private val globalErrorReceiver: GlobalErrorReceiver,
private val homeServerCapabilitiesService: DefaultHomeServerCapabilitiesService, private val homeServerCapabilitiesService: HomeServerCapabilitiesService,
private val context: Context, private val context: Context,
private val temporaryFileCreator: TemporaryFileCreator, private val temporaryFileCreator: TemporaryFileCreator,
contentUrlResolver: ContentUrlResolver, contentUrlResolver: ContentUrlResolver,

View file

@ -80,7 +80,7 @@ internal class DefaultIdentityService @Inject constructor(
private val identityApiProvider: IdentityApiProvider, private val identityApiProvider: IdentityApiProvider,
private val accountDataDataSource: UserAccountDataDataSource, private val accountDataDataSource: UserAccountDataDataSource,
private val homeServerCapabilitiesService: HomeServerCapabilitiesService, private val homeServerCapabilitiesService: HomeServerCapabilitiesService,
private val sign3pidInvitationTask: DefaultSign3pidInvitationTask, private val sign3pidInvitationTask: Sign3pidInvitationTask,
private val sessionParams: SessionParams private val sessionParams: SessionParams
) : IdentityService, SessionLifecycleObserver { ) : IdentityService, SessionLifecycleObserver {

View file

@ -21,6 +21,7 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import io.realm.RealmConfiguration import io.realm.RealmConfiguration
import okhttp3.OkHttpClient 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.database.RealmKeysUtils
import org.matrix.android.sdk.internal.di.AuthenticatedIdentity import org.matrix.android.sdk.internal.di.AuthenticatedIdentity
import org.matrix.android.sdk.internal.di.IdentityDatabase import org.matrix.android.sdk.internal.di.IdentityDatabase
@ -75,6 +76,9 @@ internal abstract class IdentityModule {
} }
} }
@Binds
abstract fun bindIdentityService(service: DefaultIdentityService): IdentityService
@Binds @Binds
@AuthenticatedIdentity @AuthenticatedIdentity
abstract fun bindAccessTokenProvider(provider: IdentityAccessTokenProvider): AccessTokenProvider abstract fun bindAccessTokenProvider(provider: IdentityAccessTokenProvider): AccessTokenProvider

View file

@ -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.pushrules.rest.PushRule
import org.matrix.android.sdk.api.session.events.model.EventType 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.api.session.sync.model.RoomsSyncResponse
import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.task.Task import org.matrix.android.sdk.internal.task.Task
@ -48,14 +49,18 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
} }
val newJoinEvents = params.syncResponse.join val newJoinEvents = params.syncResponse.join
.mapNotNull { (key, value) -> .mapNotNull { (key, value) ->
value.timeline?.events?.map { it.copy(roomId = key) } value.timeline?.events?.mapNotNull {
it.takeIf { !it.isInvitation() }?.copy(roomId = key)
}
} }
.flatten() .flatten()
val inviteEvents = params.syncResponse.invite val inviteEvents = params.syncResponse.invite
.mapNotNull { (key, value) -> .mapNotNull { (key, value) ->
value.inviteState?.events?.map { it.copy(roomId = key) } value.inviteState?.events?.map { it.copy(roomId = key) }
} }
.flatten() .flatten()
val allEvents = (newJoinEvents + inviteEvents).filter { event -> val allEvents = (newJoinEvents + inviteEvents).filter { event ->
when (event.type) { when (event.type) {
EventType.MESSAGE, EventType.MESSAGE,

View file

@ -42,11 +42,12 @@ internal class DefaultSignInAgainTask @Inject constructor(
signOutAPI.loginAgain( signOutAPI.loginAgain(
PasswordLoginParams.userIdentifier( PasswordLoginParams.userIdentifier(
// Reuse the same userId // Reuse the same userId
sessionParams.userId, user = sessionParams.userId,
params.password, password = params.password,
// The spec says the initial device name will be ignored // The spec says the initial device name will be ignored
// https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-login // https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-login
// but https://github.com/matrix-org/synapse/issues/6525 // but https://github.com/matrix-org/synapse/issues/6525
deviceDisplayName = null,
// Reuse the same deviceId // Reuse the same deviceId
deviceId = sessionParams.deviceId deviceId = sessionParams.deviceId
) )

View file

@ -160,7 +160,7 @@ Formatter\.formatShortFileSize===1
# android\.text\.TextUtils # 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 ### 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 ### Do not import temporary legacy classes
import org.matrix.android.sdk.internal.legacy.riot===3 import org.matrix.android.sdk.internal.legacy.riot===3

View file

@ -15,7 +15,7 @@ kapt {
// Note: 2 digits max for each value // Note: 2 digits max for each value
ext.versionMajor = 1 ext.versionMajor = 1
ext.versionMinor = 3 ext.versionMinor = 3
ext.versionPatch = 6 ext.versionPatch = 7
static def getGitTimestamp() { static def getGitTimestamp() {
def cmd = 'git show -s --format=%ct' def cmd = 'git show -s --format=%ct'
@ -371,7 +371,7 @@ dependencies {
implementation 'com.facebook.stetho:stetho:1.6.0' implementation 'com.facebook.stetho:stetho:1.6.0'
// Phone number https://github.com/google/libphonenumber // Phone number https://github.com/google/libphonenumber
implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.35' implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.36'
// FlowBinding // FlowBinding
implementation libs.github.flowBinding implementation libs.github.flowBinding

View file

@ -22,6 +22,7 @@ import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.core.utils.checkPermissions 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 im.vector.app.databinding.ActivityDebugPermissionBinding
import timber.log.Timber import timber.log.Timber
@AndroidEntryPoint
class DebugPermissionActivity : VectorBaseActivity<ActivityDebugPermissionBinding>() { class DebugPermissionActivity : VectorBaseActivity<ActivityDebugPermissionBinding>() {
override fun getBinding() = ActivityDebugPermissionBinding.inflate(layoutInflater) override fun getBinding() = ActivityDebugPermissionBinding.inflate(layoutInflater)

View file

@ -29,16 +29,13 @@ import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.BuildConfig import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.network.WifiDetector import im.vector.app.core.network.WifiDetector
import im.vector.app.core.pushers.PushersManager import im.vector.app.core.pushers.PushersManager
import im.vector.app.features.badge.BadgeProxy import im.vector.app.features.badge.BadgeProxy
import im.vector.app.features.notifications.NotifiableEventResolver 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.NotificationDrawerManager
import im.vector.app.features.notifications.NotificationUtils 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.VectorDataStore
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import im.vector.app.push.fcm.FcmHelper import im.vector.app.push.fcm.FcmHelper
@ -48,9 +45,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.logger.LoggerTag 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.Session
import org.matrix.android.sdk.api.session.events.model.Event
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -201,12 +196,11 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
Timber.tag(loggerTag.value).d("Fast lane: start request") Timber.tag(loggerTag.value).d("Fast lane: start request")
val event = tryOrNull { session.getEvent(roomId, eventId) } ?: return@launch val event = tryOrNull { session.getEvent(roomId, eventId) } ?: return@launch
val resolvedEvent = notifiableEventResolver.resolveInMemoryEvent(session, event) val resolvedEvent = notifiableEventResolver.resolveInMemoryEvent(session, event, canBeReplaced = true)
resolvedEvent resolvedEvent
?.also { Timber.tag(loggerTag.value).d("Fast lane: notify drawer") } ?.also { Timber.tag(loggerTag.value).d("Fast lane: notify drawer") }
?.let { ?.let {
it.isPushGatewayEvent = true
notificationDrawerManager.onNotifiableEventReceived(it) notificationDrawerManager.onNotifiableEventReceived(it)
notificationDrawerManager.refreshNotificationDrawer() notificationDrawerManager.refreshNotificationDrawer()
} }
@ -227,87 +221,4 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() {
} }
return false return false
} }
private fun handleNotificationWithoutSyncingMode(data: Map<String, String>, 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<String, String>, 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<String, String>?): 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()
)
}
} }

View file

@ -23,6 +23,8 @@ import org.matrix.android.sdk.api.auth.registration.RegisterThreePid
import org.matrix.android.sdk.internal.network.ssl.Fingerprint import org.matrix.android.sdk.internal.network.ssl.Fingerprint
sealed class LoginAction : VectorViewModelAction { sealed class LoginAction : VectorViewModelAction {
data class OnGetStarted(val resetLoginConfig: Boolean) : LoginAction()
data class UpdateServerType(val serverType: ServerType) : LoginAction() data class UpdateServerType(val serverType: ServerType) : LoginAction()
data class UpdateHomeServer(val homeServerUrl: String) : LoginAction() data class UpdateHomeServer(val homeServerUrl: String) : LoginAction()
data class UpdateSignMode(val signMode: SignMode) : LoginAction() data class UpdateSignMode(val signMode: SignMode) : LoginAction()

View file

@ -94,7 +94,6 @@ open class LoginActivity : VectorBaseActivity<ActivityLoginBinding>(), ToolbarCo
// Get config extra // Get config extra
val loginConfig = intent.getParcelableExtra<LoginConfig?>(EXTRA_CONFIG) val loginConfig = intent.getParcelableExtra<LoginConfig?>(EXTRA_CONFIG)
if (isFirstCreation()) { if (isFirstCreation()) {
// TODO Check this
loginViewModel.handle(LoginAction.InitWith(loginConfig)) loginViewModel.handle(LoginAction.InitWith(loginConfig))
} }
} }

View file

@ -87,10 +87,5 @@ class LoginServerSelectionFragment @Inject constructor() : AbstractLoginFragment
override fun updateWithState(state: LoginViewState) { override fun updateWithState(state: LoginViewState) {
updateSelectedChoice(state) updateSelectedChoice(state)
if (state.loginMode != LoginMode.Unknown) {
// LoginFlow for matrix.org has been retrieved
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved))
}
} }
} }

View file

@ -159,10 +159,5 @@ class LoginServerUrlFormFragment @Inject constructor() : AbstractLoginFragment<F
setupUi(state) setupUi(state)
views.loginServerUrlFormClearHistory.isInvisible = state.knownCustomHomeServersUrls.isEmpty() views.loginServerUrlFormClearHistory.isInvisible = state.knownCustomHomeServersUrls.isEmpty()
if (state.loginMode != LoginMode.Unknown) {
// The homeserver url is valid
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved))
}
} }
} }

View file

@ -22,9 +22,13 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import im.vector.app.BuildConfig import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.databinding.FragmentLoginSplashBinding import im.vector.app.databinding.FragmentLoginSplashBinding
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import org.matrix.android.sdk.api.failure.Failure
import java.net.UnknownHostException
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -45,7 +49,7 @@ class LoginSplashFragment @Inject constructor(
} }
private fun setupViews() { private fun setupViews() {
views.loginSplashSubmit.setOnClickListener { getStarted() } views.loginSplashSubmit.debouncedClicks { getStarted() }
if (BuildConfig.DEBUG || vectorPreferences.developerMode()) { if (BuildConfig.DEBUG || vectorPreferences.developerMode()) {
views.loginSplashVersion.isVisible = true views.loginSplashVersion.isVisible = true
@ -57,10 +61,28 @@ class LoginSplashFragment @Inject constructor(
} }
private fun getStarted() { private fun getStarted() {
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OpenServerSelection)) loginViewModel.handle(LoginAction.OnGetStarted(resetLoginConfig = false))
} }
override fun resetViewModel() { override fun resetViewModel() {
// Nothing to do // Nothing to do
} }
override fun onError(throwable: Throwable) {
if (throwable is Failure.NetworkConnection &&
throwable.ioException is UnknownHostException) {
// Invalid homeserver from URL config
val url = loginViewModel.getInitialHomeServerUrl().orEmpty()
MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.dialog_title_error)
.setMessage(getString(R.string.login_error_homeserver_from_url_not_found, url))
.setPositiveButton(R.string.login_error_homeserver_from_url_not_found_enter_manual) { _, _ ->
loginViewModel.handle(LoginAction.OnGetStarted(resetLoginConfig = true))
}
.setNegativeButton(R.string.cancel, null)
.show()
} else {
super.onError(throwable)
}
}
} }

View file

@ -18,7 +18,6 @@ package im.vector.app.features.login
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.MavericksViewModelFactory
@ -116,6 +115,7 @@ class LoginViewModel @AssistedInject constructor(
override fun handle(action: LoginAction) { override fun handle(action: LoginAction) {
when (action) { when (action) {
is LoginAction.OnGetStarted -> handleOnGetStarted(action)
is LoginAction.UpdateServerType -> handleUpdateServerType(action) is LoginAction.UpdateServerType -> handleUpdateServerType(action)
is LoginAction.UpdateSignMode -> handleUpdateSignMode(action) is LoginAction.UpdateSignMode -> handleUpdateSignMode(action)
is LoginAction.InitWith -> handleInitWith(action) is LoginAction.InitWith -> handleInitWith(action)
@ -134,6 +134,27 @@ class LoginViewModel @AssistedInject constructor(
}.exhaustive }.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) { private fun handleUserAcceptCertificate(action: LoginAction.UserAcceptCertificate) {
// It happens when we get the login flow, or during direct authentication. // It happens when we get the login flow, or during direct authentication.
// So alter the homeserver config and retrieve again the login flow // 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 currentHomeServerConnectionConfig = homeServerConnectionConfig
currentJob = viewModelScope.launch { currentJob = viewModelScope.launch {
@ -743,7 +765,11 @@ class LoginViewModel @AssistedInject constructor(
asyncHomeServerLoginFlowRequest = Loading(), asyncHomeServerLoginFlowRequest = Loading(),
// If user has entered https://matrix.org, ensure that server type is ServerType.MatrixOrg // 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 // 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 else -> LoginMode.Unsupported
} }
// FIXME We should post a view event here normally?
setState { setState {
copy( copy(
asyncHomeServerLoginFlowRequest = Uninitialized, asyncHomeServerLoginFlowRequest = Uninitialized,
@ -791,6 +816,7 @@ class LoginViewModel @AssistedInject constructor(
// Notify the UI // Notify the UI
_viewEvents.post(LoginViewEvents.OutdatedHomeserver) _viewEvents.post(LoginViewEvents.OutdatedHomeserver)
} }
_viewEvents.post(LoginViewEvents.OnLoginFlowRetrieved)
} }
} }

View file

@ -53,7 +53,7 @@ data class LoginViewState(
val loginMode: LoginMode = LoginMode.Unknown, val loginMode: LoginMode = LoginMode.Unknown,
// Supported types for the login. We cannot use a sealed class for LoginType because it is not serializable // Supported types for the login. We cannot use a sealed class for LoginType because it is not serializable
@PersistState @PersistState
val loginModeSupportedTypes: List<String> = emptyList(), val loginModeSupportedTypes: List<String> = emptyList(),
val knownCustomHomeServersUrls: List<String> = emptyList() val knownCustomHomeServersUrls: List<String> = emptyList()
) : MavericksState { ) : MavericksState {

View file

@ -547,7 +547,7 @@ class LoginViewModel2 @AssistedInject constructor(
safeLoginWizard.login( safeLoginWizard.login(
login = login, login = login,
password = password, 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) { } catch (failure: Throwable) {
_viewEvents.post(LoginViewEvents2.Failure(failure)) _viewEvents.post(LoginViewEvents2.Failure(failure))

View file

@ -15,22 +15,18 @@
*/ */
package im.vector.app.features.notifications package im.vector.app.features.notifications
import androidx.core.app.NotificationCompat
data class InviteNotifiableEvent( data class InviteNotifiableEvent(
override var matrixID: String?, val matrixID: String?,
override val eventId: String, override val eventId: String,
override val editedEventId: String?, override val editedEventId: String?,
var roomId: String, override val canBeReplaced: Boolean,
override var noisy: Boolean, val roomId: String,
override val title: String, val roomName: String?,
override val description: String, val noisy: Boolean,
override val type: String?, val title: String,
override val timestamp: Long, val description: String,
override var soundName: String?, val type: String?,
override var isPushGatewayEvent: Boolean = false) : NotifiableEvent { val timestamp: Long,
val soundName: String?,
override var hasBeenDisplayed: Boolean = false override val isRedacted: Boolean = false
override var isRedacted: Boolean = false ) : NotifiableEvent
override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
}

View file

@ -20,24 +20,11 @@ import java.io.Serializable
/** /**
* Parent interface for all events which can be displayed as a Notification * Parent interface for all events which can be displayed as a Notification
*/ */
interface NotifiableEvent : Serializable { sealed interface NotifiableEvent : Serializable {
var matrixID: String?
val eventId: String val eventId: String
val editedEventId: 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 // Used to know if event should be replaced with the one coming from eventstream
var isPushGatewayEvent: Boolean val canBeReplaced: Boolean
val isRedacted: Boolean
} }

View file

@ -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<ProcessedEvent<NotifiableEvent>>
class NotifiableEventProcessor @Inject constructor(
private val outdatedDetector: OutdatedEventDetector,
private val autoAcceptInvites: AutoAcceptInvites
) {
fun process(queuedEvents: List<NotifiableEvent>, 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
}
}

View file

@ -15,7 +15,6 @@
*/ */
package im.vector.app.features.notifications package im.vector.app.features.notifications
import androidx.core.app.NotificationCompat
import im.vector.app.BuildConfig import im.vector.app.BuildConfig
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
@ -54,21 +53,19 @@ class NotifiableEventResolver @Inject constructor(
// private val eventDisplay = RiotEventDisplay(context) // 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 roomID = event.roomId ?: return null
val eventId = event.eventId ?: return null val eventId = event.eventId ?: return null
if (event.getClearType() == EventType.STATE_ROOM_MEMBER) { 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 val timelineEvent = session.getRoom(roomID)?.getTimeLineEvent(eventId) ?: return null
when (event.getClearType()) { when (event.getClearType()) {
EventType.MESSAGE -> { EventType.MESSAGE -> {
return resolveMessageEvent(timelineEvent, session) return resolveMessageEvent(timelineEvent, session, canBeReplaced = false, isNoisy = isNoisy)
} }
EventType.ENCRYPTED -> { EventType.ENCRYPTED -> {
val messageEvent = resolveMessageEvent(timelineEvent, session) return resolveMessageEvent(timelineEvent, session, canBeReplaced = false, isNoisy = isNoisy)
messageEvent?.lockScreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
return messageEvent
} }
else -> { else -> {
// If the event can be displayed, display it as is // If the event can be displayed, display it as is
@ -85,12 +82,14 @@ class NotifiableEventResolver @Inject constructor(
description = bodyPreview, description = bodyPreview,
title = stringProvider.getString(R.string.notification_unknown_new_event), title = stringProvider.getString(R.string.notification_unknown_new_event),
soundName = null, 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 if (event.getClearType() != EventType.MESSAGE) return null
// Ignore message edition // Ignore message edition
@ -114,24 +113,14 @@ class NotifiableEventResolver @Inject constructor(
avatarUrl = user.avatarUrl avatarUrl = user.avatarUrl
) )
) )
resolveMessageEvent(timelineEvent, session, canBeReplaced = canBeReplaced, isNoisy = !notificationAction.soundName.isNullOrBlank())
val notifiableEvent = resolveMessageEvent(timelineEvent, session)
if (notifiableEvent == null) {
Timber.d("## Failed to resolve event")
// TODO
null
} else {
notifiableEvent.noisy = !notificationAction.soundName.isNullOrBlank()
notifiableEvent
}
} else { } else {
Timber.d("Matched push rule is set to not notify") Timber.d("Matched push rule is set to not notify")
null 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...) // 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*/) 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 roomName = stringProvider.getString(R.string.notification_unknown_room_name)
val senderDisplayName = event.senderInfo.disambiguatedDisplayName val senderDisplayName = event.senderInfo.disambiguatedDisplayName
val notifiableEvent = NotifiableMessageEvent( return NotifiableMessageEvent(
eventId = event.root.eventId!!, eventId = event.root.eventId!!,
editedEventId = event.getEditedEventId(), editedEventId = event.getEditedEventId(),
canBeReplaced = canBeReplaced,
timestamp = event.root.originServerTs ?: 0, timestamp = event.root.originServerTs ?: 0,
noisy = false, // will be updated noisy = isNoisy,
senderName = senderDisplayName, senderName = senderDisplayName,
senderId = event.root.senderId, senderId = event.root.senderId,
body = body.toString(), body = body.toString(),
roomId = event.root.roomId!!, roomId = event.root.roomId!!,
roomName = roomName) roomName = roomName,
matrixID = session.myUserId
notifiableEvent.matrixID = session.myUserId )
return notifiableEvent
} else { } else {
if (event.root.isEncrypted() && event.root.mxDecryptionResult == null) { if (event.root.isEncrypted() && event.root.mxDecryptionResult == null) {
// TODO use a global event decryptor? attache to session and that listen to new sessionId? // 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 roomName = room.roomSummary()?.displayName ?: ""
val senderDisplayName = event.senderInfo.disambiguatedDisplayName val senderDisplayName = event.senderInfo.disambiguatedDisplayName
val notifiableEvent = NotifiableMessageEvent( return NotifiableMessageEvent(
eventId = event.root.eventId!!, eventId = event.root.eventId!!,
editedEventId = event.getEditedEventId(), editedEventId = event.getEditedEventId(),
canBeReplaced = canBeReplaced,
timestamp = event.root.originServerTs ?: 0, timestamp = event.root.originServerTs ?: 0,
noisy = false, // will be updated noisy = isNoisy,
senderName = senderDisplayName, senderName = senderDisplayName,
senderId = event.root.senderId, senderId = event.root.senderId,
body = body, body = body,
roomId = event.root.roomId!!, roomId = event.root.roomId!!,
roomName = roomName, roomName = roomName,
roomIsDirect = room.roomSummary()?.isDirect ?: false) roomIsDirect = room.roomSummary()?.isDirect ?: false,
roomAvatarPath = session.contentUrlResolver()
notifiableEvent.matrixID = session.myUserId .resolveThumbnail(room.roomSummary()?.avatarUrl,
notifiableEvent.soundName = null 250,
250,
// Get the avatars URL ContentUrlResolver.ThumbnailMethod.SCALE),
notifiableEvent.roomAvatarPath = session.contentUrlResolver() senderAvatarPath = session.contentUrlResolver()
.resolveThumbnail(room.roomSummary()?.avatarUrl, .resolveThumbnail(event.senderInfo.avatarUrl,
250, 250,
250, 250,
ContentUrlResolver.ThumbnailMethod.SCALE) ContentUrlResolver.ThumbnailMethod.SCALE),
matrixID = session.myUserId,
notifiableEvent.senderAvatarPath = session.contentUrlResolver() soundName = null
.resolveThumbnail(event.senderInfo.avatarUrl, )
250,
250,
ContentUrlResolver.ThumbnailMethod.SCALE)
return notifiableEvent
} }
} }
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<RoomMemberContent>() ?: return null val content = event.content?.toModel<RoomMemberContent>() ?: return null
val roomId = event.roomId ?: return null val roomId = event.roomId ?: return null
val dName = event.senderId?.let { session.getRoomMember(it, roomId)?.displayName } val dName = event.senderId?.let { session.getRoomMember(it, roomId)?.displayName }
if (Membership.INVITE == content.membership) { 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) ?: stringProvider.getString(R.string.notification_new_invitation)
return InviteNotifiableEvent( return InviteNotifiableEvent(
session.myUserId, session.myUserId,
eventId = event.eventId!!, eventId = event.eventId!!,
editedEventId = null, editedEventId = null,
canBeReplaced = canBeReplaced,
roomId = roomId, roomId = roomId,
roomName = roomSummary?.displayName,
timestamp = event.originServerTs ?: 0, timestamp = event.originServerTs ?: 0,
noisy = false, // will be set later noisy = isNoisy,
title = stringProvider.getString(R.string.notification_new_invitation), title = stringProvider.getString(R.string.notification_new_invitation),
description = body.toString(), description = body.toString(),
soundName = null, // will be set later soundName = null, // will be set later
type = event.getClearType(), type = event.getClearType()
isPushGatewayEvent = false) )
} else { } else {
Timber.e("## unsupported notifiable event for eventId [${event.eventId}]") Timber.e("## unsupported notifiable event for eventId [${event.eventId}]")
if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) { if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) {

View file

@ -15,43 +15,31 @@
*/ */
package im.vector.app.features.notifications package im.vector.app.features.notifications
import androidx.core.app.NotificationCompat
import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.EventType
data class NotifiableMessageEvent( data class NotifiableMessageEvent(
override val eventId: String, override val eventId: String,
override val editedEventId: String?, override val editedEventId: String?,
override var noisy: Boolean, override val canBeReplaced: Boolean,
override val timestamp: Long, val noisy: Boolean,
var senderName: String?, val timestamp: Long,
var senderId: String?, val senderName: String?,
var body: String?, val senderId: String?,
var roomId: String, val body: String?,
var roomName: String?, val roomId: String,
var roomIsDirect: Boolean = false 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 { ) : NotifiableEvent {
override var matrixID: String? = null val type: String = EventType.MESSAGE
override var soundName: String? = null val description: String = body ?: ""
override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC val title: String = senderName ?: ""
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
} }

View file

@ -130,19 +130,20 @@ class NotificationBroadcastReceiver : BroadcastReceiver() {
val notifiableMessageEvent = NotifiableMessageEvent( val notifiableMessageEvent = NotifiableMessageEvent(
// Generate a Fake event id // Generate a Fake event id
UUID.randomUUID().toString(), eventId = UUID.randomUUID().toString(),
null, editedEventId = null,
false, noisy = false,
System.currentTimeMillis(), timestamp = System.currentTimeMillis(),
session.getRoomMember(session.myUserId, room.roomId)?.displayName senderName = session.getRoomMember(session.myUserId, room.roomId)?.displayName
?: context?.getString(R.string.notification_sender_me), ?: context?.getString(R.string.notification_sender_me),
session.myUserId, senderId = session.myUserId,
message, body = message,
room.roomId, roomId = room.roomId,
room.roomSummary()?.displayName ?: room.roomId, roomName = room.roomSummary()?.displayName ?: room.roomId,
room.roomSummary()?.isDirect == true roomIsDirect = room.roomSummary()?.isDirect == true,
outGoingMessage = true,
canBeReplaced = false
) )
notifiableMessageEvent.outGoingMessage = true
notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent) notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent)
notificationDrawerManager.refreshNotificationDrawer() notificationDrawerManager.refreshNotificationDrawer()

View file

@ -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")
}
}
}

View file

@ -16,26 +16,15 @@
package im.vector.app.features.notifications package im.vector.app.features.notifications
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.HandlerThread import android.os.HandlerThread
import androidx.annotation.WorkerThread 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.ActiveSessionDataSource
import im.vector.app.BuildConfig import im.vector.app.BuildConfig
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.FirstThrottler import im.vector.app.core.utils.FirstThrottler
import im.vector.app.features.displayname.getBestName 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 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.Session
import org.matrix.android.sdk.api.session.content.ContentUrlResolver import org.matrix.android.sdk.api.session.content.ContentUrlResolver
import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem
@ -52,14 +41,11 @@ import javax.inject.Singleton
*/ */
@Singleton @Singleton
class NotificationDrawerManager @Inject constructor(private val context: Context, class NotificationDrawerManager @Inject constructor(private val context: Context,
private val notificationUtils: NotificationUtils, private val notificationDisplayer: NotificationDisplayer,
private val vectorPreferences: VectorPreferences, private val vectorPreferences: VectorPreferences,
private val stringProvider: StringProvider,
private val activeSessionDataSource: ActiveSessionDataSource, private val activeSessionDataSource: ActiveSessionDataSource,
private val iconLoader: IconLoader, private val notifiableEventProcessor: NotifiableEventProcessor,
private val bitmapLoader: BitmapLoader, private val notificationRenderer: NotificationRenderer) {
private val outdatedDetector: OutdatedEventDetector?,
private val autoAcceptInvites: AutoAcceptInvites) {
private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY) private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY)
private var backgroundHandler: Handler private var backgroundHandler: Handler
@ -69,13 +55,23 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
backgroundHandler = Handler(handlerThread.looper) backgroundHandler = Handler(handlerThread.looper)
} }
// The first time the notification drawer is refreshed, we force re-render of all notifications /**
private var firstTime = true * The notifiable events to render
* this is our source of truth for notifications, any changes to this list will be rendered as notifications
private val eventList = loadEventInfo() * 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<ProcessedEvent<NotifiableEvent>>()
private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size) private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size)
private var currentRoomId: String? = null private var currentRoomId: String? = null
// TODO Multi-session: this will have to be improved // 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) { if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) {
Timber.d("onNotifiableEventReceived(): $notifiableEvent") Timber.d("onNotifiableEventReceived(): $notifiableEvent")
} else { } else {
Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.isPushGatewayEvent}") Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}")
} }
synchronized(eventList) { synchronized(queuedEvents) {
val existing = eventList.firstOrNull { it.eventId == notifiableEvent.eventId } val existing = queuedEvents.firstOrNull { it.eventId == notifiableEvent.eventId }
if (existing != null) { if (existing != null) {
if (existing.isPushGatewayEvent) { if (existing.canBeReplaced) {
// Use the event coming from the event stream as it may contains more info than // 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 // 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) // 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 // Use setOnlyAlertOnce to ensure update notification does not interfere with sound
// from first notify invocation as outlined in: // from first notify invocation as outlined in:
// https://developer.android.com/training/notify-user/build-notification#Updating // https://developer.android.com/training/notify-user/build-notification#Updating
notifiableEvent.hasBeenDisplayed = false queuedEvents.remove(existing)
eventList.remove(existing) queuedEvents.add(notifiableEvent)
eventList.add(notifiableEvent)
} else { } else {
// keep the existing one, do not replace // 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 // Check if this is an edit
if (notifiableEvent.editedEventId != null) { if (notifiableEvent.editedEventId != null) {
// This is an edition // This is an edition
val eventBeforeEdition = eventList.firstOrNull { val eventBeforeEdition = queuedEvents.firstOrNull {
// Edition of an event // Edition of an event
it.eventId == notifiableEvent.editedEventId || it.eventId == notifiableEvent.editedEventId ||
// or edition of an edition // or edition of an edition
@ -140,9 +135,9 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
if (eventBeforeEdition != null) { if (eventBeforeEdition != null) {
// Replace the existing notification with the new content // Replace the existing notification with the new content
eventList.remove(eventBeforeEdition) queuedEvents.remove(eventBeforeEdition)
eventList.add(notifiableEvent) queuedEvents.add(notifiableEvent)
} else { } else {
// Ignore an edit of a not displayed event in the notification drawer // 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") Timber.d("onNotifiableEventReceived(): skipping event, already seen")
} else { } else {
seenEventIds.put(notifiableEvent.eventId) 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) { fun onEventRedacted(eventId: String) {
synchronized(eventList) { synchronized(queuedEvents) {
eventList.find { it.eventId == eventId }?.apply { queuedEvents.replace(eventId) {
isRedacted = true when (it) {
hasBeenDisplayed = false 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 * Clear all known events and refresh the notification drawer
*/ */
fun clearAllEvents() { fun clearAllEvents() {
synchronized(eventList) { synchronized(queuedEvents) {
eventList.clear() queuedEvents.clear()
} }
refreshNotificationDrawer() refreshNotificationDrawer()
} }
@ -183,14 +181,8 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
fun clearMessageEventOfRoom(roomId: String?) { fun clearMessageEventOfRoom(roomId: String?) {
Timber.v("clearMessageEventOfRoom $roomId") Timber.v("clearMessageEventOfRoom $roomId")
if (roomId != null) { if (roomId != null) {
var shouldUpdate = false val shouldUpdate = removeAll { it is NotifiableMessageEvent && it.roomId == roomId }
synchronized(eventList) {
shouldUpdate = eventList.removeAll { e ->
e is NotifiableMessageEvent && e.roomId == roomId
}
}
if (shouldUpdate) { if (shouldUpdate) {
notificationUtils.cancelNotificationMessage(roomId, ROOM_MESSAGES_NOTIFICATION_ID)
refreshNotificationDrawer() refreshNotificationDrawer()
} }
} }
@ -202,7 +194,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
*/ */
fun setCurrentRoom(roomId: String?) { fun setCurrentRoom(roomId: String?) {
var hasChanged: Boolean var hasChanged: Boolean
synchronized(eventList) { synchronized(queuedEvents) {
hasChanged = roomId != currentRoomId hasChanged = roomId != currentRoomId
currentRoomId = roomId currentRoomId = roomId
} }
@ -212,12 +204,16 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
} }
fun clearMemberShipNotificationForRoom(roomId: String) { fun clearMemberShipNotificationForRoom(roomId: String) {
synchronized(eventList) { val shouldUpdate = removeAll { it is InviteNotifiableEvent && it.roomId == roomId }
eventList.removeAll { e -> if (shouldUpdate) {
e is InviteNotifiableEvent && e.roomId == roomId 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) private var firstThrottler = FirstThrottler(200)
@ -244,359 +240,36 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
private fun refreshNotificationDrawerBg() { private fun refreshNotificationDrawerBg() {
Timber.v("refreshNotificationDrawerBg()") Timber.v("refreshNotificationDrawerBg()")
val session = currentSession ?: return val newSettings = vectorPreferences.useCompleteNotificationFormat()
if (newSettings != useCompleteNotificationFormat) {
val user = session.getUser(session.myUserId) // Settings has changed, remove all current notifications
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash notificationDisplayer.cancelAllNotifications()
val myUserDisplayName = user?.toMatrixItem()?.getBestName() ?: session.myUserId useCompleteNotificationFormat = newSettings
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<String, MutableList<NotifiableMessageEvent>> = LinkedHashMap()
val simpleEvents: MutableList<SimpleNotifiableEvent> = ArrayList()
val invitationEvents: MutableList<InviteNotifiableEvent> = 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<PowerManager>()!!
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
} }
}
private fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? { val eventsToRender = synchronized(queuedEvents) {
if (events.isEmpty()) return null notifiableEventProcessor.process(queuedEvents, currentRoomId, renderedEvents).also {
queuedEvents.clear()
queuedEvents.addAll(it.onlyKeptEvents())
}
}
// Use the last event (most recent?) if (renderedEvents == eventsToRender) {
val roomAvatarPath = events.last().roomAvatarPath ?: events.last().senderAvatarPath Timber.d("Skipping notification update due to event list not changing")
} else {
return bitmapLoader.getRoomBitmap(roomAvatarPath) 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 { fun shouldIgnoreMessageEventInRoom(roomId: String?): Boolean {
@ -604,8 +277,8 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
} }
fun persistInfo() { fun persistInfo() {
synchronized(eventList) { synchronized(queuedEvents) {
if (eventList.isEmpty()) { if (queuedEvents.isEmpty()) {
deleteCachedRoomNotifications() deleteCachedRoomNotifications()
return return
} }
@ -613,7 +286,7 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME) val file = File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME)
if (!file.exists()) file.createNewFile() if (!file.exists()) file.createNewFile()
FileOutputStream(file).use { FileOutputStream(file).use {
currentSession?.securelyStoreObject(eventList, KEY_ALIAS_SECRET_STORAGE, it) currentSession?.securelyStoreObject(queuedEvents, KEY_ALIAS_SECRET_STORAGE, it)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Timber.e(e, "## Failed to save cached notification info") 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 { companion object {
private const val SUMMARY_NOTIFICATION_ID = 0 const val SUMMARY_NOTIFICATION_ID = 0
private const val ROOM_MESSAGES_NOTIFICATION_ID = 1 const val ROOM_MESSAGES_NOTIFICATION_ID = 1
private const val ROOM_EVENT_NOTIFICATION_ID = 2 const val ROOM_EVENT_NOTIFICATION_ID = 2
private const val ROOM_INVITATION_NOTIFICATION_ID = 3 const val ROOM_INVITATION_NOTIFICATION_ID = 3
// TODO Mutliaccount // TODO Mutliaccount
private const val ROOMS_NOTIFICATIONS_FILE_NAME = "im.vector.notifications.cache" 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 const val KEY_ALIAS_SECRET_STORAGE = "notificationMgr"
} }
} }
private fun MutableList<NotifiableEvent>.replace(eventId: String, block: (NotifiableEvent) -> NotifiableEvent) {
val indexToReplace = indexOfFirst { it.eventId == eventId }
if (indexToReplace == -1) {
return
}
set(indexToReplace, block(get(indexToReplace)))
}

View file

@ -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<ProcessedEvent<NotifiableMessageEvent>>
class NotificationFactory @Inject constructor(
private val notificationUtils: NotificationUtils,
private val roomGroupMessageCreator: RoomGroupMessageCreator,
private val summaryGroupMessageCreator: SummaryGroupMessageCreator
) {
fun Map<String, ProcessedMessageEvents>.toNotifications(myUserDisplayName: String, myUserAvatarUrl: String?): List<RoomNotification> {
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<ProcessedEvent<InviteNotifiableEvent>>.toNotifications(myUserId: String): List<OneShotNotification> {
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<ProcessedEvent<SimpleNotifiableEvent>>.toNotifications(myUserId: String): List<OneShotNotification> {
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<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
useCompleteNotificationFormat: Boolean): SummaryNotification {
val roomMeta = roomNotifications.filterIsInstance<RoomNotification.Message>().map { it.meta }
val invitationMeta = invitationNotifications.filterIsInstance<OneShotNotification.Append>().map { it.meta }
val simpleMeta = simpleNotifications.filterIsInstance<OneShotNotification.Append>().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
}

View file

@ -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<ProcessedEvent<NotifiableEvent>>) {
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<ProcessedEvent<NotifiableEvent>>.groupByType(): GroupedNotificationEvents {
val roomIdToEventMap: MutableMap<String, MutableList<ProcessedEvent<NotifiableMessageEvent>>> = LinkedHashMap()
val simpleEvents: MutableList<ProcessedEvent<SimpleNotifiableEvent>> = ArrayList()
val invitationEvents: MutableList<ProcessedEvent<InviteNotifiableEvent>> = 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 <T : NotifiableEvent> ProcessedEvent<NotifiableEvent>.castedToEventType(): ProcessedEvent<T> = this as ProcessedEvent<T>
data class GroupedNotificationEvents(
val roomEvents: Map<String, List<ProcessedEvent<NotifiableMessageEvent>>>,
val simpleEvents: List<ProcessedEvent<SimpleNotifiableEvent>>,
val invitationEvents: List<ProcessedEvent<InviteNotifiableEvent>>
)

View file

@ -642,7 +642,7 @@ class NotificationUtils @Inject constructor(private val context: Context,
return NotificationCompat.Builder(context, channelID) return NotificationCompat.Builder(context, channelID)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setContentTitle(stringProvider.getString(R.string.app_name)) .setContentTitle(inviteNotifiableEvent.roomName ?: stringProvider.getString(R.string.app_name))
.setContentText(inviteNotifiableEvent.description) .setContentText(inviteNotifiableEvent.description)
.setGroup(stringProvider.getString(R.string.app_name)) .setGroup(stringProvider.getString(R.string.app_name))
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)

View file

@ -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<T>(
val type: Type,
val event: T
) {
enum class Type {
KEEP,
REMOVE
}
}
fun <T> List<ProcessedEvent<T>>.onlyKeptEvents() = mapNotNull { processedEvent ->
processedEvent.event.takeIf { processedEvent.type == ProcessedEvent.Type.KEEP }
}

View file

@ -40,12 +40,11 @@ class PushRuleTriggerListener @Inject constructor(
val notificationAction = actions.toNotificationAction() val notificationAction = actions.toNotificationAction()
if (notificationAction.shouldNotify) { if (notificationAction.shouldNotify) {
val notifiableEvent = resolver.resolveEvent(event, safeSession) val notifiableEvent = resolver.resolveEvent(event, safeSession, isNoisy = !notificationAction.soundName.isNullOrBlank())
if (notifiableEvent == null) { if (notifiableEvent == null) {
Timber.v("## Failed to resolve event") Timber.v("## Failed to resolve event")
// TODO // TODO
} else { } else {
notifiableEvent.noisy = !notificationAction.soundName.isNullOrBlank()
Timber.v("New event to notify") Timber.v("New event to notify")
notificationDrawerManager.onNotifiableEventReceived(notifiableEvent) notificationDrawerManager.onNotifiableEventReceived(notifiableEvent)
} }

View file

@ -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<NotifiableMessageEvent>, 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<NotifiableMessageEvent>) {
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<NotifiableMessageEvent>, 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<NotifiableMessageEvent>): Bitmap? {
// Use the last event (most recent?)
return events.lastOrNull()
?.roomAvatarPath
?.let { bitmapLoader.getRoomBitmap(it) }
}
}
private fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed

View file

@ -15,21 +15,16 @@
*/ */
package im.vector.app.features.notifications package im.vector.app.features.notifications
import androidx.core.app.NotificationCompat
data class SimpleNotifiableEvent( data class SimpleNotifiableEvent(
override var matrixID: String?, val matrixID: String?,
override val eventId: String, override val eventId: String,
override val editedEventId: String?, override val editedEventId: String?,
override var noisy: Boolean, val noisy: Boolean,
override val title: String, val title: String,
override val description: String, val description: String,
override val type: String?, val type: String?,
override val timestamp: Long, val timestamp: Long,
override var soundName: String?, val soundName: String?,
override var isPushGatewayEvent: Boolean = false) : NotifiableEvent { override var canBeReplaced: Boolean,
override val isRedacted: Boolean = false
override var hasBeenDisplayed: Boolean = false ) : NotifiableEvent
override var isRedacted: Boolean = false
override var lockScreenVisibility = NotificationCompat.VISIBILITY_PUBLIC
}

View file

@ -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<RoomNotification.Message.Meta>,
invitationNotifications: List<OneShotNotification.Append.Meta>,
simpleNotifications: List<OneShotNotification.Append.Meta>,
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
)
}
}

View file

@ -79,15 +79,14 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti
return when (permalinkData) { return when (permalinkData) {
is PermalinkData.RoomLink -> { is PermalinkData.RoomLink -> {
val roomId = permalinkData.getRoomId() val roomId = permalinkData.getRoomId()
if (navigationInterceptor?.navToRoom(roomId, permalinkData.eventId, rawLink) != true) { openRoom(
openRoom( navigationInterceptor,
context = context, context = context,
roomId = roomId, roomId = roomId,
permalinkData = permalinkData, permalinkData = permalinkData,
rawLink = rawLink, rawLink = rawLink,
buildTask = buildTask buildTask = buildTask
) )
}
true true
} }
is PermalinkData.GroupLink -> { is PermalinkData.GroupLink -> {
@ -146,6 +145,7 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti
* Open room either joined, or not * Open room either joined, or not
*/ */
private fun openRoom( private fun openRoom(
navigationInterceptor: NavigationInterceptor?,
context: Context, context: Context,
roomId: String?, roomId: String?,
permalinkData: PermalinkData.RoomLink, permalinkData: PermalinkData.RoomLink,
@ -167,7 +167,7 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti
membership?.isActive().orFalse() -> { membership?.isActive().orFalse() -> {
if (!isSpace && membership == Membership.JOIN) { if (!isSpace && membership == Membership.JOIN) {
// If it's a room you're in, let's just open it, you can tap back if needed // 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 { } else {
// maybe open space preview navigator.openSpacePreview(context, roomId)? if already joined? // maybe open space preview navigator.openSpacePreview(context, roomId)? if already joined?
navigator.openMatrixToBottomSheet(context, rawLink.toString()) 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 { companion object {
const val MATRIX_TO_CUSTOM_SCHEME_URL_BASE = "element://" const val MATRIX_TO_CUSTOM_SCHEME_URL_BASE = "element://"
const val ROOM_LINK_PREFIX = "${MATRIX_TO_CUSTOM_SCHEME_URL_BASE}room/" const val ROOM_LINK_PREFIX = "${MATRIX_TO_CUSTOM_SCHEME_URL_BASE}room/"

View file

@ -20,12 +20,14 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import com.airbnb.mvrx.Mavericks import com.airbnb.mvrx.Mavericks
import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.appbar.MaterialToolbar
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.addFragment import im.vector.app.core.extensions.addFragment
import im.vector.app.core.platform.ToolbarConfigurable import im.vector.app.core.platform.ToolbarConfigurable
import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivitySimpleBinding import im.vector.app.databinding.ActivitySimpleBinding
@AndroidEntryPoint
class PinActivity : VectorBaseActivity<ActivitySimpleBinding>(), ToolbarConfigurable, UnlockedActivity { class PinActivity : VectorBaseActivity<ActivitySimpleBinding>(), ToolbarConfigurable, UnlockedActivity {
companion object { companion object {

View file

@ -29,6 +29,8 @@ import im.vector.app.core.extensions.addFragmentToBackstack
import im.vector.app.core.extensions.popBackstack import im.vector.app.core.extensions.popBackstack
import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivitySimpleBinding 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.CreateRoomArgs
import im.vector.app.features.roomdirectory.createroom.CreateRoomFragment import im.vector.app.features.roomdirectory.createroom.CreateRoomFragment
import im.vector.app.features.roomdirectory.picker.RoomDirectoryPickerFragment import im.vector.app.features.roomdirectory.picker.RoomDirectoryPickerFragment
@ -37,7 +39,7 @@ import kotlinx.coroutines.flow.onEach
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class RoomDirectoryActivity : VectorBaseActivity<ActivitySimpleBinding>() { class RoomDirectoryActivity : VectorBaseActivity<ActivitySimpleBinding>(), MatrixToBottomSheet.InteractionListener {
@Inject lateinit var roomDirectoryViewModelFactory: RoomDirectoryViewModel.Factory @Inject lateinit var roomDirectoryViewModelFactory: RoomDirectoryViewModel.Factory
private val roomDirectoryViewModel: RoomDirectoryViewModel by viewModel() private val roomDirectoryViewModel: RoomDirectoryViewModel by viewModel()
@ -84,6 +86,14 @@ class RoomDirectoryActivity : VectorBaseActivity<ActivitySimpleBinding>() {
} }
} }
override fun mxToBottomSheetNavigateToRoom(roomId: String) {
navigator.openRoom(this, roomId)
}
override fun mxToBottomSheetSwitchToSpace(spaceId: String) {
navigator.switchToSpace(this, spaceId, Navigator.PostSwitchSpaceAction.None)
}
companion object { companion object {
private const val INITIAL_FILTER = "INITIAL_FILTER" private const val INITIAL_FILTER = "INITIAL_FILTER"

View file

@ -20,6 +20,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Parcelable import android.os.Parcelable
import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.appbar.MaterialToolbar
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.addFragment import im.vector.app.core.extensions.addFragment
import im.vector.app.core.platform.ToolbarConfigurable import im.vector.app.core.platform.ToolbarConfigurable
@ -51,6 +52,7 @@ data class RoomPreviewData(
get() = MatrixItem.RoomItem(roomId, roomName ?: roomAlias, avatarUrl) get() = MatrixItem.RoomItem(roomId, roomName ?: roomAlias, avatarUrl)
} }
@AndroidEntryPoint
class RoomPreviewActivity : VectorBaseActivity<ActivitySimpleBinding>(), ToolbarConfigurable { class RoomPreviewActivity : VectorBaseActivity<ActivitySimpleBinding>(), ToolbarConfigurable {
companion object { companion object {

View file

@ -19,6 +19,7 @@ package im.vector.app.features.signout.hard
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivitySignedOutBinding import im.vector.app.databinding.ActivitySignedOutBinding
import im.vector.app.features.MainActivity 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 * In this screen, the user is viewing a message informing that he has been logged out
*/ */
@AndroidEntryPoint
class SignedOutActivity : VectorBaseActivity<ActivitySignedOutBinding>() { class SignedOutActivity : VectorBaseActivity<ActivitySignedOutBinding>() {
override fun getBinding() = ActivitySignedOutBinding.inflate(layoutInflater) override fun getBinding() = ActivitySignedOutBinding.inflate(layoutInflater)

View file

@ -21,6 +21,7 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.Mavericks import com.airbnb.mvrx.Mavericks
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.commitTransaction import im.vector.app.core.extensions.commitTransaction
import im.vector.app.core.platform.VectorBaseActivity 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.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@AndroidEntryPoint
class SpacePreviewActivity : VectorBaseActivity<ActivitySimpleBinding>() { class SpacePreviewActivity : VectorBaseActivity<ActivitySimpleBinding>() {
lateinit var sharedActionViewModel: SpacePreviewSharedActionViewModel lateinit var sharedActionViewModel: SpacePreviewSharedActionViewModel

View file

@ -23,6 +23,7 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.Mavericks import com.airbnb.mvrx.Mavericks
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.commitTransaction import im.vector.app.core.extensions.commitTransaction
import im.vector.app.core.extensions.hideKeyboard 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.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@AndroidEntryPoint
class SpacePeopleActivity : VectorBaseActivity<ActivitySimpleLoadingBinding>() { class SpacePeopleActivity : VectorBaseActivity<ActivitySimpleLoadingBinding>() {
override fun getBinding() = ActivitySimpleLoadingBinding.inflate(layoutInflater) override fun getBinding() = ActivitySimpleLoadingBinding.inflate(layoutInflater)

View file

@ -657,6 +657,8 @@
<string name="login_error_unknown_host">This URL is not reachable, please check it</string> <string name="login_error_unknown_host">This URL is not reachable, please check it</string>
<string name="login_error_no_homeserver_found">This is not a valid Matrix server address</string> <string name="login_error_no_homeserver_found">This is not a valid Matrix server address</string>
<string name="login_error_homeserver_not_found">Cannot reach a homeserver at this URL, please check it</string> <string name="login_error_homeserver_not_found">Cannot reach a homeserver at this URL, please check it</string>
<string name="login_error_homeserver_from_url_not_found">Cannot reach a homeserver at the URL %s. Please check your link or choose a homeserver manually.</string>
<string name="login_error_homeserver_from_url_not_found_enter_manual">Choose homeserver</string>
<string name="login_error_ssl_peer_unverified">"SSL Error: the peer's identity has not been verified."</string> <string name="login_error_ssl_peer_unverified">"SSL Error: the peer's identity has not been verified."</string>
<string name="login_error_ssl_other">"SSL Error."</string> <string name="login_error_ssl_other">"SSL Error."</string>
<string name="login_error_ssl_handshake">Your device is using an outdated TLS security protocol, vulnerable to attack, for your security you will not be able to connect</string> <string name="login_error_ssl_handshake">Your device is using an outdated TLS security protocol, vulnerable to attack, for your security you will not be able to connect</string>

View file

@ -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<NotifiableEvent>(
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<NotifiableEvent>>(
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<Type, NotifiableEvent>) = 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
)

View file

@ -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 <T> testWith(receiver: T, block: T.() -> Unit) {
receiver.block()
}

View file

@ -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<ProcessedEvent<NotifiableEvent>>()
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<Notification>()
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<RoomNotification> = emptyList(),
invitationNotifications: List<OneShotNotification> = emptyList(),
simpleNotifications: List<OneShotNotification> = 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
)
}
}

View file

@ -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
}

View file

@ -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<NotificationDisplayer>(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()
}
}

View file

@ -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<NotificationFactory>()
fun givenNotificationsFor(groupedEvents: GroupedNotificationEvents,
myUserId: String,
myUserDisplayName: String,
myUserAvatarUrl: String?,
useCompleteNotificationFormat: Boolean,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
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
}
}
}

View file

@ -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<NotificationUtils>()
fun givenBuildRoomInvitationNotificationFor(event: InviteNotifiableEvent, myUserId: String): Notification {
val mockNotification = mockk<Notification>()
every { instance.buildRoomInvitationNotification(event, myUserId) } returns mockNotification
return mockNotification
}
fun givenBuildSimpleInvitationNotificationFor(event: SimpleNotifiableEvent, myUserId: String): Notification {
val mockNotification = mockk<Notification>()
every { instance.buildSimpleEventNotification(event, myUserId) } returns mockNotification
return mockNotification
}
}

View file

@ -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<OutdatedEventDetector>()
fun givenEventIsOutOfDate(notifiableEvent: NotifiableEvent) {
every { instance.isMessageOutdated(notifiableEvent) } returns true
}
fun givenEventIsInDate(notifiableEvent: NotifiableEvent) {
every { instance.isMessageOutdated(notifiableEvent) } returns false
}
}

View file

@ -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<RoomGroupMessageCreator>()
fun givenCreatesRoomMessageFor(events: List<NotifiableMessageEvent>,
roomId: String,
userDisplayName: String,
userAvatarUrl: String?): RoomNotification.Message {
val mockMessage = mockk<RoomNotification.Message>()
every { instance.createRoomMessage(events, roomId, userDisplayName, userAvatarUrl) } returns mockMessage
return mockMessage
}
}

View file

@ -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<SummaryGroupMessageCreator>()
}

View file

@ -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<VectorPreferences>()
fun givenUseCompleteNotificationFormat(value: Boolean) {
every { instance.useCompleteNotificationFormat() } returns value
}
}