Adds Push Notification toggle to Device Manager (#7261)

* Adds push notifications switch

* Adds functionality to Push notification toggle

* Adds DefaultPushersServiceTest for togglePusher

* Adds DefaultTogglePusherTaskTest

* Adds SessionOverviewViewModelTest for toggling pusher

* Hides pusher toggle if there are no pushers of the device

* Adds changelog file

* Edits changelog file

* Fixes copyrights

* Unregisters checkedChangelistener in onDetachedFromWindow for switch view

* Fixes post merge errors

* Fixes legal copies

* Removes unused imports

* Fixes lint errors

* Fixes test errors

* Fixes error

* Fixes error

* Fixes error

* Fixes error

* Fixes error
This commit is contained in:
Eric Decanini 2022-10-10 19:21:34 -04:00 committed by GitHub
parent a096ff03c8
commit 2fe636e93b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 741 additions and 119 deletions

1
changelog.d/7261.wip Normal file
View File

@ -0,0 +1 @@
Adds pusher toggle setting to device manager v2

View File

@ -3304,6 +3304,8 @@
<string name="device_manager_session_overview_signout">Sign out of this session</string>
<string name="device_manager_session_details_title">Session details</string>
<string name="device_manager_session_details_description">Application, device, and activity information.</string>
<string name="device_manager_push_notifications_title">Push notifications</string>
<string name="device_manager_push_notifications_description">Receive push notifications on this session.</string>
<string name="device_manager_session_details_session_name">Session name</string>
<string name="device_manager_session_details_session_id">Session ID</string>
<string name="device_manager_session_details_session_last_activity">Last activity</string>

View File

@ -6,4 +6,10 @@
<attr name="sessionOverviewEntryDescription" format="string" />
</declare-styleable>
<declare-styleable name="SessionOverviewEntrySwitchView">
<attr name="sessionOverviewEntrySwitchTitle" format="string" />
<attr name="sessionOverviewEntrySwitchDescription" format="string" />
<attr name="sessionOverviewEntrySwitchEnabled" format="boolean" />
</declare-styleable>
</resources>

View File

@ -67,6 +67,14 @@ interface PushersService {
append: Boolean = true
)
/**
* Enables or disables a registered pusher.
*
* @param pusher The pusher being toggled
* @param enable Whether the pusher should be enabled or disabled
*/
suspend fun togglePusher(pusher: Pusher, enable: Boolean)
/**
* Directly ask the push gateway to send a push to this device.
* If successful, the push gateway has accepted the request. In this case, the app should receive a Push with the provided eventId.

View File

@ -42,6 +42,7 @@ internal class DefaultPushersService @Inject constructor(
private val getPusherTask: GetPushersTask,
private val pushGatewayNotifyTask: PushGatewayNotifyTask,
private val addPusherTask: AddPusherTask,
private val togglePusherTask: TogglePusherTask,
private val removePusherTask: RemovePusherTask,
private val taskExecutor: TaskExecutor
) : PushersService {
@ -108,6 +109,24 @@ internal class DefaultPushersService @Inject constructor(
)
}
override suspend fun togglePusher(pusher: Pusher, enable: Boolean) {
togglePusherTask.execute(TogglePusherTask.Params(pusher.toJsonPusher(), enable))
}
private fun Pusher.toJsonPusher() = JsonPusher(
pushKey = pushKey,
kind = kind,
appId = appId,
appDisplayName = appDisplayName,
deviceDisplayName = deviceDisplayName,
profileTag = profileTag,
lang = lang,
data = JsonPusherData(data.url, data.format),
append = false,
enabled = enabled,
deviceId = deviceId,
)
private fun enqueueAddPusher(pusher: JsonPusher): UUID {
val params = AddPusherWorker.Params(sessionId, pusher)
val request = workManagerProvider.matrixOneTimeWorkRequestBuilder<AddPusherWorker>()

View File

@ -68,6 +68,9 @@ internal abstract class PushersModule {
@Binds
abstract fun bindAddPusherTask(task: DefaultAddPusherTask): AddPusherTask
@Binds
abstract fun bindTogglePusherTask(task: DefaultTogglePusherTask): TogglePusherTask
@Binds
abstract fun bindRemovePusherTask(task: DefaultRemovePusherTask): RemovePusherTask

View File

@ -0,0 +1,52 @@
/*
* Copyright (c) 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.pushers
import com.zhuinden.monarchy.Monarchy
import org.matrix.android.sdk.internal.database.model.PusherEntity
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.RequestExecutor
import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import javax.inject.Inject
internal interface TogglePusherTask : Task<TogglePusherTask.Params, Unit> {
data class Params(val pusher: JsonPusher, val enable: Boolean)
}
internal class DefaultTogglePusherTask @Inject constructor(
private val pushersAPI: PushersAPI,
@SessionDatabase private val monarchy: Monarchy,
private val requestExecutor: RequestExecutor,
private val globalErrorReceiver: GlobalErrorReceiver
) : TogglePusherTask {
override suspend fun execute(params: TogglePusherTask.Params) {
val pusher = params.pusher.copy(enabled = params.enable)
requestExecutor.executeRequest(globalErrorReceiver) {
pushersAPI.setPusher(pusher)
}
monarchy.awaitTransaction { realm ->
val entity = PusherEntity.where(realm, params.pusher.pushKey).findFirst()
entity?.apply { enabled = params.enable }?.let { realm.insertOrUpdate(it) }
}
}
}

View File

@ -0,0 +1,66 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.pushers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.android.sdk.test.fakes.FakeAddPusherTask
import org.matrix.android.sdk.test.fakes.FakeGetPushersTask
import org.matrix.android.sdk.test.fakes.FakeMonarchy
import org.matrix.android.sdk.test.fakes.FakeRemovePusherTask
import org.matrix.android.sdk.test.fakes.FakeTaskExecutor
import org.matrix.android.sdk.test.fakes.FakeTogglePusherTask
import org.matrix.android.sdk.test.fakes.FakeWorkManagerProvider
import org.matrix.android.sdk.test.fakes.internal.FakePushGatewayNotifyTask
import org.matrix.android.sdk.test.fixtures.PusherFixture
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultPushersServiceTest {
private val workManagerProvider = FakeWorkManagerProvider()
private val monarchy = FakeMonarchy()
private val sessionId = ""
private val getPushersTask = FakeGetPushersTask()
private val pushGatewayNotifyTask = FakePushGatewayNotifyTask()
private val addPusherTask = FakeAddPusherTask()
private val togglePusherTask = FakeTogglePusherTask()
private val removePusherTask = FakeRemovePusherTask()
private val taskExecutor = FakeTaskExecutor()
private val pushersService = DefaultPushersService(
workManagerProvider.instance,
monarchy.instance,
sessionId,
getPushersTask,
pushGatewayNotifyTask,
addPusherTask,
togglePusherTask,
removePusherTask,
taskExecutor.instance,
)
@Test
fun `when togglePusher, then execute task`() = runTest {
val pusher = PusherFixture.aPusher()
val enable = true
pushersService.togglePusher(pusher, enable)
togglePusherTask.verifyExecution(pusher, enable)
}
}

View File

@ -0,0 +1,64 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.pushers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.matrix.android.sdk.internal.database.model.PusherEntity
import org.matrix.android.sdk.internal.database.model.PusherEntityFields
import org.matrix.android.sdk.test.fakes.FakeGlobalErrorReceiver
import org.matrix.android.sdk.test.fakes.FakeMonarchy
import org.matrix.android.sdk.test.fakes.FakePushersAPI
import org.matrix.android.sdk.test.fakes.FakeRequestExecutor
import org.matrix.android.sdk.test.fakes.givenEqualTo
import org.matrix.android.sdk.test.fakes.givenFindFirst
import org.matrix.android.sdk.test.fixtures.JsonPusherFixture.aJsonPusher
import org.matrix.android.sdk.test.fixtures.PusherEntityFixture.aPusherEntity
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultTogglePusherTaskTest {
private val pushersAPI = FakePushersAPI()
private val monarchy = FakeMonarchy()
private val requestExecutor = FakeRequestExecutor()
private val globalErrorReceiver = FakeGlobalErrorReceiver()
private val togglePusherTask = DefaultTogglePusherTask(pushersAPI, monarchy.instance, requestExecutor, globalErrorReceiver)
@Test
fun `execution toggles enable on both local and remote`() = runTest {
val jsonPusher = aJsonPusher(enabled = false)
val params = TogglePusherTask.Params(aJsonPusher(), true)
val pusherEntity = aPusherEntity(enabled = false)
monarchy.givenWhere<PusherEntity>()
.givenEqualTo(PusherEntityFields.PUSH_KEY, jsonPusher.pushKey)
.givenFindFirst(pusherEntity)
togglePusherTask.execute(params)
val expectedPayload = jsonPusher.copy(enabled = true)
pushersAPI.verifySetPusher(expectedPayload)
monarchy.verifyInsertOrUpdate<PusherEntity> {
withArg { actual ->
actual.enabled shouldBeEqualTo true
}
}
}
}

View File

@ -0,0 +1,22 @@
/*
* 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.test.fakes
import io.mockk.mockk
import org.matrix.android.sdk.internal.session.pushers.AddPusherTask
class FakeAddPusherTask : AddPusherTask by mockk()

View File

@ -0,0 +1,22 @@
/*
* 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.test.fakes
import io.mockk.mockk
import org.matrix.android.sdk.internal.session.pushers.GetPushersTask
class FakeGetPushersTask : GetPushersTask by mockk()

View File

@ -0,0 +1,22 @@
/*
* 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.test.fakes
import io.mockk.mockk
import org.matrix.android.sdk.internal.session.pushers.RemovePusherTask
class FakeRemovePusherTask : RemovePusherTask by mockk()

View File

@ -0,0 +1,25 @@
/*
* 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.test.fakes
import io.mockk.mockk
import org.matrix.android.sdk.internal.task.TaskExecutor
internal class FakeTaskExecutor {
val instance: TaskExecutor = mockk()
}

View File

@ -0,0 +1,35 @@
/*
* 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.test.fakes
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
import org.amshove.kluent.shouldBeEqualTo
import org.matrix.android.sdk.api.session.pushers.Pusher
import org.matrix.android.sdk.internal.session.pushers.TogglePusherTask
class FakeTogglePusherTask : TogglePusherTask by mockk(relaxed = true) {
fun verifyExecution(pusher: Pusher, enable: Boolean) {
val slot = slot<TogglePusherTask.Params>()
coVerify { execute(capture(slot)) }
val params = slot.captured
params.pusher.pushKey shouldBeEqualTo pusher.pushKey
params.enable shouldBeEqualTo enable
}
}

View File

@ -0,0 +1,22 @@
/*
* 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.test.fakes.internal
import io.mockk.mockk
import org.matrix.android.sdk.internal.session.pushers.gateway.PushGatewayNotifyTask
class FakePushGatewayNotifyTask : PushGatewayNotifyTask by mockk()

View File

@ -0,0 +1,50 @@
/*
* 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.test.fixtures
import org.matrix.android.sdk.api.session.pushers.Pusher
import org.matrix.android.sdk.api.session.pushers.PusherData
import org.matrix.android.sdk.api.session.pushers.PusherState
object PusherFixture {
fun aPusher(
pushKey: String = "",
kind: String = "",
appId: String = "",
appDisplayName: String? = "",
deviceDisplayName: String? = "",
profileTag: String? = null,
lang: String? = "",
data: PusherData = PusherData("f.o/_matrix/push/v1/notify", ""),
enabled: Boolean = true,
deviceId: String? = "",
state: PusherState = PusherState.REGISTERED,
) = Pusher(
pushKey,
kind,
appId,
appDisplayName,
deviceDisplayName,
profileTag,
lang,
data,
enabled,
deviceId,
state,
)
}

View File

@ -296,6 +296,7 @@ dependencies {
// Plant Timber tree for test
testImplementation libs.tests.timberJunitRule
testImplementation libs.airbnb.mavericksTesting
testImplementation libs.androidx.coreTesting
testImplementation(libs.jetbrains.coroutinesTest) {
exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug"
}

View File

@ -19,9 +19,14 @@ package im.vector.app.features.settings.devices.v2.overview
import im.vector.app.core.platform.VectorViewModelAction
sealed class SessionOverviewAction : VectorViewModelAction {
object VerifySession : SessionOverviewAction()
object SignoutOtherSession : SessionOverviewAction()
object SsoAuthDone : SessionOverviewAction()
data class PasswordAuthDone(val password: String) : SessionOverviewAction()
object ReAuthCancelled : SessionOverviewAction()
data class TogglePushNotifications(
val deviceId: String,
val enabled: Boolean,
) : SessionOverviewAction()
}

View File

@ -0,0 +1,86 @@
/*
* 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.overview
import android.content.Context
import android.content.res.TypedArray
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.CompoundButton
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.res.use
import im.vector.app.R
import im.vector.app.core.extensions.setAttributeBackground
import im.vector.app.databinding.ViewSessionOverviewEntrySwitchBinding
class SessionOverviewEntrySwitchView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val binding = ViewSessionOverviewEntrySwitchBinding.inflate(
LayoutInflater.from(context),
this
)
init {
initBackground()
context.obtainStyledAttributes(
attrs,
R.styleable.SessionOverviewEntrySwitchView,
0,
0
).use {
setTitle(it)
setDescription(it)
setSwitchedEnabled(it)
}
}
private fun initBackground() {
binding.root.setAttributeBackground(android.R.attr.selectableItemBackground)
}
private fun setTitle(typedArray: TypedArray) {
val title = typedArray.getString(R.styleable.SessionOverviewEntrySwitchView_sessionOverviewEntrySwitchTitle)
binding.sessionsOverviewEntryTitle.text = title
}
private fun setDescription(typedArray: TypedArray) {
val description = typedArray.getString(R.styleable.SessionOverviewEntrySwitchView_sessionOverviewEntrySwitchDescription)
binding.sessionsOverviewEntryDescription.text = description
}
private fun setSwitchedEnabled(typedArray: TypedArray) {
val enabled = typedArray.getBoolean(R.styleable.SessionOverviewEntrySwitchView_sessionOverviewEntrySwitchEnabled, true)
binding.sessionsOverviewEntrySwitch.isChecked = enabled
}
fun setChecked(checked: Boolean) {
binding.sessionsOverviewEntrySwitch.isChecked = checked
}
fun setOnCheckedChangeListener(listener: CompoundButton.OnCheckedChangeListener?) {
binding.sessionsOverviewEntrySwitch.setOnCheckedChangeListener(listener)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
binding.sessionsOverviewEntrySwitch.setOnCheckedChangeListener(null)
}
}

View File

@ -24,6 +24,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isGone
import androidx.core.view.isVisible
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.fragmentViewModel
@ -41,12 +42,14 @@ import im.vector.app.core.resources.StringProvider
import im.vector.app.databinding.FragmentSessionOverviewBinding
import im.vector.app.features.auth.ReAuthActivity
import im.vector.app.features.crypto.recover.SetupMode
import im.vector.app.features.settings.devices.v2.DeviceFullInfo
import im.vector.app.features.settings.devices.v2.list.SessionInfoViewState
import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet
import im.vector.app.features.workers.signout.SignOutUiWorker
import org.matrix.android.sdk.api.auth.data.LoginFlowTypes
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel
import org.matrix.android.sdk.api.session.pushers.Pusher
import javax.inject.Inject
/**
@ -174,9 +177,14 @@ class SessionOverviewFragment :
override fun invalidate() = withState(viewModel) { state ->
updateToolbar(state)
updateEntryDetails(state.deviceId)
updateSessionInfo(state)
updateLoading(state.isLoading)
updatePushNotificationToggle(state.deviceId, state.pushers.invoke().orEmpty())
if (state.deviceInfo is Success) {
renderSessionInfo(state.isCurrentSessionTrusted, state.deviceInfo.invoke())
} else {
hideSessionInfo()
}
}
private fun updateToolbar(viewState: SessionOverviewViewState) {
@ -189,12 +197,6 @@ class SessionOverviewFragment :
}
}
private fun updateEntryDetails(deviceId: String) {
views.sessionOverviewEntryDetails.setOnClickListener {
viewNavigator.goToSessionDetails(requireContext(), deviceId)
}
}
private fun updateSessionInfo(viewState: SessionOverviewViewState) {
if (viewState.deviceInfo is Success) {
views.sessionOverviewInfo.isVisible = true
@ -217,6 +219,35 @@ class SessionOverviewFragment :
}
}
private fun updatePushNotificationToggle(deviceId: String, pushers: List<Pusher>) {
views.sessionOverviewPushNotifications.apply {
if (pushers.isEmpty()) {
isVisible = false
} else {
val allPushersAreEnabled = pushers.all { it.enabled }
setOnCheckedChangeListener(null)
setChecked(allPushersAreEnabled)
post {
setOnCheckedChangeListener { _, isChecked ->
viewModel.handle(SessionOverviewAction.TogglePushNotifications(deviceId, isChecked))
}
}
}
}
}
private fun renderSessionInfo(isCurrentSession: Boolean, deviceFullInfo: DeviceFullInfo) {
views.sessionOverviewInfo.isVisible = true
val viewState = SessionInfoViewState(
isCurrentSession = isCurrentSession,
deviceFullInfo = deviceFullInfo,
isDetailsButtonVisible = false,
isLearnMoreLinkVisible = true,
isLastSeenDetailsVisible = true,
)
views.sessionOverviewInfo.render(viewState, dateFormatter, drawableProvider, colorProvider, stringProvider)
}
private fun updateLoading(isLoading: Boolean) {
if (isLoading) {
showLoading(null)
@ -275,4 +306,8 @@ class SessionOverviewFragment :
)
SessionLearnMoreBottomSheet.show(childFragmentManager, args)
}
private fun hideSessionInfo() {
views.sessionOverviewInfo.isGone = true
}
}

View File

@ -43,14 +43,17 @@ import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel
import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
import org.matrix.android.sdk.flow.flow
import timber.log.Timber
import javax.net.ssl.HttpsURLConnection
import kotlin.coroutines.Continuation
class SessionOverviewViewModel @AssistedInject constructor(
@Assisted val initialState: SessionOverviewViewState,
private val session: Session,
private val stringProvider: StringProvider,
private val getDeviceFullInfoUseCase: GetDeviceFullInfoUseCase,
private val checkIfCurrentSessionCanBeVerifiedUseCase: CheckIfCurrentSessionCanBeVerifiedUseCase,
@ -73,6 +76,7 @@ class SessionOverviewViewModel @AssistedInject constructor(
init {
observeSessionInfo(initialState.deviceId)
observeCurrentSessionInfo()
observePushers(initialState.deviceId)
}
private fun observeSessionInfo(deviceId: String) {
@ -94,6 +98,13 @@ class SessionOverviewViewModel @AssistedInject constructor(
}
}
private fun observePushers(deviceId: String) {
session.flow()
.livePushers()
.map { it.filter { pusher -> pusher.deviceId == deviceId } }
.execute { copy(pushers = it) }
}
override fun handle(action: SessionOverviewAction) {
when (action) {
is SessionOverviewAction.VerifySession -> handleVerifySessionAction()
@ -101,6 +112,7 @@ class SessionOverviewViewModel @AssistedInject constructor(
SessionOverviewAction.SsoAuthDone -> handleSsoAuthDone()
is SessionOverviewAction.PasswordAuthDone -> handlePasswordAuthDone(action)
SessionOverviewAction.ReAuthCancelled -> handleReAuthCancelled()
is SessionOverviewAction.TogglePushNotifications -> handleTogglePusherAction(action)
}
}
@ -198,4 +210,13 @@ class SessionOverviewViewModel @AssistedInject constructor(
private fun handleReAuthCancelled() {
pendingAuthHandler.reAuthCancelled()
}
private fun handleTogglePusherAction(action: SessionOverviewAction.TogglePushNotifications) {
viewModelScope.launch {
val devicePushers = awaitState().pushers.invoke()?.filter { it.deviceId == action.deviceId }
devicePushers?.forEach { pusher ->
session.pushersService().togglePusher(pusher, action.enabled)
}
}
}
}

View File

@ -20,12 +20,14 @@ import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
import im.vector.app.features.settings.devices.v2.DeviceFullInfo
import org.matrix.android.sdk.api.session.pushers.Pusher
data class SessionOverviewViewState(
val deviceId: String,
val isCurrentSessionTrusted: Boolean = false,
val deviceInfo: Async<DeviceFullInfo> = Uninitialized,
val isLoading: Boolean = false,
val pushers: Async<List<Pusher>> = Uninitialized,
) : MavericksState {
constructor(args: SessionOverviewArgs) : this(
deviceId = args.deviceId

View File

@ -31,6 +31,16 @@
app:sessionOverviewEntryDescription="@string/device_manager_session_details_description"
app:sessionOverviewEntryTitle="@string/device_manager_session_details_title" />
<im.vector.app.features.settings.devices.v2.overview.SessionOverviewEntrySwitchView
android:id="@+id/sessionOverviewPushNotifications"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/sessionOverviewEntryDetails"
app:sessionOverviewEntrySwitchDescription="@string/device_manager_push_notifications_description"
app:sessionOverviewEntrySwitchTitle="@string/device_manager_push_notifications_title" />
<Button
android:id="@+id/sessionOverviewSignout"
style="@style/Widget.Vector.Button.Text.Destructive"

View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<TextView
android:id="@+id/sessionsOverviewEntryTitle"
style="@style/TextAppearance.Vector.Subtitle.DevicesManagement"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/layout_horizontal_margin"
android:layout_marginTop="20dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Push notifications" />
<TextView
android:id="@+id/sessionsOverviewEntryDescription"
style="@style/TextAppearance.Vector.Body.DevicesManagement"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
app:layout_constraintEnd_toEndOf="@id/sessionsOverviewEntryTitle"
app:layout_constraintStart_toStartOf="@id/sessionsOverviewEntryTitle"
app:layout_constraintTop_toBottomOf="@id/sessionsOverviewEntryTitle"
tools:text="Receive push notifications on this session." />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/sessionsOverviewEntrySwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<View
android:id="@+id/sessionsOverviewEntryDivider"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginTop="20dp"
android:background="@drawable/divider_horizontal"
app:layout_constraintEnd_toEndOf="@id/sessionsOverviewEntryTitle"
app:layout_constraintStart_toStartOf="@id/sessionsOverviewEntryTitle"
app:layout_constraintTop_toBottomOf="@id/sessionsOverviewEntryDescription" />
</merge>

View File

@ -17,9 +17,10 @@
package im.vector.app.features.settings.devices.v2.overview
import android.os.SystemClock
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.test.MavericksTestRule
import im.vector.app.R
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.signout.InterceptSignoutFlowResponseUseCase
@ -28,8 +29,10 @@ import im.vector.app.features.settings.devices.v2.signout.SignoutSessionUseCase
import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakePendingAuthHandler
import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fakes.FakeStringProvider
import im.vector.app.test.fakes.FakeVerificationService
import im.vector.app.test.fixtures.PusherFixture.aPusher
import im.vector.app.test.test
import im.vector.app.test.testDispatcher
import io.mockk.coEvery
@ -52,10 +55,8 @@ import org.junit.Test
import org.matrix.android.sdk.api.auth.UIABaseAuth
import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel
import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth
import javax.net.ssl.HttpsURLConnection
import kotlin.coroutines.Continuation
private const val A_SESSION_ID_1 = "session-id-1"
@ -69,12 +70,16 @@ class SessionOverviewViewModelTest {
@get:Rule
val mavericksTestRule = MavericksTestRule(testDispatcher = testDispatcher)
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
private val args = SessionOverviewArgs(
deviceId = A_SESSION_ID_1
)
private val fakeSession = FakeSession()
private val getDeviceFullInfoUseCase = mockk<GetDeviceFullInfoUseCase>(relaxed = true)
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val fakeStringProvider = FakeStringProvider()
private val getDeviceFullInfoUseCase = mockk<GetDeviceFullInfoUseCase>()
private val checkIfCurrentSessionCanBeVerifiedUseCase = mockk<CheckIfCurrentSessionCanBeVerifiedUseCase>()
private val signoutSessionUseCase = mockk<SignoutSessionUseCase>()
private val interceptSignoutFlowResponseUseCase = mockk<InterceptSignoutFlowResponseUseCase>()
@ -83,6 +88,7 @@ class SessionOverviewViewModelTest {
private fun createViewModel() = SessionOverviewViewModel(
initialState = SessionOverviewViewState(args),
session = fakeSession,
stringProvider = fakeStringProvider.instance,
getDeviceFullInfoUseCase = getDeviceFullInfoUseCase,
checkIfCurrentSessionCanBeVerifiedUseCase = checkIfCurrentSessionCanBeVerifiedUseCase,
@ -108,8 +114,7 @@ class SessionOverviewViewModelTest {
}
@Test
fun `given the viewModel has been initialized then viewState is updated with session info and current session verification status`() {
// Given
fun `given the viewModel has been initialized then viewState is updated with session info`() {
val deviceFullInfo = mockk<DeviceFullInfo>()
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
givenCurrentSessionIsTrusted()
@ -117,12 +122,11 @@ class SessionOverviewViewModelTest {
deviceId = A_SESSION_ID_1,
deviceInfo = Success(deviceFullInfo),
isCurrentSessionTrusted = true,
pushers = Loading(),
)
// When
val viewModel = createViewModel()
// Then
viewModel.test()
.assertLatestState { state -> state == expectedState }
.finish()
@ -199,110 +203,6 @@ class SessionOverviewViewModelTest {
.finish()
}
@Test
fun `given another session and no reAuth is needed when handling signout action then signout process is performed`() {
// Given
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
givenSignoutSuccess(A_SESSION_ID_1)
every { refreshDevicesUseCase.execute() } just runs
val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted()
val expectedViewState = SessionOverviewViewState(
deviceId = A_SESSION_ID_1,
isCurrentSessionTrusted = true,
deviceInfo = Success(deviceFullInfo),
isLoading = false,
)
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(signoutAction)
// Then
viewModelTest
.assertStatesChanges(
expectedViewState,
{ copy(isLoading = true) },
{ copy(isLoading = false) }
)
.assertEvent { it is SessionOverviewViewEvent.SignoutSuccess }
.finish()
verify {
refreshDevicesUseCase.execute()
}
}
@Test
fun `given another session and server error during signout when handling signout action then signout process is performed`() {
// Given
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
val serverError = Failure.OtherServerError(errorBody = "", httpCode = HttpsURLConnection.HTTP_UNAUTHORIZED)
givenSignoutError(A_SESSION_ID_1, serverError)
val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted()
val expectedViewState = SessionOverviewViewState(
deviceId = A_SESSION_ID_1,
isCurrentSessionTrusted = true,
deviceInfo = Success(deviceFullInfo),
isLoading = false,
)
fakeStringProvider.given(R.string.authentication_error, AUTH_ERROR_MESSAGE)
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(signoutAction)
// Then
viewModelTest
.assertStatesChanges(
expectedViewState,
{ copy(isLoading = true) },
{ copy(isLoading = false) }
)
.assertEvent { it is SessionOverviewViewEvent.SignoutError && it.error.message == AUTH_ERROR_MESSAGE }
.finish()
}
@Test
fun `given another session and unexpected error during signout when handling signout action then signout process is performed`() {
// Given
val deviceFullInfo = mockk<DeviceFullInfo>()
every { deviceFullInfo.isCurrentDevice } returns false
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_1) } returns flowOf(deviceFullInfo)
val error = Exception()
givenSignoutError(A_SESSION_ID_1, error)
val signoutAction = SessionOverviewAction.SignoutOtherSession
givenCurrentSessionIsTrusted()
val expectedViewState = SessionOverviewViewState(
deviceId = A_SESSION_ID_1,
isCurrentSessionTrusted = true,
deviceInfo = Success(deviceFullInfo),
isLoading = false,
)
fakeStringProvider.given(R.string.matrix_error, AN_ERROR_MESSAGE)
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
viewModel.handle(signoutAction)
// Then
viewModelTest
.assertStatesChanges(
expectedViewState,
{ copy(isLoading = true) },
{ copy(isLoading = false) }
)
.assertEvent { it is SessionOverviewViewEvent.SignoutError && it.error.message == AN_ERROR_MESSAGE }
.finish()
}
@Test
fun `given another session and reAuth is needed during signout when handling signout action then requestReAuth is sent and pending auth is stored`() {
// Given
@ -447,4 +347,30 @@ class SessionOverviewViewModelTest {
every { deviceFullInfo.roomEncryptionTrustLevel } returns RoomEncryptionTrustLevel.Trusted
every { getDeviceFullInfoUseCase.execute(A_SESSION_ID_2) } returns flowOf(deviceFullInfo)
}
@Test
fun `when viewModel init, then observe pushers and emit to state`() {
val pushers = listOf(aPusher(deviceId = A_SESSION_ID_1))
fakeSession.pushersService().givenPushersLive(pushers)
val viewModel = createViewModel()
viewModel.test()
.assertLatestState { state -> state.pushers.invoke() == pushers }
.finish()
}
@Test
fun `when handle TogglePushNotifications, then toggle enabled for device pushers`() {
val pushers = listOf(
aPusher(deviceId = A_SESSION_ID_1, enabled = false),
aPusher(deviceId = "another id", enabled = false)
)
fakeSession.pushersService().givenPushersLive(pushers)
val viewModel = createViewModel()
viewModel.handle(SessionOverviewAction.TogglePushNotifications(A_SESSION_ID_1, true))
fakeSession.pushersService().verifyOnlyTogglePusherCalled(pushers.first(), true)
}
}

View File

@ -16,14 +16,30 @@
package im.vector.app.test.fakes
import androidx.lifecycle.liveData
import io.mockk.Ordering
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import org.matrix.android.sdk.api.session.pushers.HttpPusher
import org.matrix.android.sdk.api.session.pushers.Pusher
import org.matrix.android.sdk.api.session.pushers.PushersService
class FakePushersService : PushersService by mockk(relaxed = true) {
fun givenPushersLive(pushers: List<Pusher>) {
every { getPushersLive() } returns liveData { emit(pushers) }
}
fun verifyOnlyTogglePusherCalled(pusher: Pusher, enable: Boolean) {
coVerify(ordering = Ordering.ALL) {
getPushersLive() // verifies only getPushersLive and the following togglePusher was called
togglePusher(pusher, enable)
}
}
fun verifyEnqueueAddHttpPusher(): HttpPusher {
val httpPusherSlot = slot<HttpPusher>()
verify { enqueueAddHttpPusher(capture(httpPusherSlot)) }

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.test.fixtures
import org.matrix.android.sdk.api.session.pushers.Pusher
import org.matrix.android.sdk.api.session.pushers.PusherData
import org.matrix.android.sdk.api.session.pushers.PusherState
object PusherFixture {
fun aPusher(
pushKey: String = "",
kind: String = "",
appId: String = "",
appDisplayName: String? = "",
deviceDisplayName: String? = "",
profileTag: String? = null,
lang: String? = "",
data: PusherData = PusherData("f.o/_matrix/push/v1/notify", ""),
enabled: Boolean = true,
deviceId: String? = "",
state: PusherState = PusherState.REGISTERED,
) = Pusher(
pushKey,
kind,
appId,
appDisplayName,
deviceDisplayName,
profileTag,
lang,
data,
enabled,
deviceId,
state,
)
}