Merge remote-tracking branch 'origin/develop' into task/eric/replace_flatten_with_direct_parent

# Conflicts:
#	matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo033.kt
This commit is contained in:
ericdecanini 2022-07-20 10:56:07 +02:00
commit ee941cf88d
95 changed files with 1846 additions and 362 deletions

1
changelog.d/6437.feature Normal file
View File

@ -0,0 +1 @@
[Location sharing] - Delete action on a live message

1
changelog.d/6487.feature Normal file
View File

@ -0,0 +1 @@
[Timeline] - Collapse redacted events

1
changelog.d/6537.bugfix Normal file
View File

@ -0,0 +1 @@
[Location Share] - Wrong room live location status bar visibility in timeline

1
changelog.d/6546.feature Normal file
View File

@ -0,0 +1 @@
Updates FTUE registration to include username availability check and update copy

1
changelog.d/6547.feature Normal file
View File

@ -0,0 +1 @@
Updates the copy within the FTUE onboarding

1
changelog.d/6567.feature Normal file
View File

@ -0,0 +1 @@
Share location with other apps

1
changelog.d/6579.bugfix Normal file
View File

@ -0,0 +1 @@
Do not log the live location of the user

1
changelog.d/6584.misc Normal file
View File

@ -0,0 +1 @@
Adds NewAppLayoutEnabled feature flag

View File

