Merge branch 'develop' into feature/ons/toggle_ip_address_visibility

# Conflicts:
#	vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt
This commit is contained in:
Onuray Sahin 2022-11-10 17:01:50 +03:00
commit 202c0c58ab
74 changed files with 1803 additions and 562 deletions

1
changelog.d/7496.wip Normal file
View file

@ -0,0 +1 @@
[Voice Broadcast] Add seekbar in listening tile

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

@ -0,0 +1 @@
Push notifications toggle: align implementation for current session

1
changelog.d/7514.sdk Normal file
View file

@ -0,0 +1 @@
[Metrics] Add `SpannableMetricPlugin` to support spans within transactions.

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

@ -0,0 +1 @@
Fix description of verified sessions

View file

@ -8,7 +8,7 @@ ext.versions = [
def gradle = "7.3.1"
// Ref: https://kotlinlang.org/releases.html
def kotlin = "1.7.20"
def kotlin = "1.7.21"
def kotlinCoroutines = "1.6.4"
def dagger = "2.44"
def appDistribution = "16.0.0-beta05"

View file

@ -103,14 +103,12 @@ class VideoViewHolder constructor(itemView: View) :
views.videoView.setOnPreparedListener {
stopTimer()
countUpTimer = CountUpTimer(100).also {
it.tickListener = object : CountUpTimer.TickListener {
override fun onTick(milliseconds: Long) {
val duration = views.videoView.duration
val progress = views.videoView.currentPosition
val isPlaying = views.videoView.isPlaying
// Log.v("FOO", "isPlaying $isPlaying $progress/$duration")
eventListener?.get()?.onEvent(AttachmentEvents.VideoEvent(isPlaying, progress, duration))
}
it.tickListener = CountUpTimer.TickListener {
val duration = views.videoView.duration
val progress = views.videoView.currentPosition
val isPlaying = views.videoView.isPlaying
// Log.v("FOO", "isPlaying $isPlaying $progress/$duration")
eventListener?.get()?.onEvent(AttachmentEvents.VideoEvent(isPlaying, progress, duration))
}
it.resume()
}

View file

@ -66,7 +66,7 @@ class CountUpTimer(private val intervalInMs: Long = 1_000) {
coroutineScope.cancel()
}
interface TickListener {
fun interface TickListener {
fun onTick(milliseconds: Long)
}
}

View file

@ -1679,7 +1679,8 @@
<string name="create_new_room">Create New Room</string>
<string name="create_new_space">Create New Space</string>
<string name="error_no_network">No network. Please check your Internet connection.</string>
<string name="error_check_network">Something went wrong. Please check your network connection and try again.</string>
<!-- TODO delete -->
<string name="error_check_network" tools:ignore="UnusedResources">Something went wrong. Please check your network connection and try again.</string>
<string name="change_room_directory_network">"Change network"</string>
<string name="please_wait">"Please wait…"</string>
<string name="updating_your_data">Updating your data…</string>
@ -3380,7 +3381,9 @@
<string name="device_manager_learn_more_sessions_unverified_title">Unverified sessions</string>
<string name="device_manager_learn_more_sessions_unverified">Unverified sessions are sessions that have logged in with your credentials but not been cross-verified.\n\nYou should make especially certain that you recognise these sessions as they could represent an unauthorised use of your account.</string>
<string name="device_manager_learn_more_sessions_verified_title">Verified sessions</string>
<string name="device_manager_learn_more_sessions_verified">Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.\n\nThis means they hold encryption keys for your previous messages, and confirm to other users you are communicating with that these sessions are really you.</string>
<!-- TODO TO BE REMOVED -->
<string name="device_manager_learn_more_sessions_verified" tools:ignore="UnusedResources">Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.\n\nThis means they hold encryption keys for your previous messages, and confirm to other users you are communicating with that these sessions are really you.</string>
<string name="device_manager_learn_more_sessions_verified_description">Verified sessions are anywhere you are using this account after entering your passphrase or confirming your identity with another verified session.\n\nThis means that you have all the keys needed to unlock your encrypted messages and confirm to other users that you trust this session.</string>
<string name="device_manager_learn_more_session_rename_title">Renaming sessions</string>
<string name="device_manager_learn_more_session_rename">Other users in direct messages and rooms that you join are able to view a full list of your sessions.\n\nThis provides them with confidence that they are really speaking to you, but it also means they can see the session name you enter here.</string>
<string name="labs_enable_session_manager_title">Enable new session manager</string>

View file

@ -17,25 +17,51 @@
package org.matrix.android.sdk.api.extensions
import org.matrix.android.sdk.api.metrics.MetricPlugin
import org.matrix.android.sdk.api.metrics.SpannableMetricPlugin
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
/**
* Executes the given [block] while measuring the transaction.
*
* @param block Action/Task to be executed within this span.
*/
@OptIn(ExperimentalContracts::class)
inline fun measureMetric(metricMeasurementPlugins: List<MetricPlugin>, block: () -> Unit) {
inline fun List<MetricPlugin>.measureMetric(block: () -> Unit) {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
try {
metricMeasurementPlugins.forEach { plugin -> plugin.startTransaction() } // Start the transaction.
this.forEach { plugin -> plugin.startTransaction() } // Start the transaction.
block()
} catch (throwable: Throwable) {
metricMeasurementPlugins.forEach { plugin -> plugin.onError(throwable) } // Capture if there is any exception thrown.
this.forEach { plugin -> plugin.onError(throwable) } // Capture if there is any exception thrown.
throw throwable
} finally {
metricMeasurementPlugins.forEach { plugin -> plugin.finishTransaction() } // Finally, finish this transaction.
this.forEach { plugin -> plugin.finishTransaction() } // Finally, finish this transaction.
}
}
/**
* Executes the given [block] while measuring a span.
*
* @param operation Name of the new span.
* @param description Description of the new span.
* @param block Action/Task to be executed within this span.
*/
@OptIn(ExperimentalContracts::class)
inline fun List<SpannableMetricPlugin>.measureSpan(operation: String, description: String, block: () -> Unit) {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
try {
this.forEach { plugin -> plugin.startSpan(operation, description) } // Start the transaction.
block()
} catch (throwable: Throwable) {
this.forEach { plugin -> plugin.onError(throwable) } // Capture if there is any exception thrown.
throw throwable
} finally {
this.forEach { plugin -> plugin.finishSpan() } // Finally, finish this transaction.
}
}

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.metrics
/**
* A plugin that tracks span along with transactions.
*/
interface SpannableMetricPlugin : MetricPlugin {
/**
* Starts the span for a sub-task.
*
* @param operation Name of the new span.
* @param description Description of the new span.
*/
fun startSpan(operation: String, description: String)
/**
* Finish the span when sub-task is completed.
*/
fun finishSpan()
}

View file

@ -0,0 +1,32 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.metrics
import org.matrix.android.sdk.api.logger.LoggerTag
import timber.log.Timber
private val loggerTag = LoggerTag("SyncDurationMetricPlugin", LoggerTag.CRYPTO)
/**
* An spannable metric plugin for sync response handling task.
*/
interface SyncDurationMetricPlugin : SpannableMetricPlugin {
override fun logTransaction(message: String?) {
Timber.tag(loggerTag.value).v("## syncResponseHandler() : $message")
}
}

View file

@ -16,6 +16,9 @@
package org.matrix.android.sdk.api.session.homeserver
import androidx.lifecycle.LiveData
import org.matrix.android.sdk.api.util.Optional
/**
* This interface defines a method to retrieve the homeserver capabilities.
*/
@ -30,4 +33,9 @@ interface HomeServerCapabilitiesService {
* Get the HomeServer capabilities.
*/
fun getHomeServerCapabilities(): HomeServerCapabilities
/**
* Get a LiveData on the HomeServer capabilities.
*/
fun getHomeServerCapabilitiesLive(): LiveData<Optional<HomeServerCapabilities>>
}

View file

@ -355,7 +355,7 @@ internal class DeviceListManager @Inject constructor(
val relevantPlugins = metricPlugins.filterIsInstance<DownloadDeviceKeysMetricsPlugin>()
val response: KeysQueryResponse
measureMetric(relevantPlugins) {
relevantPlugins.measureMetric {
response = try {
downloadKeysForUsersTask.execute(params)
} catch (throwable: Throwable) {

View file

@ -16,8 +16,10 @@
package org.matrix.android.sdk.internal.session.homeserver
import androidx.lifecycle.LiveData
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
import org.matrix.android.sdk.api.util.Optional
import javax.inject.Inject
internal class DefaultHomeServerCapabilitiesService @Inject constructor(
@ -33,4 +35,8 @@ internal class DefaultHomeServerCapabilitiesService @Inject constructor(
return homeServerCapabilitiesDataSource.getHomeServerCapabilities()
?: HomeServerCapabilities()
}
override fun getHomeServerCapabilitiesLive(): LiveData<Optional<HomeServerCapabilities>> {
return homeServerCapabilitiesDataSource.getHomeServerCapabilitiesLive()
}
}

View file

@ -16,9 +16,14 @@
package org.matrix.android.sdk.internal.session.homeserver
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import com.zhuinden.monarchy.Monarchy
import io.realm.Realm
import io.realm.kotlin.where
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional
import org.matrix.android.sdk.internal.database.mapper.HomeServerCapabilitiesMapper
import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntity
import org.matrix.android.sdk.internal.database.query.get
@ -26,7 +31,7 @@ import org.matrix.android.sdk.internal.di.SessionDatabase
import javax.inject.Inject
internal class HomeServerCapabilitiesDataSource @Inject constructor(
@SessionDatabase private val monarchy: Monarchy
@SessionDatabase private val monarchy: Monarchy,
) {
fun getHomeServerCapabilities(): HomeServerCapabilities? {
return Realm.getInstance(monarchy.realmConfiguration).use { realm ->
@ -35,4 +40,14 @@ internal class HomeServerCapabilitiesDataSource @Inject constructor(
}
}
}
fun getHomeServerCapabilitiesLive(): LiveData<Optional<HomeServerCapabilities>> {
val liveData = monarchy.findAllMappedWithChanges(
{ realm: Realm -> realm.where<HomeServerCapabilitiesEntity>() },
{ HomeServerCapabilitiesMapper.map(it) }
)
return Transformations.map(liveData) {
it.firstOrNull().toOptional()
}
}
}

View file

@ -17,6 +17,11 @@
package org.matrix.android.sdk.internal.session.sync
import com.zhuinden.monarchy.Monarchy
import io.realm.Realm
import org.matrix.android.sdk.api.MatrixConfiguration
import org.matrix.android.sdk.api.extensions.measureMetric
import org.matrix.android.sdk.api.extensions.measureSpan
import org.matrix.android.sdk.api.metrics.SyncDurationMetricPlugin
import org.matrix.android.sdk.api.session.pushrules.PushRuleService
import org.matrix.android.sdk.api.session.pushrules.RuleScope
import org.matrix.android.sdk.api.session.sync.InitialSyncStep
@ -52,9 +57,12 @@ internal class SyncResponseHandler @Inject constructor(
private val tokenStore: SyncTokenStore,
private val processEventForPushTask: ProcessEventForPushTask,
private val pushRuleService: PushRuleService,
private val presenceSyncHandler: PresenceSyncHandler
private val presenceSyncHandler: PresenceSyncHandler,
matrixConfiguration: MatrixConfiguration,
) {
private val relevantPlugins = matrixConfiguration.metricPlugins.filterIsInstance<SyncDurationMetricPlugin>()
suspend fun handleResponse(
syncResponse: SyncResponse,
fromToken: String?,
@ -63,39 +71,91 @@ internal class SyncResponseHandler @Inject constructor(
val isInitialSync = fromToken == null
Timber.v("Start handling sync, is InitialSync: $isInitialSync")
measureTimeMillis {
if (!cryptoService.isStarted()) {
Timber.v("Should start cryptoService")
cryptoService.start()
}
cryptoService.onSyncWillProcess(isInitialSync)
}.also {
Timber.v("Finish handling start cryptoService in $it ms")
}
relevantPlugins.measureMetric {
startCryptoService(isInitialSync)
// Handle the to device events before the room ones
// to ensure to decrypt them properly
measureTimeMillis {
Timber.v("Handle toDevice")
reportSubtask(reporter, InitialSyncStep.ImportingAccountCrypto, 100, 0.1f) {
if (syncResponse.toDevice != null) {
cryptoSyncHandler.handleToDevice(syncResponse.toDevice, reporter)
// Handle the to device events before the room ones
// to ensure to decrypt them properly
handleToDevice(syncResponse, reporter)
val aggregator = SyncResponsePostTreatmentAggregator()
// Prerequisite for thread events handling in RoomSyncHandler
// Disabled due to the new fallback
// if (!lightweightSettingsStorage.areThreadMessagesEnabled()) {
// threadsAwarenessHandler.fetchRootThreadEventsIfNeeded(syncResponse)
// }
startMonarchyTransaction(syncResponse, isInitialSync, reporter, aggregator)
aggregateSyncResponse(aggregator)
postTreatmentSyncResponse(syncResponse, isInitialSync)
markCryptoSyncCompleted(syncResponse)
handlePostSync()
Timber.v("On sync completed")
}
}
private fun startCryptoService(isInitialSync: Boolean) {
relevantPlugins.measureSpan("task", "start_crypto_service") {
measureTimeMillis {
if (!cryptoService.isStarted()) {
Timber.v("Should start cryptoService")
cryptoService.start()
}
cryptoService.onSyncWillProcess(isInitialSync)
}.also {
Timber.v("Finish handling start cryptoService in $it ms")
}
}.also {
Timber.v("Finish handling toDevice in $it ms")
}
val aggregator = SyncResponsePostTreatmentAggregator()
}
// Prerequisite for thread events handling in RoomSyncHandler
// Disabled due to the new fallback
// if (!lightweightSettingsStorage.areThreadMessagesEnabled()) {
// threadsAwarenessHandler.fetchRootThreadEventsIfNeeded(syncResponse)
// }
private suspend fun handleToDevice(syncResponse: SyncResponse, reporter: ProgressReporter?) {
relevantPlugins.measureSpan("task", "handle_to_device") {
measureTimeMillis {
Timber.v("Handle toDevice")
reportSubtask(reporter, InitialSyncStep.ImportingAccountCrypto, 100, 0.1f) {
if (syncResponse.toDevice != null) {
cryptoSyncHandler.handleToDevice(syncResponse.toDevice, reporter)
}
}
}.also {
Timber.v("Finish handling toDevice in $it ms")
}
}
}
private suspend fun startMonarchyTransaction(
syncResponse: SyncResponse,
isInitialSync: Boolean,
reporter: ProgressReporter?,
aggregator: SyncResponsePostTreatmentAggregator
) {
// Start one big transaction
monarchy.awaitTransaction { realm ->
// IMPORTANT nothing should be suspend here as we are accessing the realm instance (thread local)
relevantPlugins.measureSpan("task", "monarchy_transaction") {
monarchy.awaitTransaction { realm ->
// IMPORTANT nothing should be suspend here as we are accessing the realm instance (thread local)
handleRooms(reporter, syncResponse, realm, isInitialSync, aggregator)
handleAccountData(reporter, realm, syncResponse)
handlePresence(realm, syncResponse)
tokenStore.saveToken(realm, syncResponse.nextBatch)
}
}
}
private fun handleRooms(
reporter: ProgressReporter?,
syncResponse: SyncResponse,
realm: Realm,
isInitialSync: Boolean,
aggregator: SyncResponsePostTreatmentAggregator
) {
relevantPlugins.measureSpan("task", "handle_rooms") {
measureTimeMillis {
Timber.v("Handle rooms")
reportSubtask(reporter, InitialSyncStep.ImportingAccountRoom, 1, 0.8f) {
@ -106,7 +166,11 @@ internal class SyncResponseHandler @Inject constructor(
}.also {
Timber.v("Finish handling rooms in $it ms")
}
}
}
private fun handleAccountData(reporter: ProgressReporter?, realm: Realm, syncResponse: SyncResponse) {
relevantPlugins.measureSpan("task", "handle_account_data") {
measureTimeMillis {
reportSubtask(reporter, InitialSyncStep.ImportingAccountData, 1, 0.1f) {
Timber.v("Handle accountData")
@ -115,44 +179,59 @@ internal class SyncResponseHandler @Inject constructor(
}.also {
Timber.v("Finish handling accountData in $it ms")
}
}
}
private fun handlePresence(realm: Realm, syncResponse: SyncResponse) {
relevantPlugins.measureSpan("task", "handle_presence") {
measureTimeMillis {
Timber.v("Handle Presence")
presenceSyncHandler.handle(realm, syncResponse.presence)
}.also {
Timber.v("Finish handling Presence in $it ms")
}
tokenStore.saveToken(realm, syncResponse.nextBatch)
}
}
// Everything else we need to do outside the transaction
measureTimeMillis {
aggregatorHandler.handle(aggregator)
}.also {
Timber.v("Aggregator management took $it ms")
}
measureTimeMillis {
syncResponse.rooms?.let {
checkPushRules(it, isInitialSync)
userAccountDataSyncHandler.synchronizeWithServerIfNeeded(it.invite)
dispatchInvitedRoom(it)
private suspend fun aggregateSyncResponse(aggregator: SyncResponsePostTreatmentAggregator) {
relevantPlugins.measureSpan("task", "aggregator_management") {
// Everything else we need to do outside the transaction
measureTimeMillis {
aggregatorHandler.handle(aggregator)
}.also {
Timber.v("Aggregator management took $it ms")
}
}.also {
Timber.v("SyncResponse.rooms post treatment took $it ms")
}
}
measureTimeMillis {
cryptoSyncHandler.onSyncCompleted(syncResponse)
}.also {
Timber.v("cryptoSyncHandler.onSyncCompleted took $it ms")
private suspend fun postTreatmentSyncResponse(syncResponse: SyncResponse, isInitialSync: Boolean) {
relevantPlugins.measureSpan("task", "sync_response_post_treatment") {
measureTimeMillis {
syncResponse.rooms?.let {
checkPushRules(it, isInitialSync)
userAccountDataSyncHandler.synchronizeWithServerIfNeeded(it.invite)
dispatchInvitedRoom(it)
}
}.also {
Timber.v("SyncResponse.rooms post treatment took $it ms")
}
}
}
// post sync stuffs
private fun markCryptoSyncCompleted(syncResponse: SyncResponse) {
relevantPlugins.measureSpan("task", "crypto_sync_handler_onSyncCompleted") {
measureTimeMillis {
cryptoSyncHandler.onSyncCompleted(syncResponse)
}.also {
Timber.v("cryptoSyncHandler.onSyncCompleted took $it ms")
}
}
}
private fun handlePostSync() {
monarchy.writeAsync {
roomSyncHandler.postSyncSpaceHierarchyHandle(it)
}
Timber.v("On sync completed")
}
private fun dispatchInvitedRoom(roomsSyncResponse: RoomsSyncResponse) {

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.core.notification
import im.vector.app.features.session.coroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.Session
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class EnableNotificationsSettingUpdater @Inject constructor(
private val updateEnableNotificationsSettingOnChangeUseCase: UpdateEnableNotificationsSettingOnChangeUseCase,
) {
private var job: Job? = null
fun onSessionsStarted(session: Session) {
job?.cancel()
job = session.coroutineScope.launch {
updateEnableNotificationsSettingOnChangeUseCase.execute(session)
}
}
}

View file

@ -0,0 +1,50 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.core.notification
import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.settings.devices.v2.notification.GetNotificationsStatusUseCase
import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import org.matrix.android.sdk.api.session.Session
import javax.inject.Inject
/**
* Listen for changes in either Pusher or Account data to update the local enable notifications
* setting for the current device.
*/
class UpdateEnableNotificationsSettingOnChangeUseCase @Inject constructor(
private val vectorPreferences: VectorPreferences,
private val getNotificationsStatusUseCase: GetNotificationsStatusUseCase,
) {
suspend fun execute(session: Session) {
val deviceId = session.sessionParams.deviceId ?: return
getNotificationsStatusUseCase.execute(session, deviceId)
.onEach(::updatePreference)
.collect()
}
private fun updatePreference(notificationStatus: NotificationsStatus) {
when (notificationStatus) {
NotificationsStatus.ENABLED -> vectorPreferences.setNotificationEnabledForDevice(true)
NotificationsStatus.DISABLED -> vectorPreferences.setNotificationEnabledForDevice(false)
else -> Unit
}
}
}

View file

@ -97,12 +97,6 @@ class PushersManager @Inject constructor(
return session.pushersService().getPushers().firstOrNull { it.deviceId == deviceId }
}
suspend fun togglePusherForCurrentSession(enable: Boolean) {
val session = activeSessionHolder.getSafeActiveSession() ?: return
val pusher = getPusherForCurrentSession() ?: return
session.pushersService().togglePusher(pusher, enable)
}
suspend fun unregisterEmailPusher(email: String) {
val currentSession = activeSessionHolder.getSafeActiveSession() ?: return
currentSession.pushersService().removeEmailPusher(email)

View file

@ -19,6 +19,7 @@ package im.vector.app.core.session
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import im.vector.app.core.extensions.startSyncing
import im.vector.app.core.notification.EnableNotificationsSettingUpdater
import im.vector.app.core.session.clientinfo.UpdateMatrixClientInfoUseCase
import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.settings.VectorPreferences
@ -32,6 +33,7 @@ class ConfigureAndStartSessionUseCase @Inject constructor(
private val webRtcCallManager: WebRtcCallManager,
private val updateMatrixClientInfoUseCase: UpdateMatrixClientInfoUseCase,
private val vectorPreferences: VectorPreferences,
private val enableNotificationsSettingUpdater: EnableNotificationsSettingUpdater,
) {
suspend fun execute(session: Session, startSyncing: Boolean = true) {
@ -46,5 +48,6 @@ class ConfigureAndStartSessionUseCase @Inject constructor(
if (vectorPreferences.isClientInfoRecordingEnabled()) {
updateMatrixClientInfoUseCase.execute(session)
}
enableNotificationsSettingUpdater.onSessionsStarted(session)
}
}

View file

@ -17,6 +17,7 @@
package im.vector.app.features.analytics.metrics
import im.vector.app.features.analytics.metrics.sentry.SentryDownloadDeviceKeysMetrics
import im.vector.app.features.analytics.metrics.sentry.SentrySyncDurationMetrics
import org.matrix.android.sdk.api.metrics.MetricPlugin
import javax.inject.Inject
import javax.inject.Singleton
@ -27,9 +28,10 @@ import javax.inject.Singleton
@Singleton
data class VectorPlugins @Inject constructor(
val sentryDownloadDeviceKeysMetrics: SentryDownloadDeviceKeysMetrics,
val sentrySyncDurationMetrics: SentrySyncDurationMetrics,
) {
/**
* Returns [List] of all [MetricPlugin] hold by this class.
*/
fun plugins(): List<MetricPlugin> = listOf(sentryDownloadDeviceKeysMetrics)
fun plugins(): List<MetricPlugin> = listOf(sentryDownloadDeviceKeysMetrics, sentrySyncDurationMetrics)
}

View file

@ -26,8 +26,10 @@ class SentryDownloadDeviceKeysMetrics @Inject constructor() : DownloadDeviceKeys
private var transaction: ITransaction? = null
override fun startTransaction() {
transaction = Sentry.startTransaction("download_device_keys", "task")
logTransaction("Sentry transaction started")
if (Sentry.isEnabled()) {
transaction = Sentry.startTransaction("download_device_keys", "task")
logTransaction("Sentry transaction started")
}
}
override fun finishTransaction() {

View file

@ -0,0 +1,89 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.analytics.metrics.sentry
import io.sentry.ISpan
import io.sentry.ITransaction
import io.sentry.Sentry
import io.sentry.SpanStatus
import org.matrix.android.sdk.api.metrics.SyncDurationMetricPlugin
import java.util.EmptyStackException
import java.util.Stack
import javax.inject.Inject
/**
* Sentry based implementation of SyncDurationMetricPlugin.
*/
class SentrySyncDurationMetrics @Inject constructor() : SyncDurationMetricPlugin {
private var transaction: ITransaction? = null
// Stacks to keep spans in LIFO order.
private var spans: Stack<ISpan> = Stack()
/**
* Starts the span for a sub-task.
*
* @param operation Name of the new span.
* @param description Description of the new span.
*
* @throws IllegalStateException if this is called without starting a transaction ie. `measureSpan` must be called within `measureMetric`.
*/
override fun startSpan(operation: String, description: String) {
if (Sentry.isEnabled()) {
val span = Sentry.getSpan() ?: throw IllegalStateException("measureSpan block must be called within measureMetric")
val innerSpan = span.startChild(operation, description)
spans.push(innerSpan)
logTransaction("Sentry span started: operation=[$operation], description=[$description]")
}
}
override fun finishSpan() {
try {
spans.pop()
} catch (e: EmptyStackException) {
null
}?.finish()
logTransaction("Sentry span finished")
}
override fun startTransaction() {
if (Sentry.isEnabled()) {
transaction = Sentry.startTransaction("sync_response_handler", "task", true)
logTransaction("Sentry transaction started")
}
}
override fun finishTransaction() {
transaction?.finish()
logTransaction("Sentry transaction finished")
}
override fun onError(throwable: Throwable) {
try {
spans.peek()
} catch (e: EmptyStackException) {
null
}?.apply {
this.throwable = throwable
this.status = SpanStatus.INTERNAL_ERROR
} ?: transaction?.apply {
this.throwable = throwable
this.status = SpanStatus.INTERNAL_ERROR
}
logTransaction("Sentry transaction encountered error ${throwable.message}")
}
}

View file

@ -167,12 +167,10 @@ class WebRtcCall(
private var screenSender: RtpSender? = null
private val timer = CountUpTimer(1000L).apply {
tickListener = object : CountUpTimer.TickListener {
override fun onTick(milliseconds: Long) {
val formattedDuration = formatDuration(Duration.ofMillis(milliseconds))
listeners.forEach {
tryOrNull { it.onTick(formattedDuration) }
}
tickListener = CountUpTimer.TickListener { milliseconds ->
val formattedDuration = formatDuration(Duration.ofMillis(milliseconds))
listeners.forEach {
tryOrNull { it.onTick(formattedDuration) }
}
}
}

View file

@ -20,6 +20,7 @@ import android.net.Uri
import android.view.View
import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.call.conference.ConferenceEvent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
@ -129,10 +130,10 @@ sealed class RoomDetailAction : VectorViewModelAction {
}
sealed class Listening : VoiceBroadcastAction() {
data class PlayOrResume(val voiceBroadcastId: String) : Listening()
data class PlayOrResume(val voiceBroadcast: VoiceBroadcast) : Listening()
object Pause : Listening()
object Stop : Listening()
data class SeekTo(val voiceBroadcastId: String, val positionMillis: Int) : Listening()
data class SeekTo(val voiceBroadcast: VoiceBroadcast, val positionMillis: Int, val duration: Int) : Listening()
}
}
}

View file

@ -634,10 +634,10 @@ class TimelineViewModel @AssistedInject constructor(
VoiceBroadcastAction.Recording.Pause -> voiceBroadcastHelper.pauseVoiceBroadcast(room.roomId)
VoiceBroadcastAction.Recording.Resume -> voiceBroadcastHelper.resumeVoiceBroadcast(room.roomId)
VoiceBroadcastAction.Recording.Stop -> voiceBroadcastHelper.stopVoiceBroadcast(room.roomId)
is VoiceBroadcastAction.Listening.PlayOrResume -> voiceBroadcastHelper.playOrResumePlayback(room.roomId, action.voiceBroadcastId)
is VoiceBroadcastAction.Listening.PlayOrResume -> voiceBroadcastHelper.playOrResumePlayback(action.voiceBroadcast)
VoiceBroadcastAction.Listening.Pause -> voiceBroadcastHelper.pausePlayback()
VoiceBroadcastAction.Listening.Stop -> voiceBroadcastHelper.stopPlayback()
is VoiceBroadcastAction.Listening.SeekTo -> voiceBroadcastHelper.seekTo(action.voiceBroadcastId, action.positionMillis)
is VoiceBroadcastAction.Listening.SeekTo -> voiceBroadcastHelper.seekTo(action.voiceBroadcast, action.positionMillis, action.duration)
}
}
}

View file

@ -199,11 +199,7 @@ class AudioMessageHelper @Inject constructor(
private fun startRecordingAmplitudes() {
amplitudeTicker?.stop()
amplitudeTicker = CountUpTimer(50).apply {
tickListener = object : CountUpTimer.TickListener {
override fun onTick(milliseconds: Long) {
onAmplitudeTick()
}
}
tickListener = CountUpTimer.TickListener { onAmplitudeTick() }
resume()
}
}
@ -234,11 +230,7 @@ class AudioMessageHelper @Inject constructor(
private fun startPlaybackTicker(id: String) {
playbackTicker?.stop()
playbackTicker = CountUpTimer().apply {
tickListener = object : CountUpTimer.TickListener {
override fun onTick(milliseconds: Long) {
onPlaybackTick(id)
}
}
tickListener = CountUpTimer.TickListener { onPlaybackTick(id) }
resume()
}
onPlaybackTick(id)

View file

@ -189,11 +189,9 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
val startMs = ((clock.epochMillis() - startAt)).coerceAtLeast(0)
recordingTicker?.stop()
recordingTicker = CountUpTimer().apply {
tickListener = object : CountUpTimer.TickListener {
override fun onTick(milliseconds: Long) {
val isLocked = startFromLocked || lastKnownState is RecordingUiState.Locked
onRecordingTick(isLocked, milliseconds + startMs)
}
tickListener = CountUpTimer.TickListener { milliseconds ->
val isLocked = startFromLocked || lastKnownState is RecordingUiState.Locked
onRecordingTick(isLocked, milliseconds + startMs)
}
resume()
}

View file

@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.DrawableProvider
import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.VoiceBroadcastEventsGroup
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
@ -28,6 +29,7 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadca
import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastRecordingItem_
import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer
import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder
@ -44,6 +46,7 @@ class VoiceBroadcastItemFactory @Inject constructor(
private val drawableProvider: DrawableProvider,
private val voiceBroadcastRecorder: VoiceBroadcastRecorder?,
private val voiceBroadcastPlayer: VoiceBroadcastPlayer,
private val playbackTracker: AudioMessagePlaybackTracker,
) {
fun create(
@ -58,19 +61,20 @@ class VoiceBroadcastItemFactory @Inject constructor(
val voiceBroadcastEventsGroup = params.eventsGroup?.let { VoiceBroadcastEventsGroup(it) } ?: return null
val voiceBroadcastEvent = voiceBroadcastEventsGroup.getLastDisplayableEvent().root.asVoiceBroadcastEvent() ?: return null
val voiceBroadcastContent = voiceBroadcastEvent.content ?: return null
val voiceBroadcastId = voiceBroadcastEventsGroup.voiceBroadcastId
val voiceBroadcast = VoiceBroadcast(voiceBroadcastId = voiceBroadcastEventsGroup.voiceBroadcastId, roomId = params.event.roomId)
val isRecording = voiceBroadcastContent.voiceBroadcastState != VoiceBroadcastState.STOPPED &&
voiceBroadcastEvent.root.stateKey == session.myUserId &&
messageContent.deviceId == session.sessionParams.deviceId
val voiceBroadcastAttributes = AbsMessageVoiceBroadcastItem.Attributes(
voiceBroadcastId = voiceBroadcastId,
voiceBroadcast = voiceBroadcast,
voiceBroadcastState = voiceBroadcastContent.voiceBroadcastState,
duration = voiceBroadcastEventsGroup.getDuration(),
recorderName = params.event.root.stateKey?.let { session.getUserOrDefault(it) }?.toMatrixItem()?.getBestName().orEmpty(),
recorder = voiceBroadcastRecorder,
player = voiceBroadcastPlayer,
playbackTracker = playbackTracker,
roomItem = session.getRoom(params.event.roomId)?.roomSummary()?.toMatrixItem(),
colorProvider = colorProvider,
drawableProvider = drawableProvider,
@ -89,7 +93,7 @@ class VoiceBroadcastItemFactory @Inject constructor(
voiceBroadcastAttributes: AbsMessageVoiceBroadcastItem.Attributes,
): MessageVoiceBroadcastRecordingItem {
return MessageVoiceBroadcastRecordingItem_()
.id("voice_broadcast_${voiceBroadcastAttributes.voiceBroadcastId}")
.id("voice_broadcast_${voiceBroadcastAttributes.voiceBroadcast.voiceBroadcastId}")
.attributes(attributes)
.voiceBroadcastAttributes(voiceBroadcastAttributes)
.highlighted(highlight)
@ -102,7 +106,7 @@ class VoiceBroadcastItemFactory @Inject constructor(
voiceBroadcastAttributes: AbsMessageVoiceBroadcastItem.Attributes,
): MessageVoiceBroadcastListeningItem {
return MessageVoiceBroadcastListeningItem_()
.id("voice_broadcast_${voiceBroadcastAttributes.voiceBroadcastId}")
.id("voice_broadcast_${voiceBroadcastAttributes.voiceBroadcast.voiceBroadcastId}")
.attributes(attributes)
.voiceBroadcastAttributes(voiceBroadcastAttributes)
.highlighted(highlight)

View file

@ -127,7 +127,7 @@ class AudioMessagePlaybackTracker @Inject constructor() {
}
}
private fun getPercentage(id: String): Float {
fun getPercentage(id: String): Float {
return when (val state = states[id]) {
is Listener.State.Playing -> state.percentage
is Listener.State.Paused -> state.percentage
@ -148,7 +148,7 @@ class AudioMessagePlaybackTracker @Inject constructor() {
const val RECORDING_ID = "RECORDING_ID"
}
interface Listener {
fun interface Listener {
fun onUpdate(state: State)

View file

@ -25,7 +25,9 @@ import im.vector.app.R
import im.vector.app.core.extensions.tintBackground
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.DrawableProvider
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer
import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder
import org.matrix.android.sdk.api.util.MatrixItem
@ -35,11 +37,13 @@ abstract class AbsMessageVoiceBroadcastItem<H : AbsMessageVoiceBroadcastItem.Hol
@EpoxyAttribute
lateinit var voiceBroadcastAttributes: Attributes
protected val voiceBroadcastId get() = voiceBroadcastAttributes.voiceBroadcastId
protected val voiceBroadcast get() = voiceBroadcastAttributes.voiceBroadcast
protected val voiceBroadcastState get() = voiceBroadcastAttributes.voiceBroadcastState
protected val recorderName get() = voiceBroadcastAttributes.recorderName
protected val recorder get() = voiceBroadcastAttributes.recorder
protected val player get() = voiceBroadcastAttributes.player
protected val playbackTracker get() = voiceBroadcastAttributes.playbackTracker
protected val duration get() = voiceBroadcastAttributes.duration
protected val roomItem get() = voiceBroadcastAttributes.roomItem
protected val colorProvider get() = voiceBroadcastAttributes.colorProvider
protected val drawableProvider get() = voiceBroadcastAttributes.drawableProvider
@ -92,12 +96,13 @@ abstract class AbsMessageVoiceBroadcastItem<H : AbsMessageVoiceBroadcastItem.Hol
}
data class Attributes(
val voiceBroadcastId: String,
val voiceBroadcast: VoiceBroadcast,
val voiceBroadcastState: VoiceBroadcastState?,
val duration: Int,
val recorderName: String,
val recorder: VoiceBroadcastRecorder?,
val player: VoiceBroadcastPlayer,
val playbackTracker: AudioMessagePlaybackTracker,
val roomItem: MatrixItem?,
val colorProvider: ColorProvider,
val drawableProvider: DrawableProvider,

View file

@ -140,16 +140,14 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
}
private fun renderStateBasedOnAudioPlayback(holder: Holder) {
audioMessagePlaybackTracker.track(attributes.informationData.eventId, object : AudioMessagePlaybackTracker.Listener {
override fun onUpdate(state: AudioMessagePlaybackTracker.Listener.State) {
when (state) {
is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder)
is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state)
is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state)
is AudioMessagePlaybackTracker.Listener.State.Recording -> Unit
}
audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state ->
when (state) {
is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder)
is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state)
is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state)
is AudioMessagePlaybackTracker.Listener.State.Recording -> Unit
}
})
}
}
private fun renderIdleState(holder: Holder) {

View file

@ -27,6 +27,7 @@ import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.onClick
import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker.Listener.State
import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer
import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
@ -34,6 +35,7 @@ import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem<MessageVoiceBroadcastListeningItem.Holder>() {
private lateinit var playerListener: VoiceBroadcastPlayer.Listener
private var isUserSeeking = false
override fun bind(holder: Holder) {
super.bind(holder)
@ -41,11 +43,35 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
}
private fun bindVoiceBroadcastItem(holder: Holder) {
playerListener = VoiceBroadcastPlayer.Listener { state ->
renderPlayingState(holder, state)
}
player.addListener(voiceBroadcastId, playerListener)
playerListener = VoiceBroadcastPlayer.Listener { renderPlayingState(holder, it) }
player.addListener(voiceBroadcast, playerListener)
bindSeekBar(holder)
bindButtons(holder)
}
private fun bindButtons(holder: Holder) {
with(holder) {
playPauseButton.onClick {
if (player.currentVoiceBroadcast == voiceBroadcast) {
when (player.playingState) {
VoiceBroadcastPlayer.State.PLAYING -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause)
VoiceBroadcastPlayer.State.PAUSED,
VoiceBroadcastPlayer.State.IDLE -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast))
VoiceBroadcastPlayer.State.BUFFERING -> Unit
}
} else {
callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast))
}
}
fastBackwardButton.onClick {
val newPos = seekBar.progress.minus(30_000).coerceIn(0, duration)
callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.SeekTo(voiceBroadcast, newPos, duration))
}
fastForwardButton.onClick {
val newPos = seekBar.progress.plus(30_000).coerceIn(0, duration)
callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.SeekTo(voiceBroadcast, newPos, duration))
}
}
}
override fun renderMetadata(holder: Holder) {
@ -61,50 +87,67 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
bufferingView.isVisible = state == VoiceBroadcastPlayer.State.BUFFERING
playPauseButton.isVisible = state != VoiceBroadcastPlayer.State.BUFFERING
fastBackwardButton.isInvisible = true
fastForwardButton.isInvisible = true
when (state) {
VoiceBroadcastPlayer.State.PLAYING -> {
playPauseButton.setImageResource(R.drawable.ic_play_pause_pause)
playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast)
playPauseButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause) }
seekBar.isEnabled = true
}
VoiceBroadcastPlayer.State.IDLE,
VoiceBroadcastPlayer.State.PAUSED -> {
playPauseButton.setImageResource(R.drawable.ic_play_pause_play)
playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast)
playPauseButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcastId)) }
seekBar.isEnabled = false
}
VoiceBroadcastPlayer.State.BUFFERING -> {
seekBar.isEnabled = true
}
VoiceBroadcastPlayer.State.BUFFERING -> Unit
}
}
}
private fun bindSeekBar(holder: Holder) {
holder.durationView.text = formatPlaybackTime(voiceBroadcastAttributes.duration)
holder.seekBar.max = voiceBroadcastAttributes.duration
holder.seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) = Unit
with(holder) {
durationView.text = formatPlaybackTime(duration)
seekBar.max = duration
seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) = Unit
override fun onStartTrackingTouch(seekBar: SeekBar) = Unit
override fun onStartTrackingTouch(seekBar: SeekBar) {
isUserSeeking = true
}
override fun onStopTrackingTouch(seekBar: SeekBar) {
callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.SeekTo(voiceBroadcastId, seekBar.progress))
override fun onStopTrackingTouch(seekBar: SeekBar) {
callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.SeekTo(voiceBroadcast, seekBar.progress, duration))
isUserSeeking = false
}
})
}
playbackTracker.track(voiceBroadcast.voiceBroadcastId) { playbackState ->
renderBackwardForwardButtons(holder, playbackState)
if (!isUserSeeking) {
holder.seekBar.progress = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId)
}
})
}
}
private fun renderBackwardForwardButtons(holder: Holder, playbackState: State) {
val isPlayingOrPaused = playbackState is State.Playing || playbackState is State.Paused
val playbackTime = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId)
val canBackward = isPlayingOrPaused && playbackTime > 0
val canForward = isPlayingOrPaused && playbackTime < duration
holder.fastBackwardButton.isInvisible = !canBackward
holder.fastForwardButton.isInvisible = !canForward
}
private fun formatPlaybackTime(time: Int) = DateUtils.formatElapsedTime((time / 1000).toLong())
override fun unbind(holder: Holder) {
super.unbind(holder)
player.removeListener(voiceBroadcastId, playerListener)
holder.seekBar.setOnSeekBarChangeListener(null)
player.removeListener(voiceBroadcast, playerListener)
playbackTracker.untrack(voiceBroadcast.voiceBroadcastId)
with(holder) {
seekBar.onClick(null)
playPauseButton.onClick(null)
fastForwardButton.onClick(null)
fastBackwardButton.onClick(null)
}
}
override fun getViewStubId() = STUB_ID

View file

@ -122,16 +122,14 @@ abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() {
true
}
audioMessagePlaybackTracker.track(attributes.informationData.eventId, object : AudioMessagePlaybackTracker.Listener {
override fun onUpdate(state: AudioMessagePlaybackTracker.Listener.State) {
when (state) {
is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, waveformColorIdle, waveformColorPlayed)
is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state, waveformColorIdle, waveformColorPlayed)
is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state, waveformColorIdle, waveformColorPlayed)
is AudioMessagePlaybackTracker.Listener.State.Recording -> Unit
}
audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state ->
when (state) {
is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, waveformColorIdle, waveformColorPlayed)
is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state, waveformColorIdle, waveformColorPlayed)
is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state, waveformColorIdle, waveformColorPlayed)
is AudioMessagePlaybackTracker.Listener.State.Recording -> Unit
}
})
}
}
private fun getTouchedPositionPercentage(motionEvent: MotionEvent, view: View) = (motionEvent.x / view.width).coerceIn(0f, 1f)

View file

@ -79,10 +79,8 @@ abstract class LiveLocationUserItem : VectorEpoxyModel<LiveLocationUserItem.Hold
}
}
holder.timer.tickListener = object : CountUpTimer.TickListener {
override fun onTick(milliseconds: Long) {
holder.itemLastUpdatedAtTextView.text = getFormattedLastUpdatedAt(locationUpdateTimeMillis)
}
holder.timer.tickListener = CountUpTimer.TickListener {
holder.itemLastUpdatedAtTextView.text = getFormattedLastUpdatedAt(locationUpdateTimeMillis)
}
holder.timer.resume()

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.settings.devices.v2.notification
import androidx.lifecycle.asFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.flow.unwrap
import javax.inject.Inject
class CanTogglePushNotificationsViaPusherUseCase @Inject constructor() {
fun execute(session: Session): Flow<Boolean> {
return session
.homeServerCapabilitiesService()
.getHomeServerCapabilitiesLive()
.asFlow()
.unwrap()
.map { it.canRemotelyTogglePushNotificationsOfDevices }
.distinctUntilChanged()
}
}

View file

@ -16,18 +16,15 @@
package im.vector.app.features.settings.devices.v2.notification
import im.vector.app.core.di.ActiveSessionHolder
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes
import javax.inject.Inject
class CheckIfCanTogglePushNotificationsViaAccountDataUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
) {
class CheckIfCanTogglePushNotificationsViaAccountDataUseCase @Inject constructor() {
fun execute(deviceId: String): Boolean {
return activeSessionHolder
.getSafeActiveSession()
?.accountDataService()
?.getUserAccountDataEvent(UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId) != null
fun execute(session: Session, deviceId: String): Boolean {
return session
.accountDataService()
.getUserAccountDataEvent(UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId) != null
}
}

View file

@ -16,20 +16,15 @@
package im.vector.app.features.settings.devices.v2.notification
import im.vector.app.core.di.ActiveSessionHolder
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.Session
import javax.inject.Inject
class CheckIfCanTogglePushNotificationsViaPusherUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
) {
class CheckIfCanTogglePushNotificationsViaPusherUseCase @Inject constructor() {
fun execute(): Boolean {
return activeSessionHolder
.getSafeActiveSession()
?.homeServerCapabilitiesService()
?.getHomeServerCapabilities()
?.canRemotelyTogglePushNotificationsOfDevices
.orFalse()
fun execute(session: Session): Boolean {
return session
.homeServerCapabilitiesService()
.getHomeServerCapabilities()
.canRemotelyTogglePushNotificationsOfDevices
}
}

View file

@ -16,12 +16,13 @@
package im.vector.app.features.settings.devices.v2.notification
import im.vector.app.core.di.ActiveSessionHolder
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import org.matrix.android.sdk.api.account.LocalNotificationSettingsContent
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.flow.flow
@ -29,16 +30,13 @@ import org.matrix.android.sdk.flow.unwrap
import javax.inject.Inject
class GetNotificationsStatusUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
private val checkIfCanTogglePushNotificationsViaPusherUseCase: CheckIfCanTogglePushNotificationsViaPusherUseCase,
private val canTogglePushNotificationsViaPusherUseCase: CanTogglePushNotificationsViaPusherUseCase,
private val checkIfCanTogglePushNotificationsViaAccountDataUseCase: CheckIfCanTogglePushNotificationsViaAccountDataUseCase,
) {
fun execute(deviceId: String): Flow<NotificationsStatus> {
val session = activeSessionHolder.getSafeActiveSession()
fun execute(session: Session, deviceId: String): Flow<NotificationsStatus> {
return when {
session == null -> flowOf(NotificationsStatus.NOT_SUPPORTED)
checkIfCanTogglePushNotificationsViaAccountDataUseCase.execute(deviceId) -> {
checkIfCanTogglePushNotificationsViaAccountDataUseCase.execute(session, deviceId) -> {
session.flow()
.liveUserAccountData(UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId)
.unwrap()
@ -46,15 +44,19 @@ class GetNotificationsStatusUseCase @Inject constructor(
.map { if (it == true) NotificationsStatus.ENABLED else NotificationsStatus.DISABLED }
.distinctUntilChanged()
}
checkIfCanTogglePushNotificationsViaPusherUseCase.execute() -> {
session.flow()
.livePushers()
.map { it.filter { pusher -> pusher.deviceId == deviceId } }
.map { it.takeIf { it.isNotEmpty() }?.any { pusher -> pusher.enabled } }
.map { if (it == true) NotificationsStatus.ENABLED else NotificationsStatus.DISABLED }
.distinctUntilChanged()
}
else -> flowOf(NotificationsStatus.NOT_SUPPORTED)
else -> canTogglePushNotificationsViaPusherUseCase.execute(session)
.flatMapLatest { canToggle ->
if (canToggle) {
session.flow()
.livePushers()
.map { it.filter { pusher -> pusher.deviceId == deviceId } }
.map { it.takeIf { it.isNotEmpty() }?.any { pusher -> pusher.enabled } }
.map { if (it == true) NotificationsStatus.ENABLED else NotificationsStatus.DISABLED }
.distinctUntilChanged()
} else {
flowOf(NotificationsStatus.NOT_SUPPORTED)
}
}
}
}
}

View file

@ -31,14 +31,14 @@ class TogglePushNotificationUseCase @Inject constructor(
suspend fun execute(deviceId: String, enabled: Boolean) {
val session = activeSessionHolder.getSafeActiveSession() ?: return
if (checkIfCanTogglePushNotificationsViaPusherUseCase.execute()) {
if (checkIfCanTogglePushNotificationsViaPusherUseCase.execute(session)) {
val devicePusher = session.pushersService().getPushers().firstOrNull { it.deviceId == deviceId }
devicePusher?.let { pusher ->
session.pushersService().togglePusher(pusher, enabled)
}
}
if (checkIfCanTogglePushNotificationsViaAccountDataUseCase.execute(deviceId)) {
if (checkIfCanTogglePushNotificationsViaAccountDataUseCase.execute(session, deviceId)) {
val newNotificationSettingsContent = LocalNotificationSettingsContent(isSilenced = !enabled)
session.accountDataService().updateUserAccountData(
UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId,

View file

@ -267,7 +267,10 @@ class OtherSessionsFragment :
)
)
views.otherSessionsNotFoundTextView.text = getString(R.string.device_manager_other_sessions_no_verified_sessions_found)
updateSecurityLearnMoreButton(R.string.device_manager_learn_more_sessions_verified_title, R.string.device_manager_learn_more_sessions_verified)
updateSecurityLearnMoreButton(
R.string.device_manager_learn_more_sessions_verified_title,
R.string.device_manager_learn_more_sessions_verified_description
)
}
DeviceManagerFilterType.UNVERIFIED -> {
views.otherSessionsSecurityRecommendationView.render(

View file

@ -300,7 +300,7 @@ class SessionOverviewFragment :
R.string.device_manager_verification_status_unverified
}
val descriptionResId = if (isVerified) {
R.string.device_manager_learn_more_sessions_verified
R.string.device_manager_learn_more_sessions_verified_description
} else {
R.string.device_manager_learn_more_sessions_unverified
}

View file

@ -110,9 +110,11 @@ class SessionOverviewViewModel @AssistedInject constructor(
}
private fun observeNotificationsStatus(deviceId: String) {
getNotificationsStatusUseCase.execute(deviceId)
.onEach { setState { copy(notificationsStatus = it) } }
.launchIn(viewModelScope)
activeSessionHolder.getSafeActiveSession()?.let { session ->
getNotificationsStatusUseCase.execute(session, deviceId)
.onEach { setState { copy(notificationsStatus = it) } }
.launchIn(viewModelScope)
}
}
override fun handle(action: SessionOverviewAction) {

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.settings.notifications
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.pushers.PushersManager
import im.vector.app.core.pushers.UnifiedPushHelper
import im.vector.app.features.settings.devices.v2.notification.CheckIfCanTogglePushNotificationsViaPusherUseCase
import im.vector.app.features.settings.devices.v2.notification.TogglePushNotificationUseCase
import javax.inject.Inject
class DisableNotificationsForCurrentSessionUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
private val unifiedPushHelper: UnifiedPushHelper,
private val pushersManager: PushersManager,
private val checkIfCanTogglePushNotificationsViaPusherUseCase: CheckIfCanTogglePushNotificationsViaPusherUseCase,
private val togglePushNotificationUseCase: TogglePushNotificationUseCase,
) {
suspend fun execute() {
val session = activeSessionHolder.getSafeActiveSession() ?: return
val deviceId = session.sessionParams.deviceId ?: return
if (checkIfCanTogglePushNotificationsViaPusherUseCase.execute(session)) {
togglePushNotificationUseCase.execute(deviceId, enabled = false)
} else {
unifiedPushHelper.unregister(pushersManager)
}
}
}

View file

@ -0,0 +1,71 @@
/*
* 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.settings.notifications
import androidx.fragment.app.FragmentActivity
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.pushers.FcmHelper
import im.vector.app.core.pushers.PushersManager
import im.vector.app.core.pushers.UnifiedPushHelper
import im.vector.app.features.settings.devices.v2.notification.CheckIfCanTogglePushNotificationsViaPusherUseCase
import im.vector.app.features.settings.devices.v2.notification.TogglePushNotificationUseCase
import javax.inject.Inject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class EnableNotificationsForCurrentSessionUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
private val unifiedPushHelper: UnifiedPushHelper,
private val pushersManager: PushersManager,
private val fcmHelper: FcmHelper,
private val checkIfCanTogglePushNotificationsViaPusherUseCase: CheckIfCanTogglePushNotificationsViaPusherUseCase,
private val togglePushNotificationUseCase: TogglePushNotificationUseCase,
) {
suspend fun execute(fragmentActivity: FragmentActivity) {
val pusherForCurrentSession = pushersManager.getPusherForCurrentSession()
if (pusherForCurrentSession == null) {
registerPusher(fragmentActivity)
}
val session = activeSessionHolder.getSafeActiveSession() ?: return
if (checkIfCanTogglePushNotificationsViaPusherUseCase.execute(session)) {
val deviceId = session.sessionParams.deviceId ?: return
togglePushNotificationUseCase.execute(deviceId, enabled = true)
}
}
private suspend fun registerPusher(fragmentActivity: FragmentActivity) {
suspendCoroutine { continuation ->
try {
unifiedPushHelper.register(fragmentActivity) {
if (unifiedPushHelper.isEmbeddedDistributor()) {
fcmHelper.ensureFcmTokenIsRetrieved(
fragmentActivity,
pushersManager,
registerPusher = true
)
}
continuation.resume(Unit)
}
} catch (error: Exception) {
continuation.resumeWithException(error)
}
}
}
}

View file

@ -57,7 +57,6 @@ import im.vector.app.features.settings.VectorSettingsBaseFragment
import im.vector.app.features.settings.VectorSettingsFragmentInteractionListener
import im.vector.lib.core.utils.compat.getParcelableExtraCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.Session
@ -81,6 +80,8 @@ class VectorSettingsNotificationPreferenceFragment :
@Inject lateinit var guardServiceStarter: GuardServiceStarter
@Inject lateinit var vectorFeatures: VectorFeatures
@Inject lateinit var notificationPermissionManager: NotificationPermissionManager
@Inject lateinit var disableNotificationsForCurrentSessionUseCase: DisableNotificationsForCurrentSessionUseCase
@Inject lateinit var enableNotificationsForCurrentSessionUseCase: EnableNotificationsForCurrentSessionUseCase
override var titleRes: Int = R.string.settings_notifications
override val preferenceXmlRes = R.xml.vector_settings_notifications
@ -119,48 +120,25 @@ class VectorSettingsNotificationPreferenceFragment :
(pref as SwitchPreference).isChecked = areNotifEnabledAtAccountLevel
}
findPreference<SwitchPreference>(VectorPreferences.SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY)?.let {
pushersManager.getPusherForCurrentSession()?.let { pusher ->
it.isChecked = pusher.enabled
}
findPreference<SwitchPreference>(VectorPreferences.SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY)
?.setTransactionalSwitchChangeListener(lifecycleScope) { isChecked ->
if (isChecked) {
enableNotificationsForCurrentSessionUseCase.execute(requireActivity())
it.setTransactionalSwitchChangeListener(lifecycleScope) { isChecked ->
if (isChecked) {
unifiedPushHelper.register(requireActivity()) {
// Update the summary
if (unifiedPushHelper.isEmbeddedDistributor()) {
fcmHelper.ensureFcmTokenIsRetrieved(
requireActivity(),
pushersManager,
vectorPreferences.areNotificationEnabledForDevice()
)
}
findPreference<VectorPreference>(VectorPreferences.SETTINGS_NOTIFICATION_METHOD_KEY)
?.summary = unifiedPushHelper.getCurrentDistributorName()
lifecycleScope.launch {
val result = runCatching {
pushersManager.togglePusherForCurrentSession(true)
}
result.exceptionOrNull()?.let { _ ->
Toast.makeText(context, R.string.error_check_network, Toast.LENGTH_SHORT).show()
it.isChecked = false
}
}
notificationPermissionManager.eventuallyRequestPermission(
requireActivity(),
postPermissionLauncher,
showRationale = false,
ignorePreference = true
)
} else {
disableNotificationsForCurrentSessionUseCase.execute()
notificationPermissionManager.eventuallyRevokePermission(requireActivity())
}
notificationPermissionManager.eventuallyRequestPermission(
requireActivity(),
postPermissionLauncher,
showRationale = false,
ignorePreference = true
)
} else {
unifiedPushHelper.unregister(pushersManager)
session.pushersService().refreshPushers()
notificationPermissionManager.eventuallyRevokePermission(requireActivity())
}
}
}
findPreference<VectorPreference>(VectorPreferences.SETTINGS_FDROID_BACKGROUND_SYNC_MODE)?.let {
it.onPreferenceClickListener = Preference.OnPreferenceClickListener {

View file

@ -16,7 +16,11 @@
package im.vector.app.features.voicebroadcast
import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastChunk
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.events.model.Content
import org.matrix.android.sdk.api.session.events.model.getRelationContent
import org.matrix.android.sdk.api.session.events.model.toModel
@ -34,3 +38,9 @@ fun MessageAudioEvent.getVoiceBroadcastChunk(): VoiceBroadcastChunk? {
val MessageAudioEvent.sequence: Int? get() = getVoiceBroadcastChunk()?.sequence
val MessageAudioEvent.duration get() = content.audioInfo?.duration ?: content.audioWaveformInfo?.duration ?: 0
val VoiceBroadcastEvent.isLive
get() = content?.isLive.orFalse()
val MessageVoiceBroadcastInfoContent.isLive
get() = voiceBroadcastState != null && voiceBroadcastState != VoiceBroadcastState.STOPPED

View file

@ -17,6 +17,7 @@
package im.vector.app.features.voicebroadcast
import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer
import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
import im.vector.app.features.voicebroadcast.recording.usecase.PauseVoiceBroadcastUseCase
import im.vector.app.features.voicebroadcast.recording.usecase.ResumeVoiceBroadcastUseCase
import im.vector.app.features.voicebroadcast.recording.usecase.StartVoiceBroadcastUseCase
@ -41,15 +42,13 @@ class VoiceBroadcastHelper @Inject constructor(
suspend fun stopVoiceBroadcast(roomId: String) = stopVoiceBroadcastUseCase.execute(roomId)
fun playOrResumePlayback(roomId: String, voiceBroadcastId: String) = voiceBroadcastPlayer.playOrResume(roomId, voiceBroadcastId)
fun playOrResumePlayback(voiceBroadcast: VoiceBroadcast) = voiceBroadcastPlayer.playOrResume(voiceBroadcast)
fun pausePlayback() = voiceBroadcastPlayer.pause()
fun stopPlayback() = voiceBroadcastPlayer.stop()
fun seekTo(voiceBroadcastId: String, positionMillis: Int) {
if (voiceBroadcastPlayer.currentVoiceBroadcastId == voiceBroadcastId) {
voiceBroadcastPlayer.seekTo(positionMillis)
}
fun seekTo(voiceBroadcast: VoiceBroadcast, positionMillis: Int, duration: Int) {
voiceBroadcastPlayer.seekTo(voiceBroadcast, positionMillis, duration)
}
}

View file

@ -16,12 +16,14 @@
package im.vector.app.features.voicebroadcast.listening
import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
interface VoiceBroadcastPlayer {
/**
* The current playing voice broadcast identifier, if any.
* The current playing voice broadcast, if any.
*/
val currentVoiceBroadcastId: String?
val currentVoiceBroadcast: VoiceBroadcast?
/**
* The current playing [State], [State.IDLE] by default.
@ -31,7 +33,7 @@ interface VoiceBroadcastPlayer {
/**
* Start playback of the given voice broadcast.
*/
fun playOrResume(roomId: String, voiceBroadcastId: String)
fun playOrResume(voiceBroadcast: VoiceBroadcast)
/**
* Pause playback of the current voice broadcast, if any.
@ -44,19 +46,19 @@ interface VoiceBroadcastPlayer {
fun stop()
/**
* Seek to the given playback position, is milliseconds.
* Seek the given voice broadcast playback to the given position, is milliseconds.
*/
fun seekTo(positionMillis: Int)
fun seekTo(voiceBroadcast: VoiceBroadcast, positionMillis: Int, duration: Int)
/**
* Add a [Listener] to the given voice broadcast id.
* Add a [Listener] to the given voice broadcast.
*/
fun addListener(voiceBroadcastId: String, listener: Listener)
fun addListener(voiceBroadcast: VoiceBroadcast, listener: Listener)
/**
* Remove a [Listener] from the given voice broadcast id.
* Remove a [Listener] from the given voice broadcast.
*/
fun removeListener(voiceBroadcastId: String, listener: Listener)
fun removeListener(voiceBroadcast: VoiceBroadcast, listener: Listener)
/**
* Player states.

View file

@ -18,28 +18,28 @@ package im.vector.app.features.voicebroadcast.listening
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.media.MediaPlayer.OnPreparedListener
import androidx.annotation.MainThread
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.session.coroutineScope
import im.vector.app.features.voice.VoiceFailure
import im.vector.app.features.voicebroadcast.duration
import im.vector.app.features.voicebroadcast.isLive
import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.Listener
import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.State
import im.vector.app.features.voicebroadcast.listening.usecase.GetLiveVoiceBroadcastChunksUseCase
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.sequence
import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastUseCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastEventUseCase
import im.vector.lib.core.utils.timer.CountUpTimer
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent
import timber.log.Timber
import java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject
@ -49,179 +49,161 @@ import javax.inject.Singleton
class VoiceBroadcastPlayerImpl @Inject constructor(
private val sessionHolder: ActiveSessionHolder,
private val playbackTracker: AudioMessagePlaybackTracker,
private val getVoiceBroadcastUseCase: GetVoiceBroadcastUseCase,
private val getVoiceBroadcastEventUseCase: GetVoiceBroadcastEventUseCase,
private val getLiveVoiceBroadcastChunksUseCase: GetLiveVoiceBroadcastChunksUseCase
) : VoiceBroadcastPlayer {
private val session
get() = sessionHolder.getActiveSession()
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var voiceBroadcastStateJob: Job? = null
private val session get() = sessionHolder.getActiveSession()
private val sessionScope get() = session.coroutineScope
private val mediaPlayerListener = MediaPlayerListener()
private val playbackTicker = PlaybackTicker()
private val playlist = VoiceBroadcastPlaylist()
private var fetchPlaylistTask: Job? = null
private var voiceBroadcastStateObserver: Job? = null
private var currentMediaPlayer: MediaPlayer? = null
private var nextMediaPlayer: MediaPlayer? = null
private var currentSequence: Int? = null
private var isPreparingNextPlayer: Boolean = false
private var fetchPlaylistJob: Job? = null
private var playlist = emptyList<PlaylistItem>()
private var currentVoiceBroadcastEvent: VoiceBroadcastEvent? = null
private var isLive: Boolean = false
override var currentVoiceBroadcastId: String? = null
override var currentVoiceBroadcast: VoiceBroadcast? = null
override var playingState = State.IDLE
@MainThread
set(value) {
Timber.w("## VoiceBroadcastPlayer state: $field -> $value")
field = value
// Notify state change to all the listeners attached to the current voice broadcast id
currentVoiceBroadcastId?.let { voiceBroadcastId ->
listeners[voiceBroadcastId]?.forEach { listener -> listener.onStateChanged(value) }
if (field != value) {
Timber.w("## VoiceBroadcastPlayer state: $field -> $value")
field = value
onPlayingStateChanged(value)
}
}
private var currentRoomId: String? = null
/**
* Map voiceBroadcastId to listeners.
*/
/** Map voiceBroadcastId to listeners.*/
private val listeners: MutableMap<String, CopyOnWriteArrayList<Listener>> = mutableMapOf()
override fun playOrResume(roomId: String, voiceBroadcastId: String) {
val hasChanged = currentVoiceBroadcastId != voiceBroadcastId
override fun playOrResume(voiceBroadcast: VoiceBroadcast) {
val hasChanged = currentVoiceBroadcast != voiceBroadcast
when {
hasChanged -> startPlayback(roomId, voiceBroadcastId)
hasChanged -> startPlayback(voiceBroadcast)
playingState == State.PAUSED -> resumePlayback()
else -> Unit
}
}
override fun pause() {
currentMediaPlayer?.pause()
currentVoiceBroadcastId?.let { playbackTracker.pausePlayback(it) }
playingState = State.PAUSED
pausePlayback()
}
override fun stop() {
// Stop playback
currentMediaPlayer?.stop()
currentVoiceBroadcastId?.let { playbackTracker.stopPlayback(it) }
isLive = false
// Release current player
release(currentMediaPlayer)
currentMediaPlayer = null
// Release next player
release(nextMediaPlayer)
nextMediaPlayer = null
// Do not observe anymore voice broadcast state changes
voiceBroadcastStateJob?.cancel()
voiceBroadcastStateJob = null
// Do not fetch the playlist anymore
fetchPlaylistJob?.cancel()
fetchPlaylistJob = null
// Update state
playingState = State.IDLE
// Stop and release media players
stopPlayer()
// Do not observe anymore voice broadcast changes
fetchPlaylistTask?.cancel()
fetchPlaylistTask = null
voiceBroadcastStateObserver?.cancel()
voiceBroadcastStateObserver = null
// Clear playlist
playlist = emptyList()
currentSequence = null
playlist.reset()
currentRoomId = null
currentVoiceBroadcastId = null
currentVoiceBroadcastEvent = null
currentVoiceBroadcast = null
}
override fun addListener(voiceBroadcastId: String, listener: Listener) {
listeners[voiceBroadcastId]?.add(listener) ?: run {
listeners[voiceBroadcastId] = CopyOnWriteArrayList<Listener>().apply { add(listener) }
override fun addListener(voiceBroadcast: VoiceBroadcast, listener: Listener) {
listeners[voiceBroadcast.voiceBroadcastId]?.add(listener) ?: run {
listeners[voiceBroadcast.voiceBroadcastId] = CopyOnWriteArrayList<Listener>().apply { add(listener) }
}
if (voiceBroadcastId == currentVoiceBroadcastId) listener.onStateChanged(playingState) else listener.onStateChanged(State.IDLE)
listener.onStateChanged(if (voiceBroadcast == currentVoiceBroadcast) playingState else State.IDLE)
}
override fun removeListener(voiceBroadcastId: String, listener: Listener) {
listeners[voiceBroadcastId]?.remove(listener)
override fun removeListener(voiceBroadcast: VoiceBroadcast, listener: Listener) {
listeners[voiceBroadcast.voiceBroadcastId]?.remove(listener)
}
private fun startPlayback(roomId: String, eventId: String) {
private fun startPlayback(voiceBroadcast: VoiceBroadcast) {
// Stop listening previous voice broadcast if any
if (playingState != State.IDLE) stop()
currentRoomId = roomId
currentVoiceBroadcastId = eventId
currentVoiceBroadcast = voiceBroadcast
playingState = State.BUFFERING
val voiceBroadcastState = getVoiceBroadcastUseCase.execute(roomId, eventId)?.content?.voiceBroadcastState
isLive = voiceBroadcastState != null && voiceBroadcastState != VoiceBroadcastState.STOPPED
fetchPlaylistAndStartPlayback(roomId, eventId)
observeVoiceBroadcastLiveState(voiceBroadcast)
fetchPlaylistAndStartPlayback(voiceBroadcast)
}
private fun fetchPlaylistAndStartPlayback(roomId: String, voiceBroadcastId: String) {
fetchPlaylistJob = getLiveVoiceBroadcastChunksUseCase.execute(roomId, voiceBroadcastId)
.onEach(this::updatePlaylist)
.launchIn(coroutineScope)
private fun observeVoiceBroadcastLiveState(voiceBroadcast: VoiceBroadcast) {
voiceBroadcastStateObserver = getVoiceBroadcastEventUseCase.execute(voiceBroadcast)
.onEach { currentVoiceBroadcastEvent = it.getOrNull() }
.launchIn(sessionScope)
}
private fun updatePlaylist(audioEvents: List<MessageAudioEvent>) {
val sorted = audioEvents.sortedBy { it.sequence?.toLong() ?: it.root.originServerTs }
val chunkPositions = sorted
.map { it.duration }
.runningFold(0) { acc, i -> acc + i }
.dropLast(1)
playlist = sorted.mapIndexed { index, messageAudioEvent ->
PlaylistItem(
audioEvent = messageAudioEvent,
startTime = chunkPositions.getOrNull(index) ?: 0
)
}
onPlaylistUpdated()
private fun fetchPlaylistAndStartPlayback(voiceBroadcast: VoiceBroadcast) {
fetchPlaylistTask = getLiveVoiceBroadcastChunksUseCase.execute(voiceBroadcast)
.onEach {
playlist.setItems(it)
onPlaylistUpdated()
}
.launchIn(sessionScope)
}
private fun onPlaylistUpdated() {
when (playingState) {
State.PLAYING -> {
if (nextMediaPlayer == null) {
coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() }
if (nextMediaPlayer == null && !isPreparingNextPlayer) {
prepareNextMediaPlayer()
}
}
State.PAUSED -> {
if (nextMediaPlayer == null) {
coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() }
if (nextMediaPlayer == null && !isPreparingNextPlayer) {
prepareNextMediaPlayer()
}
}
State.BUFFERING -> {
val newMediaContent = getNextAudioContent()
if (newMediaContent != null) startPlayback()
val nextItem = playlist.getNextItem()
if (nextItem != null) {
val savedPosition = currentVoiceBroadcast?.let { playbackTracker.getPlaybackTime(it.voiceBroadcastId) }
startPlayback(savedPosition?.takeIf { it > 0 })
}
}
State.IDLE -> {
val savedPosition = currentVoiceBroadcast?.let { playbackTracker.getPlaybackTime(it.voiceBroadcastId) }
startPlayback(savedPosition?.takeIf { it > 0 })
}
State.IDLE -> startPlayback()
}
}
private fun startPlayback(sequence: Int? = null, position: Int = 0) {
private fun startPlayback(position: Int? = null) {
stopPlayer()
val playlistItem = when {
sequence != null -> playlist.find { it.audioEvent.sequence == sequence }
isLive -> playlist.lastOrNull()
position != null -> playlist.findByPosition(position)
currentVoiceBroadcastEvent?.isLive.orFalse() -> playlist.lastOrNull()
else -> playlist.firstOrNull()
}
val content = playlistItem?.audioEvent?.content ?: run { Timber.w("## VoiceBroadcastPlayer: No content to play"); return }
val computedSequence = playlistItem.audioEvent.sequence
coroutineScope.launch {
val sequence = playlistItem.audioEvent.sequence ?: run { Timber.w("## VoiceBroadcastPlayer: playlist item has no sequence"); return }
val sequencePosition = position?.let { it - playlistItem.startTime } ?: 0
sessionScope.launch {
try {
currentMediaPlayer = prepareMediaPlayer(content)
currentMediaPlayer?.start()
if (position > 0) {
currentMediaPlayer?.seekTo(position)
prepareMediaPlayer(content) { mp ->
currentMediaPlayer = mp
playlist.currentSequence = sequence
mp.start()
if (sequencePosition > 0) {
mp.seekTo(sequencePosition)
}
playingState = State.PLAYING
prepareNextMediaPlayer()
}
currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) }
currentSequence = computedSequence
withContext(Dispatchers.Main) { playingState = State.PLAYING }
nextMediaPlayer = prepareNextMediaPlayer()
} catch (failure: Throwable) {
Timber.e(failure, "Unable to start playback")
throw VoiceFailure.UnableToPlay(failure)
@ -229,41 +211,59 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
}
}
private fun pausePlayback(positionMillis: Int? = null) {
if (positionMillis == null) {
currentMediaPlayer?.pause()
} else {
stopPlayer()
val voiceBroadcastId = currentVoiceBroadcast?.voiceBroadcastId
val duration = playlist.duration.takeIf { it > 0 }
if (voiceBroadcastId != null && duration != null) {
playbackTracker.updatePausedAtPlaybackTime(voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration)
}
}
playingState = State.PAUSED
}
private fun resumePlayback() {
currentMediaPlayer?.start()
currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) }
playingState = State.PLAYING
if (currentMediaPlayer != null) {
currentMediaPlayer?.start()
playingState = State.PLAYING
} else {
val position = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) }
startPlayback(position)
}
}
override fun seekTo(positionMillis: Int) {
val duration = getVoiceBroadcastDuration()
val playlistItem = playlist.lastOrNull { it.startTime <= positionMillis } ?: return
val audioEvent = playlistItem.audioEvent
val eventPosition = positionMillis - playlistItem.startTime
Timber.d("## Voice Broadcast | seekTo - duration=$duration, position=$positionMillis, sequence=${audioEvent.sequence}, sequencePosition=$eventPosition")
tryOrNull { currentMediaPlayer?.stop() }
release(currentMediaPlayer)
tryOrNull { nextMediaPlayer?.stop() }
release(nextMediaPlayer)
startPlayback(audioEvent.sequence, eventPosition)
override fun seekTo(voiceBroadcast: VoiceBroadcast, positionMillis: Int, duration: Int) {
when {
voiceBroadcast != currentVoiceBroadcast -> {
playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration)
}
playingState == State.PLAYING || playingState == State.BUFFERING -> {
startPlayback(positionMillis)
}
playingState == State.IDLE || playingState == State.PAUSED -> {
pausePlayback(positionMillis)
}
}
}
private fun getNextAudioContent(): MessageAudioContent? {
val nextSequence = currentSequence?.plus(1)
?: playlist.lastOrNull()?.audioEvent?.sequence
?: 1
return playlist.find { it.audioEvent.sequence == nextSequence }?.audioEvent?.content
private fun prepareNextMediaPlayer() {
val nextItem = playlist.getNextItem()
if (nextItem != null) {
isPreparingNextPlayer = true
sessionScope.launch {
prepareMediaPlayer(nextItem.audioEvent.content) { mp ->
nextMediaPlayer = mp
currentMediaPlayer?.setNextMediaPlayer(mp)
isPreparingNextPlayer = false
}
}
}
}
private suspend fun prepareNextMediaPlayer(): MediaPlayer? {
val nextContent = getNextAudioContent() ?: return null
return prepareMediaPlayer(nextContent)
}
private suspend fun prepareMediaPlayer(messageAudioContent: MessageAudioContent): MediaPlayer {
private suspend fun prepareMediaPlayer(messageAudioContent: MessageAudioContent, onPreparedListener: OnPreparedListener): MediaPlayer {
// Download can fail
val audioFile = try {
session.fileService().downloadFile(messageAudioContent)
@ -284,58 +284,76 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
setDataSource(fis.fd)
setOnInfoListener(mediaPlayerListener)
setOnErrorListener(mediaPlayerListener)
setOnPreparedListener(onPreparedListener)
setOnCompletionListener(mediaPlayerListener)
prepare()
}
}
}
private fun release(mp: MediaPlayer?) {
mp?.apply {
release()
setOnInfoListener(null)
setOnCompletionListener(null)
setOnErrorListener(null)
private fun stopPlayer() {
tryOrNull { currentMediaPlayer?.stop() }
currentMediaPlayer?.release()
currentMediaPlayer = null
nextMediaPlayer?.release()
nextMediaPlayer = null
isPreparingNextPlayer = false
}
private fun onPlayingStateChanged(playingState: State) {
// Notify state change to all the listeners attached to the current voice broadcast id
currentVoiceBroadcast?.voiceBroadcastId?.let { voiceBroadcastId ->
when (playingState) {
State.PLAYING -> playbackTicker.startPlaybackTicker(voiceBroadcastId)
State.PAUSED,
State.BUFFERING,
State.IDLE -> playbackTicker.stopPlaybackTicker(voiceBroadcastId)
}
listeners[voiceBroadcastId]?.forEach { listener -> listener.onStateChanged(playingState) }
}
}
private fun getCurrentPlaybackPosition(): Int? {
val playlistPosition = playlist.currentItem?.startTime
val computedPosition = currentMediaPlayer?.currentPosition?.let { playlistPosition?.plus(it) } ?: playlistPosition
val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) }
return computedPosition ?: savedPosition
}
private fun getCurrentPlaybackPercentage(): Float? {
val playlistPosition = playlist.currentItem?.startTime
val computedPosition = currentMediaPlayer?.currentPosition?.let { playlistPosition?.plus(it) } ?: playlistPosition
val duration = playlist.duration.takeIf { it > 0 }
val computedPercentage = if (computedPosition != null && duration != null) computedPosition.toFloat() / duration else null
val savedPercentage = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPercentage(it) }
return computedPercentage ?: savedPercentage
}
private inner class MediaPlayerListener :
MediaPlayer.OnInfoListener,
MediaPlayer.OnPreparedListener,
MediaPlayer.OnCompletionListener,
MediaPlayer.OnErrorListener {
override fun onInfo(mp: MediaPlayer, what: Int, extra: Int): Boolean {
when (what) {
MediaPlayer.MEDIA_INFO_STARTED_AS_NEXT -> {
release(currentMediaPlayer)
playlist.currentSequence = playlist.currentSequence?.inc()
currentMediaPlayer = mp
currentSequence = currentSequence?.plus(1)
coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() }
nextMediaPlayer = null
playingState = State.PLAYING
prepareNextMediaPlayer()
}
}
return false
}
override fun onPrepared(mp: MediaPlayer) {
when (mp) {
currentMediaPlayer -> {
nextMediaPlayer?.let { mp.setNextMediaPlayer(it) }
}
nextMediaPlayer -> {
tryOrNull { currentMediaPlayer?.setNextMediaPlayer(mp) }
}
}
}
override fun onCompletion(mp: MediaPlayer) {
if (nextMediaPlayer != null) return
val roomId = currentRoomId ?: return
val voiceBroadcastId = currentVoiceBroadcastId ?: return
val voiceBroadcastEventContent = getVoiceBroadcastUseCase.execute(roomId, voiceBroadcastId)?.content ?: return
isLive = voiceBroadcastEventContent.voiceBroadcastState != null && voiceBroadcastEventContent.voiceBroadcastState != VoiceBroadcastState.STOPPED
if (!isLive && voiceBroadcastEventContent.lastChunkSequence == currentSequence) {
val content = currentVoiceBroadcastEvent?.content
val isLive = content?.isLive.orFalse()
if (!isLive && content?.lastChunkSequence == playlist.currentSequence) {
// We'll not receive new chunks anymore so we can stop the live listening
stop()
} else {
@ -349,7 +367,48 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
}
}
private fun getVoiceBroadcastDuration() = playlist.lastOrNull()?.let { it.startTime + it.audioEvent.duration } ?: 0
private inner class PlaybackTicker(
private var playbackTicker: CountUpTimer? = null,
) {
private data class PlaylistItem(val audioEvent: MessageAudioEvent, val startTime: Int)
fun startPlaybackTicker(id: String) {
playbackTicker?.stop()
playbackTicker = CountUpTimer(50L).apply {
tickListener = CountUpTimer.TickListener { onPlaybackTick(id) }
resume()
}
onPlaybackTick(id)
}
fun stopPlaybackTicker(id: String) {
playbackTicker?.stop()
playbackTicker = null
onPlaybackTick(id)
}
private fun onPlaybackTick(id: String) {
val playbackTime = getCurrentPlaybackPosition()
val percentage = getCurrentPlaybackPercentage()
when (playingState) {
State.PLAYING -> {
if (playbackTime != null && percentage != null) {
playbackTracker.updatePlayingAtPlaybackTime(id, playbackTime, percentage)
}
}
State.PAUSED,
State.BUFFERING -> {
if (playbackTime != null && percentage != null) {
playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage)
}
}
State.IDLE -> {
if (playbackTime == null || percentage == null || (playlist.duration - playbackTime) < 50) {
playbackTracker.stopPlayback(id)
} else {
playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage)
}
}
}
}
}
}

View file

@ -0,0 +1,67 @@
/*
* 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.voicebroadcast.listening
import im.vector.app.features.voicebroadcast.duration
import im.vector.app.features.voicebroadcast.sequence
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent
class VoiceBroadcastPlaylist(
private val items: MutableList<PlaylistItem> = mutableListOf(),
) : List<PlaylistItem> by items {
var currentSequence: Int? = null
val currentItem get() = currentSequence?.let { findBySequence(it) }
val duration
get() = items.lastOrNull()?.let { it.startTime + it.audioEvent.duration } ?: 0
fun setItems(audioEvents: List<MessageAudioEvent>) {
items.clear()
val sorted = audioEvents.sortedBy { it.sequence?.toLong() ?: it.root.originServerTs }
val chunkPositions = sorted
.map { it.duration }
.runningFold(0) { acc, i -> acc + i }
.dropLast(1)
val newItems = sorted.mapIndexed { index, messageAudioEvent ->
PlaylistItem(
audioEvent = messageAudioEvent,
startTime = chunkPositions.getOrNull(index) ?: 0
)
}
items.addAll(newItems)
}
fun reset() {
currentSequence = null
items.clear()
}
fun findByPosition(positionMillis: Int): PlaylistItem? {
return items.lastOrNull { it.startTime <= positionMillis }
}
fun findBySequence(sequenceNumber: Int): PlaylistItem? {
return items.find { it.audioEvent.sequence == sequenceNumber }
}
fun getNextItem() = findBySequence(currentSequence?.plus(1) ?: 1)
fun firstOrNull() = findBySequence(1)
}
data class PlaylistItem(val audioEvent: MessageAudioEvent, val startTime: Int)

View file

@ -19,18 +19,21 @@ package im.vector.app.features.voicebroadcast.listening.usecase
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.features.voicebroadcast.getVoiceBroadcastEventId
import im.vector.app.features.voicebroadcast.isVoiceBroadcast
import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.sequence
import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastUseCase
import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastEventUseCase
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.runningReduce
import kotlinx.coroutines.runBlocking
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent
import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
@ -44,19 +47,19 @@ import javax.inject.Inject
*/
class GetLiveVoiceBroadcastChunksUseCase @Inject constructor(
private val activeSessionHolder: ActiveSessionHolder,
private val getVoiceBroadcastUseCase: GetVoiceBroadcastUseCase,
private val getVoiceBroadcastEventUseCase: GetVoiceBroadcastEventUseCase,
) {
fun execute(roomId: String, voiceBroadcastId: String): Flow<List<MessageAudioEvent>> {
fun execute(voiceBroadcast: VoiceBroadcast): Flow<List<MessageAudioEvent>> {
val session = activeSessionHolder.getSafeActiveSession() ?: return emptyFlow()
val room = session.roomService().getRoom(roomId) ?: return emptyFlow()
val room = session.roomService().getRoom(voiceBroadcast.roomId) ?: return emptyFlow()
val timeline = room.timelineService().createTimeline(null, TimelineSettings(5))
// Get initial chunks
val existingChunks = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, voiceBroadcastId)
val existingChunks = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, voiceBroadcast.voiceBroadcastId)
.mapNotNull { timelineEvent -> timelineEvent.root.asMessageAudioEvent().takeIf { it.isVoiceBroadcast() } }
val voiceBroadcastEvent = getVoiceBroadcastUseCase.execute(roomId, voiceBroadcastId)
val voiceBroadcastEvent = runBlocking { getVoiceBroadcastEventUseCase.execute(voiceBroadcast).firstOrNull()?.getOrNull() }
val voiceBroadcastState = voiceBroadcastEvent?.content?.voiceBroadcastState
return if (voiceBroadcastState == null || voiceBroadcastState == VoiceBroadcastState.STOPPED) {
@ -82,7 +85,7 @@ class GetLiveVoiceBroadcastChunksUseCase @Inject constructor(
lastSequence = stopEvent.content?.lastChunkSequence
}
val newChunks = newEvents.mapToChunkEvents(voiceBroadcastId, voiceBroadcastEvent.root.senderId)
val newChunks = newEvents.mapToChunkEvents(voiceBroadcast.voiceBroadcastId, voiceBroadcastEvent.root.senderId)
// Notify about new chunks
if (newChunks.isNotEmpty()) {

View file

@ -0,0 +1,22 @@
/*
* 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.voicebroadcast.model
data class VoiceBroadcast(
val voiceBroadcastId: String,
val roomId: String,
)

View file

@ -0,0 +1,67 @@
/*
* 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.voicebroadcast.usecase
import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional
import org.matrix.android.sdk.flow.flow
import org.matrix.android.sdk.flow.unwrap
import timber.log.Timber
import javax.inject.Inject
class GetVoiceBroadcastEventUseCase @Inject constructor(
private val session: Session,
) {
fun execute(voiceBroadcast: VoiceBroadcast): Flow<Optional<VoiceBroadcastEvent>> {
val room = session.getRoom(voiceBroadcast.roomId) ?: error("Unknown roomId: ${voiceBroadcast.roomId}")
Timber.d("## GetVoiceBroadcastUseCase: get voice broadcast $voiceBroadcast")
val initialEvent = room.timelineService().getTimelineEvent(voiceBroadcast.voiceBroadcastId)?.root?.asVoiceBroadcastEvent()
val latestEvent = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, voiceBroadcast.voiceBroadcastId)
.mapNotNull { it.root.asVoiceBroadcastEvent() }
.maxByOrNull { it.root.originServerTs ?: 0 }
?: initialEvent
return when (latestEvent?.content?.voiceBroadcastState) {
null, VoiceBroadcastState.STOPPED -> flowOf(latestEvent.toOptional())
else -> {
room.flow()
.liveStateEvent(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, QueryStringValue.Equals(latestEvent.root.stateKey.orEmpty()))
.unwrap()
.mapNotNull { it.asVoiceBroadcastEvent() }
.filter { it.reference?.eventId == voiceBroadcast.voiceBroadcastId }
.map { it.toOptional() }
}
}
}
}

View file

@ -1,40 +0,0 @@
/*
* 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.voicebroadcast.usecase
import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.getRoom
import timber.log.Timber
import javax.inject.Inject
class GetVoiceBroadcastUseCase @Inject constructor(
private val session: Session,
) {
fun execute(roomId: String, eventId: String): VoiceBroadcastEvent? {
val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId")
Timber.d("## GetVoiceBroadcastUseCase: get voice broadcast $eventId")
val initialEvent = room.timelineService().getTimelineEvent(eventId)?.root?.asVoiceBroadcastEvent() // Fallback to initial event
val relatedEvents = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, eventId).sortedBy { it.root.originServerTs }
return relatedEvents.mapNotNull { it.root.asVoiceBroadcastEvent() }.lastOrNull() ?: initialEvent
}
}

View file

@ -100,10 +100,12 @@
android:id="@+id/fastBackwardButton"
android:layout_width="24dp"
android:layout_height="24dp"
android:background="@android:color/transparent"
android:background="@drawable/bg_rounded_button"
android:contentDescription="@string/a11y_voice_broadcast_fast_backward"
android:src="@drawable/ic_player_backward_30"
app:tint="?vctr_content_secondary" />
android:visibility="invisible"
app:tint="?vctr_content_secondary"
tools:visibility="visible" />
<ImageButton
android:id="@+id/playPauseButton"
@ -121,16 +123,20 @@
android:layout_height="@dimen/voice_broadcast_player_button_size"
android:contentDescription="@string/a11y_voice_broadcast_buffering"
android:indeterminate="true"
android:indeterminateTint="?vctr_content_secondary" />
android:indeterminateTint="?vctr_content_secondary"
android:visibility="gone"
tools:visibility="visible" />
<ImageButton
android:id="@+id/fastForwardButton"
android:layout_width="24dp"
android:layout_height="24dp"
android:background="@android:color/transparent"
android:background="@drawable/bg_rounded_button"
android:contentDescription="@string/a11y_voice_broadcast_fast_forward"
android:src="@drawable/ic_player_forward_30"
app:tint="?vctr_content_secondary" />
android:visibility="invisible"
app:tint="?vctr_content_secondary"
tools:visibility="visible" />
<SeekBar
android:id="@+id/seekBar"

View file

@ -0,0 +1,90 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.core.notification
import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus
import im.vector.app.test.fakes.FakeGetNotificationsStatusUseCase
import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fakes.FakeVectorPreferences
import kotlinx.coroutines.test.runTest
import org.junit.Test
private const val A_SESSION_ID = "session-id"
class UpdateEnableNotificationsSettingOnChangeUseCaseTest {
private val fakeSession = FakeSession().also { it.givenSessionId(A_SESSION_ID) }
private val fakeVectorPreferences = FakeVectorPreferences()
private val fakeGetNotificationsStatusUseCase = FakeGetNotificationsStatusUseCase()
private val updateEnableNotificationsSettingOnChangeUseCase = UpdateEnableNotificationsSettingOnChangeUseCase(
vectorPreferences = fakeVectorPreferences.instance,
getNotificationsStatusUseCase = fakeGetNotificationsStatusUseCase.instance,
)
@Test
fun `given notifications are enabled when execute then setting is updated to true`() = runTest {
// Given
fakeGetNotificationsStatusUseCase.givenExecuteReturns(
fakeSession,
A_SESSION_ID,
NotificationsStatus.ENABLED,
)
fakeVectorPreferences.givenSetNotificationEnabledForDevice()
// When
updateEnableNotificationsSettingOnChangeUseCase.execute(fakeSession)
// Then
fakeVectorPreferences.verifySetNotificationEnabledForDevice(true)
}
@Test
fun `given notifications are disabled when execute then setting is updated to false`() = runTest {
// Given
fakeGetNotificationsStatusUseCase.givenExecuteReturns(
fakeSession,
A_SESSION_ID,
NotificationsStatus.DISABLED,
)
fakeVectorPreferences.givenSetNotificationEnabledForDevice()
// When
updateEnableNotificationsSettingOnChangeUseCase.execute(fakeSession)
// Then
fakeVectorPreferences.verifySetNotificationEnabledForDevice(false)
}
@Test
fun `given notifications toggle is not supported when execute then nothing is done`() = runTest {
// Given
fakeGetNotificationsStatusUseCase.givenExecuteReturns(
fakeSession,
A_SESSION_ID,
NotificationsStatus.NOT_SUPPORTED,
)
fakeVectorPreferences.givenSetNotificationEnabledForDevice()
// When
updateEnableNotificationsSettingOnChangeUseCase.execute(fakeSession)
// Then
fakeVectorPreferences.verifySetNotificationEnabledForDevice(true, inverse = true)
fakeVectorPreferences.verifySetNotificationEnabledForDevice(false, inverse = true)
}
}

View file

@ -29,7 +29,6 @@ import im.vector.app.test.fixtures.CryptoDeviceInfoFixture.aCryptoDeviceInfo
import im.vector.app.test.fixtures.PusherFixture
import im.vector.app.test.fixtures.SessionParamsFixture
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.matrix.android.sdk.api.session.crypto.model.UnsignedDeviceInfo
@ -101,19 +100,4 @@ class PushersManagerTest {
pusher shouldBeEqualTo expectedPusher
}
@Test
fun `when togglePusherForCurrentSession, then do service toggle pusher`() = runTest {
val deviceId = "device_id"
val sessionParams = SessionParamsFixture.aSessionParams(
credentials = CredentialsFixture.aCredentials(deviceId = deviceId)
)
session.givenSessionParams(sessionParams)
val pusher = PusherFixture.aPusher(deviceId = deviceId)
pushersService.givenGetPushers(listOf(pusher))
pushersManager.togglePusherForCurrentSession(true)
pushersService.verifyTogglePusherCalled(pusher, true)
}
}

View file

@ -19,6 +19,7 @@ package im.vector.app.core.session
import im.vector.app.core.extensions.startSyncing
import im.vector.app.core.session.clientinfo.UpdateMatrixClientInfoUseCase
import im.vector.app.test.fakes.FakeContext
import im.vector.app.test.fakes.FakeEnableNotificationsSettingUpdater
import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fakes.FakeVectorPreferences
import im.vector.app.test.fakes.FakeWebRtcCallManager
@ -43,12 +44,14 @@ class ConfigureAndStartSessionUseCaseTest {
private val fakeWebRtcCallManager = FakeWebRtcCallManager()
private val fakeUpdateMatrixClientInfoUseCase = mockk<UpdateMatrixClientInfoUseCase>()
private val fakeVectorPreferences = FakeVectorPreferences()
private val fakeEnableNotificationsSettingUpdater = FakeEnableNotificationsSettingUpdater()
private val configureAndStartSessionUseCase = ConfigureAndStartSessionUseCase(
context = fakeContext.instance,
webRtcCallManager = fakeWebRtcCallManager.instance,
updateMatrixClientInfoUseCase = fakeUpdateMatrixClientInfoUseCase,
vectorPreferences = fakeVectorPreferences.instance,
enableNotificationsSettingUpdater = fakeEnableNotificationsSettingUpdater.instance,
)
@Before
@ -68,6 +71,7 @@ class ConfigureAndStartSessionUseCaseTest {
fakeWebRtcCallManager.givenCheckForProtocolsSupportIfNeededSucceeds()
coJustRun { fakeUpdateMatrixClientInfoUseCase.execute(any()) }
fakeVectorPreferences.givenIsClientInfoRecordingEnabled(isEnabled = true)
fakeEnableNotificationsSettingUpdater.givenOnSessionsStarted(fakeSession)
// When
configureAndStartSessionUseCase.execute(fakeSession, startSyncing = true)
@ -87,6 +91,7 @@ class ConfigureAndStartSessionUseCaseTest {
fakeWebRtcCallManager.givenCheckForProtocolsSupportIfNeededSucceeds()
coJustRun { fakeUpdateMatrixClientInfoUseCase.execute(any()) }
fakeVectorPreferences.givenIsClientInfoRecordingEnabled(isEnabled = false)
fakeEnableNotificationsSettingUpdater.givenOnSessionsStarted(fakeSession)
// When
configureAndStartSessionUseCase.execute(fakeSession, startSyncing = true)
@ -106,6 +111,7 @@ class ConfigureAndStartSessionUseCaseTest {
fakeWebRtcCallManager.givenCheckForProtocolsSupportIfNeededSucceeds()
coJustRun { fakeUpdateMatrixClientInfoUseCase.execute(any()) }
fakeVectorPreferences.givenIsClientInfoRecordingEnabled(isEnabled = true)
fakeEnableNotificationsSettingUpdater.givenOnSessionsStarted(fakeSession)
// When
configureAndStartSessionUseCase.execute(fakeSession, startSyncing = false)

View file

@ -0,0 +1,65 @@
/*
* 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.settings.devices.v2.notification
import im.vector.app.test.fakes.FakeFlowLiveDataConversions
import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fakes.givenAsFlow
import im.vector.app.test.fixtures.aHomeServerCapabilities
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Before
import org.junit.Test
private val A_HOMESERVER_CAPABILITIES = aHomeServerCapabilities(canRemotelyTogglePushNotificationsOfDevices = true)
class CanTogglePushNotificationsViaPusherUseCaseTest {
private val fakeSession = FakeSession()
private val fakeFlowLiveDataConversions = FakeFlowLiveDataConversions()
private val canTogglePushNotificationsViaPusherUseCase =
CanTogglePushNotificationsViaPusherUseCase()
@Before
fun setUp() {
fakeFlowLiveDataConversions.setup()
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given current session when execute then flow of the toggle capability is returned`() = runTest {
// Given
fakeSession
.fakeHomeServerCapabilitiesService
.givenCapabilitiesLiveReturns(A_HOMESERVER_CAPABILITIES)
.givenAsFlow()
// When
val result = canTogglePushNotificationsViaPusherUseCase.execute(fakeSession).firstOrNull()
// Then
result shouldBeEqualTo A_HOMESERVER_CAPABILITIES.canRemotelyTogglePushNotificationsOfDevices
}
}

View file

@ -16,7 +16,7 @@
package im.vector.app.features.settings.devices.v2.notification
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakeSession
import io.mockk.mockk
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
@ -26,18 +26,15 @@ private const val A_DEVICE_ID = "device-id"
class CheckIfCanTogglePushNotificationsViaAccountDataUseCaseTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val fakeSession = FakeSession()
private val checkIfCanTogglePushNotificationsViaAccountDataUseCase =
CheckIfCanTogglePushNotificationsViaAccountDataUseCase(
activeSessionHolder = fakeActiveSessionHolder.instance,
)
CheckIfCanTogglePushNotificationsViaAccountDataUseCase()
@Test
fun `given current session and an account data for the device id when execute then result is true`() {
// Given
fakeActiveSessionHolder
.fakeSession
fakeSession
.accountDataService()
.givenGetUserAccountDataEventReturns(
type = UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + A_DEVICE_ID,
@ -45,7 +42,7 @@ class CheckIfCanTogglePushNotificationsViaAccountDataUseCaseTest {
)
// When
val result = checkIfCanTogglePushNotificationsViaAccountDataUseCase.execute(A_DEVICE_ID)
val result = checkIfCanTogglePushNotificationsViaAccountDataUseCase.execute(fakeSession, A_DEVICE_ID)
// Then
result shouldBeEqualTo true
@ -54,8 +51,7 @@ class CheckIfCanTogglePushNotificationsViaAccountDataUseCaseTest {
@Test
fun `given current session and NO account data for the device id when execute then result is false`() {
// Given
fakeActiveSessionHolder
.fakeSession
fakeSession
.accountDataService()
.givenGetUserAccountDataEventReturns(
type = UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + A_DEVICE_ID,
@ -63,7 +59,7 @@ class CheckIfCanTogglePushNotificationsViaAccountDataUseCaseTest {
)
// When
val result = checkIfCanTogglePushNotificationsViaAccountDataUseCase.execute(A_DEVICE_ID)
val result = checkIfCanTogglePushNotificationsViaAccountDataUseCase.execute(fakeSession, A_DEVICE_ID)
// Then
result shouldBeEqualTo false

View file

@ -16,7 +16,7 @@
package im.vector.app.features.settings.devices.v2.notification
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fixtures.aHomeServerCapabilities
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
@ -25,37 +25,22 @@ private val A_HOMESERVER_CAPABILITIES = aHomeServerCapabilities(canRemotelyToggl
class CheckIfCanTogglePushNotificationsViaPusherUseCaseTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val fakeSession = FakeSession()
private val checkIfCanTogglePushNotificationsViaPusherUseCase =
CheckIfCanTogglePushNotificationsViaPusherUseCase(
activeSessionHolder = fakeActiveSessionHolder.instance,
)
CheckIfCanTogglePushNotificationsViaPusherUseCase()
@Test
fun `given current session when execute then toggle capability is returned`() {
// Given
fakeActiveSessionHolder
.fakeSession
fakeSession
.fakeHomeServerCapabilitiesService
.givenCapabilities(A_HOMESERVER_CAPABILITIES)
// When
val result = checkIfCanTogglePushNotificationsViaPusherUseCase.execute()
val result = checkIfCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession)
// Then
result shouldBeEqualTo A_HOMESERVER_CAPABILITIES.canRemotelyTogglePushNotificationsOfDevices
}
@Test
fun `given no current session when execute then false is returned`() {
// Given
fakeActiveSessionHolder.givenGetSafeActiveSessionReturns(null)
// When
val result = checkIfCanTogglePushNotificationsViaPusherUseCase.execute()
// Then
result shouldBeEqualTo false
}
}

View file

@ -17,7 +17,7 @@
package im.vector.app.features.settings.devices.v2.notification
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fixtures.PusherFixture
import im.vector.app.test.testDispatcher
import io.mockk.every
@ -25,6 +25,7 @@ import io.mockk.mockk
import io.mockk.verifyOrder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
@ -44,17 +45,16 @@ class GetNotificationsStatusUseCaseTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val fakeCheckIfCanTogglePushNotificationsViaPusherUseCase =
mockk<CheckIfCanTogglePushNotificationsViaPusherUseCase>()
private val fakeSession = FakeSession()
private val fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase =
mockk<CheckIfCanTogglePushNotificationsViaAccountDataUseCase>()
private val fakeCanTogglePushNotificationsViaPusherUseCase =
mockk<CanTogglePushNotificationsViaPusherUseCase>()
private val getNotificationsStatusUseCase =
GetNotificationsStatusUseCase(
activeSessionHolder = fakeActiveSessionHolder.instance,
checkIfCanTogglePushNotificationsViaPusherUseCase = fakeCheckIfCanTogglePushNotificationsViaPusherUseCase,
checkIfCanTogglePushNotificationsViaAccountDataUseCase = fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase,
canTogglePushNotificationsViaPusherUseCase = fakeCanTogglePushNotificationsViaPusherUseCase,
)
@Before
@ -67,33 +67,21 @@ class GetNotificationsStatusUseCaseTest {
Dispatchers.resetMain()
}
@Test
fun `given NO current session when execute then resulting flow contains NOT_SUPPORTED value`() = runTest {
// Given
fakeActiveSessionHolder.givenGetSafeActiveSessionReturns(null)
// When
val result = getNotificationsStatusUseCase.execute(A_DEVICE_ID)
// Then
result.firstOrNull() shouldBeEqualTo NotificationsStatus.NOT_SUPPORTED
}
@Test
fun `given current session and toggle is not supported when execute then resulting flow contains NOT_SUPPORTED value`() = runTest {
// Given
every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute() } returns false
every { fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(A_DEVICE_ID) } returns false
every { fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(fakeSession, A_DEVICE_ID) } returns false
every { fakeCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession) } returns flowOf(false)
// When
val result = getNotificationsStatusUseCase.execute(A_DEVICE_ID)
val result = getNotificationsStatusUseCase.execute(fakeSession, A_DEVICE_ID)
// Then
result.firstOrNull() shouldBeEqualTo NotificationsStatus.NOT_SUPPORTED
verifyOrder {
// we should first check account data
fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(A_DEVICE_ID)
fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute()
fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(fakeSession, A_DEVICE_ID)
fakeCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession)
}
}
@ -106,12 +94,12 @@ class GetNotificationsStatusUseCaseTest {
enabled = true,
)
)
fakeActiveSessionHolder.fakeSession.pushersService().givenPushersLive(pushers)
every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute() } returns true
every { fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(A_DEVICE_ID) } returns false
fakeSession.pushersService().givenPushersLive(pushers)
every { fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(fakeSession, A_DEVICE_ID) } returns false
every { fakeCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession) } returns flowOf(true)
// When
val result = getNotificationsStatusUseCase.execute(A_DEVICE_ID)
val result = getNotificationsStatusUseCase.execute(fakeSession, A_DEVICE_ID)
// Then
result.firstOrNull() shouldBeEqualTo NotificationsStatus.ENABLED
@ -120,8 +108,7 @@ class GetNotificationsStatusUseCaseTest {
@Test
fun `given current session and toggle via account data is supported when execute then resulting flow contains status based on settings value`() = runTest {
// Given
fakeActiveSessionHolder
.fakeSession
fakeSession
.accountDataService()
.givenGetUserAccountDataEventReturns(
type = UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + A_DEVICE_ID,
@ -129,11 +116,11 @@ class GetNotificationsStatusUseCaseTest {
isSilenced = false
).toContent(),
)
every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute() } returns false
every { fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(A_DEVICE_ID) } returns true
every { fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(fakeSession, A_DEVICE_ID) } returns true
every { fakeCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession) } returns flowOf(false)
// When
val result = getNotificationsStatusUseCase.execute(A_DEVICE_ID)
val result = getNotificationsStatusUseCase.execute(fakeSession, A_DEVICE_ID)
// Then
result.firstOrNull() shouldBeEqualTo NotificationsStatus.ENABLED

View file

@ -49,10 +49,11 @@ class TogglePushNotificationUseCaseTest {
PusherFixture.aPusher(deviceId = sessionId, enabled = false),
PusherFixture.aPusher(deviceId = "another id", enabled = false)
)
activeSessionHolder.fakeSession.pushersService().givenPushersLive(pushers)
activeSessionHolder.fakeSession.pushersService().givenGetPushers(pushers)
every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute() } returns true
every { fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(sessionId) } returns false
val fakeSession = activeSessionHolder.fakeSession
fakeSession.pushersService().givenPushersLive(pushers)
fakeSession.pushersService().givenGetPushers(pushers)
every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession) } returns true
every { fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(fakeSession, sessionId) } returns false
// When
togglePushNotificationUseCase.execute(sessionId, true)
@ -69,13 +70,14 @@ class TogglePushNotificationUseCaseTest {
PusherFixture.aPusher(deviceId = sessionId, enabled = false),
PusherFixture.aPusher(deviceId = "another id", enabled = false)
)
activeSessionHolder.fakeSession.pushersService().givenPushersLive(pushers)
activeSessionHolder.fakeSession.accountDataService().givenGetUserAccountDataEventReturns(
val fakeSession = activeSessionHolder.fakeSession
fakeSession.pushersService().givenPushersLive(pushers)
fakeSession.accountDataService().givenGetUserAccountDataEventReturns(
UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + sessionId,
LocalNotificationSettingsContent(isSilenced = true).toContent()
)
every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute() } returns false
every { fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(sessionId) } returns true
every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession) } returns false
every { fakeCheckIfCanTogglePushNotificationsViaAccountDataUseCase.execute(fakeSession, sessionId) } returns true
// When
togglePushNotificationUseCase.execute(sessionId, true)

View file

@ -22,11 +22,11 @@ import com.airbnb.mvrx.Success
import com.airbnb.mvrx.test.MavericksTestRule
import im.vector.app.features.settings.devices.v2.DeviceFullInfo
import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase
import im.vector.app.features.settings.devices.v2.notification.GetNotificationsStatusUseCase
import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus
import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase
import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakeGetNotificationsStatusUseCase
import im.vector.app.test.fakes.FakePendingAuthHandler
import im.vector.app.test.fakes.FakeSharedPreferences
import im.vector.app.test.fakes.FakeSignoutSessionsUseCase
@ -76,7 +76,7 @@ class SessionOverviewViewModelTest {
private val fakePendingAuthHandler = FakePendingAuthHandler()
private val refreshDevicesUseCase = mockk<RefreshDevicesUseCase>(relaxed = true)
private val togglePushNotificationUseCase = FakeTogglePushNotificationUseCase()
private val fakeGetNotificationsStatusUseCase = mockk<GetNotificationsStatusUseCase>()
private val fakeGetNotificationsStatusUseCase = FakeGetNotificationsStatusUseCase()
private val notificationsStatus = NotificationsStatus.ENABLED
private val fakeSharedPreferences = FakeSharedPreferences()
@ -90,7 +90,7 @@ class SessionOverviewViewModelTest {
activeSessionHolder = fakeActiveSessionHolder.instance,
refreshDevicesUseCase = refreshDevicesUseCase,
togglePushNotificationUseCase = togglePushNotificationUseCase.instance,
getNotificationsStatusUseCase = fakeGetNotificationsStatusUseCase,
getNotificationsStatusUseCase = fakeGetNotificationsStatusUseCase.instance,
sharedPreferences = fakeSharedPreferences,
)
@ -101,7 +101,11 @@ class SessionOverviewViewModelTest {
every { SystemClock.elapsedRealtime() } returns 1234
givenVerificationService()
every { fakeGetNotificationsStatusUseCase.execute(A_SESSION_ID_1) } returns flowOf(notificationsStatus)
fakeGetNotificationsStatusUseCase.givenExecuteReturns(
fakeActiveSessionHolder.fakeSession,
A_SESSION_ID_1,
notificationsStatus
)
fakeSharedPreferences.givenSessionManagerShowIpAddress(false)
}
@ -416,13 +420,10 @@ class SessionOverviewViewModelTest {
@Test
fun `when viewModel init, then observe pushers and emit to state`() {
val notificationStatus = NotificationsStatus.ENABLED
every { fakeGetNotificationsStatusUseCase.execute(A_SESSION_ID_1) } returns flowOf(notificationStatus)
val viewModel = createViewModel()
viewModel.test()
.assertLatestState { state -> state.notificationsStatus == notificationStatus }
.assertLatestState { state -> state.notificationsStatus == notificationsStatus }
.finish()
}

View file

@ -0,0 +1,78 @@
/*
* 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.settings.notifications
import im.vector.app.features.settings.devices.v2.notification.CheckIfCanTogglePushNotificationsViaPusherUseCase
import im.vector.app.features.settings.devices.v2.notification.TogglePushNotificationUseCase
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakePushersManager
import im.vector.app.test.fakes.FakeUnifiedPushHelper
import io.mockk.coJustRun
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
private const val A_SESSION_ID = "session-id"
class DisableNotificationsForCurrentSessionUseCaseTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val fakeUnifiedPushHelper = FakeUnifiedPushHelper()
private val fakePushersManager = FakePushersManager()
private val fakeCheckIfCanTogglePushNotificationsViaPusherUseCase = mockk<CheckIfCanTogglePushNotificationsViaPusherUseCase>()
private val fakeTogglePushNotificationUseCase = mockk<TogglePushNotificationUseCase>()
private val disableNotificationsForCurrentSessionUseCase = DisableNotificationsForCurrentSessionUseCase(
activeSessionHolder = fakeActiveSessionHolder.instance,
unifiedPushHelper = fakeUnifiedPushHelper.instance,
pushersManager = fakePushersManager.instance,
checkIfCanTogglePushNotificationsViaPusherUseCase = fakeCheckIfCanTogglePushNotificationsViaPusherUseCase,
togglePushNotificationUseCase = fakeTogglePushNotificationUseCase,
)
@Test
fun `given toggle via pusher is possible when execute then disable notification via toggle of existing pusher`() = runTest {
// Given
val fakeSession = fakeActiveSessionHolder.fakeSession
fakeSession.givenSessionId(A_SESSION_ID)
every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession) } returns true
coJustRun { fakeTogglePushNotificationUseCase.execute(A_SESSION_ID, any()) }
// When
disableNotificationsForCurrentSessionUseCase.execute()
// Then
coVerify { fakeTogglePushNotificationUseCase.execute(A_SESSION_ID, false) }
}
@Test
fun `given toggle via pusher is NOT possible when execute then disable notification by unregistering the pusher`() = runTest {
// Given
val fakeSession = fakeActiveSessionHolder.fakeSession
fakeSession.givenSessionId(A_SESSION_ID)
every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute(fakeSession) } returns false
fakeUnifiedPushHelper.givenUnregister(fakePushersManager.instance)
// When
disableNotificationsForCurrentSessionUseCase.execute()
// Then
fakeUnifiedPushHelper.verifyUnregister(fakePushersManager.instance)
}
}

View file

@ -0,0 +1,87 @@
/*
* 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.settings.notifications
import androidx.fragment.app.FragmentActivity
import im.vector.app.features.settings.devices.v2.notification.CheckIfCanTogglePushNotificationsViaPusherUseCase
import im.vector.app.features.settings.devices.v2.notification.TogglePushNotificationUseCase
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakeFcmHelper
import im.vector.app.test.fakes.FakePushersManager
import im.vector.app.test.fakes.FakeUnifiedPushHelper
import io.mockk.coJustRun
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
private const val A_SESSION_ID = "session-id"
class EnableNotificationsForCurrentSessionUseCaseTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val fakeUnifiedPushHelper = FakeUnifiedPushHelper()
private val fakePushersManager = FakePushersManager()
private val fakeFcmHelper = FakeFcmHelper()
private val fakeCheckIfCanTogglePushNotificationsViaPusherUseCase = mockk<CheckIfCanTogglePushNotificationsViaPusherUseCase>()
private val fakeTogglePushNotificationUseCase = mockk<TogglePushNotificationUseCase>()
private val enableNotificationsForCurrentSessionUseCase = EnableNotificationsForCurrentSessionUseCase(
activeSessionHolder = fakeActiveSessionHolder.instance,
unifiedPushHelper = fakeUnifiedPushHelper.instance,
pushersManager = fakePushersManager.instance,
fcmHelper = fakeFcmHelper.instance,
checkIfCanTogglePushNotificationsViaPusherUseCase = fakeCheckIfCanTogglePushNotificationsViaPusherUseCase,
togglePushNotificationUseCase = fakeTogglePushNotificationUseCase,
)
@Test
fun `given no existing pusher for current session when execute then a new pusher is registered`() = runTest {
// Given
val fragmentActivity = mockk<FragmentActivity>()
fakePushersManager.givenGetPusherForCurrentSessionReturns(null)
fakeUnifiedPushHelper.givenRegister(fragmentActivity)
fakeUnifiedPushHelper.givenIsEmbeddedDistributorReturns(true)
fakeFcmHelper.givenEnsureFcmTokenIsRetrieved(fragmentActivity, fakePushersManager.instance)
every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute(fakeActiveSessionHolder.fakeSession) } returns false
// When
enableNotificationsForCurrentSessionUseCase.execute(fragmentActivity)
// Then
fakeUnifiedPushHelper.verifyRegister(fragmentActivity)
fakeFcmHelper.verifyEnsureFcmTokenIsRetrieved(fragmentActivity, fakePushersManager.instance, registerPusher = true)
}
@Test
fun `given toggle via Pusher is possible when execute then current pusher is toggled to true`() = runTest {
// Given
val fragmentActivity = mockk<FragmentActivity>()
fakePushersManager.givenGetPusherForCurrentSessionReturns(mockk())
val fakeSession = fakeActiveSessionHolder.fakeSession
fakeSession.givenSessionId(A_SESSION_ID)
every { fakeCheckIfCanTogglePushNotificationsViaPusherUseCase.execute(fakeActiveSessionHolder.fakeSession) } returns true
coJustRun { fakeTogglePushNotificationUseCase.execute(A_SESSION_ID, any()) }
// When
enableNotificationsForCurrentSessionUseCase.execute(fragmentActivity)
// Then
coVerify { fakeTogglePushNotificationUseCase.execute(A_SESSION_ID, true) }
}
}

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.test.fakes
import im.vector.app.core.notification.EnableNotificationsSettingUpdater
import io.mockk.justRun
import io.mockk.mockk
import org.matrix.android.sdk.api.session.Session
class FakeEnableNotificationsSettingUpdater {
val instance = mockk<EnableNotificationsSettingUpdater>()
fun givenOnSessionsStarted(session: Session) {
justRun { instance.onSessionsStarted(session) }
}
}

View file

@ -0,0 +1,44 @@
/*
* 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.fragment.app.FragmentActivity
import im.vector.app.core.pushers.FcmHelper
import im.vector.app.core.pushers.PushersManager
import io.mockk.justRun
import io.mockk.mockk
import io.mockk.verify
class FakeFcmHelper {
val instance = mockk<FcmHelper>()
fun givenEnsureFcmTokenIsRetrieved(
fragmentActivity: FragmentActivity,
pushersManager: PushersManager,
) {
justRun { instance.ensureFcmTokenIsRetrieved(fragmentActivity, pushersManager, any()) }
}
fun verifyEnsureFcmTokenIsRetrieved(
fragmentActivity: FragmentActivity,
pushersManager: PushersManager,
registerPusher: Boolean,
) {
verify { instance.ensureFcmTokenIsRetrieved(fragmentActivity, pushersManager, registerPusher) }
}
}

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.settings.devices.v2.notification.GetNotificationsStatusUseCase
import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import org.matrix.android.sdk.api.session.Session
class FakeGetNotificationsStatusUseCase {
val instance = mockk<GetNotificationsStatusUseCase>()
fun givenExecuteReturns(
session: Session,
sessionId: String,
notificationsStatus: NotificationsStatus
) {
every { instance.execute(session, sessionId) } returns flowOf(notificationsStatus)
}
}

View file

@ -16,14 +16,24 @@
package im.vector.app.test.fakes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.mockk.every
import io.mockk.mockk
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional
class FakeHomeServerCapabilitiesService : HomeServerCapabilitiesService by mockk() {
fun givenCapabilities(homeServerCapabilities: HomeServerCapabilities) {
every { getHomeServerCapabilities() } returns homeServerCapabilities
}
fun givenCapabilitiesLiveReturns(homeServerCapabilities: HomeServerCapabilities): LiveData<Optional<HomeServerCapabilities>> {
return MutableLiveData(homeServerCapabilities.toOptional()).also {
every { getHomeServerCapabilitiesLive() } returns it
}
}
}

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.test.fakes
import im.vector.app.core.pushers.PushersManager
import io.mockk.every
import io.mockk.mockk
import org.matrix.android.sdk.api.session.pushers.Pusher
class FakePushersManager {
val instance = mockk<PushersManager>()
fun givenGetPusherForCurrentSessionReturns(pusher: Pusher?) {
every { instance.getPusherForCurrentSession() } returns pusher
}
}

View file

@ -0,0 +1,53 @@
/*
* 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.fragment.app.FragmentActivity
import im.vector.app.core.pushers.PushersManager
import im.vector.app.core.pushers.UnifiedPushHelper
import io.mockk.coJustRun
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
class FakeUnifiedPushHelper {
val instance = mockk<UnifiedPushHelper>()
fun givenRegister(fragmentActivity: FragmentActivity) {
every { instance.register(fragmentActivity, any()) } answers {
secondArg<Runnable>().run()
}
}
fun verifyRegister(fragmentActivity: FragmentActivity) {
verify { instance.register(fragmentActivity, any()) }
}
fun givenUnregister(pushersManager: PushersManager) {
coJustRun { instance.unregister(pushersManager) }
}
fun verifyUnregister(pushersManager: PushersManager) {
coVerify { instance.unregister(pushersManager) }
}
fun givenIsEmbeddedDistributorReturns(isEmbedded: Boolean) {
every { instance.isEmbeddedDistributor() } returns isEmbedded
}
}

View file

@ -18,6 +18,7 @@ package im.vector.app.test.fakes
import im.vector.app.features.settings.VectorPreferences
import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk
import io.mockk.verify
@ -42,5 +43,13 @@ class FakeVectorPreferences {
}
fun givenTextFormatting(isEnabled: Boolean) =
every { instance.isTextFormattingEnabled() } returns isEnabled
every { instance.isTextFormattingEnabled() } returns isEnabled
fun givenSetNotificationEnabledForDevice() {
justRun { instance.setNotificationEnabledForDevice(any()) }
}
fun verifySetNotificationEnabledForDevice(enabled: Boolean, inverse: Boolean = false) {
verify(inverse = inverse) { instance.setNotificationEnabledForDevice(enabled) }
}
}