Call: MSC4310 sending RTC decline event and listening for Decline from other sessions
MSC4310 RTC decline event support
This commit is contained in:
commit
2ca0afff3a
10 changed files with 214 additions and 8 deletions
|
|
@ -180,12 +180,22 @@ class DefaultActiveCallManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun hungUpCall(callType: CallType) = mutex.withLock {
|
override suspend fun hungUpCall(callType: CallType) = mutex.withLock {
|
||||||
if (activeCall.value?.callType != callType) {
|
Timber.tag(tag).d("Hung up call: $callType")
|
||||||
|
val currentActiveCall = activeCall.value ?: run {
|
||||||
|
Timber.tag(tag).w("No active call, ignoring hang up")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentActiveCall.callType != callType) {
|
||||||
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
|
Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (currentActiveCall.callState is CallState.Ringing) {
|
||||||
Timber.tag(tag).d("Hung up call: $callType")
|
// Decline the call
|
||||||
|
val notificationData = currentActiveCall.callState.notificationData
|
||||||
|
matrixClientProvider.getOrRestore(notificationData.sessionId).getOrNull()
|
||||||
|
?.getRoom(notificationData.roomId)
|
||||||
|
?.declineCall(notificationData.eventId)
|
||||||
|
}
|
||||||
|
|
||||||
cancelIncomingCallNotification()
|
cancelIncomingCallNotification()
|
||||||
if (activeWakeLock?.isHeld == true) {
|
if (activeWakeLock?.isHeld == true) {
|
||||||
|
|
@ -256,6 +266,43 @@ class DefaultActiveCallManager(
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun observeRingingCall() {
|
private fun observeRingingCall() {
|
||||||
|
activeCall
|
||||||
|
.filterNotNull()
|
||||||
|
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
|
||||||
|
.flatMapLatest { activeCall ->
|
||||||
|
val callType = activeCall.callType as CallType.RoomCall
|
||||||
|
val ringingInfo = activeCall.callState as CallState.Ringing
|
||||||
|
val client = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull() ?: run {
|
||||||
|
Timber.tag(tag).d("Couldn't find session for incoming call: $activeCall")
|
||||||
|
return@flatMapLatest flowOf()
|
||||||
|
}
|
||||||
|
val room = client.getRoom(callType.roomId) ?: run {
|
||||||
|
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
|
||||||
|
return@flatMapLatest flowOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.tag(tag).d("Found room for ringing call: ${room.roomId}")
|
||||||
|
|
||||||
|
// If we have declined from another phone we want to stop ringing.
|
||||||
|
room.subscribeToCallDecline(ringingInfo.notificationData.eventId)
|
||||||
|
.filter { decliner ->
|
||||||
|
Timber.tag(tag).d("Call: $activeCall was declined by $decliner")
|
||||||
|
// only want to listen if the call was declined from another of my sessions,
|
||||||
|
// (we are ringing for an incoming call in a DM)
|
||||||
|
decliner == client.sessionId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEach { decliner ->
|
||||||
|
Timber.tag(tag).d("Call: $activeCall was declined by user from another session")
|
||||||
|
// Remove the active call and cancel the notification
|
||||||
|
activeCall.value = null
|
||||||
|
if (activeWakeLock?.isHeld == true) {
|
||||||
|
Timber.tag(tag).d("Releasing partial wakelock after call declined from another session")
|
||||||
|
activeWakeLock.release()
|
||||||
|
}
|
||||||
|
cancelIncomingCallNotification()
|
||||||
|
}
|
||||||
|
.launchIn(coroutineScope)
|
||||||
// This will observe ringing calls and ensure they're terminated if the room call is cancelled or if the user
|
// This will observe ringing calls and ensure they're terminated if the room call is cancelled or if the user
|
||||||
// has joined the call from another session.
|
// has joined the call from another session.
|
||||||
activeCall
|
activeCall
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,16 @@ import io.element.android.features.call.test.aCallNotificationData
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||||
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
||||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||||
|
|
@ -38,6 +41,8 @@ import io.element.android.libraries.push.test.notifications.push.FakeNotificatio
|
||||||
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
|
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
import io.element.android.tests.testutils.plantTestTimber
|
||||||
|
import io.mockk.coVerify
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
|
@ -164,6 +169,102 @@ class DefaultActiveCallManagerTest {
|
||||||
verify { notificationManagerCompat.cancel(notificationId) }
|
verify { notificationManagerCompat.cancel(notificationId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Decline event - Hangup on a ringing call should send a decline event`() = runTest {
|
||||||
|
setupShadowPowerManager()
|
||||||
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
|
|
||||||
|
val room = mockk<JoinedRoom>(relaxed = true)
|
||||||
|
|
||||||
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
|
givenGetRoomResult(A_ROOM_ID, room)
|
||||||
|
}
|
||||||
|
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
|
||||||
|
|
||||||
|
val manager = createActiveCallManager(
|
||||||
|
matrixClientProvider = clientProvider,
|
||||||
|
notificationManagerCompat = notificationManagerCompat
|
||||||
|
)
|
||||||
|
|
||||||
|
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
|
||||||
|
manager.registerIncomingCall(notificationData)
|
||||||
|
|
||||||
|
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
room.declineCall(notificationEventId = notificationData.eventId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@Test
|
||||||
|
fun `Decline event - Declining from another session should stop ringing`() = runTest {
|
||||||
|
setupShadowPowerManager()
|
||||||
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
|
|
||||||
|
val room = FakeJoinedRoom()
|
||||||
|
|
||||||
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
|
givenGetRoomResult(A_ROOM_ID, room)
|
||||||
|
}
|
||||||
|
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
|
||||||
|
|
||||||
|
val manager = createActiveCallManager(
|
||||||
|
matrixClientProvider = clientProvider,
|
||||||
|
notificationManagerCompat = notificationManagerCompat
|
||||||
|
)
|
||||||
|
|
||||||
|
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
|
||||||
|
manager.registerIncomingCall(notificationData)
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
// Simulate declined from other session
|
||||||
|
room.baseRoom.givenDecliner(matrixClient.sessionId, notificationData.eventId)
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertThat(manager.activeCall.value).isNull()
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isFalse()
|
||||||
|
|
||||||
|
verify { notificationManagerCompat.cancel(notificationId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@Test
|
||||||
|
fun `Decline event - Should ignore decline for other notification events`() = runTest {
|
||||||
|
plantTestTimber()
|
||||||
|
setupShadowPowerManager()
|
||||||
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
|
|
||||||
|
val room = FakeJoinedRoom()
|
||||||
|
|
||||||
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
|
givenGetRoomResult(A_ROOM_ID, room)
|
||||||
|
}
|
||||||
|
val clientProvider = FakeMatrixClientProvider({ Result.success(matrixClient) })
|
||||||
|
|
||||||
|
val manager = createActiveCallManager(
|
||||||
|
matrixClientProvider = clientProvider,
|
||||||
|
notificationManagerCompat = notificationManagerCompat
|
||||||
|
)
|
||||||
|
|
||||||
|
val notificationData = aCallNotificationData(roomId = A_ROOM_ID)
|
||||||
|
manager.registerIncomingCall(notificationData)
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
// Simulate declined for another notification event
|
||||||
|
room.baseRoom.givenDecliner(matrixClient.sessionId, AN_EVENT_ID_2)
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertThat(manager.activeCall.value).isNotNull()
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isTrue()
|
||||||
|
|
||||||
|
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
|
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
|
||||||
setupShadowPowerManager()
|
setupShadowPowerManager()
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,7 @@ private fun RoomMemberActionsBottomSheet(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = user.userId.toString(),
|
text = user.userId.value,
|
||||||
style = ElementTheme.typography.fontBodyLgRegular,
|
style = ElementTheme.typography.fontBodyLgRegular,
|
||||||
color = ElementTheme.colors.textSecondary,
|
color = ElementTheme.colors.textSecondary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ class DefaultBaseRoomLastMessageFormatterTest {
|
||||||
val info = ImageInfo(null, null, null, null, null, null, null)
|
val info = ImageInfo(null, null, null, null, null, null, null)
|
||||||
val message = createRoomEvent(false, null, aStickerContent(body, info, aMediaSource(url = "url")))
|
val message = createRoomEvent(false, null, aStickerContent(body, info, aMediaSource(url = "url")))
|
||||||
val result = formatter.format(message, false)
|
val result = formatter.format(message, false)
|
||||||
val expectedBody = someoneElseId.toString() + ": Sticker (a sticker body)"
|
val expectedBody = someoneElseId.value + ": Sticker (a sticker body)"
|
||||||
assertThat(result.toString()).isEqualTo(expectedBody)
|
assertThat(result.toString()).isEqualTo(expectedBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ object MatrixPatterns {
|
||||||
val urlMatch = match.groupValues[1]
|
val urlMatch = match.groupValues[1]
|
||||||
when (val permalink = permalinkParser.parse(urlMatch)) {
|
when (val permalink = permalinkParser.parse(urlMatch)) {
|
||||||
is PermalinkData.UserLink -> {
|
is PermalinkData.UserLink -> {
|
||||||
add(MatrixPatternResult(MatrixPatternType.USER_ID, permalink.userId.toString(), match.range.first, match.range.last + 1))
|
add(MatrixPatternResult(MatrixPatternType.USER_ID, permalink.userId.value, match.range.first, match.range.last + 1))
|
||||||
}
|
}
|
||||||
is PermalinkData.RoomLink -> {
|
is PermalinkData.RoomLink -> {
|
||||||
when (permalink.roomIdOrAlias) {
|
when (permalink.roomIdOrAlias) {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
|
||||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
||||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
|
|
||||||
|
|
@ -239,7 +240,11 @@ interface BaseRoom : Closeable {
|
||||||
*/
|
*/
|
||||||
suspend fun reportRoom(reason: String?): Result<Unit>
|
suspend fun reportRoom(reason: String?): Result<Unit>
|
||||||
|
|
||||||
/**
|
suspend fun declineCall(notificationEventId: EventId): Result<Unit>
|
||||||
|
|
||||||
|
suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId>
|
||||||
|
|
||||||
|
/**
|
||||||
* Destroy the room and release all resources associated to it.
|
* Destroy the room and release all resources associated to it.
|
||||||
*/
|
*/
|
||||||
fun destroy()
|
fun destroy()
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,12 @@ import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType
|
||||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.matrix.rustcomponents.sdk.CallDeclineListener
|
||||||
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
||||||
import org.matrix.rustcomponents.sdk.use
|
import org.matrix.rustcomponents.sdk.use
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -300,4 +302,20 @@ class RustBaseRoom(
|
||||||
innerRoom.reportRoom(reason.orEmpty())
|
innerRoom.reportRoom(reason.orEmpty())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun declineCall(notificationEventId: EventId): Result<Unit> = withContext(roomDispatcher) {
|
||||||
|
runCatchingExceptions {
|
||||||
|
innerRoom.declineCall(notificationEventId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId> = withContext(roomDispatcher) {
|
||||||
|
mxCallbackFlow {
|
||||||
|
innerRoom.subscribeToCallDeclineEvents(notificationEventId.value, object : CallDeclineListener {
|
||||||
|
override fun call(declinerUserId: String) {
|
||||||
|
trySend(UserId(declinerUserId))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ fun RustAllowRule.map(): AllowRule {
|
||||||
|
|
||||||
fun AllowRule.map(): RustAllowRule {
|
fun AllowRule.map(): RustAllowRule {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is AllowRule.RoomMembership -> RustAllowRule.RoomMembership(roomId.toString())
|
is AllowRule.RoomMembership -> RustAllowRule.RoomMembership(roomId.value)
|
||||||
is AllowRule.Custom -> RustAllowRule.Custom(json)
|
is AllowRule.Custom -> RustAllowRule.Custom(json)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
import io.element.android.tests.testutils.simulateLongTask
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
|
@ -77,6 +79,12 @@ class FakeBaseRoom(
|
||||||
_roomInfoFlow.tryEmit(roomInfo)
|
_roomInfoFlow.tryEmit(roomInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val declineCallFlowMap: MutableMap<EventId, MutableSharedFlow<UserId>> = mutableMapOf()
|
||||||
|
|
||||||
|
suspend fun givenDecliner(userId: UserId, forNotificationEventId: EventId) {
|
||||||
|
declineCallFlowMap[forNotificationEventId]?.emit(userId)
|
||||||
|
}
|
||||||
|
|
||||||
override val membersStateFlow: MutableStateFlow<RoomMembersState> = MutableStateFlow(RoomMembersState.Unknown)
|
override val membersStateFlow: MutableStateFlow<RoomMembersState> = MutableStateFlow(RoomMembersState.Unknown)
|
||||||
|
|
||||||
override suspend fun updateMembers() = updateMembersResult()
|
override suspend fun updateMembers() = updateMembersResult()
|
||||||
|
|
@ -222,6 +230,15 @@ class FakeBaseRoom(
|
||||||
|
|
||||||
override suspend fun reportRoom(reason: String?) = reportRoomResult(reason)
|
override suspend fun reportRoom(reason: String?) = reportRoomResult(reason)
|
||||||
|
|
||||||
|
override suspend fun declineCall(notificationEventId: EventId): Result<Unit> {
|
||||||
|
return Result.success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId> {
|
||||||
|
val flow = declineCallFlowMap.getOrPut(notificationEventId, { MutableSharedFlow() })
|
||||||
|
return flow
|
||||||
|
}
|
||||||
|
|
||||||
override fun predecessorRoom(): PredecessorRoom? = predecessorRoomResult()
|
override fun predecessorRoom(): PredecessorRoom? = predecessorRoomResult()
|
||||||
|
|
||||||
fun givenUpdateMembersResult(result: () -> Unit) {
|
fun givenUpdateMembersResult(result: () -> Unit) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.tests.testutils
|
||||||
|
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
fun plantTestTimber() {
|
||||||
|
Timber.plant(object : Timber.Tree() {
|
||||||
|
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||||
|
println("$tag: $message")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue