Flow-based connectivity listener
Create a `NetworkCapabilities` repository (flow-based listener API) that can replace any current network callback that exists inside of the connectivity package. This is currently intended to be proof-of-concept and can be patterned-after in order to fully flesh out the data model for the connectivity icons. Change-Id: If80905e79ee66df76a809cc1beb2934361d0f94e Test: atest NetworkCapabilitiesRepositoryTest Bug: 240492102
This commit is contained in:
@@ -255,6 +255,16 @@ public class LogModule {
|
||||
return factory.create("MediaCarouselCtlrLog", 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a {@link LogBuffer} for use in the status bar connectivity pipeline
|
||||
*/
|
||||
@Provides
|
||||
@SysUISingleton
|
||||
@StatusBarConnectivityLog
|
||||
public static LogBuffer provideStatusBarConnectivityBuffer(LogBufferFactory factory) {
|
||||
return factory.create("StatusBarConnectivityLog", 64);
|
||||
}
|
||||
|
||||
/** Allows logging buffers to be tweaked via adb on debug builds but not on prod builds. */
|
||||
@Provides
|
||||
@SysUISingleton
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* 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 com.android.systemui.log.dagger;
|
||||
|
||||
import static java.lang.annotation.RetentionPolicy.RUNTIME;
|
||||
|
||||
import com.android.systemui.log.LogBuffer;
|
||||
import com.android.systemui.statusbar.pipeline.ConnectivityInfoProcessor;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.Retention;
|
||||
|
||||
import javax.inject.Qualifier;
|
||||
|
||||
/**
|
||||
* A {@link LogBuffer} for events processed by {@link ConnectivityInfoProcessor}
|
||||
*/
|
||||
@Qualifier
|
||||
@Documented
|
||||
@Retention(RUNTIME)
|
||||
public @interface StatusBarConnectivityLog {
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* 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 com.android.systemui.statusbar.pipeline
|
||||
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import com.android.systemui.dagger.SysUISingleton
|
||||
import com.android.systemui.log.LogBuffer
|
||||
import com.android.systemui.log.LogLevel
|
||||
import com.android.systemui.log.dagger.StatusBarConnectivityLog
|
||||
import javax.inject.Inject
|
||||
|
||||
@SysUISingleton
|
||||
class ConnectivityPipelineLogger @Inject constructor(
|
||||
@StatusBarConnectivityLog private val buffer: LogBuffer,
|
||||
) {
|
||||
fun logOnCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
|
||||
buffer.log(
|
||||
TAG,
|
||||
LogLevel.INFO,
|
||||
{
|
||||
int1 = network.getNetId()
|
||||
str1 = networkCapabilities.toString()
|
||||
},
|
||||
{
|
||||
"onCapabilitiesChanged: net=$int1 capabilities=$str1"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun logOnLost(network: Network) {
|
||||
buffer.log(
|
||||
TAG,
|
||||
LogLevel.INFO,
|
||||
{
|
||||
int1 = network.getNetId()
|
||||
},
|
||||
{
|
||||
"onLost: net=$int1"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "SbConnectivityPipeline"
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (C) 2021 The Android Open Source Project
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package com.android.systemui.statusbar.pipeline.repository
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import com.android.systemui.dagger.SysUISingleton
|
||||
import com.android.systemui.dagger.qualifiers.Application
|
||||
import com.android.systemui.statusbar.pipeline.ConnectivityPipelineLogger
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.Lazily
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/** Repository that contains all relevant [NetworkCapabilites] for the current networks */
|
||||
@SysUISingleton
|
||||
class NetworkCapabilitiesRepo @Inject constructor(
|
||||
connectivityManager: ConnectivityManager,
|
||||
@Application scope: CoroutineScope,
|
||||
logger: ConnectivityPipelineLogger,
|
||||
) {
|
||||
@SuppressLint("MissingPermission")
|
||||
val dataStream: StateFlow<Map<Int, NetworkCapabilityInfo>> = run {
|
||||
var state = emptyMap<Int, NetworkCapabilityInfo>()
|
||||
callbackFlow {
|
||||
val networkRequest: NetworkRequest =
|
||||
NetworkRequest.Builder()
|
||||
.clearCapabilities()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
.build()
|
||||
val callback =
|
||||
// TODO (b/240569788): log these using [LogBuffer]
|
||||
object : ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities
|
||||
) {
|
||||
logger.logOnCapabilitiesChanged(network, networkCapabilities)
|
||||
state =
|
||||
state.toMutableMap().also {
|
||||
it[network.getNetId()] =
|
||||
NetworkCapabilityInfo(network, networkCapabilities)
|
||||
}
|
||||
trySend(state)
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
logger.logOnLost(network)
|
||||
state = state.toMutableMap().also { it.remove(network.getNetId()) }
|
||||
trySend(state)
|
||||
}
|
||||
}
|
||||
connectivityManager.registerNetworkCallback(networkRequest, callback)
|
||||
|
||||
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
|
||||
}
|
||||
.stateIn(scope, started = Lazily, initialValue = state)
|
||||
}
|
||||
}
|
||||
|
||||
/** contains info about network capabilities. */
|
||||
data class NetworkCapabilityInfo(
|
||||
val network: Network,
|
||||
val capabilities: NetworkCapabilities,
|
||||
)
|
||||
|
||||
private const val TAG = "ConnectivityRepository"
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* 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 com.android.systemui.statusbar.pipeline
|
||||
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import androidx.test.filters.SmallTest
|
||||
import com.android.systemui.SysuiTestCase
|
||||
import com.android.systemui.dump.DumpManager
|
||||
import com.android.systemui.log.LogBufferFactory
|
||||
import com.android.systemui.log.LogcatEchoTracker
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import org.junit.Test
|
||||
import org.mockito.Mockito
|
||||
import org.mockito.Mockito.mock
|
||||
|
||||
@SmallTest
|
||||
class ConnectivityPipelineLoggerTest : SysuiTestCase() {
|
||||
private val buffer = LogBufferFactory(DumpManager(), mock(LogcatEchoTracker::class.java))
|
||||
.create("buffer", 10)
|
||||
private val logger = ConnectivityPipelineLogger(buffer)
|
||||
|
||||
@Test
|
||||
fun testLogNetworkCapsChange_bufferHasInfo() {
|
||||
logger.logOnCapabilitiesChanged(NET_1, NET_1_CAPS)
|
||||
|
||||
val stringWriter = StringWriter()
|
||||
buffer.dump(PrintWriter(stringWriter), tailLength = 0)
|
||||
val actualString = stringWriter.toString()
|
||||
|
||||
val expectedNetId = NET_1_ID.toString()
|
||||
val expectedCaps = NET_1_CAPS.toString()
|
||||
|
||||
assertThat(actualString).contains(expectedNetId)
|
||||
assertThat(actualString).contains(expectedCaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLogOnLost_bufferHasNetIdOfLostNetwork() {
|
||||
logger.logOnLost(NET_1)
|
||||
|
||||
val stringWriter = StringWriter()
|
||||
buffer.dump(PrintWriter(stringWriter), tailLength = 0)
|
||||
val actualString = stringWriter.toString()
|
||||
|
||||
val expectedNetId = NET_1_ID.toString()
|
||||
|
||||
assertThat(actualString).contains(expectedNetId)
|
||||
}
|
||||
|
||||
private val NET_1_ID = 100
|
||||
private val NET_1 = com.android.systemui.util.mockito.mock<Network>().also {
|
||||
Mockito.`when`(it.getNetId()).thenReturn(NET_1_ID)
|
||||
}
|
||||
private val NET_1_CAPS = NetworkCapabilities.Builder()
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* Copyright (C) 2022 The Android Open Source Project
|
||||
*
|
||||
* 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 com.android.systemui.statusbar.pipeline.repository
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.ConnectivityManager.NetworkCallback
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED
|
||||
import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
|
||||
import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
|
||||
import android.net.NetworkCapabilities.TRANSPORT_WIFI
|
||||
import android.net.NetworkRequest
|
||||
import android.test.suitebuilder.annotation.SmallTest
|
||||
import android.testing.AndroidTestingRunner
|
||||
import com.android.systemui.SysuiTestCase
|
||||
import com.android.systemui.statusbar.pipeline.ConnectivityPipelineLogger
|
||||
import com.android.systemui.util.mockito.mock
|
||||
import com.android.systemui.util.mockito.withArgCaptor
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.Mock
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.`when` as whenever
|
||||
import org.mockito.MockitoAnnotations
|
||||
|
||||
// TODO(b/240619365): Update this test to use `runTest` when we update the testing library
|
||||
@SmallTest
|
||||
@RunWith(AndroidTestingRunner::class)
|
||||
class NetworkCapabilitiesRepoTest : SysuiTestCase() {
|
||||
@Mock private lateinit var connectivityManager: ConnectivityManager
|
||||
@Mock private lateinit var logger: ConnectivityPipelineLogger
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
MockitoAnnotations.initMocks(this)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnCapabilitiesChanged_oneNewNetwork_networkStored() = runBlocking {
|
||||
// GIVEN a repo hooked up to [ConnectivityManager]
|
||||
val scope = CoroutineScope(Dispatchers.Unconfined)
|
||||
val repo = NetworkCapabilitiesRepo(
|
||||
connectivityManager = connectivityManager,
|
||||
scope = scope,
|
||||
logger = logger,
|
||||
)
|
||||
|
||||
val job = launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
repo.dataStream.collect {
|
||||
}
|
||||
}
|
||||
|
||||
val callback: NetworkCallback = withArgCaptor {
|
||||
verify(connectivityManager)
|
||||
.registerNetworkCallback(any(NetworkRequest::class.java), capture())
|
||||
}
|
||||
|
||||
// WHEN a new network is added
|
||||
callback.onCapabilitiesChanged(NET_1, NET_1_CAPS)
|
||||
|
||||
val currentMap = repo.dataStream.value
|
||||
|
||||
// THEN it is emitted from the flow
|
||||
assertThat(currentMap[NET_1_ID]?.network).isEqualTo(NET_1)
|
||||
assertThat(currentMap[NET_1_ID]?.capabilities).isEqualTo(NET_1_CAPS)
|
||||
|
||||
job.cancel()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnCapabilitiesChanged_twoNewNetworks_bothStored() = runBlocking {
|
||||
// GIVEN a repo hooked up to [ConnectivityManager]
|
||||
val scope = CoroutineScope(Dispatchers.Unconfined)
|
||||
val repo = NetworkCapabilitiesRepo(
|
||||
connectivityManager = connectivityManager,
|
||||
scope = scope,
|
||||
logger = logger,
|
||||
)
|
||||
|
||||
val job = launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
repo.dataStream.collect {
|
||||
}
|
||||
}
|
||||
|
||||
val callback: NetworkCallback = withArgCaptor {
|
||||
verify(connectivityManager)
|
||||
.registerNetworkCallback(any(NetworkRequest::class.java), capture())
|
||||
}
|
||||
|
||||
// WHEN two new networks are added
|
||||
callback.onCapabilitiesChanged(NET_1, NET_1_CAPS)
|
||||
callback.onCapabilitiesChanged(NET_2, NET_2_CAPS)
|
||||
|
||||
val currentMap = repo.dataStream.value
|
||||
|
||||
// THEN the current state of the flow reflects 2 networks
|
||||
assertThat(currentMap[NET_1_ID]?.network).isEqualTo(NET_1)
|
||||
assertThat(currentMap[NET_1_ID]?.capabilities).isEqualTo(NET_1_CAPS)
|
||||
assertThat(currentMap[NET_2_ID]?.network).isEqualTo(NET_2)
|
||||
assertThat(currentMap[NET_2_ID]?.capabilities).isEqualTo(NET_2_CAPS)
|
||||
|
||||
job.cancel()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnCapabilitesChanged_newCapabilitiesForExistingNetwork_areCaptured() = runBlocking {
|
||||
// GIVEN a repo hooked up to [ConnectivityManager]
|
||||
val scope = CoroutineScope(Dispatchers.Unconfined)
|
||||
val repo = NetworkCapabilitiesRepo(
|
||||
connectivityManager = connectivityManager,
|
||||
scope = scope,
|
||||
logger = logger,
|
||||
)
|
||||
|
||||
val job = launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
repo.dataStream.collect {
|
||||
}
|
||||
}
|
||||
|
||||
val callback: NetworkCallback = withArgCaptor {
|
||||
verify(connectivityManager)
|
||||
.registerNetworkCallback(any(NetworkRequest::class.java), capture())
|
||||
}
|
||||
|
||||
// WHEN a network is added, and then its capabilities are changed
|
||||
callback.onCapabilitiesChanged(NET_1, NET_1_CAPS)
|
||||
callback.onCapabilitiesChanged(NET_1, NET_2_CAPS)
|
||||
|
||||
val currentMap = repo.dataStream.value
|
||||
|
||||
// THEN the current state of the flow reflects the new capabilities
|
||||
assertThat(currentMap[NET_1_ID]?.capabilities).isEqualTo(NET_2_CAPS)
|
||||
|
||||
job.cancel()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnLost_networkIsRemoved() = runBlocking {
|
||||
// GIVEN a repo hooked up to [ConnectivityManager]
|
||||
val scope = CoroutineScope(Dispatchers.Unconfined)
|
||||
val repo = NetworkCapabilitiesRepo(
|
||||
connectivityManager = connectivityManager,
|
||||
scope = scope,
|
||||
logger = logger,
|
||||
)
|
||||
|
||||
val job = launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
repo.dataStream.collect {
|
||||
}
|
||||
}
|
||||
|
||||
val callback: NetworkCallback = withArgCaptor {
|
||||
verify(connectivityManager)
|
||||
.registerNetworkCallback(any(NetworkRequest::class.java), capture())
|
||||
}
|
||||
|
||||
// WHEN two new networks are added, and one is removed
|
||||
callback.onCapabilitiesChanged(NET_1, NET_1_CAPS)
|
||||
callback.onCapabilitiesChanged(NET_2, NET_2_CAPS)
|
||||
callback.onLost(NET_1)
|
||||
|
||||
val currentMap = repo.dataStream.value
|
||||
|
||||
// THEN the current state of the flow reflects only the remaining network
|
||||
assertThat(currentMap[NET_1_ID]).isNull()
|
||||
assertThat(currentMap[NET_2_ID]?.network).isEqualTo(NET_2)
|
||||
assertThat(currentMap[NET_2_ID]?.capabilities).isEqualTo(NET_2_CAPS)
|
||||
|
||||
job.cancel()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnLost_noNetworks_doesNotCrash() = runBlocking {
|
||||
// GIVEN a repo hooked up to [ConnectivityManager]
|
||||
val scope = CoroutineScope(Dispatchers.Unconfined)
|
||||
val repo = NetworkCapabilitiesRepo(
|
||||
connectivityManager = connectivityManager,
|
||||
scope = scope,
|
||||
logger = logger,
|
||||
)
|
||||
|
||||
val job = launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
repo.dataStream.collect {
|
||||
}
|
||||
}
|
||||
|
||||
val callback: NetworkCallback = withArgCaptor {
|
||||
verify(connectivityManager)
|
||||
.registerNetworkCallback(any(NetworkRequest::class.java), capture())
|
||||
}
|
||||
|
||||
// WHEN no networks are added, and one is removed
|
||||
callback.onLost(NET_1)
|
||||
|
||||
val currentMap = repo.dataStream.value
|
||||
|
||||
// THEN the current state of the flow shows no networks
|
||||
assertThat(currentMap).isEmpty()
|
||||
|
||||
job.cancel()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private val NET_1_ID = 100
|
||||
private val NET_1 = mock<Network>().also {
|
||||
whenever(it.getNetId()).thenReturn(NET_1_ID)
|
||||
}
|
||||
private val NET_2_ID = 200
|
||||
private val NET_2 = mock<Network>().also {
|
||||
whenever(it.getNetId()).thenReturn(NET_2_ID)
|
||||
}
|
||||
|
||||
private val NET_1_CAPS = NetworkCapabilities.Builder()
|
||||
.addTransportType(TRANSPORT_CELLULAR)
|
||||
.addCapability(NET_CAPABILITY_VALIDATED)
|
||||
.build()
|
||||
|
||||
private val NET_2_CAPS = NetworkCapabilities.Builder()
|
||||
.addTransportType(TRANSPORT_WIFI)
|
||||
.addCapability(NET_CAPABILITY_NOT_METERED)
|
||||
.addCapability(NET_CAPABILITY_VALIDATED)
|
||||
.build()
|
||||
}
|
||||
Reference in New Issue
Block a user