@ -156,6 +156,20 @@ object MatrixPatterns {
return matrixId?.substringAfter(":", missingDelimiterValue = "")?.takeIf { it.isNotEmpty() }
}
/**
* Extract user name from a matrix id.
*
* @param matrixId
* @return null if the input is not a valid matrixId
*/
fun extractUserNameFromId(matrixId: String): String? {
return if (isUserId(matrixId)) {
matrixId.removePrefix("@").substringBefore(":", missingDelimiterValue = "")
} else {
null
}
}
/**
* Orders which are not strings, or do not consist solely of ascii characters in the range \x20 (space) to \x7E (~),
* or consist of more than 50 characters, are forbidden and the field should be ignored if received.

View File

@ -202,7 +202,7 @@ data class Event(
* It will return a decrypted text message or an empty string otherwise.
*/
fun getDecryptedTextSummary(): String? {
if (isRedacted()) return "Message Deleted"
if (isRedacted()) return "Message removed"
val text = getDecryptedValue() ?: run {
if (isPoll()) {
return getPollQuestion() ?: "created a poll."
@ -371,6 +371,8 @@ fun Event.isPoll(): Boolean = getClearType() in EventType.POLL_START || getClear
fun Event.isSticker(): Boolean = getClearType() == EventType.STICKER
fun Event.isLiveLocation(): Boolean = getClearType() in EventType.STATE_ROOM_BEACON_INFO
fun Event.getRelationContent(): RelationDefaultContent? {
return if (isEncrypted()) {
content.toModel<EncryptedEventContent>()?.relatesTo

View File

@ -16,7 +16,6 @@
package org.matrix.android.sdk.api.session.room.location
import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary
import org.matrix.android.sdk.api.util.Cancelable
@ -59,16 +58,21 @@ interface LocationSharingService {
*/
suspend fun stopLiveLocationShare(): UpdateLiveLocationShareResult
/**
* Redact (delete) the live associated to the given beacon info event id.
* @param beaconInfoEventId event id of the initial beacon info state event
* @param reason Optional reason string
*/
suspend fun redactLiveLocationShare(beaconInfoEventId: String, reason: String?)
/**
* Returns a LiveData on the list of current running live location shares.
*/
@MainThread
fun getRunningLiveLocationShareSummaries(): LiveData<List<LiveLocationShareAggregatedSummary>>
/**
* Returns a LiveData on the live location share summary with the given eventId.
* @param beaconInfoEventId event id of the initial beacon info state event
*/
@MainThread
fun getLiveLocationShareSummary(beaconInfoEventId: String): LiveData<Optional<LiveLocationShareAggregatedSummary>>
}

View File

@ -23,6 +23,7 @@ import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.getRelationContent
import org.matrix.android.sdk.api.session.events.model.isEdition
import org.matrix.android.sdk.api.session.events.model.isLiveLocation
import org.matrix.android.sdk.api.session.events.model.isPoll
import org.matrix.android.sdk.api.session.events.model.isReply
import org.matrix.android.sdk.api.session.events.model.isSticker
@ -165,6 +166,10 @@ fun TimelineEvent.isSticker(): Boolean {
return root.isSticker()
}
fun TimelineEvent.isLiveLocation(): Boolean {
return root.isLiveLocation()
}
/**
* Returns whether or not the event is a root thread event.
*/

View File

@ -17,14 +17,17 @@
package org.matrix.android.sdk.internal.database.migration
import io.realm.DynamicRealm
import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntityFields
import org.matrix.android.sdk.internal.util.database.RealmMigrator
/**
* Migrating to:
* Live location sharing aggregated summary: adding new field relatedEventIds.
*/
internal class MigrateSessionTo033(realm: DynamicRealm) : RealmMigrator(realm, 33) {
override fun doMigrate(realm: DynamicRealm) {
realm.schema.get("RoomSummaryEntity")
?.addRealmListField(RoomSummaryEntityFields.DIRECT_PARENT_NAMES.`$`, String::class.java)
?.transform { it.setString(RoomSummaryEntityFields.DIRECT_PARENT_NAMES.`$`, "") }
realm.schema.get("LiveLocationShareAggregatedSummaryEntity")
?.addRealmListField(LiveLocationShareAggregatedSummaryEntityFields.RELATED_EVENT_IDS.`$`, String::class.java)
}
}

View File

@ -16,6 +16,7 @@
package org.matrix.android.sdk.internal.database.model.livelocation
import io.realm.RealmList
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
@ -29,6 +30,11 @@ internal open class LiveLocationShareAggregatedSummaryEntity(
@PrimaryKey
var eventId: String = "",
/**
* List of event ids used to compute the aggregated summary data.
*/
var relatedEventIds: RealmList<String> = RealmList(),
var roomId: String = "",
var userId: String = "",

View File

@ -23,6 +23,11 @@ import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEnt
import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntityFields
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
internal fun EventAnnotationsSummaryEntity.Companion.where(realm: Realm, eventId: String): RealmQuery<EventAnnotationsSummaryEntity> {
return realm.where<EventAnnotationsSummaryEntity>()
.equalTo(EventAnnotationsSummaryEntityFields.EVENT_ID, eventId)
}
internal fun EventAnnotationsSummaryEntity.Companion.where(realm: Realm, roomId: String, eventId: String): RealmQuery<EventAnnotationsSummaryEntity> {
return realm.where<EventAnnotationsSummaryEntity>()
.equalTo(EventAnnotationsSummaryEntityFields.ROOM_ID, roomId)
@ -44,3 +49,7 @@ internal fun EventAnnotationsSummaryEntity.Companion.getOrCreate(realm: Realm, r
return EventAnnotationsSummaryEntity.where(realm, roomId, eventId).findFirst()
?: EventAnnotationsSummaryEntity.create(realm, roomId, eventId)
}
internal fun EventAnnotationsSummaryEntity.Companion.get(realm: Realm, eventId: String): EventAnnotationsSummaryEntity? {
return EventAnnotationsSummaryEntity.where(realm, eventId).findFirst()
}

View File

@ -23,6 +23,14 @@ import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEnt
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntityFields
internal fun LiveLocationShareAggregatedSummaryEntity.Companion.where(
realm: Realm,
eventId: String,
): RealmQuery<LiveLocationShareAggregatedSummaryEntity> {
return realm.where<LiveLocationShareAggregatedSummaryEntity>()
.equalTo(LiveLocationShareAggregatedSummaryEntityFields.EVENT_ID, eventId)
}
internal fun LiveLocationShareAggregatedSummaryEntity.Companion.where(
realm: Realm,
roomId: String,
@ -72,6 +80,13 @@ internal fun LiveLocationShareAggregatedSummaryEntity.Companion.get(
return LiveLocationShareAggregatedSummaryEntity.where(realm, roomId, eventId).findFirst()
}
internal fun LiveLocationShareAggregatedSummaryEntity.Companion.get(
realm: Realm,
eventId: String,
): LiveLocationShareAggregatedSummaryEntity? {
return LiveLocationShareAggregatedSummaryEntity.where(realm, eventId).findFirst()
}
internal fun LiveLocationShareAggregatedSummaryEntity.Companion.findActiveLiveInRoomForUser(
realm: Realm,
roomId: String,

View File

@ -88,6 +88,7 @@ import org.matrix.android.sdk.internal.session.room.EventRelationsAggregationPro
import org.matrix.android.sdk.internal.session.room.aggregation.poll.DefaultPollAggregationProcessor
import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollAggregationProcessor
import org.matrix.android.sdk.internal.session.room.create.RoomCreateEventProcessor
import org.matrix.android.sdk.internal.session.room.location.LiveLocationShareRedactionEventProcessor
import org.matrix.android.sdk.internal.session.room.prune.RedactionEventProcessor
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessorCoroutine
@ -321,6 +322,10 @@ internal abstract class SessionModule {
@IntoSet
abstract fun bindEventRedactionProcessor(processor: RedactionEventProcessor): EventInsertLiveProcessor
@Binds
@IntoSet
abstract fun bindLiveLocationShareRedactionEventProcessor(processor: LiveLocationShareRedactionEventProcessor): EventInsertLiveProcessor
@Binds
@IntoSet
abstract fun bindEventRelationsAggregationProcessor(processor: EventRelationsAggregationProcessor): EventInsertLiveProcessor

View File

@ -58,11 +58,13 @@ import org.matrix.android.sdk.internal.session.room.directory.SetRoomDirectoryVi
import org.matrix.android.sdk.internal.session.room.location.CheckIfExistingActiveLiveTask
import org.matrix.android.sdk.internal.session.room.location.DefaultCheckIfExistingActiveLiveTask
import org.matrix.android.sdk.internal.session.room.location.DefaultGetActiveBeaconInfoForUserTask
import org.matrix.android.sdk.internal.session.room.location.DefaultRedactLiveLocationShareTask
import org.matrix.android.sdk.internal.session.room.location.DefaultSendLiveLocationTask
import org.matrix.android.sdk.internal.session.room.location.DefaultSendStaticLocationTask
import org.matrix.android.sdk.internal.session.room.location.DefaultStartLiveLocationShareTask
import org.matrix.android.sdk.internal.session.room.location.DefaultStopLiveLocationShareTask
import org.matrix.android.sdk.internal.session.room.location.GetActiveBeaconInfoForUserTask
import org.matrix.android.sdk.internal.session.room.location.RedactLiveLocationShareTask
import org.matrix.android.sdk.internal.session.room.location.SendLiveLocationTask
import org.matrix.android.sdk.internal.session.room.location.SendStaticLocationTask
import org.matrix.android.sdk.internal.session.room.location.StartLiveLocationShareTask
@ -339,4 +341,7 @@ internal abstract class RoomModule {
@Binds
abstract fun bindCheckIfExistingActiveLiveTask(task: DefaultCheckIfExistingActiveLiveTask): CheckIfExistingActiveLiveTask
@Binds
abstract fun bindRedactLiveLocationShareTask(task: DefaultRedactLiveLocationShareTask): RedactLiveLocationShareTask
}

View File

@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.room.aggregation.livelocation
import androidx.work.ExistingWorkPolicy
import io.realm.Realm
import io.realm.RealmList
import org.matrix.android.sdk.api.extensions.orTrue
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.toContent
@ -73,6 +74,11 @@ internal class LiveLocationAggregationProcessor @Inject constructor(
eventId = targetEventId
)
if (!isLive && !event.eventId.isNullOrEmpty()) {
// in this case, the received event is a new state event related to the previous one
addRelatedEventId(event.eventId, aggregatedSummary)
}
// remote event can stay with isLive == true while the local summary is no more active
val isActive = aggregatedSummary.isActive.orTrue() && isLive
val endOfLiveTimestampMillis = content.getBestTimestampMillis()?.let { it + (content.timeout ?: 0) }
@ -144,6 +150,11 @@ internal class LiveLocationAggregationProcessor @Inject constructor(
roomId = roomId,
eventId = relatedEventId
)
if (!event.eventId.isNullOrEmpty()) {
addRelatedEventId(event.eventId, aggregatedSummary)
}
val updatedLocationTimestamp = content.getBestTimestampMillis() ?: 0
val currentLocationTimestamp = ContentMapper
.map(aggregatedSummary.lastLocationContent)
@ -160,6 +171,17 @@ internal class LiveLocationAggregationProcessor @Inject constructor(
}
}
private fun addRelatedEventId(
eventId: String,
aggregatedSummary: LiveLocationShareAggregatedSummaryEntity
) {
Timber.d("adding related event id $eventId to summary of id ${aggregatedSummary.eventId}")
val updatedEventIds = aggregatedSummary.relatedEventIds.toMutableList().also {
it.add(eventId)
}
aggregatedSummary.relatedEventIds = RealmList(*updatedEventIds.toTypedArray())
}
private fun deactivateAllPreviousBeacons(realm: Realm, roomId: String, userId: String, currentEventId: String) {
LiveLocationShareAggregatedSummaryEntity
.findActiveLiveInRoomForUser(

View File

@ -42,6 +42,7 @@ internal class DefaultLocationSharingService @AssistedInject constructor(
private val startLiveLocationShareTask: StartLiveLocationShareTask,
private val stopLiveLocationShareTask: StopLiveLocationShareTask,
private val checkIfExistingActiveLiveTask: CheckIfExistingActiveLiveTask,
private val redactLiveLocationShareTask: RedactLiveLocationShareTask,
private val liveLocationShareAggregatedSummaryMapper: LiveLocationShareAggregatedSummaryMapper,
) : LocationSharingService {
@ -102,6 +103,15 @@ internal class DefaultLocationSharingService @AssistedInject constructor(
return stopLiveLocationShareTask.execute(params)
}
override suspend fun redactLiveLocationShare(beaconInfoEventId: String, reason: String?) {
val params = RedactLiveLocationShareTask.Params(
roomId = roomId,
beaconInfoEventId = beaconInfoEventId,
reason = reason
)
return redactLiveLocationShareTask.execute(params)
}
override fun getRunningLiveLocationShareSummaries(): LiveData<List<LiveLocationShareAggregatedSummary>> {
return monarchy.findAllMappedWithChanges(
{ LiveLocationShareAggregatedSummaryEntity.findRunningLiveInRoom(it, roomId = roomId) },

View File

@ -0,0 +1,65 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.room.location
import io.realm.Realm
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.LocalEcho
import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity
import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.EventInsertType
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.query.get
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.session.EventInsertLiveProcessor
import timber.log.Timber
import javax.inject.Inject
/**
* Listens to the database for the insertion of any redaction event.
* Delete specifically the aggregated summary related to a redacted live location share event.
*/
internal class LiveLocationShareRedactionEventProcessor @Inject constructor() : EventInsertLiveProcessor {
override fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean {
return eventType == EventType.REDACTION && insertType != EventInsertType.LOCAL_ECHO
}
override suspend fun process(realm: Realm, event: Event) {
if (event.redacts.isNullOrBlank() || LocalEcho.isLocalEchoId(event.eventId.orEmpty())) {
return
}
val redactedEvent = EventEntity.where(realm, eventId = event.redacts).findFirst()
?: return
if (redactedEvent.type in EventType.STATE_ROOM_BEACON_INFO) {
val liveSummary = LiveLocationShareAggregatedSummaryEntity.get(realm, eventId = redactedEvent.eventId)
if (liveSummary != null) {
Timber.d("deleting live summary with id: ${liveSummary.eventId}")
liveSummary.deleteFromRealm()
val annotationsSummary = EventAnnotationsSummaryEntity.get(realm, eventId = redactedEvent.eventId)
if (annotationsSummary != null) {
Timber.d("deleting annotation summary with id: ${annotationsSummary.eventId}")
annotationsSummary.deleteFromRealm()
}
}
}
}
}

View File

@ -0,0 +1,78 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.room.location
import io.realm.RealmConfiguration
import org.matrix.android.sdk.internal.database.awaitTransaction
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.query.get
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
import org.matrix.android.sdk.internal.task.Task
import timber.log.Timber
import javax.inject.Inject
internal interface RedactLiveLocationShareTask : Task<RedactLiveLocationShareTask.Params, Unit> {
data class Params(
val roomId: String,
val beaconInfoEventId: String,
val reason: String?
)
}
internal class DefaultRedactLiveLocationShareTask @Inject constructor(
@SessionDatabase private val realmConfiguration: RealmConfiguration,
private val localEchoEventFactory: LocalEchoEventFactory,
private val eventSenderProcessor: EventSenderProcessor,
) : RedactLiveLocationShareTask {
override suspend fun execute(params: RedactLiveLocationShareTask.Params) {
val relatedEventIds = getRelatedEventIdsOfLive(params.beaconInfoEventId)
Timber.d("beacon with id ${params.beaconInfoEventId} has related event ids: ${relatedEventIds.joinToString(", ")}")
postRedactionWithLocalEcho(
eventId = params.beaconInfoEventId,
roomId = params.roomId,
reason = params.reason
)
relatedEventIds.forEach { eventId ->
postRedactionWithLocalEcho(
eventId = eventId,
roomId = params.roomId,
reason = params.reason
)
}
}
private suspend fun getRelatedEventIdsOfLive(beaconInfoEventId: String): List<String> {
return awaitTransaction(realmConfiguration) { realm ->
val aggregatedSummaryEntity = LiveLocationShareAggregatedSummaryEntity.get(
realm = realm,
eventId = beaconInfoEventId
)
aggregatedSummaryEntity?.relatedEventIds?.toList() ?: emptyList()
}
}
private fun postRedactionWithLocalEcho(eventId: String, roomId: String, reason: String?) {
Timber.d("posting redaction for event of id $eventId")
val redactionEcho = localEchoEventFactory.createRedactEvent(roomId, eventId, reason)
localEchoEventFactory.createLocalEcho(redactionEcho)
eventSenderProcessor.postRedaction(redactionEcho, reason)
}
}

View File

@ -74,6 +74,8 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr
when (typeToPrune) {
EventType.ENCRYPTED,
EventType.MESSAGE,
in EventType.STATE_ROOM_BEACON_INFO,
in EventType.BEACON_LOCATION_DATA,
in EventType.POLL_START -> {
Timber.d("REDACTION for message ${eventToPrune.eventId}")
val unsignedData = EventMapper.map(eventToPrune).unsignedData

View File

@ -35,6 +35,23 @@ class MatrixPatternsTest {
MatrixPatterns.isUserId(input) shouldBeEqualTo expected
}
}
@Test
fun `given matrix id cases, when extracting userName, then returns expected`() {
val cases = listOf(
MatrixIdCase("foobar", userName = null),
MatrixIdCase("@foobar", userName = null),
MatrixIdCase("foobar@matrix.org", userName = null),
MatrixIdCase("@foobar: matrix.org", userName = null),
MatrixIdCase("foobar:matrix.org", userName = null),
MatrixIdCase("@foobar:matrix.org", userName = "foobar"),
)
cases.forEach { (input, expected) ->
MatrixPatterns.extractUserNameFromId(input) shouldBeEqualTo expected
}
}
}
private data class UserIdCase(val input: String, val isUserId: Boolean)
private data class MatrixIdCase(val input: String, val userName: String?)

View File

@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.room.aggregation.livelocation
import androidx.work.ExistingWorkPolicy
import org.amshove.kluent.shouldBeEqualTo
import org.amshove.kluent.shouldContain
import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.UnsignedData
@ -199,9 +200,10 @@ internal class LiveLocationAggregationProcessorTest {
age = 123,
replacesState = AN_EVENT_ID
)
val stateEventId = "state-event-id"
val event = Event(
senderId = A_SENDER_ID,
eventId = "",
eventId = stateEventId,
unsignedData = unsignedData
)
val beaconInfo = MessageBeaconInfoContent(
@ -237,6 +239,7 @@ internal class LiveLocationAggregationProcessorTest {
aggregatedEntity.roomId shouldBeEqualTo A_ROOM_ID
aggregatedEntity.userId shouldBeEqualTo A_SENDER_ID
aggregatedEntity.isActive shouldBeEqualTo false
aggregatedEntity.relatedEventIds shouldContain stateEventId
aggregatedEntity.endOfLiveTimestampMillis shouldBeEqualTo A_TIMESTAMP + A_TIMEOUT_MILLIS
aggregatedEntity.lastLocationContent shouldBeEqualTo null
previousEntities.forEach { entity ->
@ -324,7 +327,7 @@ internal class LiveLocationAggregationProcessorTest {
val lastBeaconLocationContent = MessageBeaconLocationDataContent(
unstableTimestampMillis = A_TIMESTAMP
)
givenLastSummaryQueryReturns(
val aggregatedEntity = givenLastSummaryQueryReturns(
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
beaconLocationContent = lastBeaconLocationContent
@ -340,6 +343,7 @@ internal class LiveLocationAggregationProcessorTest {
)
result shouldBeEqualTo false
aggregatedEntity.relatedEventIds shouldContain AN_EVENT_ID
}
@Test
@ -353,7 +357,7 @@ internal class LiveLocationAggregationProcessorTest {
val lastBeaconLocationContent = MessageBeaconLocationDataContent(
unstableTimestampMillis = A_TIMESTAMP - 60_000
)
val entity = givenLastSummaryQueryReturns(
val aggregatedEntity = givenLastSummaryQueryReturns(
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
beaconLocationContent = lastBeaconLocationContent
@ -369,7 +373,8 @@ internal class LiveLocationAggregationProcessorTest {
)
result shouldBeEqualTo true
val savedLocationData = ContentMapper.map(entity.lastLocationContent).toModel<MessageBeaconLocationDataContent>()
aggregatedEntity.relatedEventIds shouldContain AN_EVENT_ID
val savedLocationData = ContentMapper.map(aggregatedEntity.lastLocationContent).toModel<MessageBeaconLocationDataContent>()
savedLocationData?.getBestTimestampMillis() shouldBeEqualTo A_TIMESTAMP
savedLocationData?.getBestLocationInfo()?.geoUri shouldBeEqualTo A_GEO_URI
}

View File

@ -22,8 +22,10 @@ import androidx.lifecycle.Transformations
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.slot
import io.mockk.unmockkAll
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -52,6 +54,7 @@ private const val A_LONGITUDE = 40.0
private const val AN_UNCERTAINTY = 5.0
private const val A_TIMEOUT = 15_000L
private const val A_DESCRIPTION = "description"
private const val A_REASON = "reason"
@ExperimentalCoroutinesApi
internal class DefaultLocationSharingServiceTest {
@ -62,6 +65,7 @@ internal class DefaultLocationSharingServiceTest {
private val startLiveLocationShareTask = mockk<StartLiveLocationShareTask>()
private val stopLiveLocationShareTask = mockk<StopLiveLocationShareTask>()
private val checkIfExistingActiveLiveTask = mockk<CheckIfExistingActiveLiveTask>()
private val redactLiveLocationShareTask = mockk<RedactLiveLocationShareTask>()
private val fakeLiveLocationShareAggregatedSummaryMapper = mockk<LiveLocationShareAggregatedSummaryMapper>()
private val defaultLocationSharingService = DefaultLocationSharingService(
@ -72,6 +76,7 @@ internal class DefaultLocationSharingServiceTest {
startLiveLocationShareTask = startLiveLocationShareTask,
stopLiveLocationShareTask = stopLiveLocationShareTask,
checkIfExistingActiveLiveTask = checkIfExistingActiveLiveTask,
redactLiveLocationShareTask = redactLiveLocationShareTask,
liveLocationShareAggregatedSummaryMapper = fakeLiveLocationShareAggregatedSummaryMapper
)
@ -209,6 +214,20 @@ internal class DefaultLocationSharingServiceTest {
coVerify { stopLiveLocationShareTask.execute(expectedParams) }
}
@Test
fun `live location share can be redacted`() = runTest {
coEvery { redactLiveLocationShareTask.execute(any()) } just runs
defaultLocationSharingService.redactLiveLocationShare(beaconInfoEventId = AN_EVENT_ID, reason = A_REASON)
val expectedParams = RedactLiveLocationShareTask.Params(
roomId = A_ROOM_ID,
beaconInfoEventId = AN_EVENT_ID,
reason = A_REASON
)
coVerify { redactLiveLocationShareTask.execute(expectedParams) }
}
@Test
fun `livedata of live summaries is correctly computed`() {
val entity = LiveLocationShareAggregatedSummaryEntity()

View File

@ -0,0 +1,126 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.room.location
import io.mockk.unmockkAll
import io.realm.RealmList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntityFields
import org.matrix.android.sdk.test.fakes.FakeEventSenderProcessor
import org.matrix.android.sdk.test.fakes.FakeLocalEchoEventFactory
import org.matrix.android.sdk.test.fakes.FakeRealm
import org.matrix.android.sdk.test.fakes.FakeRealmConfiguration
import org.matrix.android.sdk.test.fakes.givenEqualTo
import org.matrix.android.sdk.test.fakes.givenFindFirst
private const val A_ROOM_ID = "room-id"
private const val AN_EVENT_ID = "event-id"
private const val AN_EVENT_ID_1 = "event-id-1"
private const val AN_EVENT_ID_2 = "event-id-2"
private const val AN_EVENT_ID_3 = "event-id-3"
private const val A_REASON = "reason"
@ExperimentalCoroutinesApi
class DefaultRedactLiveLocationShareTaskTest {
private val fakeRealmConfiguration = FakeRealmConfiguration()
private val fakeLocalEchoEventFactory = FakeLocalEchoEventFactory()
private val fakeEventSenderProcessor = FakeEventSenderProcessor()
private val fakeRealm = FakeRealm()
private val defaultRedactLiveLocationShareTask = DefaultRedactLiveLocationShareTask(
realmConfiguration = fakeRealmConfiguration.instance,
localEchoEventFactory = fakeLocalEchoEventFactory.instance,
eventSenderProcessor = fakeEventSenderProcessor
)
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given parameters when redacting then post redact events and related and creates redact local echos`() = runTest {
val params = createParams()
val relatedEventIds = listOf(AN_EVENT_ID_1, AN_EVENT_ID_2, AN_EVENT_ID_3)
val aggregatedSummaryEntity = createSummary(relatedEventIds)
givenSummaryForId(AN_EVENT_ID, aggregatedSummaryEntity)
fakeRealmConfiguration.givenAwaitTransaction<List<String>>(fakeRealm.instance)
val redactEvents = givenCreateRedactEventWithLocalEcho(relatedEventIds + AN_EVENT_ID)
givenPostRedaction(redactEvents)
defaultRedactLiveLocationShareTask.execute(params)
verifyCreateRedactEventForEventIds(relatedEventIds + AN_EVENT_ID)
verifyCreateLocalEchoForEvents(redactEvents)
}
private fun createParams() = RedactLiveLocationShareTask.Params(
roomId = A_ROOM_ID,
beaconInfoEventId = AN_EVENT_ID,
reason = A_REASON
)
private fun createSummary(relatedEventIds: List<String>): LiveLocationShareAggregatedSummaryEntity {
return LiveLocationShareAggregatedSummaryEntity(
eventId = AN_EVENT_ID,
relatedEventIds = RealmList(*relatedEventIds.toTypedArray()),
)
}
private fun givenSummaryForId(eventId: String, aggregatedSummaryEntity: LiveLocationShareAggregatedSummaryEntity) {
fakeRealm.givenWhere<LiveLocationShareAggregatedSummaryEntity>()
.givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.EVENT_ID, eventId)
.givenFindFirst(aggregatedSummaryEntity)
}
private fun givenCreateRedactEventWithLocalEcho(eventIds: List<String>): List<Event> {
return eventIds.map { eventId ->
fakeLocalEchoEventFactory.givenCreateRedactEvent(
eventId = eventId,
withLocalEcho = true
)
}
}
private fun givenPostRedaction(redactEvents: List<Event>) {
redactEvents.forEach {
fakeEventSenderProcessor.givenPostRedaction(event = it, reason = A_REASON)
}
}
private fun verifyCreateRedactEventForEventIds(eventIds: List<String>) {
eventIds.forEach { eventId ->
fakeLocalEchoEventFactory.verifyCreateRedactEvent(
roomId = A_ROOM_ID,
eventId = eventId,
reason = A_REASON
)
}
}
private fun verifyCreateLocalEchoForEvents(events: List<Event>) {
events.forEach { redactionEvent ->
fakeLocalEchoEventFactory.verifyCreateLocalEcho(redactionEvent)
}
}
}

View File

@ -0,0 +1,106 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.room.location
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBe
import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity
import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntityFields
import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.EventEntityFields
import org.matrix.android.sdk.internal.database.model.EventInsertType
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntityFields
import org.matrix.android.sdk.test.fakes.FakeRealm
import org.matrix.android.sdk.test.fakes.givenDelete
import org.matrix.android.sdk.test.fakes.givenEqualTo
import org.matrix.android.sdk.test.fakes.givenFindFirst
private const val AN_EVENT_ID = "event-id"
private const val A_REDACTED_EVENT_ID = "redacted-event-id"
@ExperimentalCoroutinesApi
class LiveLocationShareRedactionEventProcessorTest {
private val liveLocationShareRedactionEventProcessor = LiveLocationShareRedactionEventProcessor()
private val fakeRealm = FakeRealm()
@Test
fun `given an event when checking if it should be processed then only event of type REDACTED is processed`() {
val eventId = AN_EVENT_ID
val eventType = EventType.REDACTION
val insertType = EventInsertType.INCREMENTAL_SYNC
val result = liveLocationShareRedactionEventProcessor.shouldProcess(
eventId = eventId,
eventType = eventType,
insertType = insertType
)
result shouldBe true
}
@Test
fun `given an event when checking if it should be processed then local echo is not processed`() {
val eventId = AN_EVENT_ID
val eventType = EventType.REDACTION
val insertType = EventInsertType.LOCAL_ECHO
val result = liveLocationShareRedactionEventProcessor.shouldProcess(
eventId = eventId,
eventType = eventType,
insertType = insertType
)
result shouldBe false
}
@Test
fun `given a redacted live location share event when processing it then related summaries are deleted from database`() = runTest {
val event = Event(eventId = AN_EVENT_ID, redacts = A_REDACTED_EVENT_ID)
val redactedEventEntity = EventEntity(eventId = A_REDACTED_EVENT_ID, type = EventType.STATE_ROOM_BEACON_INFO.first())
fakeRealm.givenWhere<EventEntity>()
.givenEqualTo(EventEntityFields.EVENT_ID, A_REDACTED_EVENT_ID)
.givenFindFirst(redactedEventEntity)
val liveSummary = mockk<LiveLocationShareAggregatedSummaryEntity>()
every { liveSummary.eventId } returns A_REDACTED_EVENT_ID
liveSummary.givenDelete()
fakeRealm.givenWhere<LiveLocationShareAggregatedSummaryEntity>()
.givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.EVENT_ID, A_REDACTED_EVENT_ID)
.givenFindFirst(liveSummary)
val annotationsSummary = mockk<EventAnnotationsSummaryEntity>()
every { annotationsSummary.eventId } returns A_REDACTED_EVENT_ID
annotationsSummary.givenDelete()
fakeRealm.givenWhere<EventAnnotationsSummaryEntity>()
.givenEqualTo(EventAnnotationsSummaryEntityFields.EVENT_ID, A_REDACTED_EVENT_ID)
.givenFindFirst(annotationsSummary)
liveLocationShareRedactionEventProcessor.process(fakeRealm.instance, event = event)
verify {
liveSummary.deleteFromRealm()
annotationsSummary.deleteFromRealm()
}
}
}

View File

@ -27,4 +27,8 @@ internal class FakeEventSenderProcessor : EventSenderProcessor by mockk() {
fun givenPostEventReturns(event: Event, cancelable: Cancelable) {
every { postEvent(event) } returns cancelable
}
fun givenPostRedaction(event: Event, reason: String?) {
every { postRedaction(event, reason) } returns mockk()
}
}

View File

@ -46,24 +46,6 @@ internal class FakeLocalEchoEventFactory {
return event
}
fun givenCreateLiveLocationEvent(withLocalEcho: Boolean): Event {
val event = Event()
every {
instance.createLiveLocationEvent(
beaconInfoEventId = any(),
roomId = any(),
latitude = any(),
longitude = any(),
uncertainty = any()
)
} returns event
if (withLocalEcho) {
every { instance.createLocalEcho(event) } just runs
}
return event
}
fun verifyCreateStaticLocationEvent(
roomId: String,
latitude: Double,
@ -82,6 +64,24 @@ internal class FakeLocalEchoEventFactory {
}
}
fun givenCreateLiveLocationEvent(withLocalEcho: Boolean): Event {
val event = Event()
every {
instance.createLiveLocationEvent(
beaconInfoEventId = any(),
roomId = any(),
latitude = any(),
longitude = any(),
uncertainty = any()
)
} returns event
if (withLocalEcho) {
every { instance.createLocalEcho(event) } just runs
}
return event
}
fun verifyCreateLiveLocationEvent(
roomId: String,
beaconInfoEventId: String,
@ -100,6 +100,36 @@ internal class FakeLocalEchoEventFactory {
}
}
fun givenCreateRedactEvent(eventId: String, withLocalEcho: Boolean): Event {
val event = Event()
every {
instance.createRedactEvent(
roomId = any(),
eventId = eventId,
reason = any()
)
} returns event
if (withLocalEcho) {
every { instance.createLocalEcho(event) } just runs
}
return event
}
fun verifyCreateRedactEvent(
roomId: String,
eventId: String,
reason: String?
) {
verify {
instance.createRedactEvent(
roomId = roomId,
eventId = eventId,
reason = reason
)
}
}
fun verifyCreateLocalEcho(event: Event) {
verify { instance.createLocalEcho(event) }
}

View File

@ -18,10 +18,13 @@ package org.matrix.android.sdk.test.fakes
import io.mockk.MockKVerificationScope
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import io.realm.Realm
import io.realm.RealmModel
import io.realm.RealmObject
import io.realm.RealmQuery
import io.realm.RealmResults
import io.realm.kotlin.where
@ -97,3 +100,10 @@ inline fun <reified T : RealmModel> RealmQuery<T>.givenIsNotNull(
every { isNotNull(fieldName) } returns this
return this
}
/**
* Should be called on a mocked RealmObject and not on a real RealmObject so that the underlying final method is mocked.
*/
fun RealmObject.givenDelete() {
every { deleteFromRealm() } just runs
}

View File

@ -0,0 +1,41 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.test.fakes
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import io.realm.Realm
import io.realm.RealmConfiguration
import org.matrix.android.sdk.internal.database.awaitTransaction
internal class FakeRealmConfiguration {
init {
mockkStatic("org.matrix.android.sdk.internal.database.AsyncTransactionKt")
}
val instance = mockk<RealmConfiguration>()
fun <T> givenAwaitTransaction(realm: Realm) {
val transaction = slot<suspend (Realm) -> T>()
coEvery { awaitTransaction(instance, capture(transaction)) } coAnswers {
secondArg<suspend (Realm) -> T>().invoke(realm)
}
}
}

View File

@ -280,10 +280,10 @@ android {
}
nightly {
initWith release
applicationIdSuffix ".nightly"
versionNameSuffix "-nightly"
initWith release
// Just override the background color of the launcher icon for the nightly build.
resValue "color", "launcher_background", "#07007E"

View File

@ -80,6 +80,11 @@ class DebugFeaturesStateFactory @Inject constructor(
key = DebugFeatureKeys.startDmOnFirstMsg,
factory = VectorFeatures::shouldStartDmOnFirstMessage
),
createBooleanFeature(
label = "Enable New App Layout",
key = DebugFeatureKeys.newAppLayoutEnabled,
factory = VectorFeatures::isNewAppLayoutEnabled
),
)
)
}

View File

@ -72,6 +72,9 @@ class DebugVectorFeatures(
override fun shouldStartDmOnFirstMessage(): Boolean = read(DebugFeatureKeys.startDmOnFirstMsg)
?: vectorFeatures.shouldStartDmOnFirstMessage()
override fun isNewAppLayoutEnabled(): Boolean = read(DebugFeatureKeys.newAppLayoutEnabled)
?: vectorFeatures.isNewAppLayoutEnabled()
fun <T> override(value: T?, key: Preferences.Key<T>) = updatePreferences {
if (value == null) {
it.remove(key)
@ -131,4 +134,5 @@ object DebugFeatureKeys {
val screenSharing = booleanPreferencesKey("screen-sharing")
val forceUsageOfOpusEncoder = booleanPreferencesKey("force-usage-of-opus-encoder")
val startDmOnFirstMsg = booleanPreferencesKey("start-dm-on-first-msg")
val newAppLayoutEnabled = booleanPreferencesKey("new-app-layout-enabled")
}

View File

@ -19,6 +19,7 @@ package im.vector.app.core.extensions
import android.util.Patterns
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import org.matrix.android.sdk.api.MatrixPatterns
import org.matrix.android.sdk.api.extensions.ensurePrefix
fun Boolean.toOnOff() = if (this) "ON" else "OFF"
@ -30,6 +31,8 @@ inline fun <T> T.ooi(block: (T) -> Unit): T = also(block)
*/
fun CharSequence.isEmail() = Patterns.EMAIL_ADDRESS.matcher(this).matches()
fun CharSequence.isMatrixId() = MatrixPatterns.isUserId(this.toString())
/**
* Return empty CharSequence if the CharSequence is null.
*/

View File

@ -44,8 +44,15 @@ fun TextInputLayout.content() = editText().text.toString()
fun TextInputLayout.hasContent() = !editText().text.isNullOrEmpty()
fun TextInputLayout.clearErrorOnChange(lifecycleOwner: LifecycleOwner) {
onTextChange(lifecycleOwner) {
error = null
isErrorEnabled = false
}
}
fun TextInputLayout.onTextChange(lifecycleOwner: LifecycleOwner, action: (CharSequence) -> Unit) {
editText().textChanges()
.onEach { error = null }
.onEach(action)
.launchIn(lifecycleOwner.lifecycleScope)
}

View File

@ -36,6 +36,6 @@ class AndroidSystemSettingsProvider @Inject constructor(
) : SystemSettingsProvider {
override fun getSystemFontScale(): Float {
return Settings.System.getFloat(context.contentResolver, Settings.System.FONT_SCALE)
return Settings.System.getFloat(context.contentResolver, Settings.System.FONT_SCALE, 1f)
}
}

View File

@ -32,6 +32,7 @@ interface VectorFeatures {
fun isScreenSharingEnabled(): Boolean
fun forceUsageOfOpusEncoder(): Boolean
fun shouldStartDmOnFirstMessage(): Boolean
fun isNewAppLayoutEnabled(): Boolean
enum class OnboardingVariant {
LEGACY,
@ -52,4 +53,5 @@ class DefaultVectorFeatures : VectorFeatures {
override fun isScreenSharingEnabled(): Boolean = true
override fun forceUsageOfOpusEncoder(): Boolean = false
override fun shouldStartDmOnFirstMessage(): Boolean = false
override fun isNewAppLayoutEnabled(): Boolean = false
}

View File

@ -84,6 +84,4 @@ sealed class RoomDetailViewEvents : VectorViewEvents {
data class StartChatEffect(val type: ChatEffect) : RoomDetailViewEvents()
object StopChatEffects : RoomDetailViewEvents()
object RoomReplacementStarted : RoomDetailViewEvents()
data class ChangeLocationIndicator(val isVisible: Boolean) : RoomDetailViewEvents()
}

View File

@ -75,7 +75,8 @@ data class RoomDetailViewState(
val switchToParentSpace: Boolean = false,
val rootThreadEventId: String? = null,
val threadNotificationBadgeState: ThreadNotificationBadgeState = ThreadNotificationBadgeState(),
val typingUsers: List<SenderInfo>? = null
val typingUsers: List<SenderInfo>? = null,
val isSharingLiveLocation: Boolean = false,
) : MavericksState {
constructor(args: TimelineArgs) : this(

View File

@ -498,7 +498,6 @@ class TimelineFragment @Inject constructor(
RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects()
is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it)
RoomDetailViewEvents.RoomReplacementStarted -> handleRoomReplacement()
is RoomDetailViewEvents.ChangeLocationIndicator -> handleChangeLocationIndicator(it)
}
}
@ -663,10 +662,6 @@ class TimelineFragment @Inject constructor(
)
}
private fun handleChangeLocationIndicator(event: RoomDetailViewEvents.ChangeLocationIndicator) {
views.locationLiveStatusIndicator.isVisible = event.isVisible
}
private fun displayErrorMessage(error: RoomDetailViewEvents.Failure) {
if (error.showInDialog) displayErrorDialog(error.throwable) else showErrorInSnackbar(error.throwable)
}
@ -1686,6 +1681,11 @@ class TimelineFragment @Inject constructor(
} else if (mainState.asyncInviter.complete) {
vectorBaseActivity.finish()
}
updateLiveLocationIndicator(mainState.isSharingLiveLocation)
}
private fun updateLiveLocationIndicator(isSharingLiveLocation: Boolean) {
views.locationLiveStatusIndicator.isVisible = isSharingLiveLocation
}
private fun FragmentTimelineBinding.hideComposerViews() {
@ -1706,7 +1706,7 @@ class TimelineFragment @Inject constructor(
private fun renderToolbar(roomSummary: RoomSummary?) {
when {
isLocalRoom() -> {
isLocalRoom() -> {
views.includeRoomToolbar.roomToolbarContentView.isVisible = false
views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = false
setupToolbar(views.roomToolbar)
@ -1724,7 +1724,7 @@ class TimelineFragment @Inject constructor(
}
views.includeThreadToolbar.roomToolbarThreadTitleTextView.text = resources.getText(R.string.thread_timeline_title)
}
else -> {
else -> {
views.includeRoomToolbar.roomToolbarContentView.isVisible = true
views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = false
if (roomSummary == null) {

View File

@ -48,6 +48,7 @@ import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.createdirect.DirectRoomHelper
import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy
import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider
import im.vector.app.features.home.room.detail.location.RedactLiveLocationShareEventUseCase
import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler
import im.vector.app.features.home.room.detail.timeline.factory.TimelineFactory
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
@ -105,6 +106,7 @@ import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper
import org.matrix.android.sdk.api.session.room.read.ReadService
import org.matrix.android.sdk.api.session.room.timeline.Timeline
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.isLiveLocation
import org.matrix.android.sdk.api.session.sync.SyncRequestState
import org.matrix.android.sdk.api.session.threads.ThreadNotificationBadgeState
import org.matrix.android.sdk.api.session.threads.ThreadNotificationState
@ -135,6 +137,7 @@ class TimelineViewModel @AssistedInject constructor(
private val notificationDrawerManager: NotificationDrawerManager,
private val locationSharingServiceConnection: LocationSharingServiceConnection,
private val stopLiveLocationShareUseCase: StopLiveLocationShareUseCase,
private val redactLiveLocationShareEventUseCase: RedactLiveLocationShareEventUseCase,
timelineFactory: TimelineFactory,
appStateHandler: AppStateHandler,
) : VectorViewModel<RoomDetailViewState, RoomDetailAction, RoomDetailViewEvents>(initialState),
@ -770,7 +773,13 @@ class TimelineViewModel @AssistedInject constructor(
private fun handleRedactEvent(action: RoomDetailAction.RedactAction) {
val event = room.getTimelineEvent(action.targetEventId) ?: return
room.sendService().redactEvent(event.root, action.reason)
if (event.isLiveLocation()) {
viewModelScope.launch {
redactLiveLocationShareEventUseCase.execute(event.root, room, action.reason)
}
} else {
room.sendService().redactEvent(event.root, action.reason)
}
}
private fun handleUndoReact(action: RoomDetailAction.UndoReaction) {
@ -1294,12 +1303,12 @@ class TimelineViewModel @AssistedInject constructor(
_viewEvents.post(RoomDetailViewEvents.OnNewTimelineEvents(eventIds))
}
override fun onLocationServiceRunning() {
_viewEvents.post(RoomDetailViewEvents.ChangeLocationIndicator(isVisible = true))
override fun onLocationServiceRunning(roomIds: Set<String>) {
setState { copy(isSharingLiveLocation = roomId in roomIds) }
}
override fun onLocationServiceStopped() {
_viewEvents.post(RoomDetailViewEvents.ChangeLocationIndicator(isVisible = false))
setState { copy(isSharingLiveLocation = false) }
// Bind again in case user decides to share live location without leaving the room
locationSharingServiceConnection.bind(this)
}

View File

@ -0,0 +1,30 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.location
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.Room
import javax.inject.Inject
class RedactLiveLocationShareEventUseCase @Inject constructor() {
suspend fun execute(event: Event, room: Room, reason: String?) {
event.eventId
?.takeUnless { it.isEmpty() }
?.let { room.locationSharingService().redactLiveLocationShare(it, reason) }
}
}

View File

@ -0,0 +1,37 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.timeline.action
import im.vector.app.core.di.ActiveSessionHolder
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import javax.inject.Inject
class CheckIfCanRedactEventUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder
) {
fun execute(event: TimelineEvent, actionPermissions: ActionPermissions): Boolean {
// Only some event types are supported for the moment
val canRedactEventTypes = listOf(EventType.MESSAGE, EventType.STICKER) +
EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO
return event.root.getClearType() in canRedactEventTypes &&
// Message sent by the current user can always be redacted, else check permission for messages sent by other users
(event.root.senderId == activeSessionHolder.getActiveSession().myUserId || actionPermissions.canRedact)
}
}

View File

@ -82,6 +82,7 @@ class MessageActionsViewModel @AssistedInject constructor(
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
private val vectorPreferences: VectorPreferences,
private val checkIfCanReplyEventUseCase: CheckIfCanReplyEventUseCase,
private val checkIfCanRedactEventUseCase: CheckIfCanRedactEventUseCase,
) : VectorViewModel<MessageActionState, MessageActionsAction, EmptyViewEvents>(initialState) {
private val informationData = initialState.informationData
@ -518,12 +519,7 @@ class MessageActionsViewModel @AssistedInject constructor(
}
private fun canRedact(event: TimelineEvent, actionPermissions: ActionPermissions): Boolean {
// Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment
if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START) return false
// Message sent by the current user can always be redacted
if (event.root.senderId == session.myUserId) return true
// Check permission for messages sent by other users
return actionPermissions.canRedact
return checkIfCanRedactEventUseCase.execute(event, actionPermissions)
}
private fun canRetry(event: TimelineEvent, actionPermissions: ActionPermissions): Boolean {

View File

@ -36,7 +36,6 @@ import im.vector.app.features.location.toLocationData
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.threeten.bp.LocalDateTime
import timber.log.Timber
import javax.inject.Inject
class LiveLocationShareMessageItemFactory @Inject constructor(
@ -135,7 +134,7 @@ class LiveLocationShareMessageItemFactory @Inject constructor(
liveLocationShareSummaryData.lastGeoUri.orEmpty(),
getEndOfLiveDateTime(liveLocationShareSummaryData)
)
}.also { viewState -> Timber.d("computed viewState: $viewState") }
}
}
private fun getEndOfLiveDateTime(liveLocationShareSummaryData: LiveLocationShareSummaryData): LocalDateTime? {

View File

@ -24,7 +24,6 @@ import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.MergedTimelineEventVisibilityStateChangedListener
import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityHelper
import im.vector.app.features.home.room.detail.timeline.helper.canBeMerged
import im.vector.app.features.home.room.detail.timeline.helper.isRoomConfiguration
import im.vector.app.features.home.room.detail.timeline.item.BasedMergedItem
import im.vector.app.features.home.room.detail.timeline.item.MergedRoomCreationItem
@ -35,6 +34,7 @@ import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovement
import org.matrix.android.sdk.api.crypto.MXCRYPTO_ALGORITHM_MEGOLM
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.content.EncryptionEventContent
import org.matrix.android.sdk.api.session.events.model.toModel
@ -53,6 +53,7 @@ class MergedHeaderItemFactory @Inject constructor(
private val timelineEventVisibilityHelper: TimelineEventVisibilityHelper
) {
private val mergeableEventTypes = listOf(EventType.STATE_ROOM_MEMBER, EventType.STATE_ROOM_SERVER_ACL)
private val collapsedEventIds = linkedSetOf<Long>()
private val mergeItemCollapseStates = HashMap<Long, Boolean>()
@ -78,19 +79,65 @@ class MergedHeaderItemFactory @Inject constructor(
callback: TimelineEventController.Callback?,
requestModelBuild: () -> Unit
): BasedMergedItem<*>? {
return if (nextEvent?.root?.getClearType() == EventType.STATE_ROOM_CREATE &&
event.isRoomConfiguration(nextEvent.root.getClearContent()?.toModel<RoomCreateContent>()?.creator)) {
// It's the first item before room.create
// Collapse all room configuration events
buildRoomCreationMergedSummary(currentPosition, items, partialState, event, eventIdToHighlight, requestModelBuild, callback)
} else if (!event.canBeMerged() || (nextEvent?.root?.getClearType() == event.root.getClearType() && !addDaySeparator)) {
null
} else {
buildMembershipEventsMergedSummary(currentPosition, items, partialState, event, eventIdToHighlight, requestModelBuild, callback)
return when {
isStartOfRoomCreationSummary(event, nextEvent) ->
buildRoomCreationMergedSummary(currentPosition, items, partialState, event, eventIdToHighlight, requestModelBuild, callback)
isStartOfSameTypeEventsSummary(event, nextEvent, addDaySeparator) ->
buildSameTypeEventsMergedSummary(currentPosition, items, partialState, event, eventIdToHighlight, requestModelBuild, callback)
isStartOfRedactedEventsSummary(event, items, currentPosition, addDaySeparator) ->
buildRedactedEventsMergedSummary(currentPosition, items, partialState, event, eventIdToHighlight, requestModelBuild, callback)
else -> null
}
}
private fun buildMembershipEventsMergedSummary(
/**
* @param event the main timeline event
* @param nextEvent is an older event than event
*/
private fun isStartOfRoomCreationSummary(
event: TimelineEvent,
nextEvent: TimelineEvent?,
): Boolean {
// It's the first item before room.create
// Collapse all room configuration events
return nextEvent?.root?.getClearType() == EventType.STATE_ROOM_CREATE &&
event.isRoomConfiguration(nextEvent.root.getClearContent()?.toModel<RoomCreateContent>()?.creator)
}
/**
* @param event the main timeline event
* @param nextEvent is an older event than event
* @param addDaySeparator true to add a day separator
*/
private fun isStartOfSameTypeEventsSummary(
event: TimelineEvent,
nextEvent: TimelineEvent?,
addDaySeparator: Boolean,
): Boolean {
return event.root.getClearType() in mergeableEventTypes &&
(nextEvent?.root?.getClearType() != event.root.getClearType() || addDaySeparator)
}
/**
* @param event the main timeline event
* @param items all known items, sorted from newer event to oldest event
* @param currentPosition the current position
* @param addDaySeparator true to add a day separator
*/
private fun isStartOfRedactedEventsSummary(
event: TimelineEvent,
items: List<TimelineEvent>,
currentPosition: Int,
addDaySeparator: Boolean,
): Boolean {
val nextNonRedactionEvent = items
.subList(fromIndex = currentPosition + 1, toIndex = items.size)
.find { it.root.getClearType() != EventType.REDACTION }
return event.root.isRedacted() &&
(!nextNonRedactionEvent?.root?.isRedacted().orFalse() || addDaySeparator)
}
private fun buildSameTypeEventsMergedSummary(
currentPosition: Int,
items: List<TimelineEvent>,
partialState: TimelineEventController.PartialState,
@ -102,11 +149,42 @@ class MergedHeaderItemFactory @Inject constructor(
val mergedEvents = timelineEventVisibilityHelper.prevSameTypeEvents(
items,
currentPosition,
2,
MIN_NUMBER_OF_MERGED_EVENTS,
eventIdToHighlight,
partialState.rootThreadEventId,
partialState.isFromThreadTimeline()
)
return buildSimilarEventsMergedSummary(mergedEvents, partialState, event, eventIdToHighlight, requestModelBuild, callback)
}
private fun buildRedactedEventsMergedSummary(
currentPosition: Int,
items: List<TimelineEvent>,
partialState: TimelineEventController.PartialState,
event: TimelineEvent,
eventIdToHighlight: String?,
requestModelBuild: () -> Unit,
callback: TimelineEventController.Callback?
): MergedSimilarEventsItem_? {
val mergedEvents = timelineEventVisibilityHelper.prevRedactedEvents(
items,
currentPosition,
MIN_NUMBER_OF_MERGED_EVENTS,
eventIdToHighlight,
partialState.rootThreadEventId,
partialState.isFromThreadTimeline()
)
return buildSimilarEventsMergedSummary(mergedEvents, partialState, event, eventIdToHighlight, requestModelBuild, callback)
}
private fun buildSimilarEventsMergedSummary(
mergedEvents: List<TimelineEvent>,
partialState: TimelineEventController.PartialState,
event: TimelineEvent,
eventIdToHighlight: String?,
requestModelBuild: () -> Unit,
callback: TimelineEventController.Callback?
): MergedSimilarEventsItem_? {
return if (mergedEvents.isEmpty()) {
null
} else {
@ -127,7 +205,7 @@ class MergedHeaderItemFactory @Inject constructor(
)
mergedData.add(data)
}
val mergedEventIds = mergedEvents.map { it.localId }
val mergedEventIds = mergedEvents.map { it.localId }.toSet()
// We try to find if one of the item id were used as mergeItemCollapseStates key
// => handle case where paginating from mergeable events and we get more
val previousCollapseStateKey = mergedEventIds.intersect(mergeItemCollapseStates.keys).firstOrNull()
@ -140,12 +218,7 @@ class MergedHeaderItemFactory @Inject constructor(
collapsedEventIds.removeAll(mergedEventIds)
}
val mergeId = mergedEventIds.joinToString(separator = "_") { it.toString() }
val summaryTitleResId = when (event.root.getClearType()) {
EventType.STATE_ROOM_MEMBER -> R.plurals.membership_changes
EventType.STATE_ROOM_SERVER_ACL -> R.plurals.notice_room_server_acl_changes
else -> null
}
summaryTitleResId?.let { summaryTitle ->
getSummaryTitleResId(event.root)?.let { summaryTitle ->
val attributes = MergedSimilarEventsItem.Attributes(
summaryTitleResId = summaryTitle,
isCollapsed = isCollapsed,
@ -168,6 +241,16 @@ class MergedHeaderItemFactory @Inject constructor(
}
}
private fun getSummaryTitleResId(event: Event): Int? {
val type = event.getClearType()
return when {
type == EventType.STATE_ROOM_MEMBER -> R.plurals.membership_changes
type == EventType.STATE_ROOM_SERVER_ACL -> R.plurals.notice_room_server_acl_changes
event.isRedacted() -> R.plurals.room_removed_messages
else -> null
}
}
private fun buildRoomCreationMergedSummary(
currentPosition: Int,
items: List<TimelineEvent>,
@ -191,7 +274,7 @@ class MergedHeaderItemFactory @Inject constructor(
tmpPos--
prevEvent = items.getOrNull(tmpPos)
}
return if (mergedEvents.size > 2) {
return if (mergedEvents.size > MIN_NUMBER_OF_MERGED_EVENTS) {
var highlighted = false
val mergedData = ArrayList<BasedMergedItem.Data>(mergedEvents.size)
mergedEvents.reversed()
@ -264,4 +347,8 @@ class MergedHeaderItemFactory @Inject constructor(
fun isCollapsed(localId: Long): Boolean {
return collapsedEventIds.contains(localId)
}
companion object {
private const val MIN_NUMBER_OF_MERGED_EVENTS = 2
}
}

View File

@ -113,8 +113,14 @@ class TimelineItemFactory @Inject constructor(
EventType.CALL_NEGOTIATE,
EventType.REACTION,
in EventType.POLL_RESPONSE,
in EventType.POLL_END,
in EventType.BEACON_LOCATION_DATA -> noticeItemFactory.create(params)
in EventType.POLL_END -> noticeItemFactory.create(params)
in EventType.BEACON_LOCATION_DATA -> {
if (event.root.isRedacted()) {
messageItemFactory.create(params)
} else {
noticeItemFactory.create(params)
}
}
// Calls
EventType.CALL_INVITE,
EventType.CALL_HANGUP,

View File

@ -51,12 +51,7 @@ object TimelineDisplayableEvents {
EventType.STATE_ROOM_JOIN_RULES,
EventType.KEY_VERIFICATION_DONE,
EventType.KEY_VERIFICATION_CANCEL,
) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO
}
fun TimelineEvent.canBeMerged(): Boolean {
return root.getClearType() == EventType.STATE_ROOM_MEMBER ||
root.getClearType() == EventType.STATE_ROOM_SERVER_ACL
) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO + EventType.BEACON_LOCATION_DATA
}
fun TimelineEvent.isRoomConfiguration(roomCreatorUserId: String?): Boolean {

View File

@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.helper
import im.vector.app.core.extensions.localDateTime
import im.vector.app.core.resources.UserPreferencesProvider
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.getRelationContent
@ -30,25 +31,38 @@ import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import javax.inject.Inject
class TimelineEventVisibilityHelper @Inject constructor(private val userPreferencesProvider: UserPreferencesProvider) {
class TimelineEventVisibilityHelper @Inject constructor(
private val userPreferencesProvider: UserPreferencesProvider,
) {
private interface PredicateToStopSearch {
/**
* Indicate whether a search on events should stop by comparing 2 given successive events.
* @param oldEvent the oldest event between the 2 events to compare
* @param newEvent the more recent event between the 2 events to compare
*/
fun shouldStopSearch(oldEvent: Event, newEvent: Event): Boolean
}
/**
* @param timelineEvents the events to search in
* @param timelineEvents the events to search in, sorted from oldest event to newer event
* @param index the index to start computing (inclusive)
* @param minSize the minimum number of same type events to have sequentially, otherwise will return an empty list
* @param eventIdToHighlight used to compute visibility
* @param rootThreadEventId the root thread event id if in a thread timeline
* @param isFromThreadTimeline true if the timeline is a thread
* @param predicateToStop events are taken until this condition is met
*
* @return a list of timeline events which have sequentially the same type following the next direction.
* @return a list of timeline events which meet sequentially the same criteria following the next direction.
*/
private fun nextSameTypeEvents(
private fun nextEventsUntil(
timelineEvents: List<TimelineEvent>,
index: Int,
minSize: Int,
eventIdToHighlight: String?,
rootThreadEventId: String?,
isFromThreadTimeline: Boolean
isFromThreadTimeline: Boolean,
predicateToStop: PredicateToStopSearch
): List<TimelineEvent> {
if (index >= timelineEvents.size - 1) {
return emptyList()
@ -65,13 +79,15 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
} else {
nextSubList.subList(0, indexOfNextDay)
}
val indexOfFirstDifferentEventType = nextSameDayEvents.indexOfFirst { it.root.getClearType() != timelineEvent.root.getClearType() }
val sameTypeEvents = if (indexOfFirstDifferentEventType == -1) {
val indexOfFirstDifferentEvent = nextSameDayEvents.indexOfFirst {
predicateToStop.shouldStopSearch(oldEvent = timelineEvent.root, newEvent = it.root)
}
val similarEvents = if (indexOfFirstDifferentEvent == -1) {
nextSameDayEvents
} else {
nextSameDayEvents.subList(0, indexOfFirstDifferentEventType)
nextSameDayEvents.subList(0, indexOfFirstDifferentEvent)
}
val filteredSameTypeEvents = sameTypeEvents.filter {
val filteredSimilarEvents = similarEvents.filter {
shouldShowEvent(
timelineEvent = it,
highlightedEventId = eventIdToHighlight,
@ -79,14 +95,11 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
rootThreadEventId = rootThreadEventId
)
}
if (filteredSameTypeEvents.size < minSize) {
return emptyList()
}
return filteredSameTypeEvents
return if (filteredSimilarEvents.size < minSize) emptyList() else filteredSimilarEvents
}
/**
* @param timelineEvents the events to search in
* @param timelineEvents the events to search in, sorted from newer event to oldest event
* @param index the index to start computing (inclusive)
* @param minSize the minimum number of same type events to have sequentially, otherwise will return an empty list
* @param eventIdToHighlight used to compute visibility
@ -107,7 +120,44 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
return prevSub
.reversed()
.let {
nextSameTypeEvents(it, 0, minSize, eventIdToHighlight, rootThreadEventId, isFromThreadTimeline)
nextEventsUntil(it, 0, minSize, eventIdToHighlight, rootThreadEventId, isFromThreadTimeline, object : PredicateToStopSearch {
override fun shouldStopSearch(oldEvent: Event, newEvent: Event): Boolean {
return oldEvent.getClearType() != newEvent.getClearType()
}
})
}
}
/**
* @param timelineEvents the events to search in, sorted from newer event to oldest event
* @param index the index to start computing (inclusive)
* @param minSize the minimum number of same type events to have sequentially, otherwise will return an empty list
* @param eventIdToHighlight used to compute visibility
* @param rootThreadEventId the root thread eventId
* @param isFromThreadTimeline true if the timeline is a thread
*
* @return a list of timeline events which are all redacted following the prev direction.
*/
fun prevRedactedEvents(
timelineEvents: List<TimelineEvent>,
index: Int,
minSize: Int,
eventIdToHighlight: String?,
rootThreadEventId: String?,
isFromThreadTimeline: Boolean
): List<TimelineEvent> {
val prevSub = timelineEvents
.subList(0, index + 1)
// Ensure to not take the REDACTION events into account
.filter { it.root.getClearType() != EventType.REDACTION }
return prevSub
.reversed()
.let {
nextEventsUntil(it, 0, minSize, eventIdToHighlight, rootThreadEventId, isFromThreadTimeline, object : PredicateToStopSearch {
override fun shouldStopSearch(oldEvent: Event, newEvent: Event): Boolean {
return oldEvent.isRedacted() && !newEvent.isRedacted()
}
})
}
}
@ -191,6 +241,10 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
} else root.eventId != rootThreadEventId
}
if (root.getClearType() in EventType.BEACON_LOCATION_DATA) {
return !root.isRedacted()
}
return false
}

View File

@ -26,9 +26,12 @@ import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.services.VectorAndroidService
import im.vector.app.features.location.live.GetLiveLocationShareSummaryUseCase
import im.vector.app.features.notifications.NotificationUtils
import im.vector.app.features.redaction.CheckIfEventIsRedactedUseCase
import im.vector.app.features.session.coroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
@ -55,6 +58,7 @@ class LocationSharingAndroidService : VectorAndroidService(), LocationTracker.Ca
@Inject lateinit var locationTracker: LocationTracker
@Inject lateinit var activeSessionHolder: ActiveSessionHolder
@Inject lateinit var getLiveLocationShareSummaryUseCase: GetLiveLocationShareSummaryUseCase
@Inject lateinit var checkIfEventIsRedactedUseCase: CheckIfEventIsRedactedUseCase
private val binder = LocalBinder()
@ -66,6 +70,9 @@ class LocationSharingAndroidService : VectorAndroidService(), LocationTracker.Ca
private val jobs = mutableListOf<Job>()
private var startInProgress = false
private val _roomIdsOfActiveLives = MutableSharedFlow<Set<String>>(replay = 1)
val roomIdsOfActiveLives = _roomIdsOfActiveLives.asSharedFlow()
override fun onCreate() {
super.onCreate()
Timber.i("onCreate")
@ -193,24 +200,30 @@ class LocationSharingAndroidService : VectorAndroidService(), LocationTracker.Ca
private fun addRoomArgs(beaconEventId: String, roomArgs: RoomArgs) {
Timber.i("adding roomArgs for beaconEventId: $beaconEventId")
roomArgsMap[beaconEventId] = roomArgs
launchWithActiveSession { _roomIdsOfActiveLives.emit(getRoomIdsOfActiveLives()) }
}
private fun removeRoomArgs(beaconEventId: String) {
Timber.i("removing roomArgs for beaconEventId: $beaconEventId")
roomArgsMap.remove(beaconEventId)
launchWithActiveSession { _roomIdsOfActiveLives.emit(getRoomIdsOfActiveLives()) }
}
private fun listenForLiveSummaryChanges(roomId: String, beaconEventId: String) {
launchWithActiveSession { session ->
val job = getLiveLocationShareSummaryUseCase.execute(roomId, beaconEventId)
.distinctUntilChangedBy { it.isActive }
.filter { it.isActive == false }
.distinctUntilChangedBy { it?.isActive }
.filter { it?.isActive == false || (it == null && isLiveRedacted(roomId, beaconEventId)) }
.onEach { stopSharingLocation(beaconEventId) }
.launchIn(session.coroutineScope)
jobs.add(job)
}
}
private suspend fun isLiveRedacted(roomId: String, beaconEventId: String): Boolean {
return checkIfEventIsRedactedUseCase.execute(roomId = roomId, eventId = beaconEventId)
}
private fun launchWithActiveSession(block: suspend CoroutineScope.(Session) -> Unit) =
activeSessionHolder
.getSafeActiveSession()
@ -220,6 +233,10 @@ class LocationSharingAndroidService : VectorAndroidService(), LocationTracker.Ca
)
}
fun getRoomIdsOfActiveLives(): Set<String> {
return roomArgsMap.map { it.value.roomId }.toSet()
}
override fun onBind(intent: Intent?): IBinder {
return binder
}

View File

@ -21,17 +21,22 @@ import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.features.session.coroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LocationSharingServiceConnection @Inject constructor(
private val context: Context
) : ServiceConnection,
LocationSharingAndroidService.Callback {
private val context: Context,
private val activeSessionHolder: ActiveSessionHolder
) : ServiceConnection, LocationSharingAndroidService.Callback {
interface Callback {
fun onLocationServiceRunning()
fun onLocationServiceRunning(roomIds: Set<String>)
fun onLocationServiceStopped()
fun onLocationServiceError(error: Throwable)
}
@ -44,7 +49,7 @@ class LocationSharingServiceConnection @Inject constructor(
addCallback(callback)
if (isBound) {
callback.onLocationServiceRunning()
callback.onLocationServiceRunning(getRoomIdsOfActiveLives())
} else {
Intent(context, LocationSharingAndroidService::class.java).also { intent ->
context.bindService(intent, this, 0)
@ -56,12 +61,24 @@ class LocationSharingServiceConnection @Inject constructor(
removeCallback(callback)
}
private fun getRoomIdsOfActiveLives(): Set<String> {
return locationSharingAndroidService?.getRoomIdsOfActiveLives() ?: emptySet()
}
override fun onServiceConnected(className: ComponentName, binder: IBinder) {
locationSharingAndroidService = (binder as LocationSharingAndroidService.LocalBinder).getService().also {
it.callback = this
locationSharingAndroidService = (binder as LocationSharingAndroidService.LocalBinder).getService().also { service ->
service.callback = this
getActiveSessionCoroutineScope()?.let { scope ->
service.roomIdsOfActiveLives
.onEach(::onRoomIdsUpdate)
.launchIn(scope)
}
}
isBound = true
onCallbackActionNoArg(Callback::onLocationServiceRunning)
}
private fun getActiveSessionCoroutineScope(): CoroutineScope? {
return activeSessionHolder.getSafeActiveSession()?.coroutineScope
}
override fun onServiceDisconnected(className: ComponentName) {
@ -71,6 +88,10 @@ class LocationSharingServiceConnection @Inject constructor(
onCallbackActionNoArg(Callback::onLocationServiceStopped)
}
private fun onRoomIdsUpdate(roomIds: Set<String>) {
forwardRoomIdsToCallbacks(roomIds)
}
override fun onServiceError(error: Throwable) {
forwardErrorToCallbacks(error)
}
@ -87,6 +108,10 @@ class LocationSharingServiceConnection @Inject constructor(
callbacks.toList().forEach(action)
}
private fun forwardRoomIdsToCallbacks(roomIds: Set<String>) {
callbacks.toList().forEach { it.onLocationServiceRunning(roomIds) }
}
private fun forwardErrorToCallbacks(error: Throwable) {
callbacks.toList().forEach { it.onLocationServiceError(error) }
}

View File

@ -19,7 +19,7 @@ package im.vector.app.features.location.live
import androidx.lifecycle.asFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.getRoom
@ -31,13 +31,13 @@ class GetLiveLocationShareSummaryUseCase @Inject constructor(
private val session: Session,
) {
suspend fun execute(roomId: String, eventId: String): Flow<LiveLocationShareAggregatedSummary> = withContext(session.coroutineDispatchers.main) {
suspend fun execute(roomId: String, eventId: String): Flow<LiveLocationShareAggregatedSummary?> = withContext(session.coroutineDispatchers.main) {
Timber.d("getting flow for roomId=$roomId and eventId=$eventId")
session.getRoom(roomId)
?.locationSharingService()
?.getLiveLocationShareSummary(eventId)
?.asFlow()
?.mapNotNull { it.getOrNull() }
?.map { it.getOrNull() }
?: emptyFlow()
}
}

View File

@ -0,0 +1,59 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.location.live.map
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.PopupWindow
import im.vector.app.R
import im.vector.app.databinding.ViewLiveLocationMarkerPopupBinding
class LocationLiveMapMarkerOptionsDialog(
context: Context,
) : PopupWindow() {
interface Callback {
fun onShareLocationClicked()
}
private val views: ViewLiveLocationMarkerPopupBinding
var callback: Callback? = null
init {
contentView = View.inflate(context, R.layout.view_live_location_marker_popup, null)
views = ViewLiveLocationMarkerPopupBinding.bind(contentView)
width = ViewGroup.LayoutParams.WRAP_CONTENT
height = ViewGroup.LayoutParams.WRAP_CONTENT
inputMethodMode = INPUT_METHOD_NOT_NEEDED
isFocusable = true
isTouchable = true
contentView.setOnClickListener {
callback?.onShareLocationClicked()
}
}
fun show(anchorView: View) {
contentView.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED)
// By default the left side of the dialog is aligned with the pin. We need shift it to the left to make it's center aligned with the pin.
showAsDropDown(anchorView, -contentView.measuredWidth / 2, 0)
}
}

View File

@ -33,6 +33,7 @@ import com.mapbox.mapboxsdk.maps.MapboxMap
import com.mapbox.mapboxsdk.maps.MapboxMapOptions
import com.mapbox.mapboxsdk.maps.Style
import com.mapbox.mapboxsdk.maps.SupportMapFragment
import com.mapbox.mapboxsdk.plugins.annotation.Symbol
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import com.mapbox.mapboxsdk.style.layers.Property
@ -42,6 +43,7 @@ import im.vector.app.core.extensions.addChildFragment
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.core.utils.openLocation
import im.vector.app.databinding.FragmentLocationLiveMapViewBinding
import im.vector.app.features.location.UrlMapProvider
import im.vector.app.features.location.zoomToBounds
@ -120,6 +122,10 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<Fra
this@LocationLiveMapViewFragment.mapboxMap = WeakReference(mapboxMap)
symbolManager = SymbolManager(mapFragment.view as MapView, mapboxMap, style).apply {
iconAllowOverlap = true
addClickListener {
onSymbolClicked(it)
true
}
}
pendingLiveLocations
.takeUnless { it.isEmpty() }
@ -129,6 +135,31 @@ class LocationLiveMapViewFragment @Inject constructor() : VectorBaseFragment<Fra
}
}
private fun onSymbolClicked(symbol: Symbol?) {
symbol?.let {
val screenLocation = mapboxMap?.get()?.projection?.toScreenLocation(it.latLng)
views.liveLocationPopupAnchor.apply {
x = screenLocation?.x ?: 0f
y = (screenLocation?.y ?: 0f) - views.liveLocationPopupAnchor.height
}
LocationLiveMapMarkerOptionsDialog(requireContext())
.apply {
callback = object : LocationLiveMapMarkerOptionsDialog.Callback {
override fun onShareLocationClicked() {
shareLocation(symbol)
dismiss()
}
}
}
.show(views.liveLocationPopupAnchor)
}
}
private fun shareLocation(symbol: Symbol) {
openLocation(requireActivity(), symbol.latLng.latitude, symbol.latLng.longitude)
}
private fun getOrCreateSupportMapFragment() =
childFragmentManager.findFragmentByTag(MAP_FRAGMENT_TAG) as? SupportMapFragment
?: run {

View File

@ -87,7 +87,7 @@ class LocationLiveMapViewModel @AssistedInject constructor(
}
}
override fun onLocationServiceRunning() {
override fun onLocationServiceRunning(roomIds: Set<String>) {
// NOOP
}

View File

@ -52,9 +52,13 @@ sealed interface OnboardingAction : VectorViewModelAction {
object ResendResetPassword : OnboardingAction
object ResetPasswordMailConfirmed : OnboardingAction
data class MaybeUpdateHomeserverFromMatrixId(val userId: String) : OnboardingAction
sealed interface UserNameEnteredAction : OnboardingAction {
data class Registration(val userId: String) : UserNameEnteredAction
data class Login(val userId: String) : UserNameEnteredAction
}
sealed interface AuthenticateAction : OnboardingAction {
data class Register(val username: String, val password: String, val initialDeviceName: String) : AuthenticateAction
data class RegisterWithMatrixId(val matrixId: String, val password: String, val initialDeviceName: String) : AuthenticateAction
data class Login(val username: String, val password: String, val initialDeviceName: String) : AuthenticateAction
data class LoginDirect(val matrixId: String, val password: String, val initialDeviceName: String) : AuthenticateAction
}
@ -71,6 +75,7 @@ sealed interface OnboardingAction : VectorViewModelAction {
object ResetSignMode : ResetAction
object ResetAuthenticationAttempt : ResetAction
object ResetResetPassword : ResetAction
object ResetSelectedRegistrationUserName : ResetAction
// Homeserver history
object ClearHomeServerHistory : OnboardingAction

View File

@ -28,6 +28,8 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.cancelCurrentOnSet
import im.vector.app.core.extensions.configureAndStart
import im.vector.app.core.extensions.inferNoConnectivity
import im.vector.app.core.extensions.isMatrixId
import im.vector.app.core.extensions.toReducedUrl
import im.vector.app.core.extensions.vectorStore
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.BuildMeta
@ -57,6 +59,7 @@ import org.matrix.android.sdk.api.auth.HomeServerHistoryService
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider
import org.matrix.android.sdk.api.auth.login.LoginWizard
import org.matrix.android.sdk.api.auth.registration.RegistrationAvailability
import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
import org.matrix.android.sdk.api.failure.isHomeserverUnavailable
import org.matrix.android.sdk.api.session.Session
@ -144,7 +147,7 @@ class OnboardingViewModel @AssistedInject constructor(
is OnboardingAction.UpdateSignMode -> handleUpdateSignMode(action)
is OnboardingAction.InitWith -> handleInitWith(action)
is OnboardingAction.HomeServerChange -> withAction(action) { handleHomeserverChange(action) }
is OnboardingAction.MaybeUpdateHomeserverFromMatrixId -> handleMaybeUpdateHomeserver(action)
is OnboardingAction.UserNameEnteredAction -> handleUserNameEntered(action)
is AuthenticateAction -> withAction(action) { handleAuthenticateAction(action) }
is OnboardingAction.LoginWithToken -> handleLoginWithToken(action)
is OnboardingAction.WebLoginSuccess -> handleWebLoginSuccess(action)
@ -167,13 +170,47 @@ class OnboardingViewModel @AssistedInject constructor(
}
}
private fun handleMaybeUpdateHomeserver(action: OnboardingAction.MaybeUpdateHomeserverFromMatrixId) {
val isFullMatrixId = MatrixPatterns.isUserId(action.userId)
private fun handleUserNameEntered(action: OnboardingAction.UserNameEnteredAction) {
when (action) {
is OnboardingAction.UserNameEnteredAction.Login -> maybeUpdateHomeserver(action.userId)
is OnboardingAction.UserNameEnteredAction.Registration -> maybeUpdateHomeserver(action.userId, continuation = { userName ->
checkUserNameAvailability(userName)
})
}
}
private fun maybeUpdateHomeserver(userNameOrMatrixId: String, continuation: suspend (String) -> Unit = {}) {
val isFullMatrixId = MatrixPatterns.isUserId(userNameOrMatrixId)
if (isFullMatrixId) {
val domain = action.userId.getServerName().substringBeforeLast(":").ensureProtocol()
handleHomeserverChange(OnboardingAction.HomeServerChange.EditHomeServer(domain))
val domain = userNameOrMatrixId.getServerName().substringBeforeLast(":").ensureProtocol()
handleHomeserverChange(OnboardingAction.HomeServerChange.EditHomeServer(domain), postAction = {
val userName = MatrixPatterns.extractUserNameFromId(userNameOrMatrixId) ?: throw IllegalStateException("unexpected non matrix id")
continuation(userName)
})
} else {
// ignore the action
currentJob = viewModelScope.launch { continuation(userNameOrMatrixId) }
}
}
private suspend fun checkUserNameAvailability(userName: String) {
when (val result = registrationWizard.registrationAvailable(userName)) {
RegistrationAvailability.Available -> {
setState {
copy(
registrationState = RegistrationState(
isUserNameAvailable = true,
selectedMatrixId = when {
userName.isMatrixId() -> userName
else -> "@$userName:${selectedHomeserver.userFacingUrl.toReducedUrl()}"
},
)
)
}
}
is RegistrationAvailability.NotAvailable -> {
_viewEvents.post(OnboardingViewEvents.Failure(result.failure))
}
}
}
@ -184,7 +221,12 @@ class OnboardingViewModel @AssistedInject constructor(
private fun handleAuthenticateAction(action: AuthenticateAction) {
when (action) {
is AuthenticateAction.Register -> handleRegisterWith(action)
is AuthenticateAction.Register -> handleRegisterWith(action.username, action.password, action.initialDeviceName)
is AuthenticateAction.RegisterWithMatrixId -> handleRegisterWith(
MatrixPatterns.extractUserNameFromId(action.matrixId) ?: throw IllegalStateException("unexpected non matrix id"),
action.password,
action.initialDeviceName
)
is AuthenticateAction.Login -> handleLogin(action)
is AuthenticateAction.LoginDirect -> handleDirectLogin(action, homeServerConnectionConfig = null)
}
@ -322,17 +364,17 @@ class OnboardingViewModel @AssistedInject constructor(
)
}
private fun handleRegisterWith(action: AuthenticateAction.Register) {
private fun handleRegisterWith(userName: String, password: String, initialDeviceName: String) {
setState {
val authDescription = AuthenticationDescription.Register(AuthenticationDescription.AuthenticationType.Password)
copy(selectedAuthenticationState = SelectedAuthenticationState(authDescription))
}
reAuthHelper.data = action.password
reAuthHelper.data = password
handleRegisterAction(
RegisterAction.CreateAccount(
action.username,
action.password,
action.initialDeviceName
userName,
password,
initialDeviceName
)
)
}
@ -368,7 +410,12 @@ class OnboardingViewModel @AssistedInject constructor(
OnboardingAction.ResetAuthenticationAttempt -> {
viewModelScope.launch {
authenticationService.cancelPendingLoginOrRegistration()
setState { copy(isLoading = false) }
setState {
copy(
isLoading = false,
registrationState = RegistrationState(),
)
}
}
}
OnboardingAction.ResetResetPassword -> {
@ -380,6 +427,11 @@ class OnboardingViewModel @AssistedInject constructor(
}
}
OnboardingAction.ResetDeeplinkConfig -> loginConfig = null
OnboardingAction.ResetSelectedRegistrationUserName -> {
setState {
copy(registrationState = RegistrationState())
}
}
}
}
@ -593,6 +645,7 @@ class OnboardingViewModel @AssistedInject constructor(
val homeServerCapabilities = session.homeServerCapabilitiesService().getHomeServerCapabilities()
val capabilityOverrides = vectorOverrides.forceHomeserverCapabilities?.firstOrNull()
state.personalizationState.copy(
displayName = state.registrationState.selectedMatrixId?.let { MatrixPatterns.extractUserNameFromId(it) },
supportsChangingDisplayName = capabilityOverrides?.canChangeDisplayName ?: homeServerCapabilities.canChangeDisplayName,
supportsChangingProfilePicture = capabilityOverrides?.canChangeAvatar ?: homeServerCapabilities.canChangeAvatar
)
@ -619,27 +672,31 @@ class OnboardingViewModel @AssistedInject constructor(
}
}
private fun handleHomeserverChange(action: OnboardingAction.HomeServerChange, serverTypeOverride: ServerType? = null) {
private fun handleHomeserverChange(action: OnboardingAction.HomeServerChange, serverTypeOverride: ServerType? = null, postAction: suspend () -> Unit = {}) {
val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(action.homeServerUrl)
if (homeServerConnectionConfig == null) {
// This is invalid
_viewEvents.post(OnboardingViewEvents.Failure(Throwable("Unable to create a HomeServerConnectionConfig")))
} else {
startAuthenticationFlow(action, homeServerConnectionConfig, serverTypeOverride)
startAuthenticationFlow(action, homeServerConnectionConfig, serverTypeOverride, postAction)
}
}
private fun startAuthenticationFlow(
trigger: OnboardingAction.HomeServerChange,
homeServerConnectionConfig: HomeServerConnectionConfig,
serverTypeOverride: ServerType?
serverTypeOverride: ServerType?,
postAction: suspend () -> Unit = {},
) {
currentHomeServerConnectionConfig = homeServerConnectionConfig
currentJob = viewModelScope.launch {
setState { copy(isLoading = true) }
runCatching { startAuthenticationFlowUseCase.execute(homeServerConnectionConfig) }.fold(
onSuccess = { onAuthenticationStartedSuccess(trigger, homeServerConnectionConfig, it, serverTypeOverride) },
onSuccess = {
onAuthenticationStartedSuccess(trigger, homeServerConnectionConfig, it, serverTypeOverride)
postAction()
},
onFailure = { onAuthenticationStartError(it, trigger) }
)
setState { copy(isLoading = false) }

View File

@ -48,6 +48,9 @@ data class OnboardingViewState(
val knownCustomHomeServersUrls: List<String> = emptyList(),
val isForceLoginFallbackEnabled: Boolean = false,
@PersistState
val registrationState: RegistrationState = RegistrationState(),
@PersistState
val selectedHomeserver: SelectedHomeserverState = SelectedHomeserverState(),
@ -66,7 +69,6 @@ enum class OnboardingFlow {
@Parcelize
data class SelectedHomeserverState(
val description: String? = null,
val userFacingUrl: String? = null,
val upstreamUrl: String? = null,
val preferredLoginMode: LoginMode = LoginMode.Unknown,
@ -96,3 +98,9 @@ data class ResetState(
data class SelectedAuthenticationState(
val description: AuthenticationDescription? = null,
) : Parcelable
@Parcelize
data class RegistrationState(
val isUserNameAvailable: Boolean = false,
val selectedMatrixId: String? = null,
) : Parcelable

View File

@ -16,10 +16,7 @@
package im.vector.app.features.onboarding
import im.vector.app.R
import im.vector.app.core.extensions.containsAllItems
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.ensureTrailingSlash
import im.vector.app.features.login.LoginMode
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
@ -29,7 +26,6 @@ import javax.inject.Inject
class StartAuthenticationFlowUseCase @Inject constructor(
private val authenticationService: AuthenticationService,
private val stringProvider: StringProvider
) {
suspend fun execute(config: HomeServerConnectionConfig): StartAuthenticationResult {
@ -46,10 +42,6 @@ class StartAuthenticationFlowUseCase @Inject constructor(
config: HomeServerConnectionConfig,
preferredLoginMode: LoginMode
) = SelectedHomeserverState(
description = when (config.homeServerUri.toString()) {
matrixOrgUrl() -> stringProvider.getString(R.string.ftue_auth_create_account_matrix_dot_org_server_description)
else -> null
},
userFacingUrl = config.homeServerUri.toString(),
upstreamUrl = authFlow.homeServerUrl,
preferredLoginMode = preferredLoginMode,
@ -57,8 +49,6 @@ class StartAuthenticationFlowUseCase @Inject constructor(
isLogoutDevicesSupported = authFlow.isLogoutDevicesSupported
)
private fun matrixOrgUrl() = stringProvider.getString(R.string.matrix_org_server_url).ensureTrailingSlash()
private fun LoginFlowResult.findPreferredLoginMode() = when {
supportedLoginTypes.containsAllItems(LoginFlowTypes.SSO, LoginFlowTypes.PASSWORD) -> LoginMode.SsoAndPassword(ssoIdentityProviders)
supportedLoginTypes.contains(LoginFlowTypes.SSO) -> LoginMode.Sso(ssoIdentityProviders)

View File

@ -16,15 +16,18 @@
package im.vector.app.features.onboarding.ftueauth
import android.graphics.Typeface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.text.toSpannable
import androidx.core.view.isVisible
import im.vector.app.R
import im.vector.app.core.animations.play
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.utils.isAnimationEnabled
import im.vector.app.core.utils.styleMatchingText
import im.vector.app.databinding.FragmentFtueAccountCreatedBinding
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingViewEvents
@ -47,7 +50,9 @@ class FtueAuthAccountCreatedFragment @Inject constructor(
}
private fun setupViews() {
views.accountCreatedSubtitle.text = getString(R.string.ftue_account_created_subtitle, activeSessionHolder.getActiveSession().myUserId)
val userId = activeSessionHolder.getActiveSession().myUserId
val subtitle = getString(R.string.ftue_account_created_subtitle, userId).toSpannable().styleMatchingText(userId, Typeface.BOLD)
views.accountCreatedSubtitle.text = subtitle
views.accountCreatedPersonalize.debouncedClicks { viewModel.handle(OnboardingAction.PersonalizeProfile) }
views.accountCreatedTakeMeHome.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnTakeMeHome)) }
views.accountCreatedTakeMeHomeCta.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnTakeMeHome)) }

View File

@ -25,6 +25,7 @@ import androidx.autofill.HintConstants
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import im.vector.app.R
import im.vector.app.core.extensions.clearErrorOnChange
import im.vector.app.core.extensions.content
import im.vector.app.core.extensions.editText
import im.vector.app.core.extensions.hideKeyboard
@ -41,8 +42,10 @@ import im.vector.app.features.login.render
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingViewEvents
import im.vector.app.features.onboarding.OnboardingViewState
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider
import reactivecircus.flowbinding.android.widget.textChanges
import javax.inject.Inject
class FtueAuthCombinedLoginFragment @Inject constructor(
@ -60,14 +63,18 @@ class FtueAuthCombinedLoginFragment @Inject constructor(
views.loginRoot.realignPercentagesToParent()
views.editServerButton.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.EditServerSelection)) }
views.loginPasswordInput.setOnImeDoneListener { submit() }
views.loginInput.setOnFocusLostListener { viewModel.handle(OnboardingAction.MaybeUpdateHomeserverFromMatrixId(views.loginInput.content())) }
views.loginInput.setOnFocusLostListener { viewModel.handle(OnboardingAction.UserNameEnteredAction.Login(views.loginInput.content())) }
views.loginForgotPassword.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnForgetPasswordClicked)) }
}
private fun setupSubmitButton() {
views.loginSubmit.setOnClickListener { submit() }
observeContentChangesAndResetErrors(views.loginInput, views.loginPasswordInput, views.loginSubmit)
.launchIn(viewLifecycleOwner.lifecycleScope)
views.loginInput.clearErrorOnChange(viewLifecycleOwner)
views.loginPasswordInput.clearErrorOnChange(viewLifecycleOwner)
combine(views.loginInput.editText().textChanges(), views.loginPasswordInput.editText().textChanges()) { account, password ->
views.loginSubmit.isEnabled = account.isNotEmpty() && password.isNotEmpty()
}.launchIn(viewLifecycleOwner.lifecycleScope)
}
private fun submit() {
@ -105,7 +112,6 @@ class FtueAuthCombinedLoginFragment @Inject constructor(
setupAutoFill()
views.selectedServerName.text = state.selectedHomeserver.userFacingUrl.toReducedUrl()
views.selectedServerDescription.text = state.selectedHomeserver.description
if (state.isLoading) {
// Ensure password is hidden

View File

@ -28,11 +28,14 @@ import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import im.vector.app.R
import im.vector.app.core.extensions.clearErrorOnChange
import im.vector.app.core.extensions.content
import im.vector.app.core.extensions.editText
import im.vector.app.core.extensions.hasSurroundingSpaces
import im.vector.app.core.extensions.hideKeyboard
import im.vector.app.core.extensions.hidePassword
import im.vector.app.core.extensions.isMatrixId
import im.vector.app.core.extensions.onTextChange
import im.vector.app.core.extensions.realignPercentagesToParent
import im.vector.app.core.extensions.setOnFocusLostListener
import im.vector.app.core.extensions.setOnImeDoneListener
@ -46,6 +49,7 @@ import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingAction.AuthenticateAction
import im.vector.app.features.onboarding.OnboardingViewEvents
import im.vector.app.features.onboarding.OnboardingViewState
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider
import org.matrix.android.sdk.api.failure.isHomeserverUnavailable
@ -55,8 +59,11 @@ import org.matrix.android.sdk.api.failure.isLoginEmailUnknown
import org.matrix.android.sdk.api.failure.isRegistrationDisabled
import org.matrix.android.sdk.api.failure.isUsernameInUse
import org.matrix.android.sdk.api.failure.isWeakPassword
import reactivecircus.flowbinding.android.widget.textChanges
import javax.inject.Inject
private const val MINIMUM_PASSWORD_LENGTH = 8
class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAuthFragment<FragmentFtueCombinedRegisterBinding>() {
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueCombinedRegisterBinding {
@ -69,15 +76,27 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu
views.createAccountRoot.realignPercentagesToParent()
views.editServerButton.debouncedClicks { viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.EditServerSelection)) }
views.createAccountPasswordInput.setOnImeDoneListener { submit() }
views.createAccountInput.onTextChange(viewLifecycleOwner) {
viewModel.handle(OnboardingAction.ResetSelectedRegistrationUserName)
views.createAccountEntryFooter.text = ""
}
views.createAccountInput.setOnFocusLostListener {
viewModel.handle(OnboardingAction.MaybeUpdateHomeserverFromMatrixId(views.createAccountInput.content()))
viewModel.handle(OnboardingAction.UserNameEnteredAction.Registration(views.createAccountInput.content()))
}
}
private fun setupSubmitButton() {
views.createAccountSubmit.setOnClickListener { submit() }
observeContentChangesAndResetErrors(views.createAccountInput, views.createAccountPasswordInput, views.createAccountSubmit)
.launchIn(viewLifecycleOwner.lifecycleScope)
views.createAccountInput.clearErrorOnChange(viewLifecycleOwner)
views.createAccountPasswordInput.clearErrorOnChange(viewLifecycleOwner)
combine(views.createAccountInput.editText().textChanges(), views.createAccountPasswordInput.editText().textChanges()) { account, password ->
val accountIsValid = account.isNotEmpty()
val passwordIsValid = password.length >= MINIMUM_PASSWORD_LENGTH
views.createAccountSubmit.isEnabled = accountIsValid && passwordIsValid
}.launchIn(viewLifecycleOwner.lifecycleScope)
}
private fun submit() {
@ -103,7 +122,12 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu
}
if (error == 0) {
viewModel.handle(AuthenticateAction.Register(login, password, getString(R.string.login_default_session_public_name)))
val initialDeviceName = getString(R.string.login_default_session_public_name)
val registerAction = when {
login.isMatrixId() -> AuthenticateAction.RegisterWithMatrixId(login, password, initialDeviceName)
else -> AuthenticateAction.Register(login, password, initialDeviceName)
}
viewModel.handle(registerAction)
}
}
}
@ -153,17 +177,25 @@ class FtueAuthCombinedRegisterFragment @Inject constructor() : AbstractSSOFtueAu
override fun updateWithState(state: OnboardingViewState) {
setupUi(state)
setupAutoFill()
}
private fun setupUi(state: OnboardingViewState) {
views.selectedServerName.text = state.selectedHomeserver.userFacingUrl.toReducedUrl()
views.selectedServerDescription.text = state.selectedHomeserver.description
if (state.isLoading) {
// Ensure password is hidden
views.createAccountPasswordInput.editText().hidePassword()
}
}
private fun setupUi(state: OnboardingViewState) {
views.createAccountEntryFooter.text = when {
state.registrationState.isUserNameAvailable -> getString(
R.string.ftue_auth_create_account_username_entry_footer,
state.registrationState.selectedMatrixId
)
else -> ""
}
when (state.selectedHomeserver.preferredLoginMode) {
is LoginMode.SsoAndPassword -> renderSsoProviders(state.deviceId, state.selectedHomeserver.preferredLoginMode.ssoIdentityProviders)
else -> hideSsoProviders()

View File

@ -20,14 +20,17 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import im.vector.app.R
import im.vector.app.core.extensions.associateContentStateWith
import im.vector.app.core.extensions.autofillEmail
import im.vector.app.core.extensions.clearErrorOnChange
import im.vector.app.core.extensions.content
import im.vector.app.core.extensions.isEmail
import im.vector.app.core.extensions.setOnImeDoneListener
import im.vector.app.core.extensions.toReducedUrl
import im.vector.app.databinding.FragmentFtueEmailInputBinding
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingViewState
import im.vector.app.features.onboarding.RegisterAction
import org.matrix.android.sdk.api.auth.registration.RegisterThreePid
import javax.inject.Inject
@ -56,6 +59,10 @@ class FtueAuthEmailEntryFragment @Inject constructor() : AbstractFtueAuthFragmen
viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.AddThreePid(RegisterThreePid.Email(email))))
}
override fun updateWithState(state: OnboardingViewState) {
views.emailEntryHeaderSubtitle.text = getString(R.string.ftue_auth_email_subtitle, state.selectedHomeserver.userFacingUrl.toReducedUrl())
}
override fun onError(throwable: Throwable) {
views.emailEntryInput.error = errorFormatter.toHumanReadable(throwable)
}

View File

@ -27,8 +27,10 @@ import im.vector.app.core.extensions.autofillPhoneNumber
import im.vector.app.core.extensions.content
import im.vector.app.core.extensions.editText
import im.vector.app.core.extensions.setOnImeDoneListener
import im.vector.app.core.extensions.toReducedUrl
import im.vector.app.databinding.FragmentFtuePhoneInputBinding
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingViewState
import im.vector.app.features.onboarding.RegisterAction
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -77,6 +79,10 @@ class FtueAuthPhoneEntryFragment @Inject constructor(
}
}
override fun updateWithState(state: OnboardingViewState) {
views.phoneEntryHeaderSubtitle.text = getString(R.string.ftue_auth_phone_subtitle, state.selectedHomeserver.userFacingUrl.toReducedUrl())
}
override fun onError(throwable: Throwable) {
views.phoneEntryInput.error = errorFormatter.toHumanReadable(throwable)
}

View File

@ -57,7 +57,7 @@ class FtueAuthResetPasswordBreakerFragment : AbstractFtueAuthFragment<FragmentFt
views.resetPasswordBreakerGradientContainer.setBackgroundResource(themeProvider.ftueBreakerBackground())
views.resetPasswordBreakerTitle.text = getString(R.string.ftue_auth_reset_password_breaker_title)
.colorTerminatingFullStop(ThemeUtils.getColor(requireContext(), R.attr.colorSecondary))
views.resetPasswordBreakerSubtitle.text = getString(R.string.ftue_auth_email_verification_subtitle, params.email)
views.resetPasswordBreakerSubtitle.text = getString(R.string.ftue_auth_password_reset_email_confirmation_subtitle, params.email)
views.resetPasswordBreakerResendEmail.debouncedClicks { viewModel.handle(OnboardingAction.ResendResetPassword) }
views.resetPasswordBreakerFooter.debouncedClicks {
viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnResetPasswordBreakerConfirmed))

View File

@ -21,13 +21,16 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.extensions.associateContentStateWith
import im.vector.app.core.extensions.clearErrorOnChange
import im.vector.app.core.extensions.content
import im.vector.app.core.extensions.isEmail
import im.vector.app.core.extensions.setOnImeDoneListener
import im.vector.app.core.extensions.toReducedUrl
import im.vector.app.databinding.FragmentFtueResetPasswordEmailInputBinding
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingViewState
@AndroidEntryPoint
class FtueAuthResetPasswordEmailEntryFragment : AbstractFtueAuthFragment<FragmentFtueResetPasswordEmailInputBinding>() {
@ -53,6 +56,13 @@ class FtueAuthResetPasswordEmailEntryFragment : AbstractFtueAuthFragment<Fragmen
viewModel.handle(OnboardingAction.ResetPassword(email = email, newPassword = null))
}
override fun updateWithState(state: OnboardingViewState) {
views.emailEntryHeaderSubtitle.text = getString(
R.string.ftue_auth_reset_password_email_subtitle,
state.selectedHomeserver.userFacingUrl.toReducedUrl()
)
}
override fun onError(throwable: Throwable) {
views.emailEntryInput.error = errorFormatter.toHumanReadable(throwable)
}

View File

@ -16,16 +16,10 @@
package im.vector.app.features.onboarding.ftueauth
import android.widget.Button
import com.google.android.material.textfield.TextInputLayout
import im.vector.app.R
import im.vector.app.core.extensions.hasContentFlow
import im.vector.app.features.login.SignMode
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.themes.ThemeProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onEach
fun SignMode.toAuthenticateAction(login: String, password: String, initialDeviceName: String): OnboardingAction.AuthenticateAction {
return when (this) {
@ -36,22 +30,6 @@ fun SignMode.toAuthenticateAction(login: String, password: String, initialDevice
}
}
/**
* A flow to monitor content changes from both username/id and password fields,
* clearing errors and enabling/disabling the submission button on non empty content changes.
*/
fun observeContentChangesAndResetErrors(username: TextInputLayout, password: TextInputLayout, submit: Button): Flow<*> {
return combine(
username.hasContentFlow { it.trim() },
password.hasContentFlow(),
transform = { usernameHasContent, passwordHasContent -> usernameHasContent && passwordHasContent }
).onEach {
username.error = null
password.error = null
submit.isEnabled = it
}
}
fun ThemeProvider.ftueBreakerBackground() = when (isLightTheme()) {
true -> R.drawable.bg_gradient_ftue_breaker
false -> R.drawable.bg_color_background

View File

@ -114,7 +114,9 @@ class FtueAuthTermsFragment @Inject constructor(
}
override fun updateWithState(state: OnboardingViewState) {
policyController.homeServer = state.selectedHomeserver.userFacingUrl.toReducedUrl()
val homeserverName = state.selectedHomeserver.userFacingUrl.toReducedUrl()
views.termsHeaderSubtitle.text = getString(R.string.ftue_auth_terms_subtitle, homeserverName)
policyController.homeServer = homeserverName
renderState()
}

View File

@ -0,0 +1,39 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.redaction
import org.matrix.android.sdk.api.session.Session
import timber.log.Timber
import javax.inject.Inject
class CheckIfEventIsRedactedUseCase @Inject constructor(
private val session: Session,
) {
suspend fun execute(roomId: String, eventId: String): Boolean {
Timber.d("checking if event is redacted for roomId=$roomId and eventId=$eventId")
return try {
session.eventService()
.getEvent(roomId, eventId)
.isRedacted()
.also { Timber.d("event isRedacted=$it") }
} catch (error: Exception) {
Timber.e(error, "error when getting event, it may not exist yet")
false
}
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="?android:colorBackground" />
</shape>

View File

@ -140,7 +140,7 @@
style="@style/Widget.Vector.TextInputLayout.Username"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/username"
android:hint="@string/ftue_auth_login_username_entry"
app:layout_constraintBottom_toTopOf="@id/entrySpacing"
app:layout_constraintEnd_toEndOf="@id/loginGutterEnd"
app:layout_constraintStart_toStartOf="@id/loginGutterStart"

View File

@ -34,8 +34,8 @@
android:layout_height="52dp"
app:layout_constraintBottom_toTopOf="@id/createAccountHeaderIcon"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0"
app:layout_constraintVertical_chainStyle="packed" />
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintVertical_bias="0" />
<ImageView
android:id="@+id/createAccountHeaderIcon"
@ -62,24 +62,10 @@
android:gravity="center"
android:text="@string/ftue_auth_create_account_title"
android:textColor="?vctr_content_primary"
app:layout_constraintBottom_toTopOf="@id/createAccountHeaderSubtitle"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/createAccountHeaderIcon" />
<TextView
android:id="@+id/createAccountHeaderSubtitle"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/ftue_auth_create_account_subtitle"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toTopOf="@id/titleContentSpacing"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/createAccountHeaderTitle" />
app:layout_constraintTop_toBottomOf="@id/createAccountHeaderIcon" />
<Space
android:id="@+id/titleContentSpacing"
@ -87,7 +73,7 @@
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/chooseYourServerHeader"
app:layout_constraintHeight_percent="0.03"
app:layout_constraintTop_toBottomOf="@id/createAccountHeaderSubtitle" />
app:layout_constraintTop_toBottomOf="@id/createAccountHeaderTitle" />
<TextView
android:id="@+id/chooseYourServerHeader"
@ -110,22 +96,11 @@
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textColor="?vctr_content_primary"
app:layout_constraintBottom_toTopOf="@id/selectedServerDescription"
app:layout_constraintEnd_toStartOf="@id/editServerButton"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/chooseYourServerHeader" />
<TextView
android:id="@+id/selectedServerDescription"
style="@style/Widget.Vector.TextView.Micro"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textColor="?vctr_content_tertiary"
app:layout_constraintBottom_toTopOf="@id/serverSelectionSpacing"
app:layout_constraintEnd_toStartOf="@id/editServerButton"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/selectedServerName" />
app:layout_constraintTop_toBottomOf="@id/chooseYourServerHeader"
tools:text="matrix.org" />
<Button
android:id="@+id/editServerButton"
@ -137,7 +112,7 @@
android:paddingEnd="12dp"
android:text="@string/ftue_auth_create_account_edit_server_selection"
android:textAllCaps="true"
app:layout_constraintBottom_toBottomOf="@id/selectedServerDescription"
app:layout_constraintBottom_toBottomOf="@id/selectedServerName"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintTop_toTopOf="@id/chooseYourServerHeader" />
@ -147,7 +122,7 @@
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/createAccountInput"
app:layout_constraintHeight_percent="0.05"
app:layout_constraintTop_toBottomOf="@id/selectedServerDescription" />
app:layout_constraintTop_toBottomOf="@id/selectedServerName" />
<View
android:layout_width="0dp"
@ -185,18 +160,18 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/ftue_auth_create_account_username_entry_footer"
app:layout_constraintBottom_toTopOf="@id/entrySpacing"
app:layout_constraintEnd_toEndOf="@id/createAccountGutterEnd"
app:layout_constraintStart_toStartOf="@id/createAccountGutterStart"
app:layout_constraintTop_toBottomOf="@id/createAccountInput" />
app:layout_constraintTop_toBottomOf="@id/createAccountInput"
tools:text="Others can discover you %s" />
<Space
android:id="@+id/entrySpacing"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/createAccountPasswordInput"
app:layout_constraintHeight_percent="0.03"
app:layout_constraintHeight_percent="0.02"
app:layout_constraintTop_toBottomOf="@id/createAccountEntryFooter" />
<com.google.android.material.textfield.TextInputLayout

View File

@ -58,24 +58,10 @@
android:gravity="center"
android:text="@string/ftue_display_name_title"
android:textColor="?vctr_content_primary"
app:layout_constraintBottom_toTopOf="@id/displayNameHeaderSubtitle"
app:layout_constraintEnd_toEndOf="@id/displayNameGutterEnd"
app:layout_constraintStart_toStartOf="@id/displayNameGutterStart"
app:layout_constraintTop_toBottomOf="@id/displayNameHeaderIcon" />
<TextView
android:id="@+id/displayNameHeaderSubtitle"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/ftue_display_name_subtitle"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toTopOf="@id/titleContentSpacing"
app:layout_constraintEnd_toEndOf="@id/displayNameGutterEnd"
app:layout_constraintStart_toStartOf="@id/displayNameGutterStart"
app:layout_constraintTop_toBottomOf="@id/displayNameHeaderTitle" />
app:layout_constraintTop_toBottomOf="@id/displayNameHeaderIcon" />
<Space
android:id="@+id/titleContentSpacing"
@ -83,7 +69,7 @@
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/displayNameInput"
app:layout_constraintHeight_percent="0.03"
app:layout_constraintTop_toBottomOf="@id/displayNameHeaderSubtitle" />
app:layout_constraintTop_toBottomOf="@id/displayNameHeaderTitle" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/displayNameInput"

View File

@ -70,7 +70,6 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/ftue_auth_email_subtitle"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toTopOf="@id/titleContentSpacing"
app:layout_constraintEnd_toEndOf="@id/emailEntryGutterEnd"

View File

@ -53,26 +53,12 @@
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:text="@string/ftue_auth_create_account_title"
android:text="@string/ftue_auth_captcha_title"
android:textColor="?vctr_content_primary"
app:layout_constraintBottom_toTopOf="@id/captchaHeaderSubtitle"
app:layout_constraintEnd_toEndOf="@id/captchaGutterEnd"
app:layout_constraintStart_toStartOf="@id/captchaGutterStart"
app:layout_constraintTop_toBottomOf="@id/captchaHeaderIcon" />
<TextView
android:id="@+id/captchaHeaderSubtitle"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/auth_recaptcha_message"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toTopOf="@id/titleContentSpacing"
app:layout_constraintEnd_toEndOf="@id/captchaGutterEnd"
app:layout_constraintStart_toStartOf="@id/captchaGutterStart"
app:layout_constraintTop_toBottomOf="@id/captchaHeaderTitle" />
app:layout_constraintTop_toBottomOf="@id/captchaHeaderIcon" />
<Space
android:id="@+id/titleContentSpacing"
@ -80,7 +66,7 @@
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/loginCaptchaWevView"
app:layout_constraintHeight_percent="0.03"
app:layout_constraintTop_toBottomOf="@id/captchaHeaderSubtitle" />
app:layout_constraintTop_toBottomOf="@id/captchaHeaderTitle" />
<WebView
android:id="@+id/loginCaptchaWevView"

View File

@ -73,7 +73,6 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/ftue_auth_terms_subtitle"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toTopOf="@id/titleContentSpacing"
app:layout_constraintEnd_toEndOf="@id/termsGutterEnd"

View File

@ -70,7 +70,6 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/ftue_auth_phone_subtitle"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toTopOf="@id/titleContentSpacing"
app:layout_constraintEnd_toEndOf="@id/phoneEntryGutterEnd"

View File

@ -70,7 +70,6 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/ftue_auth_reset_password_email_subtitle"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toTopOf="@id/titleContentSpacing"
app:layout_constraintEnd_toEndOf="@id/emailEntryGutterEnd"

View File

@ -97,7 +97,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/ftue_auth_choose_server_entry_hint"
app:layout_constraintBottom_toTopOf="@id/chooseServerEntryFooter"
app:layout_constraintBottom_toTopOf="@id/actionSpacing"
app:layout_constraintEnd_toEndOf="@id/chooseServerGutterEnd"
app:layout_constraintStart_toStartOf="@id/chooseServerGutterStart"
app:layout_constraintTop_toBottomOf="@id/titleContentSpacing">
@ -111,25 +111,13 @@
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/chooseServerEntryFooter"
style="@style/Widget.Vector.TextView.Micro"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/ftue_auth_choose_server_entry_footer"
app:layout_constraintBottom_toTopOf="@id/actionSpacing"
app:layout_constraintEnd_toEndOf="@id/chooseServerGutterEnd"
app:layout_constraintStart_toStartOf="@id/chooseServerGutterStart"
app:layout_constraintTop_toBottomOf="@id/chooseServerInput" />
<Space
android:id="@+id/actionSpacing"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/chooseServerSubmit"
app:layout_constraintHeight_percent="0.02"
app:layout_constraintTop_toBottomOf="@id/chooseServerEntryFooter" />
app:layout_constraintHeight_percent="0.03"
app:layout_constraintTop_toBottomOf="@id/chooseServerInput" />
<Button
android:id="@+id/chooseServerSubmit"

View File

@ -6,6 +6,11 @@
android:layout_height="match_parent"
android:background="@drawable/bg_live_location_users_bottom_sheet">
<View
android:id="@+id/liveLocationPopupAnchor"
android:layout_width="40dp"
android:layout_height="40dp" />
<FrameLayout
android:id="@+id/liveLocationMapFragmentContainer"
android:layout_width="match_parent"

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_live_location_marker_popup"
android:elevation="8dp"
android:padding="8dp">
<ImageView
android:id="@+id/shareLocationImageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:importantForAccessibility="no"
android:src="@drawable/ic_share_external"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="?vctr_content_tertiary" />
<TextView
style="@style/TextAppearance.Vector.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/live_location_share_location_item_share"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/shareLocationImageView"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -10,57 +10,5 @@
<string name="cut_the_slack_from_teams" translatable="false">Cut the slack from teams.</string>
<!-- WIP -->
<string name="ftue_auth_create_account_title">Create your account</string>
<string name="ftue_auth_create_account_subtitle">We\'ll need some info to get you set up.</string>
<string name="ftue_auth_create_account_username_entry_footer">You can\'t change this later</string>
<string name="ftue_auth_create_account_password_entry_footer">Must be 8 characters or more</string>
<string name="ftue_auth_create_account_choose_server_header">Choose your server to store your data</string>
<string name="ftue_auth_create_account_sso_section_header">Or</string>
<string name="ftue_auth_create_account_matrix_dot_org_server_description">Join millions for free on the largest public server</string>
<string name="ftue_auth_create_account_edit_server_selection">Edit</string>
<string name="ftue_auth_welcome_back_title">Welcome back!</string>
<string name="ftue_auth_choose_server_title">Choose your server</string>
<string name="ftue_auth_choose_server_subtitle">What is the address of your server? Server is like a home for all your data.</string>
<string name="ftue_auth_choose_server_entry_hint">Server URL</string>
<string name="ftue_auth_choose_server_entry_footer">You can only connect to a server that has already been set up</string>
<string name="ftue_auth_choose_server_ems_title">Want to host your own server?</string>
<string name="ftue_auth_choose_server_ems_subtitle">Element Matrix Services (EMS) is a robust and reliable hosting service for fast, secure and real time communication. Find out how on <a href="${ftue_ems_url}">element.io/ems</a></string>
<string name="ftue_auth_choose_server_ems_cta">Get in touch</string>
<string name="ftue_auth_terms_title">Privacy policy</string>
<string name="ftue_auth_terms_subtitle">Please read through T&amp;C. You must accept in order to continue.</string>
<string name="ftue_auth_email_title">Enter your email address</string>
<string name="ftue_auth_email_subtitle">This will help verify your account and enables password recovery.</string>
<string name="ftue_auth_email_entry_title">Email Address</string>
<string name="ftue_auth_phone_title">Enter your phone number</string>
<string name="ftue_auth_phone_subtitle">This will help verify your account and enables password recovery.</string>
<string name="ftue_auth_phone_entry_title">Phone Number</string>
<string name="ftue_auth_phone_confirmation_entry_title">Confirmation code</string>
<string name="ftue_auth_reset_password_email_subtitle">We will send you a verification link.</string>
<string name="ftue_auth_reset_password_breaker_title">Check your email.</string>
<string name="ftue_auth_new_password_entry_title">New Password</string>
<string name="ftue_auth_new_password_title">Choose a new password</string>
<string name="ftue_auth_new_password_subtitle">Make sure it\'s 8 characters or more.</string>
<string name="ftue_auth_reset_password">Reset password</string>
<string name="ftue_auth_sign_out_all_devices">Sign out all devices</string>
<string name="ftue_auth_phone_confirmation_title">Confirm your phone number</string>
<!-- Note for translators, %s is the users international phone number -->
<string name="ftue_auth_phone_confirmation_subtitle">We just sent a code to %s. Enter it below to verify it\'s you.</string>
<string name="ftue_auth_phone_confirmation_resend_code">Resend code</string>
<string name="ftue_auth_email_verification_title">Check your email to verify.</string>
<!-- Note for translators, %s is the users email address -->
<string name="ftue_auth_email_verification_subtitle">To confirm your email address, tap the button in the email we just sent to %s</string>
<string name="ftue_auth_email_verification_footer">Did not receive an email?</string>
<string name="ftue_auth_email_resend_email">Resend email</string>
<string name="ftue_auth_forgot_password">Forgot password</string>
<string name="ftue_auth_password_reset_confirmation">Password reset</string>
<string name="location_map_view_copyright" translatable="false">© MapTiler © OpenStreetMap contributors</string>
</resources>

View File

@ -1612,7 +1612,7 @@
<string name="message_view_reaction">View Reactions</string>
<string name="reactions">Reactions</string>
<string name="event_redacted">Message deleted</string>
<string name="event_redacted">Message removed</string>
<string name="settings_show_redacted">Show removed messages</string>
<string name="settings_show_redacted_summary">Show a placeholder for removed messages</string>
<string name="event_redacted_by_user_reason">Event deleted by user</string>
@ -1907,31 +1907,93 @@
<string name="ftue_auth_carousel_workplace_body">${app_name} is also great for the workplace. Its trusted by the worlds most secure organisations.</string>
<string name="ftue_auth_use_case_title">Who will you chat to the most?</string>
<string name="ftue_auth_use_case_subtitle">We\'ll help you get connected.</string>
<string name="ftue_auth_use_case_subtitle">We\'ll help you get connected</string>
<string name="ftue_auth_use_case_option_one">Friends and family</string>
<string name="ftue_auth_use_case_option_two">Teams</string>
<string name="ftue_auth_use_case_option_three">Communities</string>
<!-- Note to translators: the %s is replaced by the content of ftue_auth_use_case_skip_partial -->
<string name="ftue_auth_use_case_skip">Not sure yet? You can %s</string>
<string name="ftue_auth_use_case_skip_partial">skip this question</string>
<string name="ftue_auth_use_case_skip">Not sure yet? %s</string>
<string name="ftue_auth_use_case_skip_partial">Skip this question</string>
<string name="ftue_auth_use_case_join_existing_server">Looking to join an existing server?</string>
<string name="ftue_auth_use_case_connect_to_server">Connect to server</string>
<string name="ftue_account_created_personalize">Personalize profile</string>
<string name="ftue_account_created_take_me_home">Take me home</string>
<string name="ftue_account_created_congratulations_title">Congratulations!</string>
<string name="ftue_account_created_subtitle">Your account %s has been created.</string>
<string name="ftue_account_created_subtitle">Your account %s has been created</string>
<string name="ftue_auth_create_account_title">Create your account</string>
<!-- Note for translators, %s is the full matrix of the account being created, eg @hello:matrix.org -->
<string name="ftue_auth_create_account_username_entry_footer">Others can discover you %s</string>
<string name="ftue_auth_create_account_password_entry_footer">Must be 8 characters or more</string>
<string name="ftue_auth_create_account_choose_server_header">Where your conversations will live</string>
<string name="ftue_auth_create_account_sso_section_header">Or</string>
<string name="ftue_auth_create_account_edit_server_selection">Edit</string>
<string name="ftue_auth_welcome_back_title">Welcome back!</string>
<string name="ftue_auth_choose_server_title">Select your server</string>
<string name="ftue_auth_choose_server_subtitle">What is the address of your server? This is like a home for all your data</string>
<string name="ftue_auth_choose_server_entry_hint">Server URL</string>
<string name="ftue_auth_choose_server_ems_title">Want to host your own server?</string>
<string name="ftue_auth_choose_server_ems_subtitle">Element Matrix Services (EMS) is a robust and reliable hosting service for fast, secure and real time communication. Find out how on <a href="${ftue_ems_url}">element.io/ems</a></string>
<string name="ftue_auth_choose_server_ems_cta">Get in touch</string>
<string name="ftue_auth_terms_title">Server policies</string>
<!-- Note for translators, %s is the homeserver name, eg matrix.org -->
<string name="ftue_auth_terms_subtitle">Please read through %s\'s terns and policies</string>
<string name="ftue_auth_email_title">Enter your email</string>
<!-- Note for translators, %s is the homeserver name, eg matrix.org -->
<string name="ftue_auth_email_subtitle">%s needs to verify your account</string>
<string name="ftue_auth_email_entry_title">Email</string>
<string name="ftue_auth_phone_title">Enter your phone number</string>
<!-- Note for translators, %s is the homeserver name, eg matrix.org -->
<string name="ftue_auth_phone_subtitle">%s needs to verify your account</string>
<string name="ftue_auth_phone_entry_title">Phone Number</string>
<string name="ftue_auth_phone_confirmation_entry_title">Confirmation code</string>
<!-- Note for translators, %s is the homeserver name, eg matrix.org -->
<string name="ftue_auth_reset_password_email_subtitle">%s will send you a verification link</string>
<string name="ftue_auth_reset_password_breaker_title">Check your email.</string>
<string name="ftue_auth_new_password_entry_title">New Password</string>
<string name="ftue_auth_new_password_title">Choose a new password</string>
<string name="ftue_auth_new_password_subtitle">Make sure it\'s 8 characters or more.</string>
<string name="ftue_auth_reset_password">Reset password</string>
<string name="ftue_auth_sign_out_all_devices">Sign out all devices</string>
<string name="ftue_auth_phone_confirmation_title">Confirm your phone number</string>
<!-- Note for translators, %s is the users international phone number -->
<string name="ftue_auth_phone_confirmation_subtitle">A code was sent to %s</string>
<string name="ftue_auth_phone_confirmation_resend_code">Resend code</string>
<string name="ftue_auth_email_verification_title">Check your email to verify.</string>
<!-- Note for translators, %s is the users email address -->
<string name="ftue_auth_email_verification_subtitle">To confirm your email, tap the button in the email we just sent to %s</string>
<string name="ftue_auth_email_verification_footer">Did not receive an email?</string>
<string name="ftue_auth_email_resend_email">Resend email</string>
<string name="ftue_auth_forgot_password">Forgot password</string>
<string name="ftue_auth_password_reset_confirmation">Password reset</string>
<!-- Note for translators, %s is the users email address -->
<string name="ftue_auth_password_reset_email_confirmation_subtitle">Follow the instructions send to %s</string>
<string name="ftue_auth_captcha_title">Are you a human?</string>
<string name="ftue_auth_login_username_entry">Username / Email / Phone</string>
<string name="ftue_display_name_title">Choose a display name</string>
<!-- TODO remove -->
<!--suppress UnusedResources -->
<string name="ftue_display_name_subtitle">This will be shown when you send messages.</string>
<string name="ftue_display_name_entry_title">Display Name</string>
<string name="ftue_display_name_entry_footer">You can change this later</string>
<string name="ftue_profile_picture_title">Add a profile picture</string>
<string name="ftue_profile_picture_subtitle">You can change this anytime.</string>
<string name="ftue_profile_picture_subtitle">Time to put a face to the name</string>
<string name="ftue_personalize_lets_go">Let\'s go</string>
<string name="ftue_personalize_complete_title">You\'re all set!</string>
<string name="ftue_personalize_complete_subtitle">Your preferences have been saved.</string>
<string name="ftue_personalize_complete_title">Looking good!</string>
<string name="ftue_personalize_complete_subtitle">Head to settings anytime to update your profile</string>
<string name="ftue_personalize_submit">Save and continue</string>
<string name="ftue_personalize_skip_this_step">Skip this step</string>
@ -3058,6 +3120,7 @@
<!-- TODO remove key -->
<string name="live_location_bottom_sheet_stop_sharing" tools:ignore="UnusedResources">Stop sharing</string>
<string name="live_location_bottom_sheet_last_updated_at">Updated %1$s ago</string>
<string name="live_location_share_location_item_share">Share location</string>
<string name="message_bubbles">Show Message bubbles</string>
@ -3107,4 +3170,8 @@
<string name="live_location_labs_promotion_description">Please note: this is a labs feature using a temporary implementation. This means you will not be able to delete your location history, and advanced users will be able to see your location history even after you stop sharing your live location with this room.</string>
<string name="live_location_labs_promotion_switch_title">Enable location sharing</string>
<plurals name="room_removed_messages">
<item quantity="one">%d message removed</item>
<item quantity="other">%d messages removed</item>
</plurals>
</resources>

View File

@ -0,0 +1,51 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.location
import im.vector.app.test.fakes.FakeRoom
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.Event
private const val AN_EVENT_ID = "event-id"
private const val A_REASON = "reason"
class RedactLiveLocationShareEventUseCaseTest {
private val fakeRoom = FakeRoom()
private val redactLiveLocationShareEventUseCase = RedactLiveLocationShareEventUseCase()
@Test
fun `given an event with valid id when calling use case then event is redacted in the room`() = runTest {
val event = Event(eventId = AN_EVENT_ID)
fakeRoom.locationSharingService().givenRedactLiveLocationShare(beaconInfoEventId = AN_EVENT_ID, reason = A_REASON)
redactLiveLocationShareEventUseCase.execute(event = event, room = fakeRoom, reason = A_REASON)
fakeRoom.locationSharingService().verifyRedactLiveLocationShare(beaconInfoEventId = AN_EVENT_ID, reason = A_REASON)
}
@Test
fun `given an event with empty id when calling use case then nothing is done`() = runTest {
val event = Event(eventId = "")
redactLiveLocationShareEventUseCase.execute(event = event, room = fakeRoom, reason = A_REASON)
fakeRoom.locationSharingService().verifyRedactLiveLocationShare(inverse = true, beaconInfoEventId = "", reason = A_REASON)
}
}

View File

@ -0,0 +1,115 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.home.room.detail.timeline.action
import im.vector.app.test.fakes.FakeActiveSessionHolder
import io.mockk.mockk
import org.amshove.kluent.shouldBe
import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
class CheckIfCanRedactEventUseCaseTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val checkIfCanRedactEventUseCase = CheckIfCanRedactEventUseCase(
activeSessionHolder = fakeActiveSessionHolder.instance
)
@Test
fun `given an event which can be redacted and owned by user when use case executes then the result is true`() {
val canRedactEventTypes = listOf(EventType.MESSAGE, EventType.STICKER) +
EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO
canRedactEventTypes.forEach { eventType ->
val event = givenAnEvent(
eventType = eventType,
senderId = fakeActiveSessionHolder.fakeSession.myUserId
)
val actionPermissions = givenActionPermissions(canRedact = false)
val result = checkIfCanRedactEventUseCase.execute(event, actionPermissions)
result shouldBe true
}
}
@Test
fun `given redact permission and an event which can be redacted and sent by another user when use case executes then the result is true`() {
val event = givenAnEvent(
eventType = EventType.MESSAGE,
senderId = "user-id"
)
val actionPermissions = givenActionPermissions(canRedact = true)
val result = checkIfCanRedactEventUseCase.execute(event, actionPermissions)
result shouldBe true
}
@Test
fun `given an event which cannot be redacted when use case executes then the result is false`() {
val event = givenAnEvent(
eventType = EventType.CALL_ANSWER,
senderId = fakeActiveSessionHolder.fakeSession.myUserId
)
val actionPermissions = givenActionPermissions(canRedact = false)
val result = checkIfCanRedactEventUseCase.execute(event, actionPermissions)
result shouldBe false
}
@Test
fun `given missing redact permission and an event which can be redacted and sent by another user when use case executes then the result is false`() {
val event = givenAnEvent(
eventType = EventType.MESSAGE,
senderId = "user-id"
)
val actionPermissions = givenActionPermissions(canRedact = false)
val result = checkIfCanRedactEventUseCase.execute(event, actionPermissions)
result shouldBe false
}
private fun givenAnEvent(eventType: String, senderId: String): TimelineEvent {
val eventId = "event-id"
return TimelineEvent(
root = Event(
eventId = eventId,
type = eventType,
senderId = senderId
),
localId = 123L,
eventId = eventId,
displayIndex = 1,
ownedByThreadChunk = false,
senderInfo = mockk()
)
}
private fun givenActionPermissions(canRedact: Boolean): ActionPermissions {
return ActionPermissions(canRedact = canRedact)
}
}

View File

@ -53,7 +53,7 @@ class GetLiveLocationShareSummaryUseCaseTest {
}
@Test
fun `given a room id and event id when calling use case then live data on summary is returned`() = runTest {
fun `given a room id and event id when calling use case then flow on summary is returned`() = runTest {
val summary = LiveLocationShareAggregatedSummary(
userId = "userId",
isActive = true,
@ -70,4 +70,17 @@ class GetLiveLocationShareSummaryUseCaseTest {
result shouldBeEqualTo summary
}
@Test
fun `given a room id, event id and a null summary when calling use case then null is emitted in the flow`() = runTest {
fakeSession.roomService()
.getRoom(A_ROOM_ID)
.locationSharingService()
.givenLiveLocationShareSummaryReturns(AN_EVENT_ID, null)
.givenAsFlowReturns(Optional(null))
val result = getLiveLocationShareSummaryUseCase.execute(A_ROOM_ID, AN_EVENT_ID).first()
result shouldBeEqualTo null
}
}

View File

@ -33,6 +33,7 @@ import im.vector.app.test.fakes.FakeHomeServerConnectionConfigFactory
import im.vector.app.test.fakes.FakeHomeServerHistoryService
import im.vector.app.test.fakes.FakeLoginWizard
import im.vector.app.test.fakes.FakeRegistrationActionHandler
import im.vector.app.test.fakes.FakeRegistrationWizard
import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fakes.FakeStartAuthenticationFlowUseCase
import im.vector.app.test.fakes.FakeStringProvider
@ -41,6 +42,7 @@ import im.vector.app.test.fakes.FakeUriFilenameResolver
import im.vector.app.test.fakes.FakeVectorFeatures
import im.vector.app.test.fakes.FakeVectorOverrides
import im.vector.app.test.fakes.toTestString
import im.vector.app.test.fixtures.a401ServerError
import im.vector.app.test.fixtures.aBuildMeta
import im.vector.app.test.fixtures.aHomeServerCapabilities
import im.vector.app.test.test
@ -50,11 +52,13 @@ import org.junit.Rule
import org.junit.Test
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.registration.Stage
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
private const val A_DISPLAY_NAME = "a display name"
private const val A_PICTURE_FILENAME = "a-picture.png"
private val A_SERVER_ERROR = a401ServerError()
private val AN_ERROR = RuntimeException("an error!")
private val A_LOADABLE_REGISTER_ACTION = RegisterAction.StartRegistration
private val A_NON_LOADABLE_REGISTER_ACTION = RegisterAction.CheckIfEmailHasBeenValidated(delayMillis = -1L)
@ -64,10 +68,12 @@ private val ANY_CONTINUING_REGISTRATION_RESULT = RegistrationActionHandler.Resul
private val A_DIRECT_LOGIN = OnboardingAction.AuthenticateAction.LoginDirect("@a-user:id.org", "a-password", "a-device-name")
private const val A_HOMESERVER_URL = "https://edited-homeserver.org"
private val A_HOMESERVER_CONFIG = HomeServerConnectionConfig(FakeUri().instance)
private val SELECTED_HOMESERVER_STATE = SelectedHomeserverState(preferredLoginMode = LoginMode.Password)
private val SELECTED_HOMESERVER_STATE = SelectedHomeserverState(preferredLoginMode = LoginMode.Password, userFacingUrl = A_HOMESERVER_URL)
private val SELECTED_HOMESERVER_STATE_SUPPORTED_LOGOUT_DEVICES = SelectedHomeserverState(isLogoutDevicesSupported = true)
private const val AN_EMAIL = "hello@example.com"
private const val A_PASSWORD = "a-password"
private const val A_USERNAME = "hello-world"
private const val A_MATRIX_ID = "@$A_USERNAME:matrix.org"
class OnboardingViewModelTest {
@ -290,13 +296,13 @@ class OnboardingViewModelTest {
}
@Test
fun `given a full matrix id, when maybe updating homeserver, then updates selected homeserver state and emits edited event`() = runTest {
viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignUp))
fun `given a full matrix id, when a login username is entered, then updates selected homeserver state and emits edited event`() = runTest {
viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignIn))
givenCanSuccessfullyUpdateHomeserver(A_HOMESERVER_URL, SELECTED_HOMESERVER_STATE)
val test = viewModel.test()
val fullMatrixId = "@a-user:${A_HOMESERVER_URL.removePrefix("https://")}"
viewModel.handle(OnboardingAction.MaybeUpdateHomeserverFromMatrixId(fullMatrixId))
viewModel.handle(OnboardingAction.UserNameEnteredAction.Login(fullMatrixId))
test
.assertStatesChanges(
@ -311,12 +317,11 @@ class OnboardingViewModelTest {
}
@Test
fun `given a username, when maybe updating homeserver, then does nothing`() = runTest {
viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignUp))
fun `given a username, when a login username is entered, then does nothing`() = runTest {
val test = viewModel.test()
val onlyUsername = "a-username"
viewModel.handle(OnboardingAction.MaybeUpdateHomeserverFromMatrixId(onlyUsername))
viewModel.handle(OnboardingAction.UserNameEnteredAction.Login(onlyUsername))
test
.assertStates(initialState)
@ -324,6 +329,84 @@ class OnboardingViewModelTest {
.finish()
}
@Test
fun `given available username, when a register username is entered, then emits available registration state`() = runTest {
viewModelWith(initialRegistrationState(A_HOMESERVER_URL))
val onlyUsername = "a-username"
givenUserNameIsAvailable(onlyUsername)
val test = viewModel.test()
viewModel.handle(OnboardingAction.UserNameEnteredAction.Registration(onlyUsername))
test
.assertStatesChanges(
initialState,
{ copy(registrationState = availableRegistrationState(onlyUsername, A_HOMESERVER_URL)) }
)
.assertNoEvents()
.finish()
}
@Test
fun `given unavailable username, when a register username is entered, then emits availability error`() = runTest {
viewModelWith(initialRegistrationState(A_HOMESERVER_URL))
val onlyUsername = "a-username"
givenUserNameIsUnavailable(onlyUsername, A_SERVER_ERROR)
val test = viewModel.test()
viewModel.handle(OnboardingAction.UserNameEnteredAction.Registration(onlyUsername))
test
.assertState(initialState)
.assertEvents(OnboardingViewEvents.Failure(A_SERVER_ERROR))
.finish()
}
@Test
fun `given available full matrix id, when a register username is entered, then changes homeserver and emits available registration state`() = runTest {
viewModelWith(initialRegistrationState("ignored-url"))
givenCanSuccessfullyUpdateHomeserver(A_HOMESERVER_URL, SELECTED_HOMESERVER_STATE)
val userName = "a-user"
val fullMatrixId = "@$userName:${A_HOMESERVER_URL.removePrefix("https://")}"
givenUserNameIsAvailable(userName)
val test = viewModel.test()
viewModel.handle(OnboardingAction.UserNameEnteredAction.Registration(fullMatrixId))
test
.assertStatesChanges(
initialState,
{ copy(isLoading = true) },
{ copy(selectedHomeserver = SELECTED_HOMESERVER_STATE) },
{ copy(registrationState = availableRegistrationState(userName, A_HOMESERVER_URL)) },
{ copy(isLoading = false) },
)
.assertEvents(OnboardingViewEvents.OnHomeserverEdited)
.finish()
}
@Test
fun `given unavailable full matrix id, when a register username is entered, then emits availability error`() = runTest {
viewModelWith(initialRegistrationState("ignored-url"))
givenCanSuccessfullyUpdateHomeserver(A_HOMESERVER_URL, SELECTED_HOMESERVER_STATE)
val userName = "a-user"
val fullMatrixId = "@$userName:${A_HOMESERVER_URL.removePrefix("https://")}"
givenUserNameIsUnavailable(userName, A_SERVER_ERROR)
val test = viewModel.test()
viewModel.handle(OnboardingAction.UserNameEnteredAction.Registration(fullMatrixId))
test
.assertStatesChanges(
initialState,
{ copy(isLoading = true) },
{ copy(selectedHomeserver = SELECTED_HOMESERVER_STATE) },
{ copy(isLoading = false) },
)
.assertEvents(OnboardingViewEvents.OnHomeserverEdited, OnboardingViewEvents.Failure(A_SERVER_ERROR))
.finish()
}
@Test
fun `given in the sign up flow, when editing homeserver errors, then does not update the selected homeserver state and emits error`() = runTest {
viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignUp))
@ -343,7 +426,8 @@ class OnboardingViewModelTest {
}
@Test
fun `given personalisation enabled, when registering account, then updates state and emits account created event`() = runTest {
fun `given matrix id and personalisation enabled, when registering account, then updates state and emits account created event`() = runTest {
viewModelWith(initialState.copy(registrationState = RegistrationState(selectedMatrixId = A_MATRIX_ID)))
fakeVectorFeatures.givenPersonalisationEnabled()
givenSuccessfullyCreatesAccount(A_HOMESERVER_CAPABILITIES)
givenRegistrationResultFor(RegisterAction.StartRegistration, RegistrationActionHandler.Result.RegistrationComplete(fakeSession))
@ -355,7 +439,7 @@ class OnboardingViewModelTest {
.assertStatesChanges(
initialState,
{ copy(isLoading = true) },
{ copy(isLoading = false, personalizationState = A_HOMESERVER_CAPABILITIES.toPersonalisationState()) }
{ copy(isLoading = false, personalizationState = A_HOMESERVER_CAPABILITIES.toPersonalisationState(A_USERNAME)) }
)
.assertEvents(OnboardingViewEvents.OnAccountCreated)
.finish()
@ -640,9 +724,27 @@ class OnboardingViewModelTest {
givenRegistrationResultFor(RegisterAction.StartRegistration, RegistrationActionHandler.Result.Error(error))
fakeHomeServerHistoryService.expectUrlToBeAdded(A_HOMESERVER_CONFIG.homeServerUri.toString())
}
private fun givenUserNameIsAvailable(userName: String) {
fakeAuthenticationService.givenRegistrationWizard(FakeRegistrationWizard().also { it.givenUserNameIsAvailable(userName) })
}
private fun givenUserNameIsUnavailable(userName: String, failure: Failure.ServerError) {
fakeAuthenticationService.givenRegistrationWizard(FakeRegistrationWizard().also { it.givenUserNameIsUnavailable(userName, failure) })
}
private fun availableRegistrationState(userName: String, homeServerUrl: String) = RegistrationState(
isUserNameAvailable = true,
selectedMatrixId = "@$userName:${homeServerUrl.removePrefix("https://")}"
)
private fun initialRegistrationState(homeServerUrl: String) = initialState.copy(
onboardingFlow = OnboardingFlow.SignUp, selectedHomeserver = SelectedHomeserverState(userFacingUrl = homeServerUrl)
)
}
private fun HomeServerCapabilities.toPersonalisationState() = PersonalizationState(
private fun HomeServerCapabilities.toPersonalisationState(displayName: String? = null) = PersonalizationState(
supportsChangingDisplayName = canChangeDisplayName,
supportsChangingProfilePicture = canChangeAvatar
supportsChangingProfilePicture = canChangeAvatar,
displayName = displayName,
)

View File

@ -16,13 +16,10 @@
package im.vector.app.features.onboarding
import im.vector.app.R
import im.vector.app.features.login.LoginMode
import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase.StartAuthenticationResult
import im.vector.app.test.fakes.FakeAuthenticationService
import im.vector.app.test.fakes.FakeStringProvider
import im.vector.app.test.fakes.FakeUri
import im.vector.app.test.fakes.toTestString
import io.mockk.coVerifyOrder
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
@ -33,7 +30,6 @@ import org.matrix.android.sdk.api.auth.data.LoginFlowResult
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider
private const val MATRIX_ORG_URL = "https://any-value.org/"
private const val A_DECLARED_HOMESERVER_URL = "https://foo.bar"
private val A_HOMESERVER_CONFIG = HomeServerConnectionConfig(homeServerUri = FakeUri().instance)
private val SSO_IDENTITY_PROVIDERS = emptyList<SsoIdentityProvider>()
@ -41,9 +37,8 @@ private val SSO_IDENTITY_PROVIDERS = emptyList<SsoIdentityProvider>()
class StartAuthenticationFlowUseCaseTest {
private val fakeAuthenticationService = FakeAuthenticationService()
private val fakeStringProvider = FakeStringProvider()
private val useCase = StartAuthenticationFlowUseCase(fakeAuthenticationService, fakeStringProvider.instance)
private val useCase = StartAuthenticationFlowUseCase(fakeAuthenticationService)
@Before
fun setUp() {
@ -106,21 +101,6 @@ class StartAuthenticationFlowUseCaseTest {
verifyClearsAndThenStartsLogin(A_HOMESERVER_CONFIG)
}
@Test
fun `given matrix dot org url when starting authentication flow then provides description`() = runTest {
val matrixOrgConfig = HomeServerConnectionConfig(homeServerUri = FakeUri(MATRIX_ORG_URL).instance)
fakeStringProvider.given(R.string.matrix_org_server_url, result = MATRIX_ORG_URL)
fakeAuthenticationService.givenLoginFlow(matrixOrgConfig, aLoginResult())
val result = useCase.execute(matrixOrgConfig)
result shouldBeEqualTo expectedResult(
description = R.string.ftue_auth_create_account_matrix_dot_org_server_description.toTestString(),
homeserverSourceUrl = MATRIX_ORG_URL
)
verifyClearsAndThenStartsLogin(matrixOrgConfig)
}
private fun aLoginResult(
supportedLoginTypes: List<String> = emptyList()
) = LoginFlowResult(
@ -134,14 +114,12 @@ class StartAuthenticationFlowUseCaseTest {
private fun expectedResult(
isHomeserverOutdated: Boolean = false,
description: String? = null,
preferredLoginMode: LoginMode = LoginMode.Unsupported,
supportedLoginTypes: List<String> = emptyList(),
homeserverSourceUrl: String = A_HOMESERVER_CONFIG.homeServerUri.toString()
) = StartAuthenticationResult(
isHomeserverOutdated,
SelectedHomeserverState(
description = description,
userFacingUrl = homeserverSourceUrl,
upstreamUrl = A_DECLARED_HOMESERVER_URL,
preferredLoginMode = preferredLoginMode,

View File

@ -0,0 +1,60 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.redaction
import im.vector.app.test.fakes.FakeSession
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.UnsignedData
private const val A_ROOM_ID = "room_id"
private const val AN_EVENT_ID = "event_id"
class CheckIfEventIsRedactedUseCaseTest {
private val fakeSession = FakeSession()
private val checkIfEventIsRedactedUseCase = CheckIfEventIsRedactedUseCase(
session = fakeSession
)
@Test
fun `given a room id and event id for redacted event when calling use case then true is returned`() = runTest {
val event = Event(
unsignedData = UnsignedData(age = 123, redactedEvent = Event())
)
fakeSession.eventService()
.givenGetEventReturns(event)
val result = checkIfEventIsRedactedUseCase.execute(A_ROOM_ID, AN_EVENT_ID)
result shouldBeEqualTo true
}
@Test
fun `given a room id and event id for non redacted event when calling use case then false is returned`() = runTest {
val event = Event()
fakeSession.eventService()
.givenGetEventReturns(event)
val result = checkIfEventIsRedactedUseCase.execute(A_ROOM_ID, AN_EVENT_ID)
result shouldBeEqualTo false
}
}

View File

@ -0,0 +1,29 @@
/*
* 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 io.mockk.coEvery
import io.mockk.mockk
import org.matrix.android.sdk.api.session.events.EventService
import org.matrix.android.sdk.api.session.events.model.Event
class FakeEventService : EventService by mockk() {
fun givenGetEventReturns(event: Event) {
coEvery { getEvent(any(), any()) } returns event
}
}

View File

@ -19,8 +19,11 @@ package im.vector.app.test.fakes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import org.matrix.android.sdk.api.session.room.location.LocationSharingService
import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult
import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary
@ -38,7 +41,7 @@ class FakeLocationSharingService : LocationSharingService by mockk() {
fun givenLiveLocationShareSummaryReturns(
eventId: String,
summary: LiveLocationShareAggregatedSummary
summary: LiveLocationShareAggregatedSummary?
): LiveData<Optional<LiveLocationShareAggregatedSummary>> {
return MutableLiveData(Optional(summary)).also {
every { getLiveLocationShareSummary(eventId) } returns it
@ -48,4 +51,17 @@ class FakeLocationSharingService : LocationSharingService by mockk() {
fun givenStopLiveLocationShareReturns(result: UpdateLiveLocationShareResult) {
coEvery { stopLiveLocationShare() } returns result
}
fun givenRedactLiveLocationShare(beaconInfoEventId: String, reason: String?) {
coEvery { redactLiveLocationShare(beaconInfoEventId, reason) } just runs
}
/**
* @param inverse when true it will check redaction of the live did not happen
* @param beaconInfoEventId event id of the beacon related to the live
* @param reason reason explaining the redaction
*/
fun verifyRedactLiveLocationShare(inverse: Boolean = false, beaconInfoEventId: String, reason: String?) {
coVerify(inverse = inverse) { redactLiveLocationShare(beaconInfoEventId, reason) }
}
}

View File

@ -20,8 +20,10 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import org.matrix.android.sdk.api.auth.registration.RegisterThreePid
import org.matrix.android.sdk.api.auth.registration.RegistrationAvailability
import org.matrix.android.sdk.api.auth.registration.RegistrationResult
import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.Session
class FakeRegistrationWizard : RegistrationWizard by mockk(relaxed = false) {
@ -43,6 +45,14 @@ class FakeRegistrationWizard : RegistrationWizard by mockk(relaxed = false) {
}
}
fun givenUserNameIsAvailable(userName: String) {
coEvery { registrationAvailable(userName) } returns RegistrationAvailability.Available
}
fun givenUserNameIsUnavailable(userName: String, failure: Failure.ServerError) {
coEvery { registrationAvailable(userName) } returns RegistrationAvailability.NotAvailable(failure)
}
fun verifyCheckedEmailedVerification(times: Int) {
coVerify(exactly = times) { checkIfEmailHasBeenValidated(any()) }
}

View File

@ -35,6 +35,7 @@ class FakeSession(
val fakeHomeServerCapabilitiesService: FakeHomeServerCapabilitiesService = FakeHomeServerCapabilitiesService(),
val fakeSharedSecretStorageService: FakeSharedSecretStorageService = FakeSharedSecretStorageService(),
private val fakeRoomService: FakeRoomService = FakeRoomService(),
private val fakeEventService: FakeEventService = FakeEventService(),
) : Session by mockk(relaxed = true) {
init {
@ -50,6 +51,7 @@ class FakeSession(
override fun homeServerCapabilitiesService(): HomeServerCapabilitiesService = fakeHomeServerCapabilitiesService
override fun sharedSecretStorageService() = fakeSharedSecretStorageService
override fun roomService() = fakeRoomService
override fun eventService() = fakeEventService
fun givenVectorStore(vectorSessionStore: VectorSessionStore) {
coEvery {