Refactor command client
This commit is contained in:
@@ -16,15 +16,9 @@ import androidx.recyclerview.widget.GridLayoutManager
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||||
import go.Seq
|
|
||||||
import io.nekohasekai.libbox.CommandClient
|
|
||||||
import io.nekohasekai.libbox.CommandClientHandler
|
|
||||||
import io.nekohasekai.libbox.CommandClientOptions
|
|
||||||
import io.nekohasekai.libbox.Libbox
|
import io.nekohasekai.libbox.Libbox
|
||||||
import io.nekohasekai.libbox.OutboundGroup
|
import io.nekohasekai.libbox.OutboundGroup
|
||||||
import io.nekohasekai.libbox.OutboundGroupItem
|
import io.nekohasekai.libbox.OutboundGroupItem
|
||||||
import io.nekohasekai.libbox.OutboundGroupIterator
|
|
||||||
import io.nekohasekai.libbox.StatusMessage
|
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.constant.Status
|
import io.nekohasekai.sfa.constant.Status
|
||||||
import io.nekohasekai.sfa.databinding.FragmentDashboardGroupsBinding
|
import io.nekohasekai.sfa.databinding.FragmentDashboardGroupsBinding
|
||||||
@@ -33,23 +27,26 @@ import io.nekohasekai.sfa.databinding.ViewDashboardGroupItemBinding
|
|||||||
import io.nekohasekai.sfa.ktx.colorForURLTestDelay
|
import io.nekohasekai.sfa.ktx.colorForURLTestDelay
|
||||||
import io.nekohasekai.sfa.ktx.errorDialogBuilder
|
import io.nekohasekai.sfa.ktx.errorDialogBuilder
|
||||||
import io.nekohasekai.sfa.ui.MainActivity
|
import io.nekohasekai.sfa.ui.MainActivity
|
||||||
|
import io.nekohasekai.sfa.utils.CommandClient
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
|
||||||
class GroupsFragment : Fragment(), CommandClientHandler {
|
class GroupsFragment : Fragment(), CommandClient.Handler {
|
||||||
|
|
||||||
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
|
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
|
||||||
private var _binding: FragmentDashboardGroupsBinding? = null
|
private var _binding: FragmentDashboardGroupsBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private var commandClient: CommandClient? = null
|
|
||||||
|
|
||||||
private var _adapter: Adapter? = null
|
private var _adapter: Adapter? = null
|
||||||
private val adapter get() = _adapter!!
|
private val adapter get() = _adapter!!
|
||||||
|
|
||||||
|
private val commandClient =
|
||||||
|
CommandClient(lifecycleScope, CommandClient.ConnectionType.Groups, this)
|
||||||
|
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
@@ -65,41 +62,11 @@ class GroupsFragment : Fragment(), CommandClientHandler {
|
|||||||
binding.container.layoutManager = LinearLayoutManager(requireContext())
|
binding.container.layoutManager = LinearLayoutManager(requireContext())
|
||||||
activity.serviceStatus.observe(viewLifecycleOwner) {
|
activity.serviceStatus.observe(viewLifecycleOwner) {
|
||||||
if (it == Status.Started) {
|
if (it == Status.Started) {
|
||||||
reconnect()
|
commandClient.connect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reconnect() {
|
|
||||||
disconnect()
|
|
||||||
val options = CommandClientOptions()
|
|
||||||
options.command = Libbox.CommandGroup
|
|
||||||
options.statusInterval = 2 * 1000 * 1000 * 1000
|
|
||||||
val commandClient = CommandClient(requireContext().filesDir.absolutePath, this, options)
|
|
||||||
this.commandClient = commandClient
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
for (i in 1..3) {
|
|
||||||
delay(100)
|
|
||||||
try {
|
|
||||||
commandClient.connect()
|
|
||||||
break
|
|
||||||
} catch (e: Exception) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun disconnect() {
|
|
||||||
commandClient?.apply {
|
|
||||||
runCatching {
|
|
||||||
disconnect()
|
|
||||||
}
|
|
||||||
Seq.destroyRef(refnum)
|
|
||||||
}
|
|
||||||
commandClient = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private var displayed = false
|
private var displayed = false
|
||||||
private fun updateDisplayed(newValue: Boolean) {
|
private fun updateDisplayed(newValue: Boolean) {
|
||||||
if (displayed != newValue) {
|
if (displayed != newValue) {
|
||||||
@@ -109,24 +76,20 @@ class GroupsFragment : Fragment(), CommandClientHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun connected() {
|
override fun onConnected() {
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
updateDisplayed(true)
|
updateDisplayed(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun disconnected(message: String?) {
|
override fun onDisconnected() {
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
updateDisplayed(false)
|
updateDisplayed(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
override fun writeGroups(message: OutboundGroupIterator) {
|
override fun updateGroups(groups: List<OutboundGroup>) {
|
||||||
val groups = mutableListOf<OutboundGroup>()
|
|
||||||
while (message.hasNext()) {
|
|
||||||
groups.add(message.next())
|
|
||||||
}
|
|
||||||
activity?.runOnUiThread {
|
activity?.runOnUiThread {
|
||||||
updateDisplayed(groups.isNotEmpty())
|
updateDisplayed(groups.isNotEmpty())
|
||||||
adapter.groups = groups
|
adapter.groups = groups
|
||||||
@@ -134,12 +97,6 @@ class GroupsFragment : Fragment(), CommandClientHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun writeLog(message: String?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun writeStatus(message: StatusMessage?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private class Adapter : RecyclerView.Adapter<GroupView>() {
|
private class Adapter : RecyclerView.Adapter<GroupView>() {
|
||||||
|
|
||||||
lateinit var groups: List<OutboundGroup>
|
lateinit var groups: List<OutboundGroup>
|
||||||
|
|||||||
@@ -10,12 +10,7 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.divider.MaterialDividerItemDecoration
|
import com.google.android.material.divider.MaterialDividerItemDecoration
|
||||||
import go.Seq
|
|
||||||
import io.nekohasekai.libbox.CommandClient
|
|
||||||
import io.nekohasekai.libbox.CommandClientHandler
|
|
||||||
import io.nekohasekai.libbox.CommandClientOptions
|
|
||||||
import io.nekohasekai.libbox.Libbox
|
import io.nekohasekai.libbox.Libbox
|
||||||
import io.nekohasekai.libbox.OutboundGroupIterator
|
|
||||||
import io.nekohasekai.libbox.StatusMessage
|
import io.nekohasekai.libbox.StatusMessage
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.bg.BoxService
|
import io.nekohasekai.sfa.bg.BoxService
|
||||||
@@ -27,18 +22,20 @@ import io.nekohasekai.sfa.databinding.FragmentDashboardOverviewBinding
|
|||||||
import io.nekohasekai.sfa.databinding.ViewProfileItemBinding
|
import io.nekohasekai.sfa.databinding.ViewProfileItemBinding
|
||||||
import io.nekohasekai.sfa.ktx.errorDialogBuilder
|
import io.nekohasekai.sfa.ktx.errorDialogBuilder
|
||||||
import io.nekohasekai.sfa.ui.MainActivity
|
import io.nekohasekai.sfa.ui.MainActivity
|
||||||
|
import io.nekohasekai.sfa.utils.CommandClient
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class OverviewFragment : Fragment(), CommandClientHandler {
|
class OverviewFragment : Fragment(), CommandClient.Handler {
|
||||||
|
|
||||||
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
|
private val activity: MainActivity? get() = super.getActivity() as MainActivity?
|
||||||
private var _binding: FragmentDashboardOverviewBinding? = null
|
private var _binding: FragmentDashboardOverviewBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private var commandClient: CommandClient? = null
|
private val commandClient =
|
||||||
|
CommandClient(lifecycleScope, CommandClient.ConnectionType.Status, this)
|
||||||
|
|
||||||
private var _adapter: Adapter? = null
|
private var _adapter: Adapter? = null
|
||||||
private val adapter get() = _adapter!!
|
private val adapter get() = _adapter!!
|
||||||
@@ -64,47 +61,17 @@ class OverviewFragment : Fragment(), CommandClientHandler {
|
|||||||
activity.serviceStatus.observe(viewLifecycleOwner) {
|
activity.serviceStatus.observe(viewLifecycleOwner) {
|
||||||
binding.statusContainer.isVisible = it == Status.Starting || it == Status.Started
|
binding.statusContainer.isVisible = it == Status.Starting || it == Status.Started
|
||||||
if (it == Status.Started) {
|
if (it == Status.Started) {
|
||||||
reconnect()
|
commandClient.connect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ProfileManager.registerCallback(this::updateProfiles)
|
ProfileManager.registerCallback(this::updateProfiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reconnect() {
|
|
||||||
disconnect()
|
|
||||||
val options = CommandClientOptions()
|
|
||||||
options.command = Libbox.CommandStatus
|
|
||||||
options.statusInterval = 2 * 1000 * 1000 * 1000
|
|
||||||
val commandClient = CommandClient(requireContext().filesDir.absolutePath, this, options)
|
|
||||||
this.commandClient = commandClient
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
for (i in 1..3) {
|
|
||||||
delay(100)
|
|
||||||
try {
|
|
||||||
commandClient.connect()
|
|
||||||
break
|
|
||||||
} catch (e: Exception) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun disconnect() {
|
|
||||||
commandClient?.apply {
|
|
||||||
runCatching {
|
|
||||||
disconnect()
|
|
||||||
}
|
|
||||||
Seq.destroyRef(refnum)
|
|
||||||
}
|
|
||||||
commandClient = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
_adapter = null
|
_adapter = null
|
||||||
_binding = null
|
_binding = null
|
||||||
disconnect()
|
commandClient.disconnect()
|
||||||
ProfileManager.unregisterCallback(this::updateProfiles)
|
ProfileManager.unregisterCallback(this::updateProfiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +79,7 @@ class OverviewFragment : Fragment(), CommandClientHandler {
|
|||||||
_adapter?.reload()
|
_adapter?.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun connected() {
|
override fun onConnected() {
|
||||||
val binding = _binding ?: return
|
val binding = _binding ?: return
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
binding.memoryText.text = getString(R.string.loading)
|
binding.memoryText.text = getString(R.string.loading)
|
||||||
@@ -120,7 +87,7 @@ class OverviewFragment : Fragment(), CommandClientHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun disconnected(message: String?) {
|
override fun onDisconnected() {
|
||||||
val binding = _binding ?: return
|
val binding = _binding ?: return
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
binding.memoryText.text = getString(R.string.loading)
|
binding.memoryText.text = getString(R.string.loading)
|
||||||
@@ -128,30 +95,24 @@ class OverviewFragment : Fragment(), CommandClientHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun writeLog(message: String) {
|
override fun updateStatus(status: StatusMessage) {
|
||||||
}
|
|
||||||
|
|
||||||
override fun writeStatus(message: StatusMessage) {
|
|
||||||
val binding = _binding ?: return
|
val binding = _binding ?: return
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
binding.memoryText.text = Libbox.formatBytes(message.memory)
|
binding.memoryText.text = Libbox.formatBytes(status.memory)
|
||||||
binding.goroutinesText.text = message.goroutines.toString()
|
binding.goroutinesText.text = status.goroutines.toString()
|
||||||
val trafficAvailable = message.trafficAvailable
|
val trafficAvailable = status.trafficAvailable
|
||||||
binding.trafficContainer.isVisible = trafficAvailable
|
binding.trafficContainer.isVisible = trafficAvailable
|
||||||
if (trafficAvailable) {
|
if (trafficAvailable) {
|
||||||
binding.inboundConnectionsText.text = message.connectionsIn.toString()
|
binding.inboundConnectionsText.text = status.connectionsIn.toString()
|
||||||
binding.outboundConnectionsText.text = message.connectionsOut.toString()
|
binding.outboundConnectionsText.text = status.connectionsOut.toString()
|
||||||
binding.uplinkText.text = Libbox.formatBytes(message.uplink) + "/s"
|
binding.uplinkText.text = Libbox.formatBytes(status.uplink) + "/s"
|
||||||
binding.downlinkText.text = Libbox.formatBytes(message.downlink) + "/s"
|
binding.downlinkText.text = Libbox.formatBytes(status.downlink) + "/s"
|
||||||
binding.uplinkTotalText.text = Libbox.formatBytes(message.uplinkTotal)
|
binding.uplinkTotalText.text = Libbox.formatBytes(status.uplinkTotal)
|
||||||
binding.downlinkTotalText.text = Libbox.formatBytes(message.downlinkTotal)
|
binding.downlinkTotalText.text = Libbox.formatBytes(status.downlinkTotal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun writeGroups(message: OutboundGroupIterator?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
class Adapter(
|
class Adapter(
|
||||||
internal val scope: CoroutineScope,
|
internal val scope: CoroutineScope,
|
||||||
private val parent: FragmentDashboardOverviewBinding
|
private val parent: FragmentDashboardOverviewBinding
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import androidx.recyclerview.widget.RecyclerView
|
|||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.database.Profile
|
import io.nekohasekai.sfa.database.Profile
|
||||||
import io.nekohasekai.sfa.database.ProfileManager
|
import io.nekohasekai.sfa.database.ProfileManager
|
||||||
import io.nekohasekai.sfa.database.TypedProfile
|
|
||||||
import io.nekohasekai.sfa.databinding.FragmentConfigurationBinding
|
import io.nekohasekai.sfa.databinding.FragmentConfigurationBinding
|
||||||
import io.nekohasekai.sfa.databinding.ViewConfigutationItemBinding
|
import io.nekohasekai.sfa.databinding.ViewConfigutationItemBinding
|
||||||
import io.nekohasekai.sfa.ktx.errorDialogBuilder
|
import io.nekohasekai.sfa.ktx.errorDialogBuilder
|
||||||
|
|||||||
120
app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt
Normal file
120
app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package io.nekohasekai.sfa.utils
|
||||||
|
|
||||||
|
import go.Seq
|
||||||
|
import io.nekohasekai.libbox.CommandClient
|
||||||
|
import io.nekohasekai.libbox.CommandClientHandler
|
||||||
|
import io.nekohasekai.libbox.CommandClientOptions
|
||||||
|
import io.nekohasekai.libbox.Libbox
|
||||||
|
import io.nekohasekai.libbox.OutboundGroup
|
||||||
|
import io.nekohasekai.libbox.OutboundGroupIterator
|
||||||
|
import io.nekohasekai.libbox.StatusMessage
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class CommandClient(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val connectionType: ConnectionType,
|
||||||
|
private val handler: Handler
|
||||||
|
) {
|
||||||
|
|
||||||
|
enum class ConnectionType {
|
||||||
|
Status, Groups, Log
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Handler {
|
||||||
|
|
||||||
|
fun onConnected() {}
|
||||||
|
fun onDisconnected() {}
|
||||||
|
fun updateStatus(status: StatusMessage) {}
|
||||||
|
fun updateGroups(groups: List<OutboundGroup>) {}
|
||||||
|
fun appendLog(message: String) {}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private var commandClient: CommandClient? = null
|
||||||
|
private val clientHandler = ClientHandler()
|
||||||
|
fun connect() {
|
||||||
|
disconnect()
|
||||||
|
val options = CommandClientOptions()
|
||||||
|
options.command = when (connectionType) {
|
||||||
|
ConnectionType.Status -> Libbox.CommandStatus
|
||||||
|
ConnectionType.Groups -> Libbox.CommandGroup
|
||||||
|
ConnectionType.Log -> Libbox.CommandLog
|
||||||
|
}
|
||||||
|
options.statusInterval = 2 * 1000 * 1000 * 1000
|
||||||
|
val commandClient = CommandClient(clientHandler, options)
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
for (i in 1..10) {
|
||||||
|
delay(100 + i.toLong() * 50)
|
||||||
|
try {
|
||||||
|
commandClient.connect()
|
||||||
|
} catch (ignored: Exception) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!isActive) {
|
||||||
|
runCatching {
|
||||||
|
commandClient.disconnect()
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
this@CommandClient.commandClient = commandClient
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
commandClient.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
commandClient?.apply {
|
||||||
|
runCatching {
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
Seq.destroyRef(refnum)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class ClientHandler : CommandClientHandler {
|
||||||
|
|
||||||
|
override fun connected() {
|
||||||
|
handler.onConnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun disconnected(message: String?) {
|
||||||
|
handler.onDisconnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun writeGroups(message: OutboundGroupIterator?) {
|
||||||
|
if (message == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val groups = mutableListOf<OutboundGroup>()
|
||||||
|
while (message.hasNext()) {
|
||||||
|
groups.add(message.next())
|
||||||
|
}
|
||||||
|
handler.updateGroups(groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun writeLog(message: String?) {
|
||||||
|
if (message == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler.appendLog(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun writeStatus(message: StatusMessage?) {
|
||||||
|
if (message == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler.updateStatus(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user