Ассистент Порфирий: тула времени, сессии, чат-UI, голосовой цикл; BT-транспорт очки↔телефон
- BT (RFCOMM SPP 00001105) вместо WiFi: BtGlassesTransport/BtServerTransport, реконнект 3с - PlaybackService (foreground, WAKE_LOCK) против морозов HONOR - Ассистент: AgentTools (TimeTool ISO 8601), TOOL_CALL-протокол, сессии SQLite (UUID) - Чат-UI: создание/сброс/переключение сессий, автообновление ленты 2с - LlmClient на прямом OkHttp (Koog давал пустой content на длинных историях) - Голосовой цикл: тройной тап на очках → STT → активная сессия → ShowText - Тесты: AgentToolsTest (3), GlassesServerTest переписан (6) — зелёные
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
package pw.binom.viewmate.glasses
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.viewmate.core.log
|
||||
import pw.binom.viewmate.core.net.BtTransportConstants
|
||||
import pw.binom.viewmate.core.net.GlassesTransport
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStream
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Bluetooth-транспорт, сторона очков («клиент») — TASK-transport.md п.3:
|
||||
* RFCOMM/SPP-клиент ([BtTransportConstants.SPP_UUID]): [connect] — бесконечный
|
||||
* цикл подключения с реконнектом через [BtTransportConstants.RETRY_DELAY_MS]
|
||||
* (цикл живёт внутри транспорта), строки-сообщения (JSON + перевод строки —
|
||||
* разделитель) читаются на Dispatchers.IO, запись — под синхронизацией на
|
||||
* сокете. Адрес телефона берётся из [GlassesConfig.btRemoteMac].
|
||||
* Включается флагом [GlassesConfig.USE_BLUETOOTH] (дефолт — WiFi).
|
||||
*/
|
||||
class BtGlassesTransport(
|
||||
private val context: Context,
|
||||
) : GlassesTransport {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Runtime-пермишены Bluetooth, зависящие от версии платформы
|
||||
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
|
||||
* 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
|
||||
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
|
||||
*/
|
||||
fun runtimePermissions(): Array<String> =
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun hasBtPermissions(ctx: Context): Boolean =
|
||||
runtimePermissions().all {
|
||||
ContextCompat.checkSelfPermission(ctx, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
override val name = "bluetooth"
|
||||
|
||||
@Volatile
|
||||
private var closed = false
|
||||
|
||||
@Volatile
|
||||
private var socket: BluetoothSocket? = null
|
||||
|
||||
private val outLock = Any()
|
||||
private var out: OutputStream? = null
|
||||
|
||||
/** Бесконечный цикл подключения+реконнекта: в корутине вызывающего, до [close]. */
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun connect(
|
||||
onMessage: suspend (String) -> Unit,
|
||||
onConnected: suspend () -> Unit,
|
||||
onDisconnected: suspend () -> Unit,
|
||||
) {
|
||||
while (!closed) {
|
||||
var connected = false
|
||||
if (socket == null) {
|
||||
if (!ensureSocket()) {
|
||||
log("bt", "SPP не создан (радио/пермишен/адрес?) — ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
|
||||
delay(BtTransportConstants.RETRY_DELAY_MS)
|
||||
continue
|
||||
}
|
||||
log("bt", "SPP-клиент готов: ${GlassesConfig.btRemoteMac()}")
|
||||
}
|
||||
try {
|
||||
val sock = socket!!
|
||||
withContext(Dispatchers.IO) { sock.connect() }
|
||||
connected = true
|
||||
out = sock.outputStream
|
||||
log("bt", "SPP-канал открыт: ${GlassesConfig.btRemoteMac()}")
|
||||
onConnected()
|
||||
// readLines: EOF (партнёр закрыл) или исключение (обрыв/close)
|
||||
readLines(sock, onMessage)
|
||||
if (!closed) {
|
||||
log("bt", "SPP-соединение оборвано")
|
||||
onDisconnected()
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("bt", "SPP-ошибка (${e::class.simpleName}: ${e.message}) — ретрай через ${BtTransportConstants.RETRY_DELAY_MS / 1000}с")
|
||||
if (!closed && connected) runCatching { onDisconnected() }
|
||||
}
|
||||
runCatching { out?.close() }
|
||||
out = null
|
||||
runCatching { socket?.close() }
|
||||
socket = null
|
||||
if (closed) break
|
||||
delay(BtTransportConstants.RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** RFCOMM/SPP-сокет к [GlassesConfig.btRemoteMac]; без радио/пермишена/адреса — false. */
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun ensureSocket(): Boolean {
|
||||
val mac = GlassesConfig.btRemoteMac()
|
||||
if (mac.isNullOrBlank()) {
|
||||
log("bt", "адрес телефона не задан (GlassesConfig.BT_REMOTE_MAC) — SPP недоступен")
|
||||
return false
|
||||
}
|
||||
val adapter = BluetoothAdapter.getDefaultAdapter() ?: run {
|
||||
log("bt", "BT-радио не найдено — SPP недоступен")
|
||||
return false
|
||||
}
|
||||
if (!adapter.isEnabled) {
|
||||
log("bt", "Bluetooth выключен — включите в настройках Android")
|
||||
return false
|
||||
}
|
||||
if (!hasBtPermissions(context)) {
|
||||
log("bt", "нет runtime-пермишена Bluetooth: ${runtimePermissions().joinToString(", ")}")
|
||||
return false
|
||||
}
|
||||
val dev = try {
|
||||
adapter.getRemoteDevice(mac)
|
||||
} catch (e: Exception) {
|
||||
log("bt", "устройство не найдено: ${e.message}")
|
||||
null
|
||||
}
|
||||
if (dev == null) return false
|
||||
val s = try {
|
||||
dev.createRfcommSocketToServiceRecord(UUID.fromString(BtTransportConstants.SPP_UUID))
|
||||
} catch (e: Exception) {
|
||||
log("bt", "не удалось создать SPP-сокет: ${e.message}")
|
||||
null
|
||||
}
|
||||
if (s == null) return false
|
||||
socket = s
|
||||
return true
|
||||
}
|
||||
|
||||
/** Read-поток (Dispatchers.IO): строки по [BtTransportConstants.NEWLINE] → [onMessage]. */
|
||||
private suspend fun readLines(s: BluetoothSocket, onMessage: suspend (String) -> Unit) {
|
||||
val reader = BufferedReader(InputStreamReader(s.inputStream))
|
||||
while (!closed) {
|
||||
val line = withContext(Dispatchers.IO) { reader.readLine() } ?: break
|
||||
if (line.isNotBlank()) onMessage(line)
|
||||
}
|
||||
runCatching { reader.close() }
|
||||
}
|
||||
|
||||
/** JSON-строка в сокет: UTF-8 + разделитель, под синхронизацией. Без соединения — no-op. */
|
||||
@SuppressLint("MissingPermission")
|
||||
override suspend fun send(json: String) {
|
||||
val o = out ?: return
|
||||
runCatching {
|
||||
synchronized(outLock) {
|
||||
o.write(json.toByteArray(Charsets.UTF_8))
|
||||
o.write(BtTransportConstants.NEWLINE.code)
|
||||
o.flush()
|
||||
}
|
||||
}.onFailure { log("bt", "запись не удалась: ${it.message}") }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closed = true
|
||||
try {
|
||||
socket?.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
socket = null
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,19 @@ object GlassesConfig {
|
||||
// Fallback: эмулятор — 10.0.2.2 (localhost хоста); на устройстве телефон
|
||||
// находится авто-обнаружением по mDNS (_viewmate._tcp).
|
||||
const val HOST_URL = "ws://10.0.2.2:8080/ws/glasses"
|
||||
|
||||
/**
|
||||
* Выбор транспорта (TASK-transport.md п.4): `false` (дефолт) — WiFi
|
||||
* ([WifiGlassesTransport], поведение без изменений); `true` — Bluetooth SPP
|
||||
* ([BtGlassesTransport], manual-включение).
|
||||
*/
|
||||
const val USE_BLUETOOTH = true
|
||||
|
||||
/** MAC-адрес телефона-партнёра для SPP (нужен только при USE_BLUETOOTH=true). */
|
||||
val BT_REMOTE_MAC: String? = "08:84:FB:D5:BD:3E"
|
||||
|
||||
/** Адрес SPP-партнёра (null → SPP недоступен, транспорт ляжет с ретраями). */
|
||||
fun btRemoteMac(): String? = BT_REMOTE_MAC
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,12 +12,15 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.core.net.GlassesWsClient
|
||||
import pw.binom.viewmate.core.net.GlassesTransport
|
||||
import pw.binom.viewmate.core.net.WifiGlassesTransport
|
||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||
import pw.binom.viewmate.core.protocol.DeleteFiles
|
||||
@@ -37,13 +40,15 @@ import pw.binom.viewmate.core.protocol.SttCancel
|
||||
import pw.binom.viewmate.core.protocol.SttDone
|
||||
import pw.binom.viewmate.core.protocol.SttPhrase
|
||||
import pw.binom.viewmate.core.protocol.Welcome
|
||||
import pw.binom.viewmate.core.protocol.protocolJson
|
||||
|
||||
/**
|
||||
* Обвязка над [GlassesWsClient] для UI очков:
|
||||
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес), реконнект,
|
||||
* Hello при подключении, периодический статус/позиция,
|
||||
* обработка входящих HostToGlasses (PlayVideo/MediaCommand выполняются плеером,
|
||||
* DownloadFiles/DeleteFiles — менеджером скачивания).
|
||||
* Обвязка над [GlassesTransport] для UI очков (TASK-transport.md п.1/п.4):
|
||||
* mDNS-поиск телефона (с fallback'ом на эмуляторный адрес, остаётся на
|
||||
* WiFi-стороне — [WifiGlassesTransport]), реконнект, Hello при подключении,
|
||||
* периодический статус/позиция, обработка входящих HostToGlasses (строкой из
|
||||
* транспорта; декод — здесь, не в транспорте). Дефолтный транспорт — WiFi;
|
||||
* Bluetooth SPP ([BtGlassesTransport]) — флагом [GlassesConfig.USE_BLUETOOTH].
|
||||
*/
|
||||
class HostConnection(
|
||||
/** Адрес-фолбэк (эмулятор: ws://10.0.2.2:8080/ws/glasses); mDNS-поиск в приоритете. */
|
||||
@@ -56,11 +61,14 @@ class HostConnection(
|
||||
private val discovery = MdnsClient(context)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** Активный WS-клиент (создаётся после выбора адреса mDNS/fallback). */
|
||||
@Volatile
|
||||
private var client: GlassesWsClient? = null
|
||||
/** Транспортом включается выбор [GlassesConfig.USE_BLUETOOTH]: дефолт — WiFi. */
|
||||
private val useBt: Boolean = GlassesConfig.USE_BLUETOOTH
|
||||
|
||||
/** Последний найденный адрес — реконнект переиспользует его (без нового mDNS-цикла). */
|
||||
/** Активный транспорт (создаётся после выбора адреса: mDNS/fallback или SPP-MAC). */
|
||||
@Volatile
|
||||
private var transport: GlassesTransport? = null
|
||||
|
||||
/** Последний найденный адрес — реконнект переиспользует его (WiFi: URL, BT: MAC). */
|
||||
@Volatile
|
||||
private var lastUrl: String? = null
|
||||
|
||||
@@ -81,10 +89,10 @@ class HostConnection(
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(5_000)
|
||||
client?.send(buildStatus())
|
||||
sendToHost(buildStatus())
|
||||
val snapshot = withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||
if (snapshot.playing) movieController.markPosition(snapshot.positionMs)
|
||||
client?.send(
|
||||
sendToHost(
|
||||
PlaybackPosition(
|
||||
positionMs = snapshot.positionMs,
|
||||
playing = snapshot.playing,
|
||||
@@ -96,36 +104,80 @@ class HostConnection(
|
||||
}
|
||||
}
|
||||
|
||||
/** Развилка по [useBt]: WiFi (mDNS/scan/fallback) или SPP (адрес — [GlassesConfig.btRemoteMac]). */
|
||||
private suspend fun connectLoop() {
|
||||
if (useBt) connectLoopBt() else connectLoopWifi()
|
||||
}
|
||||
|
||||
/**
|
||||
* Первое подключение: mDNS-дискавери телефона (до 15с на устройстве, 3с на
|
||||
* эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
|
||||
* Первое подключение (WiFi): mDNS-дискавери телефона (до 15с на устройстве,
|
||||
* 3с на эмуляторе), найденный адрес — в приоритете, иначе scan /24 (план Б).
|
||||
* Fallback на эмуляторный 10.0.2.2 только на эмуляторе; на устройстве при
|
||||
* пустом адресе — повторный поиск через 3с (lastUrl не запоминается).
|
||||
* Внутри [GlassesWsClient.connect] живёт бесконечный реконнект с тем же
|
||||
* Внутри [GlassesTransport.connect] живёт бесконечный реконнект с тем же
|
||||
* адресом (переиспользование последнего найденного); при его отсутствии —
|
||||
* повторный mDNS-цикл.
|
||||
*/
|
||||
private suspend fun connectLoop() {
|
||||
private suspend fun connectLoopWifi() {
|
||||
val url = lastUrl ?: discoverPhoneUrl()
|
||||
if (url.isEmpty()) {
|
||||
log("app", "телефон не найден — повтор поиска через 3с")
|
||||
delay(3_000)
|
||||
connectLoop()
|
||||
connectLoopWifi()
|
||||
return
|
||||
}
|
||||
lastUrl = url
|
||||
log("app", "подключаюсь к $url")
|
||||
log("app", "подключаюсь к $url (WiFi)")
|
||||
|
||||
val c = GlassesWsClient(url)
|
||||
client = c
|
||||
c.connect(
|
||||
onHostMessage = { msg -> handleHostMessage(msg) },
|
||||
val t = WifiGlassesTransport(url)
|
||||
transport = t
|
||||
t.connect(
|
||||
onMessage = { text -> handleHostMessageText(text) },
|
||||
onConnected = {
|
||||
_connected.value = true
|
||||
_status.value = "подключено: $url"
|
||||
_status.value = "подключено: ${t.name} $url"
|
||||
addMessage("соединение установлено")
|
||||
discovery.stop()
|
||||
c.send(Hello(appVersion = APP_VERSION))
|
||||
sendToHost(Hello(appVersion = APP_VERSION))
|
||||
if (reconnectResume.consume()) {
|
||||
withContext(Dispatchers.Main) { movieController.resume() }
|
||||
addMessage("связь восстановлена — продолжаю")
|
||||
}
|
||||
},
|
||||
onDisconnected = {
|
||||
_connected.value = false
|
||||
_status.value = "нет соединения (реконнект через 3с)"
|
||||
val wasPlaying = withContext(Dispatchers.Main) { movieController.pauseIfPlaying() }
|
||||
reconnectResume.onConnectionLost(wasPlaying)
|
||||
if (wasPlaying) addMessage("связь потеряна — пауза")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SPP-цикл (BT): адрес телефона — [GlassesConfig.btRemoteMac] (введено
|
||||
* вручную), реконнект-цикл живёт внутри [BtGlassesTransport.connect].
|
||||
*/
|
||||
private suspend fun connectLoopBt() {
|
||||
val mac = lastUrl ?: (GlassesConfig.btRemoteMac() ?: "")
|
||||
if (mac.isEmpty()) {
|
||||
log("app", "BT: MAC-адрес телефона не задан — SPP недоступен")
|
||||
delay(3_000)
|
||||
connectLoopBt()
|
||||
return
|
||||
}
|
||||
lastUrl = mac
|
||||
log("app", "подключаюсь к $mac (Bluetooth SPP)")
|
||||
|
||||
val t = BtGlassesTransport(appContext)
|
||||
transport = t
|
||||
t.connect(
|
||||
onMessage = { text -> handleHostMessageText(text) },
|
||||
onConnected = {
|
||||
_connected.value = true
|
||||
_status.value = "подключено: bluetooth $mac"
|
||||
addMessage("соединение установлено")
|
||||
sendToHost(Hello(appVersion = APP_VERSION))
|
||||
if (reconnectResume.consume()) {
|
||||
withContext(Dispatchers.Main) { movieController.resume() }
|
||||
addMessage("связь восстановлена — продолжаю")
|
||||
@@ -145,7 +197,7 @@ class HostConnection(
|
||||
* Цепочка поиска адреса телефона: mDNS → scan /24 (план Б).
|
||||
* На эмуляторе scan пропускается (телефона в эмуляторной /24 нет) и сразу
|
||||
* наступает fallback на 10.0.2.2. На устройстве fallback запрещён — при
|
||||
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoop]).
|
||||
* неудачном поиске возвращается пустой адрес (повторный поиск в [connectLoopWifi]).
|
||||
*/
|
||||
private suspend fun discoverPhoneUrl(): String {
|
||||
val found = discovery.findPhoneIp(timeoutMs = MdnsClient.mdnsTimeoutMs(isEmulator()))
|
||||
@@ -171,18 +223,29 @@ class HostConnection(
|
||||
return ""
|
||||
}
|
||||
|
||||
/** Входящая JSON-строка из транспорта: декод конверта [HostToGlasses] здесь (не в транспорте). */
|
||||
private suspend fun handleHostMessageText(text: String) {
|
||||
val msg = runCatching {
|
||||
protocolJson.decodeFromString(HostToGlasses.serializer(), text)
|
||||
}.onFailure {
|
||||
log("glasses", "ОШИБКА декодирования входящего: ${it::class.simpleName}: ${it.message} (текст: ${text.take(120)})")
|
||||
}.getOrNull() ?: return
|
||||
handleHostMessage(msg)
|
||||
}
|
||||
|
||||
/** Отправка сообщения на телефон (используется менеджером скачивания). */
|
||||
suspend fun sendToHost(msg: GlassesToHost) {
|
||||
client?.send(msg)
|
||||
val t = transport ?: return
|
||||
t.send(protocolJson.encodeToString(GlassesToHost.serializer(), msg))
|
||||
}
|
||||
|
||||
/** Немедленно отправить текущую позицию (пауза/возобновление экрана). */
|
||||
fun sendPlaybackPositionNow() {
|
||||
val c = client ?: return
|
||||
if (transport == null) return
|
||||
scope.launch {
|
||||
val (positionMs, playing, itemId, audioIndex) =
|
||||
withContext(Dispatchers.Main) { movieController.playbackPosition() }
|
||||
c.send(PlaybackPosition(positionMs, playing, itemId, audioIndex))
|
||||
sendToHost(PlaybackPosition(positionMs, playing, itemId, audioIndex))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +369,7 @@ class HostConnection(
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
client?.close()
|
||||
transport?.close()
|
||||
discovery.stop()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -116,6 +117,7 @@ class MainActivity : BaseEventActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
GlassesApp.instance.mainActivity = this
|
||||
requestBtPermissions()
|
||||
collectTempleActions()
|
||||
disableTrackpadSounds()
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
@@ -193,6 +195,16 @@ class MainActivity : BaseEventActivity() {
|
||||
* голосовой ввод (безусловный вызов), Click во время прослушивания —
|
||||
* завершить распознавание, DoubleClick — отмена.
|
||||
*/
|
||||
private fun requestBtPermissions() {
|
||||
if (!GlassesConfig.USE_BLUETOOTH) return
|
||||
val granted = BtGlassesTransport.runtimePermissions().all {
|
||||
checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (granted) return
|
||||
requestPermissions(BtGlassesTransport.runtimePermissions(), 1008)
|
||||
log("app", "запрошены Bluetooth-пермишены (SPP): ${BtGlassesTransport.runtimePermissions().joinToString()}")
|
||||
}
|
||||
|
||||
private fun collectTempleActions() {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
|
||||
Reference in New Issue
Block a user