Merge branch 'develop' into feature/fga/load_room_members_by_chunk

This commit is contained in:
ganfra 2022-06-29 11:45:52 +02:00 committed by GitHub
commit 1a33f6e094
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
81 changed files with 1720 additions and 429 deletions

View File

@ -8,8 +8,9 @@ on:
# Enrich gradle.properties for CI/CD
env:
CI_GRADLE_ARG_PROPERTIES: >
-Porg.gradle.jvmargs=-Xmx2g
-Porg.gradle.jvmargs=-Xmx4g
-Porg.gradle.parallel=false
--no-daemon
jobs:
debug:

View File

@ -13,6 +13,7 @@ env:
CI_GRADLE_ARG_PROPERTIES: >
-Porg.gradle.jvmargs=-Xmx4g
-Porg.gradle.parallel=false
--no-daemon
jobs:

View File

@ -9,6 +9,8 @@ on:
env:
CI_GRADLE_ARG_PROPERTIES: >
-Porg.gradle.jvmargs=-Xmx4g
-Porg.gradle.parallel=false
--no-daemon
jobs:
check:

View File

@ -8,8 +8,9 @@ on:
# Enrich gradle.properties for CI/CD
env:
CI_GRADLE_ARG_PROPERTIES: >
-Porg.gradle.jvmargs=-Xmx2g
-Porg.gradle.jvmargs=-Xmx4g
-Porg.gradle.parallel=false
--no-daemon
jobs:
tests:

View File

