feat: fkm существует
This commit is contained in:
@@ -16,6 +16,9 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
@@ -126,6 +129,24 @@
|
||||
android:name=".CallForegroundService"
|
||||
android:foregroundServiceType="microphone|mediaProjection"
|
||||
android:exported="false" />
|
||||
<service
|
||||
android:name=".FkmService"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:exported="false">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Maintains the app's own persistent connection to the messaging server so that message notifications are delivered on builds without Google Play Services" />
|
||||
</service>
|
||||
<receiver
|
||||
android:name=".FkmDisableReceiver"
|
||||
android:exported="false" />
|
||||
<receiver
|
||||
android:name=".FkmBootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name=".CallActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package ru.komet.app
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
object FkmChannel {
|
||||
private const val NAME = "ru.komet.app/fkm"
|
||||
const val NOTIF_PERMS_REQUEST = 7713
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val worker = Executors.newSingleThreadExecutor()
|
||||
|
||||
private var channel: MethodChannel? = null
|
||||
private var permResult: MethodChannel.Result? = null
|
||||
|
||||
fun attach(engine: FlutterEngine, activity: Activity) {
|
||||
val ctx = activity.applicationContext
|
||||
FkmState.restore(ctx)
|
||||
val ch = MethodChannel(engine.dartExecutor.binaryMessenger, NAME)
|
||||
channel = ch
|
||||
ch.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"isEnabled" -> result.success(FkmState.enabled)
|
||||
|
||||
"setEnabled" -> {
|
||||
val enabled = call.argument<Boolean>("enabled") ?: false
|
||||
FkmState.applyEnabled(ctx, enabled)
|
||||
if (enabled) FkmService.start(ctx) else FkmService.stop(ctx)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
"setConnected" -> {
|
||||
FkmState.connected = call.argument<Boolean>("connected") ?: false
|
||||
FkmService.refresh(ctx)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
"showMessage" -> result.success(deliver(ctx, call, "showMessage"))
|
||||
|
||||
"showCall" -> result.success(deliver(ctx, call, "showCall"))
|
||||
|
||||
"hasNotificationPermission" ->
|
||||
result.success(NotificationManagerCompat.from(ctx).areNotificationsEnabled())
|
||||
|
||||
"requestNotificationPermission" -> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
NotificationManagerCompat.from(ctx).areNotificationsEnabled()
|
||||
) {
|
||||
result.success(
|
||||
NotificationManagerCompat.from(ctx).areNotificationsEnabled(),
|
||||
)
|
||||
} else {
|
||||
permResult?.success(false)
|
||||
permResult = result
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
NOTIF_PERMS_REQUEST,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"isIgnoringBatteryOptimizations" -> {
|
||||
val power = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
result.success(power.isIgnoringBatteryOptimizations(ctx.packageName))
|
||||
}
|
||||
|
||||
"requestIgnoreBatteryOptimizations" -> {
|
||||
try {
|
||||
activity.startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||
Uri.parse("package:${ctx.packageName}"),
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w("Fkm", "battery settings failed: ${e.message}")
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Отрисовка тянет аватарку по сети — только не на главном потоке.
|
||||
private fun deliver(ctx: Context, call: MethodCall, tag: String): Boolean {
|
||||
val data = call.argument<Map<String, String>>("data") ?: return false
|
||||
worker.execute {
|
||||
try {
|
||||
KometNotifier(ctx).handle(data)
|
||||
FkmState.countDelivered(ctx)
|
||||
FkmService.refresh(ctx)
|
||||
} catch (e: Exception) {
|
||||
Log.w("Fkm", "$tag failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
channel?.setMethodCallHandler(null)
|
||||
channel = null
|
||||
permResult?.success(false)
|
||||
permResult = null
|
||||
}
|
||||
|
||||
fun onPermissionResult(grantResults: IntArray) {
|
||||
val pending = permResult ?: return
|
||||
permResult = null
|
||||
pending.success(
|
||||
grantResults.isNotEmpty() &&
|
||||
grantResults.all { it == PackageManager.PERMISSION_GRANTED },
|
||||
)
|
||||
}
|
||||
|
||||
fun notifyDisabled() {
|
||||
main.post {
|
||||
try {
|
||||
channel?.invokeMethod("disabled", null)
|
||||
} catch (e: Exception) {
|
||||
Log.w("Fkm", "notifyDisabled failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package ru.komet.app
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
object FkmState {
|
||||
private const val PREFS = "komet_fkm"
|
||||
private const val KEY_ENABLED = "enabled"
|
||||
private const val KEY_DELIVERED = "delivered"
|
||||
|
||||
@Volatile
|
||||
var enabled = false
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var connected = false
|
||||
|
||||
@Volatile
|
||||
var delivered = 0
|
||||
private set
|
||||
|
||||
private fun prefs(ctx: Context) =
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
fun restore(ctx: Context) {
|
||||
val p = prefs(ctx)
|
||||
enabled = p.getBoolean(KEY_ENABLED, false)
|
||||
delivered = p.getInt(KEY_DELIVERED, 0)
|
||||
}
|
||||
|
||||
fun applyEnabled(ctx: Context, value: Boolean) {
|
||||
enabled = value
|
||||
// Счётчик обнуляем только на выключении, чтобы перезапуск приложения
|
||||
// не сбрасывал накопленное.
|
||||
if (!value) {
|
||||
delivered = 0
|
||||
connected = false
|
||||
}
|
||||
prefs(ctx).edit()
|
||||
.putBoolean(KEY_ENABLED, value)
|
||||
.putInt(KEY_DELIVERED, delivered)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun countDelivered(ctx: Context) {
|
||||
delivered += 1
|
||||
prefs(ctx).edit().putInt(KEY_DELIVERED, delivered).apply()
|
||||
}
|
||||
}
|
||||
|
||||
object FkmNotification {
|
||||
const val CHANNEL_ID = "komet_fkm"
|
||||
const val NOTIFICATION_ID = 424244
|
||||
|
||||
fun build(ctx: Context): Notification {
|
||||
ensureChannel(ctx)
|
||||
|
||||
val status = if (FkmState.connected) {
|
||||
ctx.getString(R.string.fkm_status_active)
|
||||
} else {
|
||||
ctx.getString(R.string.fkm_status_inactive)
|
||||
}
|
||||
val title = ctx.getString(R.string.fkm_title)
|
||||
val text = ctx.getString(R.string.fkm_status_line, status, FkmState.delivered)
|
||||
|
||||
val immutable = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val open = PendingIntent.getActivity(
|
||||
ctx,
|
||||
0,
|
||||
Intent(ctx, MainActivity::class.java)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||
immutable,
|
||||
)
|
||||
val disable = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
1,
|
||||
Intent(ctx, FkmDisableReceiver::class.java).apply {
|
||||
action = FkmDisableReceiver.ACTION_DISABLE
|
||||
},
|
||||
immutable,
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(ctx, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(CallConst.ACCENT)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle()
|
||||
.setBigContentTitle(title)
|
||||
.bigText("$text\n\n${ctx.getString(R.string.fkm_explain)}"),
|
||||
)
|
||||
.setContentIntent(open)
|
||||
.addAction(0, ctx.getString(R.string.fkm_disable), disable)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(false)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_SECRET)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun ensureChannel(ctx: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = manager(ctx)
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
ctx.getString(R.string.fkm_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = ctx.getString(R.string.fkm_channel_description)
|
||||
setShowBadge(false)
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
enableLights(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun manager(ctx: Context): NotificationManager =
|
||||
ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
}
|
||||
|
||||
class FkmService : Service() {
|
||||
|
||||
companion object {
|
||||
fun start(ctx: Context) {
|
||||
val intent = Intent(ctx, FkmService::class.java)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ctx.startForegroundService(intent)
|
||||
} else {
|
||||
ctx.startService(intent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("Fkm", "service start failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Перерисовка уже висящего уведомления, без перезапуска сервиса.
|
||||
fun refresh(ctx: Context) {
|
||||
if (!FkmState.enabled) return
|
||||
try {
|
||||
FkmNotification.manager(ctx)
|
||||
.notify(FkmNotification.NOTIFICATION_ID, FkmNotification.build(ctx))
|
||||
} catch (e: Exception) {
|
||||
Log.w("Fkm", "notification refresh failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(ctx: Context) {
|
||||
try {
|
||||
ctx.stopService(Intent(ctx, FkmService::class.java))
|
||||
} catch (e: Exception) {
|
||||
Log.w("Fkm", "service stop failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var inForeground = false
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
FkmState.restore(applicationContext)
|
||||
FkmNotification.ensureChannel(this)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (!FkmState.enabled) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
inForeground = false
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
goForeground()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (inForeground) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
inForeground = false
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun goForeground() {
|
||||
val notification = FkmNotification.build(this)
|
||||
if (inForeground) {
|
||||
FkmNotification.manager(this)
|
||||
.notify(FkmNotification.NOTIFICATION_ID, notification)
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
FkmNotification.NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
|
||||
)
|
||||
} else {
|
||||
startForeground(FkmNotification.NOTIFICATION_ID, notification)
|
||||
}
|
||||
inForeground = true
|
||||
} catch (e: Exception) {
|
||||
Log.w("Fkm", "startForeground failed: ${e.message}")
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FkmDisableReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
const val ACTION_DISABLE = "ru.komet.app.FKM_DISABLE"
|
||||
}
|
||||
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
if (intent.action != ACTION_DISABLE) return
|
||||
val app = ctx.applicationContext
|
||||
FkmState.applyEnabled(app, false)
|
||||
FkmService.stop(app)
|
||||
FkmChannel.notifyDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
class FkmBootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
val app = ctx.applicationContext
|
||||
FkmState.restore(app)
|
||||
if (!FkmState.enabled) return
|
||||
// Движка после ребута нет — уведомление честно скажет «не активно»,
|
||||
// пока приложение не откроют.
|
||||
FkmState.connected = false
|
||||
FkmService.start(app)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,12 @@ class KometFcmService : FirebaseMessagingService() {
|
||||
val data = message.data
|
||||
Log.d("KometFcm", "onMessageReceived type=${data["type"]} keys=${data.keys}")
|
||||
if (data.isEmpty()) return
|
||||
val type = data["type"]
|
||||
FkmState.restore(applicationContext)
|
||||
if (FkmState.enabled && type != "InboundCall" && type != "CallFinished") {
|
||||
Log.d("KometFcm", "message push dropped: FKM handles messages")
|
||||
return
|
||||
}
|
||||
KometNotifier(applicationContext).handle(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ class MainActivity : FlutterActivity() {
|
||||
private companion object {
|
||||
const val LOG_TAG = "VpnBypass"
|
||||
const val NFC_TAG = "NfcExchange"
|
||||
const val CALL_ENGINE_ID = "komet_call_engine"
|
||||
const val KEEP_ENGINE_ID = "komet_keep_engine"
|
||||
const val NFC_PHASE_MIN_MS = 350L
|
||||
const val NFC_PHASE_JITTER_MS = 400
|
||||
const val BLE_PERMS_REQUEST = 7711
|
||||
@@ -438,6 +438,8 @@ class MainActivity : FlutterActivity() {
|
||||
ChatNotifications.sink = null
|
||||
}
|
||||
})
|
||||
|
||||
FkmChannel.attach(flutterEngine, this)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -738,6 +740,10 @@ class MainActivity : FlutterActivity() {
|
||||
grantResults: IntArray,
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == FkmChannel.NOTIF_PERMS_REQUEST) {
|
||||
FkmChannel.onPermissionResult(grantResults)
|
||||
return
|
||||
}
|
||||
if (requestCode == NOTE_PERMS_REQUEST) {
|
||||
val pending = notePermResult
|
||||
notePermResult = null
|
||||
@@ -813,30 +819,35 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Движок переживает смерть активити, пока идёт звонок или включён FKM:
|
||||
// в обоих случаях в фоне должно жить то же соединение, что и в UI.
|
||||
private fun keepEngineAlive(): Boolean = CallState.inCall || FkmState.enabled
|
||||
|
||||
override fun provideFlutterEngine(context: Context): FlutterEngine? {
|
||||
val cache = FlutterEngineCache.getInstance()
|
||||
val cached = cache.get(CALL_ENGINE_ID)
|
||||
val cached = cache.get(KEEP_ENGINE_ID)
|
||||
if (cached != null) {
|
||||
if (CallState.inCall) return cached
|
||||
cache.remove(CALL_ENGINE_ID)
|
||||
if (keepEngineAlive()) return cached
|
||||
cache.remove(KEEP_ENGINE_ID)
|
||||
cached.destroy()
|
||||
}
|
||||
return super.provideFlutterEngine(context)
|
||||
}
|
||||
|
||||
override fun shouldDestroyEngineWithHost(): Boolean = !CallState.inCall
|
||||
override fun shouldDestroyEngineWithHost(): Boolean = !keepEngineAlive()
|
||||
|
||||
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
if (!CallState.inCall) {
|
||||
FlutterEngineCache.getInstance().remove(CALL_ENGINE_ID)
|
||||
if (!keepEngineAlive()) {
|
||||
FlutterEngineCache.getInstance().remove(KEEP_ENGINE_ID)
|
||||
FkmChannel.detach()
|
||||
}
|
||||
super.cleanUpFlutterEngine(flutterEngine)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (CallState.inCall && isFinishing) {
|
||||
Log.d("KometFcm", "task removed during call, caching engine")
|
||||
flutterEngine?.let { FlutterEngineCache.getInstance().put(CALL_ENGINE_ID, it) }
|
||||
if (keepEngineAlive() && isFinishing) {
|
||||
Log.d("KometFcm", "task removed, caching engine (call=${CallState.inCall} fkm=${FkmState.enabled})")
|
||||
flutterEngine?.let { FlutterEngineCache.getInstance().put(KEEP_ENGINE_ID, it) }
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
<resources>
|
||||
<string name="upload_channel_name">Отправка медиа</string>
|
||||
<string name="fkm_channel_name">Сервис уведомлений</string>
|
||||
<string name="fkm_channel_description">Держит фоновое соединение с сервером</string>
|
||||
<string name="fkm_title">Komet · сервис уведомлений</string>
|
||||
<string name="fkm_status_active">Соединение активно</string>
|
||||
<string name="fkm_status_inactive">Соединение не активно</string>
|
||||
<string name="fkm_status_line">%1$s · принято %2$d</string>
|
||||
<string name="fkm_explain">Это уведомление держит фоновое соединение с сервером, чтобы сообщения приходили без гугловых пушей. Убрать его можно, выключив FKM — кнопкой ниже или в Настройки → Уведомления → FKM.</string>
|
||||
<string name="fkm_disable">Выключить</string>
|
||||
</resources>
|
||||
|
||||
@@ -2,4 +2,12 @@
|
||||
<string name="nfc_service_description">Komet contact exchange</string>
|
||||
<string name="nfc_aid_group_description">Komet contact exchange</string>
|
||||
<string name="upload_channel_name">Sending media</string>
|
||||
<string name="fkm_channel_name">Notification service</string>
|
||||
<string name="fkm_channel_description">Keeps the background connection to the server alive</string>
|
||||
<string name="fkm_title">Komet · notification service</string>
|
||||
<string name="fkm_status_active">Connection active</string>
|
||||
<string name="fkm_status_inactive">Connection inactive</string>
|
||||
<string name="fkm_status_line">%1$s · %2$d delivered</string>
|
||||
<string name="fkm_explain">This notification is what keeps a background connection to the server, so messages arrive without Google push. To get rid of it, turn FKM off — with the button below, or in Settings → Notifications → FKM.</string>
|
||||
<string name="fkm_disable">Turn off</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user