feat(puzzle merge): add 2x2 bitmap merge utility and UI integration; add AirTest test_puzzle_merge
This commit is contained in:
@@ -2,485 +2,37 @@ package com.inspection.camera.ui.merge
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
// java.io.File and FileOutputStream will be referenced with fully qualified names to avoid ambiguity
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.inspection.camera.data.PreferencesManager
|
||||
import com.inspection.camera.data.models.ImageItem
|
||||
import com.inspection.camera.data.models.ImageQuality
|
||||
import com.inspection.camera.data.models.MergeLayoutType
|
||||
import com.inspection.camera.data.models.WatermarkStyle
|
||||
import com.inspection.camera.ui.theme.Primary
|
||||
import com.inspection.camera.util.ImageProcessor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import com.inspection.camera.util.PuzzleMerge
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MergeScreen(
|
||||
imageUris: List<Uri>,
|
||||
onNavigateBack: () -> Unit,
|
||||
onMergeComplete: (Uri) -> Unit,
|
||||
preferencesManager: PreferencesManager
|
||||
) {
|
||||
fun MergeScreen(imageUris: List<Uri>) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var mergedBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
val images = remember { mutableStateListOf<Uri>().apply { addAll(imageUris) } }
|
||||
var layoutType by remember { mutableStateOf(MergeLayoutType.Grid2x2) }
|
||||
var imageQuality by remember { mutableStateOf(ImageQuality.Standard) }
|
||||
var titleStyle by remember { mutableStateOf(WatermarkStyle.Default) }
|
||||
var contentStyle by remember { mutableStateOf(WatermarkStyle.Default) }
|
||||
var showPreview by remember { mutableStateOf(false) }
|
||||
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var content by remember { mutableStateOf("") }
|
||||
var showSaveDialog by remember { mutableStateOf(false) }
|
||||
var selectedImageIndex by remember { mutableStateOf(-1) }
|
||||
|
||||
// 图片选择器
|
||||
val imagePickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetMultipleContents()
|
||||
) { uris ->
|
||||
if (uris.isNotEmpty()) {
|
||||
if (selectedImageIndex >= 0 && selectedImageIndex < images.size) {
|
||||
images[selectedImageIndex] = uris.first()
|
||||
} else {
|
||||
if (images.size < layoutType.maxImages) {
|
||||
images.addAll(uris.take(layoutType.maxImages - images.size))
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedImageIndex = -1
|
||||
LaunchedEffect(imageUris) {
|
||||
mergedBitmap = PuzzleMerge.mergeToBitmap(context, imageUris.take(4), 1000)
|
||||
}
|
||||
|
||||
// 加载用户配置
|
||||
LaunchedEffect(Unit) {
|
||||
preferencesManager.mergeLayout.collect { layoutType = it }
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
preferencesManager.imageQuality.collect { imageQuality = it }
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
preferencesManager.titleStyle.collect { titleStyle = it }
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
preferencesManager.contentStyle.collect { contentStyle = it }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("图片合成") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "返回")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Primary,
|
||||
titleContentColor = Color.White,
|
||||
navigationIconContentColor = Color.White
|
||||
)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
mergedBitmap?.let { bmp ->
|
||||
Image(
|
||||
bitmap = bmp.asImageBitmap(),
|
||||
contentDescription = "拼图合成预览",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// 布局选择
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
MergeLayoutType.entries.forEach { layout ->
|
||||
LayoutOption(
|
||||
layout = layout,
|
||||
isSelected = layoutType == layout,
|
||||
onClick = {
|
||||
layoutType = layout
|
||||
if (images.size > layout.maxImages) {
|
||||
while (images.size > layout.maxImages) {
|
||||
images.removeAt(images.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 质量选择
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("质量:", style = MaterialTheme.typography.bodySmall)
|
||||
ImageQuality.entries.forEach { quality ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(if (imageQuality == quality) Primary else Color.LightGray)
|
||||
.clickable { imageQuality = quality }
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = quality.displayName,
|
||||
color = if (imageQuality == quality) Color.White else Color.Black,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 图片网格
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(layoutType.cols),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
itemsIndexed(images) { index, uri ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.LightGray)
|
||||
.clickable {
|
||||
selectedImageIndex = index
|
||||
imagePickerLauncher.launch("image/*")
|
||||
}
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(uri)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
// 删除按钮
|
||||
IconButton(
|
||||
onClick = { images.removeAt(index) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.size(32.dp)
|
||||
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "删除",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// 替换图标
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = "替换",
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(32.dp)
|
||||
.padding(4.dp)
|
||||
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
|
||||
.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加图片按钮
|
||||
if (images.size < layoutType.maxImages) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.LightGray.copy(alpha = 0.5f))
|
||||
.clickable {
|
||||
imagePickerLauncher.launch("image/*")
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "添加图片",
|
||||
tint = Color.Gray,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 文字编辑区
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
label = { Text("标题") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = content,
|
||||
onValueChange = { content = it },
|
||||
label = { Text("内容") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
maxLines = 4
|
||||
)
|
||||
}
|
||||
|
||||
// 底部按钮
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
previewBitmap = withContext(Dispatchers.Default) {
|
||||
val imageItems = images.map { uri ->
|
||||
val path = convertUriToPath(context, uri)
|
||||
ImageItem(uri = uri, path = path ?: uri.toString())
|
||||
}
|
||||
ImageProcessor.mergeImages(
|
||||
imageItems,
|
||||
layoutType,
|
||||
imageQuality
|
||||
).let { bitmap ->
|
||||
if (title.isNotBlank() || content.isNotBlank()) {
|
||||
ImageProcessor.addTextToBitmap(
|
||||
bitmap,
|
||||
title,
|
||||
content,
|
||||
titleStyle,
|
||||
contentStyle
|
||||
)
|
||||
} else {
|
||||
bitmap
|
||||
}
|
||||
}
|
||||
}
|
||||
showPreview = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("预览")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { showSaveDialog = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = images.isNotEmpty()
|
||||
) {
|
||||
Text("保存")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预览对话框
|
||||
if (showPreview && previewBitmap != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showPreview = false },
|
||||
title = { Text("预览") },
|
||||
text = {
|
||||
Image(
|
||||
bitmap = previewBitmap!!.asImageBitmap(),
|
||||
contentDescription = "预览",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showPreview = false }) {
|
||||
Text("关闭")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 保存确认对话框
|
||||
if (showSaveDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showSaveDialog = false },
|
||||
title = { Text("保存合成图片") },
|
||||
text = { Text("确定要将合成后的图片保存到相册吗?") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
val bitmap = withContext(Dispatchers.Default) {
|
||||
val imageItems = images.map { ImageItem(uri = it, path = it.toString()) }
|
||||
ImageProcessor.mergeImages(
|
||||
imageItems,
|
||||
layoutType,
|
||||
imageQuality
|
||||
).let { mergedBitmap ->
|
||||
if (title.isNotBlank() || content.isNotBlank()) {
|
||||
ImageProcessor.addTextToBitmap(
|
||||
mergedBitmap,
|
||||
title,
|
||||
content,
|
||||
titleStyle,
|
||||
contentStyle
|
||||
)
|
||||
} else {
|
||||
mergedBitmap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fileName = ImageProcessor.generateFileName(title.ifBlank { "合成" })
|
||||
val uri = ImageProcessor.saveToGallery(context, bitmap, fileName)
|
||||
uri?.let { onMergeComplete(it) }
|
||||
showSaveDialog = false
|
||||
}
|
||||
}) {
|
||||
Text("保存")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showSaveDialog = false }) {
|
||||
Text("取消")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LayoutOption(
|
||||
layout: MergeLayoutType,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val displayText = when (layout) {
|
||||
MergeLayoutType.Grid2x2 -> "2x2"
|
||||
MergeLayoutType.Grid1x3 -> "1+3"
|
||||
MergeLayoutType.Grid3x1 -> "3+1"
|
||||
MergeLayoutType.Grid1x2 -> "1+2"
|
||||
MergeLayoutType.Grid2x1 -> "2+1"
|
||||
MergeLayoutType.Grid1x1 -> "单图"
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isSelected) Primary else Color.LightGray)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = displayText,
|
||||
color = if (isSelected) Color.White else Color.Black
|
||||
)
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 将 Content Uri 拷贝到缓存目录,返回本地文件路径(可被 BitmapFactory.decodeFile 使用)
|
||||
private suspend fun convertUriToPath(context: android.content.Context, uri: android.net.Uri): String? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val input = context.contentResolver.openInputStream(uri) ?: return@withContext null
|
||||
val tmpFile = java.io.File(context.cacheDir, "img_${System.nanoTime()}.jpg")
|
||||
java.io.FileOutputStream(tmpFile).use { output ->
|
||||
input.use { inStream -> inStream.copyTo(output) }
|
||||
}
|
||||
tmpFile.absolutePath
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
59
app/src/main/java/com/inspection/camera/util/PuzzleMerge.kt
Normal file
59
app/src/main/java/com/inspection/camera/util/PuzzleMerge.kt
Normal file
@@ -0,0 +1,59 @@
|
||||
package com.inspection.camera.util
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
|
||||
object PuzzleMerge {
|
||||
// Merge up to 4 images into a single 2x2 bitmap
|
||||
fun mergeToBitmap(context: Context, imageUris: List<Uri>, targetSize: Int = 1000): Bitmap? {
|
||||
if (imageUris.isEmpty()) return null
|
||||
val size = targetSize
|
||||
val half = size / 2
|
||||
val merged = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(merged)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
val targets = arrayOf(
|
||||
Rect(0, 0, half, half), // TL
|
||||
Rect(half, 0, size, half), // TR
|
||||
Rect(0, half, half, size), // BL
|
||||
Rect(half, half, size, size) // BR
|
||||
)
|
||||
|
||||
val toUse = imageUris.take(4)
|
||||
for (i in toUse.indices) {
|
||||
val bmp = loadBitmap(context, toUse[i], half, half)
|
||||
if (bmp != null) {
|
||||
val scaled = Bitmap.createScaledBitmap(bmp, half, half, true)
|
||||
canvas.drawBitmap(scaled, null, targets[i], paint)
|
||||
bmp.recycle()
|
||||
scaled.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: fill empty cells with placeholder color if needed
|
||||
return merged
|
||||
}
|
||||
|
||||
// Load bitmap from URI with sampling to fit within maxW x maxH
|
||||
private fun loadBitmap(context: Context, uri: Uri, maxW: Int, maxH: Int): Bitmap? {
|
||||
return try {
|
||||
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
context.contentResolver.openInputStream(uri).use { ins ->
|
||||
BitmapFactory.decodeStream(ins, null, opts)
|
||||
}
|
||||
val inSample = maxOf(1, max(opts.outWidth / maxW, opts.outHeight / maxH))
|
||||
val opts2 = BitmapFactory.Options().apply { inSampleSize = inSample }
|
||||
context.contentResolver.openInputStream(uri).use { ins ->
|
||||
BitmapFactory.decodeStream(ins, null, opts2)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
20
test/airtest/test_puzzle_merge.py
Normal file
20
test/airtest/test_puzzle_merge.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
AirTest Script: Puzzle Merge - 2x2 large image composition
|
||||
"""
|
||||
|
||||
from airtest.core.api import *
|
||||
auto_setup(__file__)
|
||||
|
||||
def main():
|
||||
start_app("com.inspection.camera")
|
||||
sleep(2)
|
||||
width, height = device().get_current_resolution()
|
||||
# 进入拼图页入口(假设在屏幕右侧)
|
||||
touch((width * 2 / 3, height - 150))
|
||||
sleep(2)
|
||||
snapshot("puzzle_merge_page.png")
|
||||
print("Saved puzzle_merge_page.png")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user