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:
		
							parent
							
								
									a096ff03c8
								
							
						
					
					
						commit
						2fe636e93b
					
				
							
								
								
									
										1
									
								
								changelog.d/7261.wip
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								changelog.d/7261.wip
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1 @@
 | 
			
		||||
Adds pusher toggle setting to device manager v2
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
@ -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.
 | 
			
		||||
 | 
			
		||||
@ -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>()
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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) }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -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)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -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
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -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()
 | 
			
		||||
@ -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()
 | 
			
		||||
@ -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()
 | 
			
		||||
@ -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()
 | 
			
		||||
}
 | 
			
		||||
@ -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
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -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()
 | 
			
		||||
							
								
								
									
										50
									
								
								matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/PusherFixture.kt
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fixtures/PusherFixture.kt
									
									
									
									
										vendored
									
									
										Normal 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,
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
@ -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"
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -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()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -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
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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"
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
@ -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)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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)) }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										50
									
								
								vector/src/test/java/im/vector/app/test/fixtures/PusherFixture.kt
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								vector/src/test/java/im/vector/app/test/fixtures/PusherFixture.kt
									
									
									
									
										vendored
									
									
										Normal 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,
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user