@ -29,7 +29,7 @@ buildscript {
classpath 'com.google.android.gms:oss-licenses-plugin:0.10.5'
classpath "com.likethesalad.android:stem-plugin:2.1.1"
classpath 'org.owasp:dependency-check-gradle:7.1.1'
classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.6.21"
classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.7.0"
classpath "org.jetbrains.kotlinx:kotlinx-knit:0.4.0"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
@ -43,7 +43,7 @@ plugins {
id "io.gitlab.arturbosch.detekt" version "1.20.0"
// Dependency Analysis
id 'com.autonomousapps.dependency-analysis' version "1.8.0"
id 'com.autonomousapps.dependency-analysis' version "1.9.0"
}
// https://github.com/jeremylong/DependencyCheck
@ -267,6 +267,8 @@ dependencyAnalysis {
onUnusedDependencies {
// False positives
exclude(
"androidx.fragment:fragment-testing",
"com.facebook.soloader:soloader",
"com.vanniktech:emoji-google",
"com.vanniktech:emoji-material",
"org.maplibre.gl:android-plugin-annotation-v9",

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

@ -0,0 +1 @@
Refactor - better naming, return native user id and not sip user id and create a dm with the native user instead of with the sip user.

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

@ -0,0 +1 @@
Fix | Some user verification requests couldn't be accepted/declined

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

@ -0,0 +1 @@
[Location sharing] Fix stop of a live not possible from another device

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

@ -0,0 +1 @@
[Location sharing] - Stop any active live before starting a new one

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

@ -0,0 +1 @@
Poll view state unit tests

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

@ -0,0 +1 @@
[Location Share] - Adding missing prefix "u=" for uncertainty in geo URI

1
changelog.d/6396.doc Normal file
View File

@ -0,0 +1 @@
Update the PR process doc to come back to one reviewer with optional additional reviewers.

View File

@ -21,6 +21,7 @@ def markwon = "4.6.2"
def moshi = "1.13.0"
def lifecycle = "2.4.1"
def flowBinding = "1.2.0"
def flipper = "0.151.1"
def epoxy = "4.6.2"
def mavericks = "2.7.0"
def glide = "4.13.2"
@ -91,6 +92,10 @@ ext.libs = [
'hiltAndroidTesting' : "com.google.dagger:hilt-android-testing:$dagger",
'hiltCompiler' : "com.google.dagger:hilt-compiler:$dagger"
],
flipper : [
'flipper' : "com.facebook.flipper:flipper:$flipper",
'flipperNetworkPlugin' : "com.facebook.flipper:flipper-network-plugin:$flipper",
],
squareup : [
'moshi' : "com.squareup.moshi:moshi:$moshi",
'moshiKt' : "com.squareup.moshi:moshi-kotlin:$moshi",

View File

@ -83,15 +83,16 @@ Exceptions can occur:
##### PR Review Assignment
We use automatic assignment for PR reviews. A PR is automatically routed by GitHub to 2 team members using the round robin algorithm. The process is the following:
We use automatic assignment for PR reviews. **A PR is automatically routed by GitHub to one team member** using the round robin algorithm. Additional reviewers can be used for complex changes or when the first reviewer is not confident enough on the changes.
The process is the following:
- The PR creator can assign specific people if they have another Android developer in their team or they think a specific reviewer should take a look at the PR.
- If there are missing reviewers, the PR creator assigns the [element-android-reviewers](https://github.com/orgs/vector-im/teams/element-android-reviewers) team as a reviewer.
- GitHub automatically assigns other reviewers. If one of the chosen reviewers is not available (holiday, etc.), remove them and set again the team, GitHub will select another reviewer.
- The PR creator selects the [element-android-reviewers](https://github.com/orgs/vector-im/teams/element-android-reviewers) team as a reviewer.
- GitHub automatically assign the reviewer. If the reviewer is not available (holiday, etc.), remove them and set again the team, GitHub will select another reviewer.
- Alternatively, the PR creator can directly assign specific people if they have another Android developer in their team or they think a specific reviewer should take a look at their PR.
- Reviewers get a notification to make the review: they review the code following the good practice (see the rest of this document).
- After making their own review, if they feel not confident enough, they can ask another person for a full review, or they can tag someone within a PR comment to check specific lines.
For PRs coming from the community, the issue wrangler can assign either the team [element-android-reviewers](https://github.com/orgs/vector-im/teams/element-android-reviewers) or any members directly.
For PRs coming from the community, the issue wrangler can assign either the team [element-android-reviewers](https://github.com/orgs/vector-im/teams/element-android-reviewers) or any member directly.
##### PR review time
@ -102,6 +103,7 @@ Some tips to achieve it:
- Set up your GH notifications correctly
- Check your pulls page: [https://github.com/pulls](https://github.com/pulls)
- Check your pending assigned PRs before starting or resuming your day to day tasks
- If you are busy with high priority tasks, inform the author. They will find another developer
It is hard to define a deadline for a review. It depends on the PR size and the complexity. Let's start with a goal of 24h (working day!) for a PR smaller than 500 lines. If bigger, the submitter and the reviewer should discuss.

View File

@ -16,9 +16,11 @@
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
import org.matrix.android.sdk.api.util.Optional
/**
* Manage all location sharing related features.
@ -59,5 +61,13 @@ interface LocationSharingService {
/**
* 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

@ -22,7 +22,7 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class LocationInfo(
/**
* Required. RFC5870 formatted geo uri 'geo:latitude,longitude;uncertainty' like 'geo:40.05,29.24;30' representing this location.
* Required. RFC5870 formatted geo uri 'geo:latitude,longitude;u=uncertainty' like 'geo:40.05,29.24;u=30' representing this location.
*/
@Json(name = "uri") val geoUri: String? = null,

View File

@ -35,7 +35,7 @@ data class MessageLocationContent(
@Json(name = "body") override val body: String,
/**
* Required. RFC5870 formatted geo uri 'geo:latitude,longitude;uncertainty' like 'geo:40.05,29.24;30' representing this location.
* Required. RFC5870 formatted geo uri 'geo:latitude,longitude;u=uncertainty' like 'geo:40.05,29.24;u=30' representing this location.
*/
@Json(name = "geo_uri") val geoUri: String,

View File

@ -25,4 +25,7 @@ data class PollCreationInfo(
@Json(name = "kind") val kind: PollType? = PollType.DISCLOSED_UNSTABLE,
@Json(name = "max_selections") val maxSelections: Int = 1,
@Json(name = "answers") val answers: List<PollAnswer>? = null
)
) {
fun isUndisclosed() = kind in listOf(PollType.UNDISCLOSED_UNSTABLE, PollType.UNDISCLOSED)
}

View File

@ -62,7 +62,7 @@ internal class VerificationMessageProcessor @Inject constructor(
// If the request is in the future by more than 5 minutes or more than 10 minutes in the past,
// the message should be ignored by the receiver.
if (event.ageLocalTs != null && !VerificationService.isValidRequest(event.ageLocalTs, clock.epochMillis())) return Unit.also {
if (!VerificationService.isValidRequest(event.ageLocalTs, clock.epochMillis())) return Unit.also {
Timber.d("## SAS Verification live observer: msgId: ${event.eventId} is outdated age:$event.ageLocalTs ms")
}

View File

@ -271,7 +271,7 @@ private fun HashMap<String, RoomMemberContent?>.addSenderState(realm: Realm, roo
* Create an EventEntity for the root thread event or get an existing one.
*/
private fun createEventEntity(realm: Realm, roomId: String, event: Event, currentTimeMillis: Long): EventEntity {
val ageLocalTs = event.unsignedData?.age?.let { currentTimeMillis - it }
val ageLocalTs = currentTimeMillis - (event.unsignedData?.age ?: 0)
return event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION)
}

View File

@ -130,7 +130,7 @@ internal fun EventEntity.asDomain(castJsonNumbers: Boolean = false): Event {
internal fun Event.toEntity(
roomId: String,
sendState: SendState,
ageLocalTs: Long?,
ageLocalTs: Long,
contentToInject: String? = null
): EventEntity {
return EventMapper.map(this, roomId).apply {

View File

@ -52,6 +52,10 @@ internal class MigrateSessionTo030(realm: DynamicRealm) : RealmMigrator(realm, 3
timelineEvents.deleteAllFromRealm()
}
chunks.deleteAllFromRealm()
Timber.d("MigrateSessionTo030: $nbOfDeletedChunks deleted chunk(s), $nbOfDeletedTimelineEvents deleted TimelineEvent(s) and $nbOfDeletedEvents deleted Event(s).")
Timber.d(
"MigrateSessionTo030: $nbOfDeletedChunks deleted chunk(s)," +
" $nbOfDeletedTimelineEvents deleted TimelineEvent(s)" +
" and $nbOfDeletedEvents deleted Event(s)."
)
}
}

View File

@ -76,7 +76,7 @@ internal fun LiveLocationShareAggregatedSummaryEntity.Companion.findActiveLiveIn
realm: Realm,
roomId: String,
userId: String,
ignoredEventId: String
ignoredEventId: String,
): List<LiveLocationShareAggregatedSummaryEntity> {
return LiveLocationShareAggregatedSummaryEntity
.whereRoomId(realm, roomId = roomId)

View File

@ -51,10 +51,14 @@ import org.matrix.android.sdk.internal.session.room.directory.DefaultSetRoomDire
import org.matrix.android.sdk.internal.session.room.directory.GetPublicRoomTask
import org.matrix.android.sdk.internal.session.room.directory.GetRoomDirectoryVisibilityTask
import org.matrix.android.sdk.internal.session.room.directory.SetRoomDirectoryVisibilityTask
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.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.SendLiveLocationTask
import org.matrix.android.sdk.internal.session.room.location.SendStaticLocationTask
import org.matrix.android.sdk.internal.session.room.location.StartLiveLocationShareTask
@ -319,4 +323,10 @@ internal abstract class RoomModule {
@Binds
abstract fun bindSendLiveLocationTask(task: DefaultSendLiveLocationTask): SendLiveLocationTask
@Binds
abstract fun bindGetActiveBeaconInfoForUserTask(task: DefaultGetActiveBeaconInfoForUserTask): GetActiveBeaconInfoForUserTask
@Binds
abstract fun bindCheckIfExistingActiveLiveTask(task: DefaultCheckIfExistingActiveLiveTask): CheckIfExistingActiveLiveTask
}

View File

@ -0,0 +1,45 @@
/*
* 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 org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
import org.matrix.android.sdk.internal.task.Task
import javax.inject.Inject
internal interface CheckIfExistingActiveLiveTask : Task<CheckIfExistingActiveLiveTask.Params, Boolean> {
data class Params(
val roomId: String,
)
}
internal class DefaultCheckIfExistingActiveLiveTask @Inject constructor(
private val getActiveBeaconInfoForUserTask: GetActiveBeaconInfoForUserTask,
) : CheckIfExistingActiveLiveTask {
override suspend fun execute(params: CheckIfExistingActiveLiveTask.Params): Boolean {
val getActiveBeaconTaskParams = GetActiveBeaconInfoForUserTask.Params(
roomId = params.roomId
)
return getActiveBeaconInfoForUserTask.execute(getActiveBeaconTaskParams)
?.getClearContent()
?.toModel<MessageBeaconInfoContent>()
?.isLive
.orFalse()
}
}

View File

@ -17,6 +17,7 @@
package org.matrix.android.sdk.internal.session.room.location
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import com.zhuinden.monarchy.Monarchy
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -25,9 +26,12 @@ 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
import org.matrix.android.sdk.api.util.Cancelable
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional
import org.matrix.android.sdk.internal.database.mapper.LiveLocationShareAggregatedSummaryMapper
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.query.findRunningLiveInRoom
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase
internal class DefaultLocationSharingService @AssistedInject constructor(
@ -37,6 +41,7 @@ internal class DefaultLocationSharingService @AssistedInject constructor(
private val sendLiveLocationTask: SendLiveLocationTask,
private val startLiveLocationShareTask: StartLiveLocationShareTask,
private val stopLiveLocationShareTask: StopLiveLocationShareTask,
private val checkIfExistingActiveLiveTask: CheckIfExistingActiveLiveTask,
private val liveLocationShareAggregatedSummaryMapper: LiveLocationShareAggregatedSummaryMapper,
) : LocationSharingService {
@ -68,6 +73,13 @@ internal class DefaultLocationSharingService @AssistedInject constructor(
}
override suspend fun startLiveLocationShare(timeoutMillis: Long): UpdateLiveLocationShareResult {
// Ensure to stop any active live before starting a new one
if (checkIfExistingActiveLive()) {
val result = stopLiveLocationShare()
if (result is UpdateLiveLocationShareResult.Failure) {
return result
}
}
val params = StartLiveLocationShareTask.Params(
roomId = roomId,
timeoutMillis = timeoutMillis
@ -75,6 +87,13 @@ internal class DefaultLocationSharingService @AssistedInject constructor(
return startLiveLocationShareTask.execute(params)
}
private suspend fun checkIfExistingActiveLive(): Boolean {
val params = CheckIfExistingActiveLiveTask.Params(
roomId = roomId
)
return checkIfExistingActiveLiveTask.execute(params)
}
override suspend fun stopLiveLocationShare(): UpdateLiveLocationShareResult {
val params = StopLiveLocationShareTask.Params(
roomId = roomId,
@ -88,4 +107,15 @@ internal class DefaultLocationSharingService @AssistedInject constructor(
liveLocationShareAggregatedSummaryMapper
)
}
override fun getLiveLocationShareSummary(beaconInfoEventId: String): LiveData<Optional<LiveLocationShareAggregatedSummary>> {
return Transformations.map(
monarchy.findAllMappedWithChanges(
{ LiveLocationShareAggregatedSummaryEntity.where(it, roomId = roomId, eventId = beaconInfoEventId) },
liveLocationShareAggregatedSummaryMapper
)
) {
it.firstOrNull().toOptional()
}
}
}

View File

@ -0,0 +1,54 @@
/*
* 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 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.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource
import org.matrix.android.sdk.internal.task.Task
import javax.inject.Inject
internal interface GetActiveBeaconInfoForUserTask : Task<GetActiveBeaconInfoForUserTask.Params, Event?> {
data class Params(
val roomId: String,
)
}
internal class DefaultGetActiveBeaconInfoForUserTask @Inject constructor(
@UserId private val userId: String,
private val stateEventDataSource: StateEventDataSource,
) : GetActiveBeaconInfoForUserTask {
override suspend fun execute(params: GetActiveBeaconInfoForUserTask.Params): Event? {
return EventType.STATE_ROOM_BEACON_INFO
.mapNotNull {
stateEventDataSource.getStateEvent(
roomId = params.roomId,
eventType = it,
stateKey = QueryStringValue.Equals(userId)
)
}
.firstOrNull { beaconInfoEvent ->
beaconInfoEvent.getClearContent()?.toModel<MessageBeaconInfoContent>()?.isLive.orFalse()
}
}
}

View File

@ -16,17 +16,13 @@
package org.matrix.android.sdk.internal.session.room.location
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.toContent
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.room.state.SendStateTask
import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource
import org.matrix.android.sdk.internal.task.Task
import javax.inject.Inject
@ -37,13 +33,12 @@ internal interface StopLiveLocationShareTask : Task<StopLiveLocationShareTask.Pa
}
internal class DefaultStopLiveLocationShareTask @Inject constructor(
@UserId private val userId: String,
private val sendStateTask: SendStateTask,
private val stateEventDataSource: StateEventDataSource,
private val getActiveBeaconInfoForUserTask: GetActiveBeaconInfoForUserTask,
) : StopLiveLocationShareTask {
override suspend fun execute(params: StopLiveLocationShareTask.Params): UpdateLiveLocationShareResult {
val beaconInfoStateEvent = getLiveLocationBeaconInfoForUser(userId, params.roomId) ?: return getResultForIncorrectBeaconInfoEvent()
val beaconInfoStateEvent = getActiveLiveLocationBeaconInfoForUser(params.roomId) ?: return getResultForIncorrectBeaconInfoEvent()
val stateKey = beaconInfoStateEvent.stateKey ?: return getResultForIncorrectBeaconInfoEvent()
val content = beaconInfoStateEvent.getClearContent()?.toModel<MessageBeaconInfoContent>() ?: return getResultForIncorrectBeaconInfoEvent()
val updatedContent = content.copy(isLive = false).toContent()
@ -68,17 +63,10 @@ internal class DefaultStopLiveLocationShareTask @Inject constructor(
private fun getResultForIncorrectBeaconInfoEvent() =
UpdateLiveLocationShareResult.Failure(Exception("incorrect last beacon info event"))
private fun getLiveLocationBeaconInfoForUser(userId: String, roomId: String): Event? {
return EventType.STATE_ROOM_BEACON_INFO
.mapNotNull {
stateEventDataSource.getStateEvent(
roomId = roomId,
eventType = it,
stateKey = QueryStringValue.Equals(userId)
)
}
.firstOrNull { beaconInfoEvent ->
beaconInfoEvent.getClearContent()?.toModel<MessageBeaconInfoContent>()?.isLive.orFalse()
}
private suspend fun getActiveLiveLocationBeaconInfoForUser(roomId: String): Event? {
val params = GetActiveBeaconInfoForUserTask.Params(
roomId = roomId
)
return getActiveBeaconInfoForUserTask.execute(params)
}
}

View File

@ -116,7 +116,7 @@ internal class DefaultLoadRoomMembersTask @Inject constructor(
if (roomMemberEvent.eventId == null || roomMemberEvent.stateKey == null || roomMemberEvent.type == null) {
continue
}
val ageLocalTs = roomMemberEvent.unsignedData?.age?.let { now - it }
val ageLocalTs = now - (roomMemberEvent.unsignedData?.age ?: 0)
val eventEntity = roomMemberEvent.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION)
CurrentStateEventEntity.getOrCreate(
realm,

View File

@ -209,7 +209,8 @@ internal class DefaultFetchThreadTimelineTask @Inject constructor(
* Create an EventEntity to be added in the TimelineEventEntity.
*/
private fun createEventEntity(roomId: String, event: Event, realm: Realm): EventEntity {
val ageLocalTs = event.unsignedData?.age?.let { clock.epochMillis() - it }
val now = clock.epochMillis()
val ageLocalTs = now - (event.unsignedData?.age ?: 0)
return event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION)
}

View File

@ -708,7 +708,7 @@ internal class LocalEchoEventFactory @Inject constructor(
}
/**
* Returns RFC5870 formatted geo uri 'geo:latitude,longitude;uncertainty' like 'geo:40.05,29.24;30'
* Returns RFC5870 formatted geo uri 'geo:latitude,longitude;u=uncertainty' like 'geo:40.05,29.24;u=30'
* Uncertainty of the location is in meters and not required.
*/
private fun buildGeoUri(latitude: Double, longitude: Double, uncertainty: Double?): String {
@ -718,7 +718,7 @@ internal class LocalEchoEventFactory @Inject constructor(
append(",")
append(longitude)
uncertainty?.let {
append(";")
append(";u=")
append(it)
}
}

View File

@ -61,7 +61,7 @@ internal class DefaultGetEventTask @Inject constructor(
}
}
event.ageLocalTs = event.unsignedData?.age?.let { clock.epochMillis() - it }
event.ageLocalTs = clock.epochMillis() - (event.unsignedData?.age ?: 0)
return event
}

View File

@ -142,7 +142,7 @@ internal class TokenChunkEventPersistor @Inject constructor(
val now = clock.epochMillis()
stateEvents?.forEach { stateEvent ->
val ageLocalTs = stateEvent.unsignedData?.age?.let { now - it }
val ageLocalTs = now - (stateEvent.unsignedData?.age ?: 0)
val stateEventEntity = stateEvent.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION)
currentChunk.addStateEvent(roomId, stateEventEntity, direction)
if (stateEvent.type == EventType.STATE_ROOM_MEMBER && stateEvent.stateKey != null) {
@ -155,7 +155,7 @@ internal class TokenChunkEventPersistor @Inject constructor(
if (event.eventId == null || event.senderId == null) {
return@forEach
}
val ageLocalTs = event.unsignedData?.age?.let { now - it }
val ageLocalTs = now - (event.unsignedData?.age ?: 0)
val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION)
if (event.type == EventType.STATE_ROOM_MEMBER && event.stateKey != null) {
val contentToUse = if (direction == PaginationDirection.BACKWARDS) {

View File

@ -244,7 +244,7 @@ internal class RoomSyncHandler @Inject constructor(
if (event.eventId == null || event.stateKey == null || event.type == null) {
continue
}
val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it }
val ageLocalTs = syncLocalTimestampMillis - (event.unsignedData?.age ?: 0)
val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType)
Timber.v("## received state event ${event.type} and key ${event.stateKey}")
CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply {
@ -306,7 +306,7 @@ internal class RoomSyncHandler @Inject constructor(
if (event.stateKey == null || event.type == null) {
return@forEach
}
val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it }
val ageLocalTs = syncLocalTimestampMillis - (event.unsignedData?.age ?: 0)
val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType)
CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply {
eventId = eventEntity.eventId
@ -336,7 +336,7 @@ internal class RoomSyncHandler @Inject constructor(
if (event.eventId == null || event.stateKey == null || event.type == null) {
continue
}
val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it }
val ageLocalTs = syncLocalTimestampMillis - (event.unsignedData?.age ?: 0)
val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType)
CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply {
eventId = event.eventId
@ -348,7 +348,7 @@ internal class RoomSyncHandler @Inject constructor(
if (event.eventId == null || event.senderId == null || event.type == null) {
continue
}
val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it }
val ageLocalTs = syncLocalTimestampMillis - (event.unsignedData?.age ?: 0)
val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType)
if (event.stateKey != null) {
CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply {
@ -401,7 +401,10 @@ internal class RoomSyncHandler @Inject constructor(
for (rawEvent in eventList) {
// It's annoying roomId is not there, but lot of code rely on it.
// And had to do it now as copy would delete all decryption results..
val event = rawEvent.copy(roomId = roomId)
val ageLocalTs = syncLocalTimestampMillis - (rawEvent.unsignedData?.age ?: 0)
val event = rawEvent.copy(roomId = roomId).also {
it.ageLocalTs = ageLocalTs
}
if (event.eventId == null || event.senderId == null || event.type == null) {
continue
}
@ -423,7 +426,6 @@ internal class RoomSyncHandler @Inject constructor(
contentToInject = threadsAwarenessHandler.makeEventThreadAware(realm, roomId, event)
}
val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it }
val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs, contentToInject).copyToRealmOrIgnore(realm, insertType)
if (event.stateKey != null) {
CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply {

View File

@ -53,6 +53,7 @@ import org.matrix.android.sdk.internal.session.permalinks.PermalinkFactory
import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
import org.matrix.android.sdk.internal.session.room.timeline.GetEventTask
import org.matrix.android.sdk.internal.util.awaitTransaction
import org.matrix.android.sdk.internal.util.time.Clock
import javax.inject.Inject
/**
@ -64,7 +65,8 @@ internal class ThreadsAwarenessHandler @Inject constructor(
private val permalinkFactory: PermalinkFactory,
@SessionDatabase private val monarchy: Monarchy,
private val lightweightSettingsStorage: LightweightSettingsStorage,
private val getEventTask: GetEventTask
private val getEventTask: GetEventTask,
private val clock: Clock,
) {
// This caching is responsible to improve the performance when we receive a root event
@ -120,7 +122,7 @@ internal class ThreadsAwarenessHandler @Inject constructor(
private suspend fun fetchThreadsEvents(threadsToFetch: Map<String, String>) {
val eventEntityList = threadsToFetch.mapNotNull { (eventId, roomId) ->
fetchEvent(eventId, roomId)?.let {
it.toEntity(roomId, SendState.SYNCED, it.ageLocalTs)
it.toEntity(roomId, SendState.SYNCED, it.ageLocalTs ?: clock.epochMillis())
}
}

View File

@ -46,7 +46,7 @@ private const val A_TIMEOUT_MILLIS = 15 * 60 * 1000L
private const val A_LATITUDE = 40.05
private const val A_LONGITUDE = 29.24
private const val A_UNCERTAINTY = 30.0
private const val A_GEO_URI = "geo:$A_LATITUDE,$A_LONGITUDE;$A_UNCERTAINTY"
private const val A_GEO_URI = "geo:$A_LATITUDE,$A_LONGITUDE;u=$A_UNCERTAINTY"
internal class LiveLocationAggregationProcessorTest {

View File

@ -0,0 +1,105 @@
/*
* 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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
import org.matrix.android.sdk.test.fakes.FakeGetActiveBeaconInfoForUserTask
private const val A_USER_ID = "user-id"
private const val A_ROOM_ID = "room-id"
private const val A_TIMEOUT = 15_000L
private const val AN_EPOCH = 1655210176L
@ExperimentalCoroutinesApi
class DefaultCheckIfExistingActiveLiveTaskTest {
private val fakeGetActiveBeaconInfoForUserTask = FakeGetActiveBeaconInfoForUserTask()
private val defaultCheckIfExistingActiveLiveTask = DefaultCheckIfExistingActiveLiveTask(
getActiveBeaconInfoForUserTask = fakeGetActiveBeaconInfoForUserTask
)
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given parameters and existing active live event when calling the task then result is true`() = runTest {
val params = CheckIfExistingActiveLiveTask.Params(
roomId = A_ROOM_ID
)
val currentStateEvent = Event(
stateKey = A_USER_ID,
content = MessageBeaconInfoContent(
timeout = A_TIMEOUT,
isLive = true,
unstableTimestampMillis = AN_EPOCH
).toContent()
)
fakeGetActiveBeaconInfoForUserTask.givenExecuteReturns(currentStateEvent)
val result = defaultCheckIfExistingActiveLiveTask.execute(params)
result shouldBeEqualTo true
val expectedGetActiveBeaconParams = GetActiveBeaconInfoForUserTask.Params(
roomId = params.roomId
)
fakeGetActiveBeaconInfoForUserTask.verifyExecute(expectedGetActiveBeaconParams)
}
@Test
fun `given parameters and no existing active live event when calling the task then result is false`() = runTest {
val params = CheckIfExistingActiveLiveTask.Params(
roomId = A_ROOM_ID
)
val inactiveEvents = listOf(
// no event
null,
// null content
Event(
stateKey = A_USER_ID,
content = null
),
// inactive live
Event(
stateKey = A_USER_ID,
content = MessageBeaconInfoContent(
timeout = A_TIMEOUT,
isLive = false,
unstableTimestampMillis = AN_EPOCH
).toContent()
)
)
inactiveEvents.forEach { currentStateEvent ->
fakeGetActiveBeaconInfoForUserTask.givenExecuteReturns(currentStateEvent)
val result = defaultCheckIfExistingActiveLiveTask.execute(params)
result shouldBeEqualTo false
}
}
}

View File

@ -0,0 +1,75 @@
/*
* 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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
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.events.model.toContent
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
import org.matrix.android.sdk.test.fakes.FakeStateEventDataSource
private const val A_USER_ID = "user-id"
private const val A_ROOM_ID = "room-id"
private const val A_TIMEOUT = 15_000L
private const val AN_EPOCH = 1655210176L
@ExperimentalCoroutinesApi
class DefaultGetActiveBeaconInfoForUserTaskTest {
private val fakeStateEventDataSource = FakeStateEventDataSource()
private val defaultGetActiveBeaconInfoForUserTask = DefaultGetActiveBeaconInfoForUserTask(
userId = A_USER_ID,
stateEventDataSource = fakeStateEventDataSource.instance
)
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given parameters and no error when calling the task then result is computed`() = runTest {
val currentStateEvent = Event(
stateKey = A_USER_ID,
content = MessageBeaconInfoContent(
timeout = A_TIMEOUT,
isLive = true,
unstableTimestampMillis = AN_EPOCH
).toContent()
)
fakeStateEventDataSource.givenGetStateEventReturns(currentStateEvent)
val params = GetActiveBeaconInfoForUserTask.Params(
roomId = A_ROOM_ID
)
val result = defaultGetActiveBeaconInfoForUserTask.execute(params)
result shouldBeEqualTo currentStateEvent
fakeStateEventDataSource.verifyGetStateEvent(
roomId = params.roomId,
eventType = EventType.STATE_ROOM_BEACON_INFO.first(),
stateKey = A_USER_ID
)
}
}

View File

@ -16,18 +16,27 @@
package org.matrix.android.sdk.internal.session.room.location
import androidx.arch.core.util.Function
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkAll
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult
import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary
import org.matrix.android.sdk.api.util.Cancelable
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional
import org.matrix.android.sdk.internal.database.mapper.LiveLocationShareAggregatedSummaryMapper
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntityFields
@ -46,24 +55,30 @@ private const val A_TIMEOUT = 15_000L
@ExperimentalCoroutinesApi
internal class DefaultLocationSharingServiceTest {
private val fakeRoomId = A_ROOM_ID
private val fakeMonarchy = FakeMonarchy()
private val sendStaticLocationTask = mockk<SendStaticLocationTask>()
private val sendLiveLocationTask = mockk<SendLiveLocationTask>()
private val startLiveLocationShareTask = mockk<StartLiveLocationShareTask>()
private val stopLiveLocationShareTask = mockk<StopLiveLocationShareTask>()
private val checkIfExistingActiveLiveTask = mockk<CheckIfExistingActiveLiveTask>()
private val fakeLiveLocationShareAggregatedSummaryMapper = mockk<LiveLocationShareAggregatedSummaryMapper>()
private val defaultLocationSharingService = DefaultLocationSharingService(
roomId = fakeRoomId,
roomId = A_ROOM_ID,
monarchy = fakeMonarchy.instance,
sendStaticLocationTask = sendStaticLocationTask,
sendLiveLocationTask = sendLiveLocationTask,
startLiveLocationShareTask = startLiveLocationShareTask,
stopLiveLocationShareTask = stopLiveLocationShareTask,
checkIfExistingActiveLiveTask = checkIfExistingActiveLiveTask,
liveLocationShareAggregatedSummaryMapper = fakeLiveLocationShareAggregatedSummaryMapper
)
@Before
fun setUp() {
mockkStatic("androidx.lifecycle.Transformations")
}
@After
fun tearDown() {
unmockkAll()
@ -117,17 +132,65 @@ internal class DefaultLocationSharingServiceTest {
}
@Test
fun `live location share can be started with a given timeout`() = runTest {
fun `given existing active live can be stopped when starting a live then the current live is stopped and the new live is started`() = runTest {
coEvery { checkIfExistingActiveLiveTask.execute(any()) } returns true
coEvery { stopLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Success("stopped-event-id")
coEvery { startLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Success(AN_EVENT_ID)
val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT)
result shouldBeEqualTo UpdateLiveLocationShareResult.Success(AN_EVENT_ID)
val expectedParams = StartLiveLocationShareTask.Params(
val expectedCheckExistingParams = CheckIfExistingActiveLiveTask.Params(
roomId = A_ROOM_ID
)
coVerify { checkIfExistingActiveLiveTask.execute(expectedCheckExistingParams) }
val expectedStopParams = StopLiveLocationShareTask.Params(
roomId = A_ROOM_ID
)
coVerify { stopLiveLocationShareTask.execute(expectedStopParams) }
val expectedStartParams = StartLiveLocationShareTask.Params(
roomId = A_ROOM_ID,
timeoutMillis = A_TIMEOUT
)
coVerify { startLiveLocationShareTask.execute(expectedParams) }
coVerify { startLiveLocationShareTask.execute(expectedStartParams) }
}
@Test
fun `given existing active live cannot be stopped when starting a live then the result is failure`() = runTest {
coEvery { checkIfExistingActiveLiveTask.execute(any()) } returns true
val error = Throwable()
coEvery { stopLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Failure(error)
val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT)
result shouldBeEqualTo UpdateLiveLocationShareResult.Failure(error)
val expectedCheckExistingParams = CheckIfExistingActiveLiveTask.Params(
roomId = A_ROOM_ID
)
coVerify { checkIfExistingActiveLiveTask.execute(expectedCheckExistingParams) }
val expectedStopParams = StopLiveLocationShareTask.Params(
roomId = A_ROOM_ID
)
coVerify { stopLiveLocationShareTask.execute(expectedStopParams) }
}
@Test
fun `given no existing active live when starting a live then the new live is started`() = runTest {
coEvery { checkIfExistingActiveLiveTask.execute(any()) } returns false
coEvery { startLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Success(AN_EVENT_ID)
val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT)
result shouldBeEqualTo UpdateLiveLocationShareResult.Success(AN_EVENT_ID)
val expectedCheckExistingParams = CheckIfExistingActiveLiveTask.Params(
roomId = A_ROOM_ID
)
coVerify { checkIfExistingActiveLiveTask.execute(expectedCheckExistingParams) }
val expectedStartParams = StartLiveLocationShareTask.Params(
roomId = A_ROOM_ID,
timeoutMillis = A_TIMEOUT
)
coVerify { startLiveLocationShareTask.execute(expectedStartParams) }
}
@Test
@ -154,7 +217,7 @@ internal class DefaultLocationSharingServiceTest {
)
fakeMonarchy.givenWhere<LiveLocationShareAggregatedSummaryEntity>()
.givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.ROOM_ID, fakeRoomId)
.givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.ROOM_ID, A_ROOM_ID)
.givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.IS_ACTIVE, true)
.givenIsNotEmpty(LiveLocationShareAggregatedSummaryEntityFields.USER_ID)
.givenIsNotNull(LiveLocationShareAggregatedSummaryEntityFields.LAST_LOCATION_CONTENT)
@ -168,4 +231,38 @@ internal class DefaultLocationSharingServiceTest {
result shouldBeEqualTo listOf(summary)
}
@Test
fun `given an event id when getting livedata on corresponding live summary then it is correctly computed`() {
val entity = LiveLocationShareAggregatedSummaryEntity()
val summary = LiveLocationShareAggregatedSummary(
userId = "",
isActive = true,
endOfLiveTimestampMillis = 123,
lastLocationDataContent = null
)
fakeMonarchy.givenWhere<LiveLocationShareAggregatedSummaryEntity>()
.givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.ROOM_ID, A_ROOM_ID)
.givenEqualTo(LiveLocationShareAggregatedSummaryEntityFields.EVENT_ID, AN_EVENT_ID)
val liveData = fakeMonarchy.givenFindAllMappedWithChangesReturns(
realmEntities = listOf(entity),
mappedResult = listOf(summary),
fakeLiveLocationShareAggregatedSummaryMapper
)
val mapper = slot<Function<List<LiveLocationShareAggregatedSummary>, Optional<LiveLocationShareAggregatedSummary>>>()
every {
Transformations.map(
liveData,
capture(mapper)
)
} answers {
val value = secondArg<Function<List<LiveLocationShareAggregatedSummary>, Optional<LiveLocationShareAggregatedSummary>>>().apply(listOf(summary))
MutableLiveData(value)
}
val result = defaultLocationSharingService.getLiveLocationShareSummary(AN_EVENT_ID).value
result shouldBeEqualTo summary.toOptional()
}
}

View File

@ -27,11 +27,10 @@ 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.toContent
import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
import org.matrix.android.sdk.internal.session.room.state.SendStateTask
import org.matrix.android.sdk.test.fakes.FakeGetActiveBeaconInfoForUserTask
import org.matrix.android.sdk.test.fakes.FakeSendStateTask
import org.matrix.android.sdk.test.fakes.FakeStateEventDataSource
private const val A_USER_ID = "user-id"
private const val A_ROOM_ID = "room-id"
@ -43,12 +42,11 @@ private const val AN_EPOCH = 1655210176L
class DefaultStopLiveLocationShareTaskTest {
private val fakeSendStateTask = FakeSendStateTask()
private val fakeStateEventDataSource = FakeStateEventDataSource()
private val fakeGetActiveBeaconInfoForUserTask = FakeGetActiveBeaconInfoForUserTask()
private val defaultStopLiveLocationShareTask = DefaultStopLiveLocationShareTask(
userId = A_USER_ID,
sendStateTask = fakeSendStateTask,
stateEventDataSource = fakeStateEventDataSource.instance
getActiveBeaconInfoForUserTask = fakeGetActiveBeaconInfoForUserTask
)
@After
@ -67,7 +65,7 @@ class DefaultStopLiveLocationShareTaskTest {
unstableTimestampMillis = AN_EPOCH
).toContent()
)
fakeStateEventDataSource.givenGetStateEventReturns(currentStateEvent)
fakeGetActiveBeaconInfoForUserTask.givenExecuteReturns(currentStateEvent)
fakeSendStateTask.givenExecuteRetryReturns(AN_EVENT_ID)
val result = defaultStopLiveLocationShareTask.execute(params)
@ -78,20 +76,21 @@ class DefaultStopLiveLocationShareTaskTest {
isLive = false,
unstableTimestampMillis = AN_EPOCH
).toContent()
val expectedParams = SendStateTask.Params(
val expectedSendParams = SendStateTask.Params(
roomId = params.roomId,
stateKey = A_USER_ID,
eventType = EventType.STATE_ROOM_BEACON_INFO.first(),
body = expectedBeaconContent
)
fakeSendStateTask.verifyExecuteRetry(
params = expectedParams,
params = expectedSendParams,
remainingRetry = 3
)
fakeStateEventDataSource.verifyGetStateEvent(
roomId = params.roomId,
eventType = EventType.STATE_ROOM_BEACON_INFO.first(),
stateKey = A_USER_ID
val expectedGetBeaconParams = GetActiveBeaconInfoForUserTask.Params(
roomId = params.roomId
)
fakeGetActiveBeaconInfoForUserTask.verifyExecute(
expectedGetBeaconParams
)
}
@ -109,18 +108,15 @@ class DefaultStopLiveLocationShareTaskTest {
unstableTimestampMillis = AN_EPOCH
).toContent()
),
// incorrect content
// null content
Event(
stateKey = A_USER_ID,
content = MessageAudioContent(
msgType = "",
body = ""
).toContent()
content = null
)
)
incorrectCurrentStateEvents.forEach { currentStateEvent ->
fakeStateEventDataSource.givenGetStateEventReturns(currentStateEvent)
fakeGetActiveBeaconInfoForUserTask.givenExecuteReturns(currentStateEvent)
fakeSendStateTask.givenExecuteRetryReturns(AN_EVENT_ID)
val params = StopLiveLocationShareTask.Params(roomId = A_ROOM_ID)
@ -141,7 +137,7 @@ class DefaultStopLiveLocationShareTaskTest {
unstableTimestampMillis = AN_EPOCH
).toContent()
)
fakeStateEventDataSource.givenGetStateEventReturns(currentStateEvent)
fakeGetActiveBeaconInfoForUserTask.givenExecuteReturns(currentStateEvent)
fakeSendStateTask.givenExecuteRetryReturns("")
val result = defaultStopLiveLocationShareTask.execute(params)
@ -160,7 +156,7 @@ class DefaultStopLiveLocationShareTaskTest {
unstableTimestampMillis = AN_EPOCH
).toContent()
)
fakeStateEventDataSource.givenGetStateEventReturns(currentStateEvent)
fakeGetActiveBeaconInfoForUserTask.givenExecuteReturns(currentStateEvent)
val error = Throwable()
fakeSendStateTask.givenExecuteRetryThrows(error)

View File

@ -0,0 +1,34 @@
/*
* 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.coVerify
import io.mockk.mockk
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.internal.session.room.location.GetActiveBeaconInfoForUserTask
internal class FakeGetActiveBeaconInfoForUserTask : GetActiveBeaconInfoForUserTask by mockk() {
fun givenExecuteReturns(event: Event?) {
coEvery { execute(any()) } returns event
}
fun verifyExecute(params: GetActiveBeaconInfoForUserTask.Params) {
coVerify { execute(params) }
}
}

View File

@ -16,6 +16,7 @@
package org.matrix.android.sdk.test.fakes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.zhuinden.monarchy.Monarchy
import io.mockk.MockKVerificationScope
@ -60,10 +61,11 @@ internal class FakeMonarchy {
realmEntities: List<T>,
mappedResult: List<R>,
mapper: Monarchy.Mapper<R, T>
) {
): LiveData<List<R>> {
every { mapper.map(any()) } returns mockk()
val monarchyQuery = slot<Monarchy.Query<T>>()
val monarchyMapper = slot<Monarchy.Mapper<R, T>>()
val result = MutableLiveData(mappedResult)
every {
instance.findAllMappedWithChanges(capture(monarchyQuery), capture(monarchyMapper))
} answers {
@ -71,7 +73,8 @@ internal class FakeMonarchy {
realmEntities.forEach {
monarchyMapper.captured.map(it)
}
MutableLiveData(mappedResult)
result
}
return result
}
}

View File

@ -372,7 +372,6 @@ dependencies {
implementation "com.gabrielittner.threetenbp:lazythreetenbp:0.10.0"
implementation libs.squareup.moshi
implementation libs.squareup.moshiKt
kapt libs.squareup.moshiKotlin
// Lifecycle
@ -534,10 +533,10 @@ dependencies {
}
// Flipper, debug builds only
debugImplementation('com.facebook.flipper:flipper:0.150.0') {
debugImplementation(libs.flipper.flipper) {
exclude group: 'com.facebook.fbjni', module: 'fbjni'
}
debugImplementation('com.facebook.flipper:flipper-network-plugin:0.150.0') {
debugImplementation(libs.flipper.flipperNetworkPlugin) {
exclude group: 'com.facebook.fbjni', module: 'fbjni'
}
debugImplementation 'com.facebook.soloader:soloader:0.10.3'

View File

@ -44,11 +44,11 @@ class ActiveSessionHolder @Inject constructor(
private val guardServiceStarter: GuardServiceStarter
) {
private var activeSession: AtomicReference<Session?> = AtomicReference()
private var activeSessionReference: AtomicReference<Session?> = AtomicReference()
fun setActiveSession(session: Session) {
Timber.w("setActiveSession of ${session.myUserId}")
activeSession.set(session)
activeSessionReference.set(session)
activeSessionDataSource.post(Option.just(session))
keyRequestHandler.start(session)
@ -68,7 +68,7 @@ class ActiveSessionHolder @Inject constructor(
it.removeListener(sessionListener)
}
activeSession.set(null)
activeSessionReference.set(null)
activeSessionDataSource.post(Option.empty())
keyRequestHandler.stop()
@ -80,15 +80,15 @@ class ActiveSessionHolder @Inject constructor(
}
fun hasActiveSession(): Boolean {
return activeSession.get() != null
return activeSessionReference.get() != null
}
fun getSafeActiveSession(): Session? {
return activeSession.get()
return activeSessionReference.get()
}
fun getActiveSession(): Session {
return activeSession.get()
return activeSessionReference.get()
?: throw IllegalStateException("You should authenticate before using this")
}

View File

@ -0,0 +1,59 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.core.ui.list
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.google.android.material.button.MaterialButton
import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.epoxy.onClick
import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence
/**
* A generic button list item.
*/
@EpoxyModelClass(layout = R.layout.item_positive_destrutive_buttons)
abstract class ButtonPositiveDestructiveButtonBarItem : VectorEpoxyModel<ButtonPositiveDestructiveButtonBarItem.Holder>() {
@EpoxyAttribute
var positiveText: EpoxyCharSequence? = null
@EpoxyAttribute
var destructiveText: EpoxyCharSequence? = null
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var positiveButtonClickAction: ClickListener? = null
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var destructiveButtonClickAction: ClickListener? = null
override fun bind(holder: Holder) {
super.bind(holder)
positiveText?.charSequence?.let { holder.positiveButton.text = it }
destructiveText?.charSequence?.let { holder.destructiveButton.text = it }
holder.positiveButton.onClick(positiveButtonClickAction)
holder.destructiveButton.onClick(destructiveButtonClickAction)
}
class Holder : VectorEpoxyHolder() {
val destructiveButton by bind<MaterialButton>(R.id.destructive_button)
val positiveButton by bind<MaterialButton>(R.id.positive_button)
}
}

View File

@ -22,6 +22,7 @@ import im.vector.app.features.call.vectorCallService
import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.createdirect.DirectRoomHelper
import org.matrix.android.sdk.api.session.Session
import timber.log.Timber
import javax.inject.Inject
class DialPadLookup @Inject constructor(
@ -42,18 +43,23 @@ class DialPadLookup @Inject constructor(
val sipUserId = thirdPartyUser.userId
val nativeLookupResults = session.sipNativeLookup(thirdPartyUser.userId)
// If I have a native user I check for an existing native room with him...
val roomId = if (nativeLookupResults.isNotEmpty()) {
if (nativeLookupResults.isNotEmpty()) {
val nativeUserId = nativeLookupResults.first().userId
if (nativeUserId == session.myUserId) {
throw Failure.NumberIsYours
}
session.roomService().getExistingDirectRoomWithUser(nativeUserId)
// if there is not, just create a DM with the sip user
?: directRoomHelper.ensureDMExists(sipUserId)
} else {
// do the same if there is no corresponding native user.
directRoomHelper.ensureDMExists(sipUserId)
var nativeRoomId = session.roomService().getExistingDirectRoomWithUser(nativeUserId)
if (nativeRoomId == null) {
// if there is no existing native room with the existing native user,
// just create a DM with the native user
nativeRoomId = directRoomHelper.ensureDMExists(nativeUserId)
}
Timber.d("lookupPhoneNumber with nativeUserId: $nativeUserId and nativeRoomId: $nativeRoomId")
return Result(userId = nativeUserId, roomId = nativeRoomId)
}
return Result(userId = sipUserId, roomId = roomId)
// If there is no native user then we return sipUserId and sipRoomId - this is usually a PSTN call.
val sipRoomId = directRoomHelper.ensureDMExists(sipUserId)
Timber.d("lookupPhoneNumber with sipRoomId: $sipRoomId and sipUserId: $sipUserId")
return Result(userId = sipUserId, roomId = sipRoomId)
}
}

View File

@ -30,6 +30,8 @@ sealed class VerificationAction : VectorViewModelAction {
data class GotItConclusion(val verified: Boolean) : VerificationAction()
object SkipVerification : VerificationAction()
object VerifyFromPassphrase : VerificationAction()
object ReadyPendingVerification : VerificationAction()
object CancelPendingVerification : VerificationAction()
data class GotResultFromSsss(val cypherData: String, val alias: String) : VerificationAction()
object CancelledFromSsss : VerificationAction()
object SecuredStorageHasBeenReset : VerificationAction()

View File

@ -360,6 +360,27 @@ class VerificationBottomSheetViewModel @AssistedInject constructor(
as? SasVerificationTransaction)
?.shortCodeDoesNotMatch()
}
is VerificationAction.ReadyPendingVerification -> {
state.pendingRequest.invoke()?.let { request ->
// will only be there for dm verif
if (state.roomId != null) {
session.cryptoService().verificationService()
.readyPendingVerificationInDMs(
supportedVerificationMethodsProvider.provide(),
state.otherUserId,
state.roomId,
request.transactionId ?: ""
)
}
}
}
is VerificationAction.CancelPendingVerification -> {
state.pendingRequest.invoke()?.let {
session.cryptoService().verificationService()
.cancelVerificationRequest(it)
}
_viewEvents.post(VerificationBottomSheetViewEvents.Dismiss)
}
is VerificationAction.GotItConclusion -> {
if (state.isVerificationRequired && !action.verified) {
// we should go back to first screen

View File

@ -21,6 +21,7 @@ import im.vector.app.R
import im.vector.app.core.epoxy.bottomSheetDividerItem
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.ui.list.buttonPositiveDestructiveButtonBarItem
import im.vector.app.features.crypto.verification.epoxy.bottomSheetVerificationActionItem
import im.vector.app.features.crypto.verification.epoxy.bottomSheetVerificationNoticeItem
import im.vector.app.features.crypto.verification.epoxy.bottomSheetVerificationQrCodeItem
@ -108,6 +109,15 @@ class VerificationChooseMethodController @Inject constructor(
iconColor(host.colorProvider.getColorFromAttribute(R.attr.vctr_content_primary))
listener { host.listener?.doVerifyBySas() }
}
} else if (!state.isReadySent) {
// a bit of a special case, if you tapped on the timeline cell but not on a button
buttonPositiveDestructiveButtonBarItem {
id("accept_decline")
positiveText(host.stringProvider.getString(R.string.action_accept).toEpoxyCharSequence())
destructiveText(host.stringProvider.getString(R.string.action_decline).toEpoxyCharSequence())
positiveButtonClickAction { host.listener?.acceptRequest() }
destructiveButtonClickAction { host.listener?.declineRequest() }
}
}
if (state.isMe && state.canCrossSign) {
@ -131,5 +141,7 @@ class VerificationChooseMethodController @Inject constructor(
fun openCamera()
fun doVerifyBySas()
fun onClickOnWasNotMe()
fun acceptRequest()
fun declineRequest()
}
}

View File

@ -100,6 +100,14 @@ class VerificationChooseMethodFragment @Inject constructor(
sharedViewModel.itWasNotMe()
}
override fun acceptRequest() {
sharedViewModel.handle(VerificationAction.ReadyPendingVerification)
}
override fun declineRequest() {
sharedViewModel.handle(VerificationAction.CancelPendingVerification)
}
private fun doOpenQRCodeScanner() {
QrCodeScannerActivity.startForResult(requireActivity(), scanActivityResultLauncher)
}

View File

@ -44,7 +44,8 @@ data class VerificationChooseMethodViewState(
val qrCodeText: String? = null,
val sasModeAvailable: Boolean = false,
val isMe: Boolean = false,
val canCrossSign: Boolean = false
val canCrossSign: Boolean = false,
val isReadySent: Boolean = false
) : MavericksState
class VerificationChooseMethodViewModel @AssistedInject constructor(
@ -81,7 +82,8 @@ class VerificationChooseMethodViewModel @AssistedInject constructor(
copy(
otherCanShowQrCode = pvr?.otherCanShowQrCode().orFalse(),
otherCanScanQrCode = pvr?.otherCanScanQrCode().orFalse(),
sasModeAvailable = pvr?.isSasSupported().orFalse()
sasModeAvailable = pvr?.isSasSupported().orFalse(),
isReadySent = pvr?.isReady.orFalse(),
)
}
}

View File

@ -53,6 +53,7 @@ import im.vector.app.features.home.room.detail.timeline.factory.TimelineFactory
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.home.room.typing.TypingHelper
import im.vector.app.features.location.LocationSharingServiceConnection
import im.vector.app.features.location.live.StopLiveLocationShareUseCase
import im.vector.app.features.notifications.NotificationDrawerManager
import im.vector.app.features.powerlevel.PowerLevelsFlowFactory
import im.vector.app.features.raw.wellknown.getOutboundSessionKeySharingStrategyOrDefault
@ -92,6 +93,7 @@ import org.matrix.android.sdk.api.session.file.FileService
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.getStateEvent
import org.matrix.android.sdk.api.session.room.getTimelineEvent
import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult
import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState
import org.matrix.android.sdk.api.session.room.members.roomMemberQueryParams
import org.matrix.android.sdk.api.session.room.model.Membership
@ -133,8 +135,9 @@ class TimelineViewModel @AssistedInject constructor(
private val decryptionFailureTracker: DecryptionFailureTracker,
private val notificationDrawerManager: NotificationDrawerManager,
private val locationSharingServiceConnection: LocationSharingServiceConnection,
private val stopLiveLocationShareUseCase: StopLiveLocationShareUseCase,
timelineFactory: TimelineFactory,
appStateHandler: AppStateHandler
appStateHandler: AppStateHandler,
) : VectorViewModel<RoomDetailViewState, RoomDetailAction, RoomDetailViewEvents>(initialState),
Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener, LocationSharingServiceConnection.Callback {
@ -1139,7 +1142,12 @@ class TimelineViewModel @AssistedInject constructor(
}
private fun handleStopLiveLocationSharing() {
locationSharingServiceConnection.stopLiveLocationSharing(room.roomId)
viewModelScope.launch {
val result = stopLiveLocationShareUseCase.execute(room.roomId)
if (result is UpdateLiveLocationShareResult.Failure) {
_viewEvents.post(RoomDetailViewEvents.Failure(throwable = result.error, showInDialog = true))
}
}
}
private fun observeRoomSummary() {
@ -1310,7 +1318,7 @@ class TimelineViewModel @AssistedInject constructor(
// we should also mark it as read here, for the scenario that the user
// is already in the thread timeline
markThreadTimelineAsReadLocal()
locationSharingServiceConnection.unbind()
locationSharingServiceConnection.unbind(this)
super.onCleared()
}
}

View File

@ -59,12 +59,6 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem
import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem_
import im.vector.app.features.home.room.detail.timeline.item.PollItem
import im.vector.app.features.home.room.detail.timeline.item.PollItem_
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState.PollEnded
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState.PollReady
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState.PollSending
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState.PollUndisclosed
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState.PollVoted
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem
import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem_
import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem
@ -81,18 +75,11 @@ import im.vector.app.features.location.UrlMapProvider
import im.vector.app.features.location.toLocationData
import im.vector.app.features.media.ImageContentRenderer
import im.vector.app.features.media.VideoContentRenderer
import im.vector.app.features.poll.PollState
import im.vector.app.features.poll.PollState.Ended
import im.vector.app.features.poll.PollState.Ready
import im.vector.app.features.poll.PollState.Sending
import im.vector.app.features.poll.PollState.Undisclosed
import im.vector.app.features.poll.PollState.Voted
import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.voice.AudioWaveformView
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import me.gujun.android.span.span
import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.crypto.attachments.toElementToDecrypt
import org.matrix.android.sdk.api.session.events.model.RelationType
@ -113,8 +100,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.matrix.android.sdk.api.session.room.model.message.PollAnswer
import org.matrix.android.sdk.api.session.room.model.message.PollType
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl
import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
@ -149,6 +134,7 @@ class MessageItemFactory @Inject constructor(
private val vectorPreferences: VectorPreferences,
private val urlMapProvider: UrlMapProvider,
private val liveLocationShareMessageItemFactory: LiveLocationShareMessageItemFactory,
private val pollItemViewStateFactory: PollItemViewStateFactory,
) {
// TODO inject this properly?
@ -251,62 +237,21 @@ class MessageItemFactory @Inject constructor(
callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes,
): PollItem {
val pollResponseSummary = informationData.pollResponseAggregatedSummary
val pollState = createPollState(informationData, pollResponseSummary, pollContent)
val pollCreationInfo = pollContent.getBestPollCreationInfo()
val questionText = pollCreationInfo?.question?.getBestQuestion().orEmpty()
val question = createPollQuestion(informationData, questionText, callback)
val optionViewStates = pollCreationInfo?.answers?.mapToOptions(pollState, informationData)
val totalVotesText = createTotalVotesText(pollState, pollResponseSummary)
val pollViewState = pollItemViewStateFactory.create(pollContent, informationData)
return PollItem_()
.attributes(attributes)
.eventId(informationData.eventId)
.pollQuestion(question)
.canVote(pollState.isVotable())
.totalVotesText(totalVotesText)
.optionViewStates(optionViewStates)
.pollQuestion(createPollQuestion(informationData, pollViewState.question, callback))
.canVote(pollViewState.canVote)
.totalVotesText(pollViewState.totalVotes)
.optionViewStates(pollViewState.optionViewStates)
.edited(informationData.hasBeenEdited)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
.callback(callback)
}
private fun createPollState(
informationData: MessageInformationData,
pollResponseSummary: PollResponseData?,
pollContent: MessagePollContent,
): PollState = when {
!informationData.sendState.isSent() -> Sending
pollResponseSummary?.isClosed.orFalse() -> Ended
pollContent.getBestPollCreationInfo()?.kind == PollType.UNDISCLOSED -> Undisclosed
pollResponseSummary?.myVote?.isNotEmpty().orFalse() -> Voted(pollResponseSummary?.totalVotes ?: 0)
else -> Ready
}
private fun List<PollAnswer>.mapToOptions(
pollState: PollState,
informationData: MessageInformationData,
) = map { answer ->
val pollResponseSummary = informationData.pollResponseAggregatedSummary
val winnerVoteCount = pollResponseSummary?.winnerVoteCount
val optionId = answer.id ?: ""
val optionAnswer = answer.getBestAnswer() ?: ""
val voteSummary = pollResponseSummary?.votes?.get(answer.id)
val voteCount = voteSummary?.total ?: 0
val votePercentage = voteSummary?.percentage ?: 0.0
val isMyVote = pollResponseSummary?.myVote == answer.id
val isWinner = winnerVoteCount != 0 && voteCount == winnerVoteCount
when (pollState) {
Sending -> PollSending(optionId, optionAnswer)
Ready -> PollReady(optionId, optionAnswer)
is Voted -> PollVoted(optionId, optionAnswer, voteCount, votePercentage, isMyVote)
Undisclosed -> PollUndisclosed(optionId, optionAnswer, isMyVote)
Ended -> PollEnded(optionId, optionAnswer, voteCount, votePercentage, isWinner)
}
}
private fun createPollQuestion(
informationData: MessageInformationData,
question: String,
@ -317,20 +262,6 @@ class MessageItemFactory @Inject constructor(
question
}.toEpoxyCharSequence()
private fun createTotalVotesText(
pollState: PollState,
pollResponseSummary: PollResponseData?,
): String {
val votes = pollResponseSummary?.totalVotes ?: 0
return when {
pollState is Ended -> stringProvider.getQuantityString(R.plurals.poll_total_vote_count_after_ended, votes, votes)
pollState is Undisclosed -> ""
pollState is Voted -> stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, votes, votes)
votes == 0 -> stringProvider.getString(R.string.poll_no_votes_cast)
else -> stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_not_voted, votes, votes)
}
}
private fun buildAudioMessageItem(
params: TimelineItemFactoryParams,
messageContent: MessageAudioContent,

View File

@ -0,0 +1,165 @@
/*
* 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.factory
import im.vector.app.R
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import im.vector.app.features.poll.PollViewState
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.PollCreationInfo
import javax.inject.Inject
class PollItemViewStateFactory @Inject constructor(
private val stringProvider: StringProvider,
) {
fun create(
pollContent: MessagePollContent,
informationData: MessageInformationData,
): PollViewState {
val pollCreationInfo = pollContent.getBestPollCreationInfo()
val question = pollCreationInfo?.question?.getBestQuestion().orEmpty()
val pollResponseSummary = informationData.pollResponseAggregatedSummary
val winnerVoteCount = pollResponseSummary?.winnerVoteCount
val totalVotes = pollResponseSummary?.totalVotes ?: 0
return when {
!informationData.sendState.isSent() -> {
createSendingPollViewState(question, pollCreationInfo)
}
informationData.pollResponseAggregatedSummary?.isClosed.orFalse() -> {
createEndedPollViewState(question, pollCreationInfo, pollResponseSummary, totalVotes, winnerVoteCount)
}
pollContent.getBestPollCreationInfo()?.isUndisclosed().orFalse() -> {
createUndisclosedPollViewState(question, pollCreationInfo, pollResponseSummary)
}
informationData.pollResponseAggregatedSummary?.myVote?.isNotEmpty().orFalse() -> {
createVotedPollViewState(question, pollCreationInfo, pollResponseSummary, totalVotes)
}
else -> {
createReadyPollViewState(question, pollCreationInfo, totalVotes)
}
}
}
private fun createSendingPollViewState(question: String, pollCreationInfo: PollCreationInfo?): PollViewState {
return PollViewState(
question = question,
totalVotes = stringProvider.getString(R.string.poll_no_votes_cast),
canVote = false,
optionViewStates = pollCreationInfo?.answers?.map { answer ->
PollOptionViewState.PollSending(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
},
)
}
private fun createEndedPollViewState(
question: String,
pollCreationInfo: PollCreationInfo?,
pollResponseSummary: PollResponseData?,
totalVotes: Int,
winnerVoteCount: Int?,
): PollViewState {
return PollViewState(
question = question,
totalVotes = stringProvider.getQuantityString(R.plurals.poll_total_vote_count_after_ended, totalVotes, totalVotes),
canVote = false,
optionViewStates = pollCreationInfo?.answers?.map { answer ->
val voteSummary = pollResponseSummary?.getVoteSummaryOfAnOption(answer.id ?: "")
PollOptionViewState.PollEnded(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: "",
voteCount = voteSummary?.total ?: 0,
votePercentage = voteSummary?.percentage ?: 0.0,
isWinner = winnerVoteCount != 0 && voteSummary?.total == winnerVoteCount
)
},
)
}
private fun createUndisclosedPollViewState(
question: String,
pollCreationInfo: PollCreationInfo?,
pollResponseSummary: PollResponseData?
): PollViewState {
return PollViewState(
question = question,
totalVotes = "",
canVote = true,
optionViewStates = pollCreationInfo?.answers?.map { answer ->
val isMyVote = pollResponseSummary?.myVote == answer.id
PollOptionViewState.PollUndisclosed(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: "",
isSelected = isMyVote
)
},
)
}
private fun createVotedPollViewState(
question: String,
pollCreationInfo: PollCreationInfo?,
pollResponseSummary: PollResponseData?,
totalVotes: Int
): PollViewState {
return PollViewState(
question = question,
totalVotes = stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, totalVotes, totalVotes),
canVote = true,
optionViewStates = pollCreationInfo?.answers?.map { answer ->
val isMyVote = pollResponseSummary?.myVote == answer.id
val voteSummary = pollResponseSummary?.getVoteSummaryOfAnOption(answer.id ?: "")
PollOptionViewState.PollVoted(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: "",
voteCount = voteSummary?.total ?: 0,
votePercentage = voteSummary?.percentage ?: 0.0,
isSelected = isMyVote
)
},
)
}
private fun createReadyPollViewState(question: String, pollCreationInfo: PollCreationInfo?, totalVotes: Int): PollViewState {
val totalVotesText = if (totalVotes == 0) {
stringProvider.getString(R.string.poll_no_votes_cast)
} else {
stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_not_voted, totalVotes, totalVotes)
}
return PollViewState(
question = question,
totalVotes = totalVotesText,
canVote = true,
optionViewStates = pollCreationInfo?.answers?.map { answer ->
PollOptionViewState.PollReady(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
},
)
}
}

View File

@ -91,7 +91,10 @@ data class PollResponseData(
val totalVotes: Int = 0,
val winnerVoteCount: Int = 0,
val isClosed: Boolean = false
) : Parcelable
) : Parcelable {
fun getVoteSummaryOfAnOption(optionId: String) = votes?.get(optionId)
}
@Parcelize
data class PollVoteSummaryData(

View File

@ -30,7 +30,7 @@ data class LocationData(
/**
* Creates location data from a MessageLocationContent.
* "geo:40.05,29.24;30" -> LocationData(40.05, 29.24, 30)
* "geo:40.05,29.24;u=30" -> LocationData(40.05, 29.24, 30)
* @return location data or null if geo uri is not valid
*/
fun MessageLocationContent.toLocationData(): LocationData? {
@ -39,7 +39,7 @@ fun MessageLocationContent.toLocationData(): LocationData? {
/**
* Creates location data from a geoUri String.
* "geo:40.05,29.24;30" -> LocationData(40.05, 29.24, 30)
* "geo:40.05,29.24;u=30" -> LocationData(40.05, 29.24, 30)
* @return location data or null if geo uri is null or not valid
*/
fun String?.toLocationData(): LocationData? {

View File

@ -23,17 +23,21 @@ import android.os.Parcelable
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.services.VectorService
import im.vector.app.features.location.live.GetLiveLocationShareSummaryUseCase
import im.vector.app.features.notifications.NotificationUtils
import im.vector.app.features.session.coroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult
import timber.log.Timber
import java.util.Timer
import java.util.TimerTask
import javax.inject.Inject
@AndroidEntryPoint
@ -49,6 +53,7 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
@Inject lateinit var notificationUtils: NotificationUtils
@Inject lateinit var locationTracker: LocationTracker
@Inject lateinit var activeSessionHolder: ActiveSessionHolder
@Inject lateinit var getLiveLocationShareSummaryUseCase: GetLiveLocationShareSummaryUseCase
private val binder = LocalBinder()
@ -56,37 +61,50 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
* Keep track of a map between beacon event Id starting the live and RoomArgs.
*/
private val roomArgsMap = mutableMapOf<String, RoomArgs>()
private val timers = mutableListOf<Timer>()
var callback: Callback? = null
private val jobs = mutableListOf<Job>()
private var startInProgress = false
override fun onCreate() {
super.onCreate()
Timber.i("### LocationSharingService.onCreate")
Timber.i("onCreate")
initLocationTracking()
}
private fun initLocationTracking() {
// Start tracking location
locationTracker.addCallback(this)
locationTracker.start()
launchWithActiveSession { session ->
val job = locationTracker.locations
.onEach(this@LocationSharingService::onLocationUpdate)
.launchIn(session.coroutineScope)
jobs.add(job)
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startInProgress = true
val roomArgs = intent?.getParcelableExtra(EXTRA_ROOM_ARGS) as? RoomArgs
Timber.i("### LocationSharingService.onStartCommand. sessionId - roomId ${roomArgs?.sessionId} - ${roomArgs?.roomId}")
Timber.i("onStartCommand. sessionId - roomId ${roomArgs?.sessionId} - ${roomArgs?.roomId}")
if (roomArgs != null) {
// Show a sticky notification
val notification = notificationUtils.buildLiveLocationSharingNotification()
startForeground(roomArgs.roomId.hashCode(), notification)
// Schedule a timer to stop sharing
scheduleTimer(roomArgs.roomId, roomArgs.durationMillis)
// Send beacon info state event
launchInIO { session ->
launchWithActiveSession { session ->
sendStartingLiveBeaconInfo(session, roomArgs)
}
}
startInProgress = false
return START_STICKY
}
@ -100,7 +118,8 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
?.let { result ->
when (result) {
is UpdateLiveLocationShareResult.Success -> {
roomArgsMap[result.beaconEventId] = roomArgs
addRoomArgs(result.beaconEventId, roomArgs)
listenForLiveSummaryChanges(roomArgs.roomId, result.beaconEventId)
locationTracker.requestLastKnownLocation()
}
is UpdateLiveLocationShareResult.Failure -> {
@ -110,55 +129,19 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
}
}
?: run {
Timber.w("### LocationSharingService.sendStartingLiveBeaconInfo error, no received beacon info id")
Timber.w("sendStartingLiveBeaconInfo error, no received beacon info id")
tryToDestroyMe()
}
}
private fun scheduleTimer(roomId: String, durationMillis: Long) {
Timer()
.apply {
schedule(object : TimerTask() {
override fun run() {
stopSharingLocation(roomId)
timers.remove(this@apply)
}
}, durationMillis)
}
.also {
timers.add(it)
}
private fun stopSharingLocation(beaconEventId: String) {
Timber.i("stopSharingLocation for beacon $beaconEventId")
removeRoomArgs(beaconEventId)
tryToDestroyMe()
}
fun stopSharingLocation(roomId: String) {
Timber.i("### LocationSharingService.stopSharingLocation for $roomId")
launchInIO { session ->
when (val result = sendStoppedBeaconInfo(session, roomId)) {
is UpdateLiveLocationShareResult.Success -> {
synchronized(roomArgsMap) {
val beaconIds = roomArgsMap
.filter { it.value.roomId == roomId }
.map { it.key }
beaconIds.forEach { roomArgsMap.remove(it) }
tryToDestroyMe()
}
}
is UpdateLiveLocationShareResult.Failure -> callback?.onServiceError(result.error)
else -> Unit
}
}
}
private suspend fun sendStoppedBeaconInfo(session: Session, roomId: String): UpdateLiveLocationShareResult? {
return session.getRoom(roomId)
?.locationSharingService()
?.stopLiveLocationShare()
}
override fun onLocationUpdate(locationData: LocationData) {
Timber.i("### LocationSharingService.onLocationUpdate. Uncertainty: ${locationData.uncertainty}")
private fun onLocationUpdate(locationData: LocationData) {
Timber.i("onLocationUpdate. Uncertainty: ${locationData.uncertainty}")
// Emit location update to all rooms in which live location sharing is active
roomArgsMap.toMap().forEach { item ->
@ -171,7 +154,7 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
beaconInfoEventId: String,
locationData: LocationData
) {
launchInIO { session ->
launchWithActiveSession { session ->
session.getRoom(roomId)
?.locationSharingService()
?.sendLiveLocation(
@ -189,31 +172,46 @@ class LocationSharingService : VectorService(), LocationTracker.Callback {
}
private fun tryToDestroyMe() {
if (roomArgsMap.isEmpty()) {
Timber.i("### LocationSharingService. Destroying self, time is up for all rooms")
destroyMe()
if (startInProgress.not() && roomArgsMap.isEmpty()) {
Timber.i("Destroying self, time is up for all rooms")
stopSelf()
}
}
private fun destroyMe() {
locationTracker.removeCallback(this)
timers.forEach { it.cancel() }
timers.clear()
stopSelf()
}
override fun onDestroy() {
super.onDestroy()
Timber.i("### LocationSharingService.onDestroy")
destroyMe()
Timber.i("onDestroy")
jobs.forEach { it.cancel() }
jobs.clear()
locationTracker.removeCallback(this)
}
private fun launchInIO(block: suspend CoroutineScope.(Session) -> Unit) =
private fun addRoomArgs(beaconEventId: String, roomArgs: RoomArgs) {
Timber.i("adding roomArgs for beaconEventId: $beaconEventId")
roomArgsMap[beaconEventId] = roomArgs
}
private fun removeRoomArgs(beaconEventId: String) {
Timber.i("removing roomArgs for beaconEventId: $beaconEventId")
roomArgsMap.remove(beaconEventId)
}
private fun listenForLiveSummaryChanges(roomId: String, beaconEventId: String) {
launchWithActiveSession { session ->
val job = getLiveLocationShareSummaryUseCase.execute(roomId, beaconEventId)
.distinctUntilChangedBy { it.isActive }
.filter { it.isActive == false }
.onEach { stopSharingLocation(beaconEventId) }
.launchIn(session.coroutineScope)
jobs.add(job)
}
}
private fun launchWithActiveSession(block: suspend CoroutineScope.(Session) -> Unit) =
activeSessionHolder
.getSafeActiveSession()
?.let { session ->
session.coroutineScope.launch(
context = session.coroutineDispatchers.io,
block = { block(session) }
)
}

View File

@ -22,7 +22,9 @@ import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LocationSharingServiceConnection @Inject constructor(
private val context: Context
) : ServiceConnection, LocationSharingService.Callback {
@ -33,12 +35,12 @@ class LocationSharingServiceConnection @Inject constructor(
fun onLocationServiceError(error: Throwable)
}
private var callback: Callback? = null
private val callbacks = mutableSetOf<Callback>()
private var isBound = false
private var locationSharingService: LocationSharingService? = null
fun bind(callback: Callback) {
this.callback = callback
addCallback(callback)
if (isBound) {
callback.onLocationServiceRunning()
@ -49,12 +51,8 @@ class LocationSharingServiceConnection @Inject constructor(
}
}
fun unbind() {
callback = null
}
fun stopLiveLocationSharing(roomId: String) {
locationSharingService?.stopSharingLocation(roomId)
fun unbind(callback: Callback) {
removeCallback(callback)
}
override fun onServiceConnected(className: ComponentName, binder: IBinder) {
@ -62,17 +60,33 @@ class LocationSharingServiceConnection @Inject constructor(
it.callback = this
}
isBound = true
callback?.onLocationServiceRunning()
onCallbackActionNoArg(Callback::onLocationServiceRunning)
}
override fun onServiceDisconnected(className: ComponentName) {
isBound = false
locationSharingService?.callback = null
locationSharingService = null
callback?.onLocationServiceStopped()
onCallbackActionNoArg(Callback::onLocationServiceStopped)
}
override fun onServiceError(error: Throwable) {
callback?.onLocationServiceError(error)
forwardErrorToCallbacks(error)
}
private fun addCallback(callback: Callback) {
callbacks.add(callback)
}
private fun removeCallback(callback: Callback) {
callbacks.remove(callback)
}
private fun onCallbackActionNoArg(action: Callback.() -> Unit) {
callbacks.toList().forEach(action)
}
private fun forwardErrorToCallbacks(error: Throwable) {
callbacks.toList().forEach { it.onLocationServiceError(error) }
}
}

View File

@ -39,6 +39,7 @@ import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.getUser
import org.matrix.android.sdk.api.util.toMatrixItem
import timber.log.Timber
/**
* Sampling period to compare target location and user location.
@ -65,13 +66,20 @@ class LocationSharingViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory<LocationSharingViewModel, LocationSharingViewState> by hiltMavericksViewModelFactory()
init {
locationTracker.addCallback(this)
locationTracker.start()
initLocationTracking()
setUserItem()
updatePin()
compareTargetAndUserLocation()
}
private fun initLocationTracking() {
locationTracker.addCallback(this)
locationTracker.locations
.onEach(::onLocationUpdate)
.launchIn(viewModelScope)
locationTracker.start()
}
private fun setUserItem() {
setState { copy(userItem = session.getUser(session.myUserId)?.toMatrixItem()) }
}
@ -172,7 +180,8 @@ class LocationSharingViewModel @AssistedInject constructor(
)
}
override fun onLocationUpdate(locationData: LocationData) {
private fun onLocationUpdate(locationData: LocationData) {
Timber.d("onLocationUpdate()")
setState {
copy(lastKnownUserLocation = locationData)
}

View File

@ -25,28 +25,27 @@ import androidx.annotation.VisibleForTesting
import androidx.core.content.getSystemService
import androidx.core.location.LocationListenerCompat
import im.vector.app.BuildConfig
import im.vector.app.core.utils.Debouncer
import im.vector.app.core.utils.createBackgroundHandler
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.features.session.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
private const val BKG_HANDLER_NAME = "LocationTracker.BKG_HANDLER_NAME"
private const val LOCATION_DEBOUNCE_ID = "LocationTracker.LOCATION_DEBOUNCE_ID"
@Singleton
class LocationTracker @Inject constructor(
context: Context
context: Context,
private val activeSessionHolder: ActiveSessionHolder
) : LocationListenerCompat {
private val locationManager = context.getSystemService<LocationManager>()
interface Callback {
/**
* Called on every location update.
*/
fun onLocationUpdate(locationData: LocationData)
/**
* Called when no location provider is available to request location updates.
*/
@ -62,9 +61,16 @@ class LocationTracker @Inject constructor(
@VisibleForTesting
var hasLocationFromGPSProvider = false
private var lastLocation: LocationData? = null
private val _locations = MutableSharedFlow<Location>(replay = 1)
private val debouncer = Debouncer(createBackgroundHandler(BKG_HANDLER_NAME))
/**
* SharedFlow to collect location updates.
*/
val locations = _locations.asSharedFlow()
.onEach { Timber.d("new location emitted") }
.debounce(MIN_TIME_TO_UPDATE_LOCATION_MILLIS)
.onEach { Timber.d("new location emitted after debounce") }
.map { it.toLocationData() }
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
fun start() {
@ -119,33 +125,35 @@ class LocationTracker @Inject constructor(
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
@VisibleForTesting
fun stop() {
Timber.d("stop()")
locationManager?.removeUpdates(this)
synchronized(this) {
callbacks.clear()
}
debouncer.cancelAll()
callbacks.clear()
hasLocationFromGPSProvider = false
hasLocationFromFusedProvider = false
}
/**
* Request the last known location. It will be given async through Callback.
* Please ensure adding a callback to receive the value.
* Request the last known location. It will be given async through corresponding flow.
* Please ensure collecting the flow before calling this method.
*/
fun requestLastKnownLocation() {
lastLocation?.let { locationData -> onLocationUpdate(locationData) }
Timber.d("requestLastKnownLocation")
activeSessionHolder.getSafeActiveSession()?.coroutineScope?.launch {
_locations.replayCache.firstOrNull()?.let {
Timber.d("emitting last location from cache")
_locations.emit(it)
}
}
}
@Synchronized
fun addCallback(callback: Callback) {
if (!callbacks.contains(callback)) {
callbacks.add(callback)
}
}
@Synchronized
fun removeCallback(callback: Callback) {
callbacks.remove(callback)
if (callbacks.size == 0) {
@ -183,21 +191,19 @@ class LocationTracker @Inject constructor(
}
}
debouncer.debounce(LOCATION_DEBOUNCE_ID, MIN_TIME_TO_UPDATE_LOCATION_MILLIS) {
notifyLocation(location)
}
notifyLocation(location)
}
private fun notifyLocation(location: Location) {
if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) {
Timber.d("notify location: $location")
} else {
Timber.d("notify location: ${location.provider}")
}
activeSessionHolder.getSafeActiveSession()?.coroutineScope?.launch {
if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) {
Timber.d("notify location: $location")
} else {
Timber.d("notify location: ${location.provider}")
}
val locationData = location.toLocationData()
lastLocation = locationData
onLocationUpdate(locationData)
_locations.emit(location)
}
}
override fun onProviderDisabled(provider: String) {
@ -215,9 +221,8 @@ class LocationTracker @Inject constructor(
}
}
@Synchronized
private fun onNoLocationProviderAvailable() {
callbacks.forEach {
callbacks.toList().forEach {
try {
it.onNoLocationProviderAvailable()
} catch (error: Exception) {
@ -226,17 +231,6 @@ class LocationTracker @Inject constructor(
}
}
@Synchronized
private fun onLocationUpdate(locationData: LocationData) {
callbacks.forEach {
try {
it.onLocationUpdate(locationData)
} catch (error: Exception) {
Timber.e(error, "error in onLocationUpdate callback $it")
}
}
}
private fun Location.toLocationData(): LocationData {
return LocationData(latitude, longitude, accuracy.toDouble())
}

View File

@ -0,0 +1,43 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.location.live
import androidx.lifecycle.asFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.withContext
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary
import timber.log.Timber
import javax.inject.Inject
class GetLiveLocationShareSummaryUseCase @Inject constructor(
private val session: Session,
) {
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() }
?: emptyFlow()
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.location.live
import im.vector.app.core.di.ActiveSessionHolder
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult
import javax.inject.Inject
class StopLiveLocationShareUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder
) {
suspend fun execute(roomId: String): UpdateLiveLocationShareResult? {
return sendStoppedBeaconInfo(roomId)
}
private suspend fun sendStoppedBeaconInfo(roomId: String): UpdateLiveLocationShareResult? {
return activeSessionHolder.getActiveSession()
.getRoom(roomId)
?.locationSharingService()
?.stopLiveLocationShare()
}
}

View File

@ -24,13 +24,17 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.location.LocationSharingServiceConnection
import im.vector.app.features.location.live.StopLiveLocationShareUseCase
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult
class LocationLiveMapViewModel @AssistedInject constructor(
@Assisted private val initialState: LocationLiveMapViewState,
getListOfUserLiveLocationUseCase: GetListOfUserLiveLocationUseCase,
private val locationSharingServiceConnection: LocationSharingServiceConnection,
private val stopLiveLocationShareUseCase: StopLiveLocationShareUseCase,
) : VectorViewModel<LocationLiveMapViewState, LocationLiveMapAction, LocationLiveMapViewEvents>(initialState), LocationSharingServiceConnection.Callback {
@AssistedFactory
@ -47,6 +51,11 @@ class LocationLiveMapViewModel @AssistedInject constructor(
locationSharingServiceConnection.bind(this)
}
override fun onCleared() {
locationSharingServiceConnection.unbind(this)
super.onCleared()
}
override fun handle(action: LocationLiveMapAction) {
when (action) {
is LocationLiveMapAction.AddMapSymbol -> handleAddMapSymbol(action)
@ -70,7 +79,12 @@ class LocationLiveMapViewModel @AssistedInject constructor(
}
private fun handleStopSharing() {
locationSharingServiceConnection.stopLiveLocationSharing(initialState.roomId)
viewModelScope.launch {
val result = stopLiveLocationShareUseCase.execute(initialState.roomId)
if (result is UpdateLiveLocationShareResult.Failure) {
_viewEvents.post(LocationLiveMapViewEvents.Error(result.error))
}
}
}
override fun onLocationServiceRunning() {

View File

@ -16,12 +16,11 @@
package im.vector.app.features.poll
sealed interface PollState {
object Sending : PollState
object Ready : PollState
data class Voted(val votes: Int) : PollState
object Undisclosed : PollState
object Ended : PollState
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
fun isVotable() = this !is Sending && this !is Ended
}
data class PollViewState(
val question: String,
val totalVotes: String,
val canVote: Boolean,
val optionViewStates: List<PollOptionViewState>?,
)

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="horizontal">
<Button
android:id="@+id/destructive_button"
style="@style/Widget.Vector.Button.Destructive"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
tools:text="@string/action_decline" />
<Button
android:id="@+id/positive_button"
style="@style/Widget.Vector.Button.Positive"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="@string/action_accept" />
</LinearLayout>

View File

@ -0,0 +1,223 @@
/*
* 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.factory
import im.vector.app.R
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import im.vector.app.features.home.room.detail.timeline.item.PollVoteSummaryData
import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryData
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.poll.PollViewState
import im.vector.app.test.fakes.FakeStringProvider
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.PollAnswer
import org.matrix.android.sdk.api.session.room.model.message.PollCreationInfo
import org.matrix.android.sdk.api.session.room.model.message.PollQuestion
import org.matrix.android.sdk.api.session.room.model.message.PollType
import org.matrix.android.sdk.api.session.room.send.SendState
private val A_MESSAGE_INFORMATION_DATA = MessageInformationData(
eventId = "eventId",
senderId = "senderId",
ageLocalTS = 0,
avatarUrl = "",
sendState = SendState.SENT,
messageLayout = TimelineMessageLayout.Default(showAvatar = true, showDisplayName = true, showTimestamp = true),
reactionsSummary = ReactionsSummaryData(),
sentByMe = true,
)
private val A_POLL_RESPONSE_DATA = PollResponseData(
myVote = null,
votes = emptyMap(),
)
private val A_POLL_OPTION_IDS = listOf("5ef5f7b0-c9a1-49cf-a0b3-374729a43e76", "ec1a4db0-46d8-4d7a-9bb6-d80724715938", "3677ca8e-061b-40ab-bffe-b22e4e88fcad")
private val A_POLL_CONTENT = MessagePollContent(
unstablePollCreationInfo = PollCreationInfo(
question = PollQuestion(
unstableQuestion = "What is your favourite coffee?"
),
kind = PollType.UNDISCLOSED_UNSTABLE,
maxSelections = 1,
answers = listOf(
PollAnswer(
id = A_POLL_OPTION_IDS[0],
unstableAnswer = "Double Espresso"
),
PollAnswer(
id = A_POLL_OPTION_IDS[1],
unstableAnswer = "Macchiato"
),
PollAnswer(
id = A_POLL_OPTION_IDS[2],
unstableAnswer = "Iced Coffee"
),
)
)
)
class PollItemViewStateFactoryTest {
@Test
fun `given a sending poll state then poll is not votable and option states are PollSending`() {
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val sendingPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(sendState = SendState.SENDING)
val pollViewState = pollItemViewStateFactory.create(
pollContent = A_POLL_CONTENT,
informationData = sendingPollInformationData,
)
pollViewState shouldBeEqualTo PollViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "",
totalVotes = stringProvider.instance.getString(R.string.poll_no_votes_cast),
canVote = false,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer ->
PollOptionViewState.PollSending(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
},
)
}
@Test
fun `given a sent poll state when poll is closed then poll is not votable and option states are Ended`() {
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val closedPollSummary = A_POLL_RESPONSE_DATA.copy(isClosed = true)
val closedPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = closedPollSummary)
val pollViewState = pollItemViewStateFactory.create(
pollContent = A_POLL_CONTENT,
informationData = closedPollInformationData,
)
pollViewState shouldBeEqualTo PollViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "",
totalVotes = stringProvider.instance.getQuantityString(R.plurals.poll_total_vote_count_after_ended, 0, 0),
canVote = false,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer ->
PollOptionViewState.PollEnded(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: "",
voteCount = 0,
votePercentage = 0.0,
isWinner = false
)
},
)
}
@Test
fun `given a sent poll when undisclosed poll type is selected then poll is votable and option states are PollUndisclosed`() {
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val pollViewState = pollItemViewStateFactory.create(
pollContent = A_POLL_CONTENT,
informationData = A_MESSAGE_INFORMATION_DATA,
)
pollViewState shouldBeEqualTo PollViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "",
totalVotes = "",
canVote = true,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer ->
PollOptionViewState.PollUndisclosed(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: "",
isSelected = false
)
},
)
}
@Test
fun `given a sent poll when my vote exists then poll is still votable and options states are PollVoted`() {
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val votedPollData = A_POLL_RESPONSE_DATA.copy(
totalVotes = 1,
myVote = A_POLL_OPTION_IDS[0],
votes = mapOf(A_POLL_OPTION_IDS[0] to PollVoteSummaryData(total = 1, percentage = 1.0))
)
val disclosedPollContent = A_POLL_CONTENT.copy(
unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy(
kind = PollType.DISCLOSED_UNSTABLE
),
)
val votedInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = votedPollData)
val pollViewState = pollItemViewStateFactory.create(
pollContent = disclosedPollContent,
informationData = votedInformationData,
)
pollViewState shouldBeEqualTo PollViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "",
totalVotes = stringProvider.instance.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, 1, 1),
canVote = true,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.mapIndexed { index, answer ->
PollOptionViewState.PollVoted(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: "",
voteCount = if (index == 0) 1 else 0,
votePercentage = if (index == 0) 1.0 else 0.0,
isSelected = index == 0
)
},
)
}
@Test
fun `given a sent poll when poll type is disclosed then poll is votable and option view states are PollReady`() {
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val disclosedPollContent = A_POLL_CONTENT.copy(
unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy(
kind = PollType.DISCLOSED_UNSTABLE
)
)
val pollViewState = pollItemViewStateFactory.create(
pollContent = disclosedPollContent,
informationData = A_MESSAGE_INFORMATION_DATA,
)
pollViewState shouldBeEqualTo PollViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "",
totalVotes = stringProvider.instance.getString(R.string.poll_no_votes_cast),
canVote = true,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer ->
PollOptionViewState.PollReady(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
},
)
}
}

View File

@ -28,19 +28,26 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageLocationCont
class LocationDataTest {
@Test
fun validCases() {
parseGeo("geo:12.34,56.78;13.56") shouldBeEqualTo
parseGeo("geo:12.34,56.78;u=13.56") shouldBeEqualTo
LocationData(latitude = 12.34, longitude = 56.78, uncertainty = 13.56)
parseGeo("geo:12.34,56.78") shouldBeEqualTo
LocationData(latitude = 12.34, longitude = 56.78, uncertainty = null)
}
@Test
fun lenientCases() {
// Error is ignored in case of invalid uncertainty
parseGeo("geo:12.34,56.78;13.5z6") shouldBeEqualTo
parseGeo("geo:12.34,56.78;u=13.5z6") shouldBeEqualTo
LocationData(latitude = 12.34, longitude = 56.78, uncertainty = null)
parseGeo("geo:12.34,56.78;13. 56") shouldBeEqualTo
parseGeo("geo:12.34,56.78;u=13. 56") shouldBeEqualTo
LocationData(latitude = 12.34, longitude = 56.78, uncertainty = null)
// Space are ignored (trim)
parseGeo("geo: 12.34,56.78;13.56") shouldBeEqualTo
parseGeo("geo: 12.34,56.78;u=13.56") shouldBeEqualTo
LocationData(latitude = 12.34, longitude = 56.78, uncertainty = 13.56)
parseGeo("geo:12.34,56.78; 13.56") shouldBeEqualTo
parseGeo("geo:12.34,56.78; u=13.56") shouldBeEqualTo
LocationData(latitude = 12.34, longitude = 56.78, uncertainty = 13.56)
// missing "u=" for uncertainty is ignored
parseGeo("geo:12.34,56.78;13.56") shouldBeEqualTo
LocationData(latitude = 12.34, longitude = 56.78, uncertainty = 13.56)
}
@ -50,17 +57,17 @@ class LocationDataTest {
parseGeo("geo").shouldBeNull()
parseGeo("geo:").shouldBeNull()
parseGeo("geo:12.34").shouldBeNull()
parseGeo("geo:12.34;13.56").shouldBeNull()
parseGeo("gea:12.34,56.78;13.56").shouldBeNull()
parseGeo("geo:12.x34,56.78;13.56").shouldBeNull()
parseGeo("geo:12.34,56.7y8;13.56").shouldBeNull()
parseGeo("geo:12.34;u=13.56").shouldBeNull()
parseGeo("gea:12.34,56.78;u=13.56").shouldBeNull()
parseGeo("geo:12.x34,56.78;u=13.56").shouldBeNull()
parseGeo("geo:12.34,56.7y8;u=13.56").shouldBeNull()
// Spaces are not ignored if inside the numbers
parseGeo("geo:12.3 4,56.78;13.56").shouldBeNull()
parseGeo("geo:12.34,56.7 8;13.56").shouldBeNull()
parseGeo("geo:12.3 4,56.78;u=13.56").shouldBeNull()
parseGeo("geo:12.34,56.7 8;u=13.56").shouldBeNull()
// Or in the protocol part
parseGeo(" geo:12.34,56.78;13.56").shouldBeNull()
parseGeo("ge o:12.34,56.78;13.56").shouldBeNull()
parseGeo("geo :12.34,56.78;13.56").shouldBeNull()
parseGeo(" geo:12.34,56.78;u=13.56").shouldBeNull()
parseGeo("ge o:12.34,56.78;u=13.56").shouldBeNull()
parseGeo("geo :12.34,56.78;u=13.56").shouldBeNull()
}
@Test
@ -77,7 +84,7 @@ class LocationDataTest {
@Test
fun unstablePrefixTest() {
val geoUri = "geo :12.34,56.78;13.56"
val geoUri = "aGeoUri"
val contentWithUnstablePrefixes = MessageLocationContent(body = "", geoUri = "", unstableLocationInfo = LocationInfo(geoUri = geoUri))
contentWithUnstablePrefixes.getBestLocationInfo()?.geoUri.shouldBeEqualTo(geoUri)

View File

@ -19,21 +19,21 @@ package im.vector.app.features.location
import android.content.Context
import android.location.Location
import android.location.LocationManager
import im.vector.app.core.utils.Debouncer
import im.vector.app.core.utils.createBackgroundHandler
import im.vector.app.features.session.coroutineScope
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakeContext
import im.vector.app.test.fakes.FakeHandler
import im.vector.app.test.fakes.FakeLocationManager
import im.vector.app.test.test
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.slot
import io.mockk.unmockkAll
import io.mockk.verify
import io.mockk.verifyOrder
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Before
@ -45,26 +45,18 @@ private const val AN_ACCURACY = 5.0f
class LocationTrackerTest {
private val fakeHandler = FakeHandler()
private val fakeLocationManager = FakeLocationManager()
private val fakeContext = FakeContext().also {
it.givenService(Context.LOCATION_SERVICE, android.location.LocationManager::class.java, fakeLocationManager.instance)
}
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private lateinit var locationTracker: LocationTracker
@Before
fun setUp() {
mockkConstructor(Debouncer::class)
every { anyConstructed<Debouncer>().cancelAll() } just runs
val runnable = slot<Runnable>()
every { anyConstructed<Debouncer>().debounce(any(), MIN_TIME_TO_UPDATE_LOCATION_MILLIS, capture(runnable)) } answers {
runnable.captured.run()
true
}
mockkStatic("im.vector.app.core.utils.HandlerKt")
every { createBackgroundHandler(any()) } returns fakeHandler.instance
locationTracker = LocationTracker(fakeContext.instance)
mockkStatic("im.vector.app.features.session.SessionCoroutineScopesKt")
locationTracker = LocationTracker(fakeContext.instance, fakeActiveSessionHolder.instance)
fakeLocationManager.givenRemoveUpdates(locationTracker)
}
@ -139,13 +131,11 @@ class LocationTrackerTest {
}
@Test
fun `when location updates are received from fused provider then fused locations are taken in priority`() {
fun `when location updates are received from fused provider then fused locations are taken in priority`() = runTest {
every { fakeActiveSessionHolder.fakeSession.coroutineScope } returns this
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.FUSED_PROVIDER, LocationManager.NETWORK_PROVIDER)
mockAvailableProviders(providers)
val callback = mockCallback()
locationTracker.addCallback(callback)
locationTracker.start()
val fusedLocation = mockLocation(
provider = LocationManager.FUSED_PROVIDER,
latitude = 1.0,
@ -159,29 +149,31 @@ class LocationTrackerTest {
val networkLocation = mockLocation(
provider = LocationManager.NETWORK_PROVIDER
)
val resultUpdates = locationTracker.locations.test(this)
locationTracker.onLocationChanged(fusedLocation)
locationTracker.onLocationChanged(gpsLocation)
locationTracker.onLocationChanged(networkLocation)
advanceTimeBy(MIN_TIME_TO_UPDATE_LOCATION_MILLIS + 1)
val expectedLocationData = LocationData(
latitude = 1.0,
longitude = 3.0,
uncertainty = 4.0
)
verify { callback.onLocationUpdate(expectedLocationData) }
verify { anyConstructed<Debouncer>().debounce(any(), MIN_TIME_TO_UPDATE_LOCATION_MILLIS, any()) }
resultUpdates
.assertValues(listOf(expectedLocationData))
.finish()
locationTracker.hasLocationFromFusedProvider shouldBeEqualTo true
locationTracker.hasLocationFromGPSProvider shouldBeEqualTo false
}
@Test
fun `when location updates are received from gps provider then gps locations are taken if none are received from fused provider`() {
fun `when location updates are received from gps provider then gps locations are taken if none are received from fused provider`() = runTest {
every { fakeActiveSessionHolder.fakeSession.coroutineScope } returns this
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.FUSED_PROVIDER, LocationManager.NETWORK_PROVIDER)
mockAvailableProviders(providers)
val callback = mockCallback()
locationTracker.addCallback(callback)
locationTracker.start()
val gpsLocation = mockLocation(
provider = LocationManager.GPS_PROVIDER,
latitude = 1.0,
@ -192,66 +184,75 @@ class LocationTrackerTest {
val networkLocation = mockLocation(
provider = LocationManager.NETWORK_PROVIDER
)
val resultUpdates = locationTracker.locations.test(this)
locationTracker.onLocationChanged(gpsLocation)
locationTracker.onLocationChanged(networkLocation)
advanceTimeBy(MIN_TIME_TO_UPDATE_LOCATION_MILLIS + 1)
val expectedLocationData = LocationData(
latitude = 1.0,
longitude = 3.0,
uncertainty = 4.0
)
verify { callback.onLocationUpdate(expectedLocationData) }
verify { anyConstructed<Debouncer>().debounce(any(), MIN_TIME_TO_UPDATE_LOCATION_MILLIS, any()) }
resultUpdates
.assertValues(listOf(expectedLocationData))
.finish()
locationTracker.hasLocationFromFusedProvider shouldBeEqualTo false
locationTracker.hasLocationFromGPSProvider shouldBeEqualTo true
}
@Test
fun `when location updates are received from network provider then network locations are taken if none are received from fused or gps provider`() {
fun `when location updates are received from network provider then network locations are taken if none are received from fused, gps provider`() = runTest {
every { fakeActiveSessionHolder.fakeSession.coroutineScope } returns this
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.FUSED_PROVIDER, LocationManager.NETWORK_PROVIDER)
mockAvailableProviders(providers)
val callback = mockCallback()
locationTracker.addCallback(callback)
locationTracker.start()
val networkLocation = mockLocation(
provider = LocationManager.NETWORK_PROVIDER,
latitude = 1.0,
longitude = 3.0,
accuracy = 4f
)
val resultUpdates = locationTracker.locations.test(this)
locationTracker.onLocationChanged(networkLocation)
advanceTimeBy(MIN_TIME_TO_UPDATE_LOCATION_MILLIS + 1)
val expectedLocationData = LocationData(
latitude = 1.0,
longitude = 3.0,
uncertainty = 4.0
)
verify { callback.onLocationUpdate(expectedLocationData) }
verify { anyConstructed<Debouncer>().debounce(any(), MIN_TIME_TO_UPDATE_LOCATION_MILLIS, any()) }
resultUpdates
.assertValues(listOf(expectedLocationData))
.finish()
locationTracker.hasLocationFromFusedProvider shouldBeEqualTo false
locationTracker.hasLocationFromGPSProvider shouldBeEqualTo false
}
@Test
fun `when requesting the last location then last location is notified via callback`() {
fun `when requesting the last location then last location is notified via location updates flow`() = runTest {
every { fakeActiveSessionHolder.fakeSession.coroutineScope } returns this
val providers = listOf(LocationManager.GPS_PROVIDER)
fakeLocationManager.givenActiveProviders(providers)
val lastLocation = mockLocation(provider = LocationManager.GPS_PROVIDER)
fakeLocationManager.givenLastLocationForProvider(provider = LocationManager.GPS_PROVIDER, location = lastLocation)
fakeLocationManager.givenRequestUpdatesForProvider(provider = LocationManager.GPS_PROVIDER, listener = locationTracker)
val callback = mockCallback()
locationTracker.addCallback(callback)
locationTracker.start()
val resultUpdates = locationTracker.locations.test(this)
locationTracker.requestLastKnownLocation()
advanceTimeBy(MIN_TIME_TO_UPDATE_LOCATION_MILLIS + 1)
val expectedLocationData = LocationData(
latitude = A_LATITUDE,
longitude = A_LONGITUDE,
uncertainty = AN_ACCURACY.toDouble()
)
verify { callback.onLocationUpdate(expectedLocationData) }
resultUpdates
.assertValues(listOf(expectedLocationData))
.finish()
}
@Test
@ -259,7 +260,6 @@ class LocationTrackerTest {
locationTracker.stop()
verify { fakeLocationManager.instance.removeUpdates(locationTracker) }
verify { anyConstructed<Debouncer>().cancelAll() }
locationTracker.callbacks.isEmpty() shouldBeEqualTo true
locationTracker.hasLocationFromGPSProvider shouldBeEqualTo false
locationTracker.hasLocationFromFusedProvider shouldBeEqualTo false
@ -276,7 +276,6 @@ class LocationTrackerTest {
private fun mockCallback(): LocationTracker.Callback {
return mockk<LocationTracker.Callback>().also {
every { it.onNoLocationProviderAvailable() } just runs
every { it.onLocationUpdate(any()) } just runs
}
}

View File

@ -16,21 +16,16 @@
package im.vector.app.features.location.domain.usecase
import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.features.location.LocationData
import im.vector.app.test.fakes.FakeSession
import io.mockk.MockKAnnotations
import io.mockk.impl.annotations.OverrideMockKs
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class CompareLocationsUseCaseTest {
@get:Rule
val mvRxTestRule = MvRxTestRule()
private val session = FakeSession()
@OverrideMockKs

View File

@ -0,0 +1,73 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.location.live
import im.vector.app.test.fakes.FakeFlowLiveDataConversions
import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fakes.givenAsFlowReturns
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent
import org.matrix.android.sdk.api.util.Optional
private const val A_ROOM_ID = "room_id"
private const val AN_EVENT_ID = "event_id"
class GetLiveLocationShareSummaryUseCaseTest {
private val fakeSession = FakeSession()
private val fakeFlowLiveDataConversions = FakeFlowLiveDataConversions()
private val getLiveLocationShareSummaryUseCase = GetLiveLocationShareSummaryUseCase(
session = fakeSession
)
@Before
fun setUp() {
fakeFlowLiveDataConversions.setup()
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given a room id and event id when calling use case then live data on summary is returned`() = runTest {
val summary = LiveLocationShareAggregatedSummary(
userId = "userId",
isActive = true,
endOfLiveTimestampMillis = 123,
lastLocationDataContent = MessageBeaconLocationDataContent()
)
fakeSession.roomService()
.getRoom(A_ROOM_ID)
.locationSharingService()
.givenLiveLocationShareSummaryReturns(AN_EVENT_ID, summary)
.givenAsFlowReturns(Optional(summary))
val result = getLiveLocationShareSummaryUseCase.execute(A_ROOM_ID, AN_EVENT_ID).first()
result shouldBeEqualTo summary
}
}

View File

@ -0,0 +1,73 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.location.live
import im.vector.app.test.fakes.FakeActiveSessionHolder
import io.mockk.unmockkAll
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Test
import org.matrix.android.sdk.api.session.room.location.UpdateLiveLocationShareResult
private const val A_ROOM_ID = "room_id"
private const val AN_EVENT_ID = "event_id"
class StopLiveLocationShareUseCaseTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val stopLiveLocationShareUseCase = StopLiveLocationShareUseCase(
activeSessionHolder = fakeActiveSessionHolder.instance
)
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given a room id when calling use case then the current live is stopped with success`() = runTest {
val updateLiveResult = UpdateLiveLocationShareResult.Success(AN_EVENT_ID)
fakeActiveSessionHolder
.fakeSession
.roomService()
.getRoom(A_ROOM_ID)
.locationSharingService()
.givenStopLiveLocationShareReturns(updateLiveResult)
val result = stopLiveLocationShareUseCase.execute(A_ROOM_ID)
result shouldBeEqualTo updateLiveResult
}
@Test
fun `given a room id and error during the process when calling use case then result is failure`() = runTest {
val error = Throwable()
val updateLiveResult = UpdateLiveLocationShareResult.Failure(error)
fakeActiveSessionHolder
.fakeSession
.roomService()
.getRoom(A_ROOM_ID)
.locationSharingService()
.givenStopLiveLocationShareReturns(updateLiveResult)
val result = stopLiveLocationShareUseCase.execute(A_ROOM_ID)
result shouldBeEqualTo updateLiveResult
}
}

View File

@ -16,52 +16,48 @@
package im.vector.app.features.location.live.map
import androidx.lifecycle.asFlow
import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.features.location.LocationData
import im.vector.app.test.fakes.FakeFlowLiveDataConversions
import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fakes.givenAsFlowReturns
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.internal.assertEquals
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.matrix.android.sdk.api.session.room.model.livelocation.LiveLocationShareAggregatedSummary
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent
import org.matrix.android.sdk.api.util.MatrixItem
private const val A_ROOM_ID = "room_id"
class GetListOfUserLiveLocationUseCaseTest {
@get:Rule
val mvRxTestRule = MvRxTestRule()
private val fakeSession = FakeSession()
private val viewStateMapper = mockk<UserLiveLocationViewStateMapper>()
private val fakeFlowLiveDataConversions = FakeFlowLiveDataConversions()
private val getListOfUserLiveLocationUseCase = GetListOfUserLiveLocationUseCase(fakeSession, viewStateMapper)
private val getListOfUserLiveLocationUseCase = GetListOfUserLiveLocationUseCase(
session = fakeSession,
userLiveLocationViewStateMapper = viewStateMapper
)
@Before
fun setUp() {
mockkStatic("androidx.lifecycle.FlowLiveDataConversions")
fakeFlowLiveDataConversions.setup()
}
@After
fun tearDown() {
unmockkStatic("androidx.lifecycle.FlowLiveDataConversions")
unmockkAll()
}
@Test
fun `given a room id then the correct flow of view states list is collected`() = runTest {
val roomId = "roomId"
val summary1 = LiveLocationShareAggregatedSummary(
userId = "userId1",
isActive = true,
@ -81,12 +77,11 @@ class GetListOfUserLiveLocationUseCaseTest {
lastLocationDataContent = MessageBeaconLocationDataContent()
)
val summaries = listOf(summary1, summary2, summary3)
val liveData = fakeSession.roomService()
.getRoom(roomId)
fakeSession.roomService()
.getRoom(A_ROOM_ID)
.locationSharingService()
.givenRunningLiveLocationShareSummaries(summaries)
every { liveData.asFlow() } returns flowOf(summaries)
.givenRunningLiveLocationShareSummariesReturns(summaries)
.givenAsFlowReturns(summaries)
val viewState1 = UserLiveLocationViewState(
matrixItem = MatrixItem.UserItem(id = "@userId1:matrix.org", displayName = "User 1", avatarUrl = ""),
@ -108,8 +103,8 @@ class GetListOfUserLiveLocationUseCaseTest {
coEvery { viewStateMapper.map(summary2) } returns viewState2
coEvery { viewStateMapper.map(summary3) } returns null
val viewStates = getListOfUserLiveLocationUseCase.execute(roomId).first()
val viewStates = getListOfUserLiveLocationUseCase.execute(A_ROOM_ID).first()
assertEquals(listOf(viewState1, viewState2), viewStates)
viewStates shouldBeEqualTo listOf(viewState1, viewState2)
}
}

View File

@ -18,39 +18,47 @@ package im.vector.app.features.location.live.map
import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.features.location.LocationData
import im.vector.app.features.location.LocationSharingServiceConnection
import im.vector.app.features.location.live.StopLiveLocationShareUseCase
import im.vector.app.test.fakes.FakeLocationSharingServiceConnection
import im.vector.app.test.test
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.matrix.android.sdk.api.util.MatrixItem
private const val A_ROOM_ID = "room_id"
class LocationLiveMapViewModelTest {
@get:Rule
val mvrxTestRule = MvRxTestRule()
val mvRxTestRule = MvRxTestRule(testDispatcher = UnconfinedTestDispatcher())
private val fakeRoomId = ""
private val args = LocationLiveMapViewArgs(roomId = fakeRoomId)
private val args = LocationLiveMapViewArgs(roomId = A_ROOM_ID)
private val getListOfUserLiveLocationUseCase = mockk<GetListOfUserLiveLocationUseCase>()
private val locationServiceConnection = mockk<LocationSharingServiceConnection>()
private val locationServiceConnection = FakeLocationSharingServiceConnection()
private val stopLiveLocationShareUseCase = mockk<StopLiveLocationShareUseCase>()
private fun createViewModel(): LocationLiveMapViewModel {
return LocationLiveMapViewModel(
LocationLiveMapViewState(args),
getListOfUserLiveLocationUseCase,
locationServiceConnection
locationServiceConnection.instance,
stopLiveLocationShareUseCase
)
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given the viewModel has been initialized then viewState contains user locations list`() = runTest {
val userLocations = listOf(
@ -63,8 +71,8 @@ class LocationLiveMapViewModelTest {
showStopSharingButton = false
)
)
every { locationServiceConnection.bind(any()) } just runs
every { getListOfUserLiveLocationUseCase.execute(fakeRoomId) } returns flowOf(userLocations)
locationServiceConnection.givenBind()
every { getListOfUserLiveLocationUseCase.execute(A_ROOM_ID) } returns flowOf(userLocations)
val viewModel = createViewModel()
viewModel
@ -76,6 +84,6 @@ class LocationLiveMapViewModelTest {
)
.finish()
verify { locationServiceConnection.bind(viewModel) }
locationServiceConnection.verifyBind(viewModel)
}
}

View File

@ -46,7 +46,7 @@ private const val A_LOCATION_TIMESTAMP = 122L
private const val A_LATITUDE = 40.05
private const val A_LONGITUDE = 29.24
private const val A_UNCERTAINTY = 30.0
private const val A_GEO_URI = "geo:$A_LATITUDE,$A_LONGITUDE;$A_UNCERTAINTY"
private const val A_GEO_URI = "geo:$A_LATITUDE,$A_LONGITUDE;u=$A_UNCERTAINTY"
class UserLiveLocationViewStateMapperTest {

View File

@ -19,7 +19,6 @@ package im.vector.app.features.media.domain.usecase
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.core.intent.getMimeTypeFromUri
import im.vector.app.core.utils.saveMedia
import im.vector.app.features.notifications.NotificationUtils
@ -42,14 +41,10 @@ import io.mockk.verifyAll
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class DownloadMediaUseCaseTest {
@get:Rule
val mvRxTestRule = MvRxTestRule()
@MockK
lateinit var appContext: Context

View File

@ -16,13 +16,15 @@
package im.vector.app.test
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
private val testDispatcher = UnconfinedTestDispatcher()
internal val testCoroutineDispatchers = MatrixCoroutineDispatchers(
io = Dispatchers.Main,
computation = Dispatchers.Main,
main = Dispatchers.Main,
crypto = Dispatchers.Main,
dmVerif = Dispatchers.Main
io = testDispatcher,
computation = testDispatcher,
main = testDispatcher,
crypto = testDispatcher,
dmVerif = testDispatcher
)

View File

@ -23,10 +23,11 @@ import io.mockk.mockk
import org.matrix.android.sdk.api.session.Session
class FakeActiveSessionHolder(
private val fakeSession: FakeSession = FakeSession()
val fakeSession: FakeSession = FakeSession()
) {
val instance = mockk<ActiveSessionHolder> {
every { getActiveSession() } returns fakeSession
every { getSafeActiveSession() } returns fakeSession
}
fun expectSetsActiveSession(session: Session) {

View File

@ -0,0 +1,33 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.test.fakes
import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow
import io.mockk.every
import io.mockk.mockkStatic
import kotlinx.coroutines.flow.flowOf
class FakeFlowLiveDataConversions {
fun setup() {
mockkStatic("androidx.lifecycle.FlowLiveDataConversions")
}
}
fun <T> LiveData<T>.givenAsFlowReturns(value: T) {
every { asFlow() } returns flowOf(value)
}

View File

@ -18,17 +18,34 @@ package im.vector.app.test.fakes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
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
import org.matrix.android.sdk.api.util.Optional
class FakeLocationSharingService : LocationSharingService by mockk() {
fun givenRunningLiveLocationShareSummaries(summaries: List<LiveLocationShareAggregatedSummary>):
LiveData<List<LiveLocationShareAggregatedSummary>> {
fun givenRunningLiveLocationShareSummariesReturns(
summaries: List<LiveLocationShareAggregatedSummary>
): LiveData<List<LiveLocationShareAggregatedSummary>> {
return MutableLiveData(summaries).also {
every { getRunningLiveLocationShareSummaries() } returns it
}
}
fun givenLiveLocationShareSummaryReturns(
eventId: String,
summary: LiveLocationShareAggregatedSummary
): LiveData<Optional<LiveLocationShareAggregatedSummary>> {
return MutableLiveData(Optional(summary)).also {
every { getLiveLocationShareSummary(eventId) } returns it
}
}
fun givenStopLiveLocationShareReturns(result: UpdateLiveLocationShareResult) {
coEvery { stopLiveLocationShare() } returns result
}
}

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.test.fakes
import im.vector.app.features.location.LocationSharingServiceConnection
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
class FakeLocationSharingServiceConnection {
val instance = mockk<LocationSharingServiceConnection>()
fun givenBind() {
every { instance.bind(any()) } just runs
}
fun verifyBind(callback: LocationSharingServiceConnection.Callback) {
verify { instance.bind(callback) }
}
}

View File

@ -27,6 +27,10 @@ class FakeStringProvider {
every { instance.getString(any()) } answers {
"test-${args[0]}"
}
every { instance.getQuantityString(any(), any(), any()) } answers {
"test-${args[0]}-${args[1]}"
}
}
fun given(id: Int, result: String) {