feat: add runtime logcat recording
This commit is contained in:
@@ -318,7 +318,15 @@ fun MainScreen(initialNavigation: String = "", viewModel: MainViewModel = viewMo
|
||||
animatedComposable(Route.DARK_THEME) { DarkThemePage(onNavigateBack = onNavigateBack) }
|
||||
animatedComposable(Route.LANGUAGE) { LanguagePage(onNavigateBack = onNavigateBack) }
|
||||
animatedComposable(Route.DATA_MANAGEMENT) {
|
||||
DataManagementPage(onNavigateBack = onNavigateBack)
|
||||
DataManagementPage(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToLogcat = {
|
||||
navController.navigate(Route.LOGCAT) { launchSingleTop = true }
|
||||
}
|
||||
)
|
||||
}
|
||||
animatedComposable(Route.LOGCAT) {
|
||||
LogcatPage(onNavigateBack = onNavigateBack)
|
||||
}
|
||||
animatedComposable(Route.NETWORK) { NetworkPage(onNavigateBack = onNavigateBack) }
|
||||
animatedComposable(Route.ABOUT) {
|
||||
|
||||
@@ -12,6 +12,7 @@ object Route {
|
||||
const val DARK_THEME = "dark_theme"
|
||||
const val LANGUAGE = "language"
|
||||
const val DATA_MANAGEMENT = "data_management"
|
||||
const val LOGCAT = "logcat"
|
||||
const val NETWORK = "network"
|
||||
const val ABOUT = "about"
|
||||
const val LICENSE = "license"
|
||||
|
||||
+15
-7
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.DriveFileMove
|
||||
import androidx.compose.material.icons.automirrored.outlined.Assignment
|
||||
import androidx.compose.material.icons.outlined.Restore
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -15,7 +16,6 @@ import androidx.compose.ui.res.stringResource
|
||||
import com.antoniegil.astronia.R
|
||||
import com.antoniegil.astronia.ui.component.BackButton
|
||||
import com.antoniegil.astronia.ui.component.PreferenceItem
|
||||
import com.antoniegil.astronia.ui.component.PreferenceSubtitle
|
||||
import com.antoniegil.astronia.util.manager.DataManager
|
||||
import com.antoniegil.astronia.util.manager.rememberBackupExportLauncher
|
||||
import com.antoniegil.astronia.util.manager.rememberHistoryRestoreLauncher
|
||||
@@ -25,7 +25,10 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DataManagementPage(onNavigateBack: () -> Unit) {
|
||||
fun DataManagementPage(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToLogcat: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val resources = androidx.compose.ui.platform.LocalResources.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -56,13 +59,18 @@ fun DataManagementPage(onNavigateBack: () -> Unit) {
|
||||
contentPadding = paddingValues
|
||||
) {
|
||||
item {
|
||||
PreferenceSubtitle(text = stringResource(R.string.data_backup))
|
||||
PreferenceItem(
|
||||
title = stringResource(R.string.logcat),
|
||||
description = stringResource(R.string.logcat_desc),
|
||||
icon = Icons.AutoMirrored.Outlined.Assignment,
|
||||
onClick = onNavigateToLogcat
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
PreferenceItem(
|
||||
title = stringResource(R.string.export_data),
|
||||
description = stringResource(R.string.export_data_desc),
|
||||
title = stringResource(R.string.export_history),
|
||||
description = stringResource(R.string.export_history_desc),
|
||||
icon = Icons.AutoMirrored.Outlined.DriveFileMove,
|
||||
onClick = {
|
||||
if (!isBackingUp) {
|
||||
@@ -90,8 +98,8 @@ fun DataManagementPage(onNavigateBack: () -> Unit) {
|
||||
|
||||
item {
|
||||
PreferenceItem(
|
||||
title = stringResource(R.string.restore_data),
|
||||
description = stringResource(R.string.restore_data_desc),
|
||||
title = stringResource(R.string.restore_history),
|
||||
description = stringResource(R.string.restore_history_desc),
|
||||
icon = Icons.Outlined.Restore,
|
||||
onClick = {
|
||||
restoreLauncher.launch(restoreMimeType)
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package com.antoniegil.astronia.ui.page.settings.data
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.automirrored.outlined.DriveFileMove
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.antoniegil.astronia.R
|
||||
import com.antoniegil.astronia.ui.component.BackButton
|
||||
import com.antoniegil.astronia.ui.component.ChannelCard
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import androidx.core.content.edit
|
||||
import android.os.Process
|
||||
|
||||
data class LogcatRecord(
|
||||
val fileName: String,
|
||||
val timestamp: Long,
|
||||
val duration: Long,
|
||||
val filePath: String
|
||||
)
|
||||
|
||||
class LogcatViewModel : ViewModel() {
|
||||
var isRecording by mutableStateOf(false)
|
||||
var recordStartTime by mutableLongStateOf(0L)
|
||||
var logcatRecords by mutableStateOf(listOf<LogcatRecord>())
|
||||
|
||||
private fun getPrefs(context: Context) = context.getSharedPreferences("logcat_data", Context.MODE_PRIVATE)
|
||||
|
||||
fun loadData(context: Context) {
|
||||
val prefs = getPrefs(context)
|
||||
isRecording = prefs.getBoolean("is_recording", false)
|
||||
recordStartTime = prefs.getLong("start_time", 0L)
|
||||
|
||||
val savedRecords = mutableListOf<LogcatRecord>()
|
||||
val recordCount = prefs.getInt("record_count", 0)
|
||||
for (i in 0 until recordCount) {
|
||||
val fileName = prefs.getString("record_${i}_fileName", "") ?: ""
|
||||
val timestamp = prefs.getLong("record_${i}_timestamp", 0L)
|
||||
val duration = prefs.getLong("record_${i}_duration", 0L)
|
||||
val filePath = prefs.getString("record_${i}_filePath", "") ?: ""
|
||||
if (fileName.isNotEmpty() && File(filePath).exists()) {
|
||||
savedRecords.add(LogcatRecord(fileName, timestamp, duration, filePath))
|
||||
}
|
||||
}
|
||||
logcatRecords = savedRecords
|
||||
}
|
||||
|
||||
fun saveRecords(context: Context) {
|
||||
getPrefs(context).edit {
|
||||
putInt("record_count", logcatRecords.size)
|
||||
logcatRecords.forEachIndexed { index, record ->
|
||||
putString("record_${index}_fileName", record.fileName)
|
||||
putLong("record_${index}_timestamp", record.timestamp)
|
||||
putLong("record_${index}_duration", record.duration)
|
||||
putString("record_${index}_filePath", record.filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startRecording(context: Context) {
|
||||
recordStartTime = System.currentTimeMillis()
|
||||
isRecording = true
|
||||
getPrefs(context).edit {
|
||||
putBoolean("is_recording", true)
|
||||
putLong("start_time", recordStartTime)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopRecording(context: Context, locale: Locale, onComplete: () -> Unit) {
|
||||
if (!isRecording) return
|
||||
|
||||
viewModelScope.launch {
|
||||
val duration = System.currentTimeMillis() - recordStartTime
|
||||
val fileName = "logcat_${SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", locale).format(Date(recordStartTime))}.txt"
|
||||
|
||||
val logcatContent = withContext(Dispatchers.IO) {
|
||||
Runtime.getRuntime().exec("logcat -d -v time").inputStream.bufferedReader().readText()
|
||||
.let { if (it.length > 20 * 1024 * 1024) it.takeLast(20 * 1024 * 1024) else it }
|
||||
}
|
||||
|
||||
val file = File(context.getExternalFilesDir(null), fileName)
|
||||
withContext(Dispatchers.IO) { file.writeText(logcatContent) }
|
||||
|
||||
logcatRecords = listOf(LogcatRecord(fileName, recordStartTime, duration, file.absolutePath)) + logcatRecords
|
||||
isRecording = false
|
||||
|
||||
getPrefs(context).edit {
|
||||
putBoolean("is_recording", false)
|
||||
putLong("start_time", 0L)
|
||||
}
|
||||
saveRecords(context)
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("LocalContextGetResourceValueCall")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LogcatPage(onNavigateBack: () -> Unit) {
|
||||
val viewModel: LogcatViewModel = viewModel()
|
||||
val context = LocalContext.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadData(context)
|
||||
if (viewModel.isRecording && System.currentTimeMillis() - viewModel.recordStartTime > 30 * 60 * 1000) {
|
||||
viewModel.stopRecording(context, configuration.locales[0]) {}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(R.string.logcat)) },
|
||||
navigationIcon = { BackButton(onClick = onNavigateBack) }
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { paddingValues ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable { if (!viewModel.isRecording) viewModel.startRecording(context) }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (viewModel.isRecording) stringResource(R.string.recording) else stringResource(R.string.logcat_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { viewModel.stopRecording(context, configuration.locales[0]) {} },
|
||||
enabled = viewModel.isRecording
|
||||
) {
|
||||
if (viewModel.isRecording) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Stop,
|
||||
contentDescription = stringResource(R.string.stop_recording),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
|
||||
if (viewModel.logcatRecords.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.history),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(viewModel.logcatRecords) { record ->
|
||||
val dateFormat = SimpleDateFormat("yy-MM-dd-HH-mm-ss", configuration.locales[0])
|
||||
|
||||
ChannelCard(
|
||||
name = dateFormat.format(Date(record.timestamp)),
|
||||
url = "${record.duration / 1000}s",
|
||||
onDelete = {
|
||||
val deletedRecord = record
|
||||
val deletedIndex = viewModel.logcatRecords.indexOf(record)
|
||||
viewModel.logcatRecords = viewModel.logcatRecords.filter { it != record }
|
||||
File(record.filePath).delete()
|
||||
viewModel.saveRecords(context)
|
||||
scope.launch {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.item_deleted),
|
||||
actionLabel = context.getString(R.string.undo),
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
viewModel.logcatRecords = viewModel.logcatRecords.toMutableList().apply {
|
||||
add(deletedIndex.coerceAtMost(size), deletedRecord)
|
||||
}
|
||||
viewModel.saveRecords(context)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TITLE, record.fileName)
|
||||
})
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TITLE, record.fileName)
|
||||
})
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.DriveFileMove,
|
||||
contentDescription = stringResource(R.string.save),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
@@ -320,12 +320,10 @@ internal class PreferenceManagerImpl(context: Context) {
|
||||
private const val PREF_SETTINGS = "astronia_settings"
|
||||
private const val PREF_HISTORY = "astronia_history"
|
||||
private const val KEY_AUTO_PLAY = "auto_play"
|
||||
private const val KEY_REMEMBER_POSITION = "remember_position"
|
||||
private const val KEY_AUTO_HIDE_CONTROLS = "auto_hide_controls"
|
||||
private const val KEY_EPG_MARKERS_COUNT = "epg_markers_count"
|
||||
private const val KEY_ENABLE_PIP = "enable_pip"
|
||||
private const val KEY_BACKGROUND_PLAY = "background_play"
|
||||
private const val KEY_HARDWARE_ACCELERATION = "hardware_acceleration"
|
||||
private const val KEY_ASPECT_RATIO = "aspect_ratio"
|
||||
private const val KEY_DECODER_TYPE = "decoder_type"
|
||||
private const val KEY_MIRROR_FLIP = "mirror_flip"
|
||||
|
||||
@@ -45,7 +45,7 @@ object M3UParser {
|
||||
val infoContent = currentLine.drop(M3U_INFO_MARK.length).trim()
|
||||
val commaIndex = infoContent.lastIndexOf(',')
|
||||
if (commaIndex != -1) {
|
||||
val metadataText = infoContent.substring(0, commaIndex)
|
||||
val metadataText = infoContent.take(commaIndex)
|
||||
title = infoContent.substring(commaIndex + 1).trim()
|
||||
metadata = parseMetadata(metadataText)
|
||||
}
|
||||
|
||||
@@ -126,15 +126,20 @@
|
||||
<string name="follow_system">跟随系统</string>
|
||||
<string name="system_settings">系统设置</string>
|
||||
|
||||
<string name="data_backup">数据备份</string>
|
||||
<string name="export_data">导出资料</string>
|
||||
<string name="export_data_desc">导出播放历史记录</string>
|
||||
<string name="logcat">运行日志</string>
|
||||
<string name="logcat_desc">应用运行日志</string>
|
||||
<string name="logcat_subtitle">轻触以启动</string>
|
||||
<string name="recording">录制中…</string>
|
||||
<string name="stop_recording">停止录制</string>
|
||||
<string name="no_logs_available">暂无日志</string>
|
||||
<string name="export_history">导出记录</string>
|
||||
<string name="export_history_desc">导出历史记录到文件进行备份</string>
|
||||
<string name="export_success">导出成功</string>
|
||||
<string name="export_success_at">导出成功,保存在 %s</string>
|
||||
<string name="export_failed">导出失败</string>
|
||||
<string name="no_history_to_export">没有历史记录可导出</string>
|
||||
<string name="restore_data">导入资料</string>
|
||||
<string name="restore_data_desc">从备份文件导入历史记录</string>
|
||||
<string name="restore_history">导入记录</string>
|
||||
<string name="restore_history_desc">从备份文件导入历史记录</string>
|
||||
<plurals name="restore_success">
|
||||
<item quantity="other">成功恢复 %d 条历史记录</item>
|
||||
</plurals>
|
||||
|
||||
@@ -126,15 +126,20 @@
|
||||
<string name="follow_system">跟隨系統</string>
|
||||
<string name="system_settings">系統設定</string>
|
||||
|
||||
<string name="data_backup">數據備份</string>
|
||||
<string name="export_data">匯出資料</string>
|
||||
<string name="export_data_desc">匯出播放紀錄</string>
|
||||
<string name="logcat">運行日誌</string>
|
||||
<string name="logcat_desc">應用程式運行日誌</string>
|
||||
<string name="logcat_subtitle">輕觸以啟動</string>
|
||||
<string name="recording">錄製緊…</string>
|
||||
<string name="stop_recording">停止錄製</string>
|
||||
<string name="no_logs_available">暫無日誌</string>
|
||||
<string name="export_history">匯出記錄</string>
|
||||
<string name="export_history_desc">匯出歷史記錄到檔案進行備份</string>
|
||||
<string name="export_success">匯出成功</string>
|
||||
<string name="export_success_at">匯出成功,儲存喺 %s</string>
|
||||
<string name="export_failed">匯出失敗</string>
|
||||
<string name="no_history_to_export">冇紀錄可以匯出</string>
|
||||
<string name="restore_data">匯入資料</string>
|
||||
<string name="restore_data_desc">從備份檔案匯入播放紀錄</string>
|
||||
<string name="no_history_to_export">冇記錄可以匯出</string>
|
||||
<string name="restore_history">匯入記錄</string>
|
||||
<string name="restore_history_desc">從備份檔案匯入歷史記錄</string>
|
||||
<plurals name="restore_success">
|
||||
<item quantity="other">成功恢復 %d 條紀錄</item>
|
||||
</plurals>
|
||||
|
||||
@@ -126,15 +126,20 @@
|
||||
<string name="follow_system">跟隨系統</string>
|
||||
<string name="system_settings">系統設定</string>
|
||||
|
||||
<string name="data_backup">資料備份</string>
|
||||
<string name="export_data">匯出資料</string>
|
||||
<string name="export_data_desc">匯出播放紀錄</string>
|
||||
<string name="logcat">執行日誌</string>
|
||||
<string name="logcat_desc">應用程式執行日誌</string>
|
||||
<string name="logcat_subtitle">輕觸以啟動</string>
|
||||
<string name="recording">錄製中…</string>
|
||||
<string name="stop_recording">停止錄製</string>
|
||||
<string name="no_logs_available">暫無日誌</string>
|
||||
<string name="export_history">匯出記錄</string>
|
||||
<string name="export_history_desc">匯出歷史記錄到檔案進行備份</string>
|
||||
<string name="export_success">匯出成功</string>
|
||||
<string name="export_success_at">匯出成功,儲存於 %s</string>
|
||||
<string name="export_failed">匯出失敗</string>
|
||||
<string name="no_history_to_export">沒有紀錄可供匯出</string>
|
||||
<string name="restore_data">匯入資料</string>
|
||||
<string name="restore_data_desc">從備份檔案匯入紀錄</string>
|
||||
<string name="no_history_to_export">沒有記錄可供匯出</string>
|
||||
<string name="restore_history">匯入記錄</string>
|
||||
<string name="restore_history_desc">從備份檔案匯入歷史記錄</string>
|
||||
<plurals name="restore_success">
|
||||
<item quantity="other">成功還原 %d 條紀錄</item>
|
||||
</plurals>
|
||||
|
||||
@@ -129,15 +129,20 @@
|
||||
<string name="follow_system">Follow System</string>
|
||||
<string name="system_settings">System Settings</string>
|
||||
|
||||
<string name="data_backup">Data Backup</string>
|
||||
<string name="export_data">Export Data</string>
|
||||
<string name="export_data_desc">Export play history</string>
|
||||
<string name="logcat">Logcat</string>
|
||||
<string name="logcat_desc">Application runtime logs</string>
|
||||
<string name="logcat_subtitle">Tap to start</string>
|
||||
<string name="recording">Recording…</string>
|
||||
<string name="stop_recording">Stop Recording</string>
|
||||
<string name="no_logs_available">No logs available</string>
|
||||
<string name="export_history">Export History</string>
|
||||
<string name="export_history_desc">Export history to file for backup</string>
|
||||
<string name="export_success">Export successful</string>
|
||||
<string name="export_success_at">Export successful, saved at: %s</string>
|
||||
<string name="export_failed">Export failed</string>
|
||||
<string name="no_history_to_export">No history to export</string>
|
||||
<string name="restore_data">Import Data</string>
|
||||
<string name="restore_data_desc">Import history from backup file</string>
|
||||
<string name="restore_history">Import History</string>
|
||||
<string name="restore_history_desc">Import history from backup file</string>
|
||||
<plurals name="restore_success">
|
||||
<item quantity="one">Successfully restored %d history item</item>
|
||||
<item quantity="other">Successfully restored %d history items</item>
|
||||
|
||||
Reference in New Issue
Block a user