Plug input data to EditPageScreen
This commit is contained in:
@@ -124,6 +124,7 @@ class MainActivity : ComponentActivity() {
|
||||
val importState by cameraViewModel.importState.collectAsStateWithLifecycle()
|
||||
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
|
||||
val documentUiState by viewModel.documentUiState.collectAsStateWithLifecycle()
|
||||
val cropInitialState by viewModel.cropInitState.collectAsStateWithLifecycle()
|
||||
val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val cameraPermission = rememberCameraPermissionState()
|
||||
CollectCameraEvents(cameraViewModel, viewModel)
|
||||
@@ -179,10 +180,10 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
is Screen.Main.EditImage -> {
|
||||
val pageIndex = (currentScreen as Screen.Main.EditImage).pageIndex
|
||||
EditPageScreen(
|
||||
pageId = documentUiState.document.pages[pageIndex].key.pageId,
|
||||
imageRepository = imageRepository,
|
||||
pageId = documentUiState.currentPage?.key?.pageId ?: "",
|
||||
onLoad = { id -> viewModel.loadCropInitialState(id)},
|
||||
initState = cropInitialState,
|
||||
navigation = navigation,
|
||||
onUpdatePageQuad = { id, quad, onComplete -> },
|
||||
)
|
||||
@@ -467,7 +468,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun navigation(viewModel: MainViewModel, launchMode: LaunchMode): Navigation = Navigation(
|
||||
toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) },
|
||||
toEditImageScreen = { pageIndex -> viewModel.navigateTo(Screen.Main.EditImage(pageIndex)) },
|
||||
toEditImageScreen = { viewModel.navigateTo(Screen.Main.EditImage) },
|
||||
toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) },
|
||||
toExportScreen = { viewModel.navigateTo(Screen.Main.Export) },
|
||||
toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) },
|
||||
|
||||
@@ -14,12 +14,16 @@
|
||||
*/
|
||||
package org.fairscan.app
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -34,14 +38,17 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.data.ImageRepository
|
||||
import org.fairscan.app.domain.CapturedPage
|
||||
import org.fairscan.app.domain.Rotation
|
||||
import org.fairscan.app.domain.ScanPage
|
||||
import org.fairscan.app.ui.NavigationState
|
||||
import org.fairscan.app.ui.Screen
|
||||
import org.fairscan.app.ui.screens.document.CurrentPageUiState
|
||||
import org.fairscan.app.ui.screens.document.DocumentUiState
|
||||
import org.fairscan.app.ui.screens.edit.CropInitState
|
||||
import org.fairscan.app.ui.state.DocumentUiModel
|
||||
import org.fairscan.app.ui.state.PageThumbnail
|
||||
import org.fairscan.imageprocessing.ColorMode
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import kotlin.math.min
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -95,7 +102,8 @@ class MainViewModel(val imageRepository: ImageRepository): ViewModel() {
|
||||
page?.let {
|
||||
val isLoading = (it.id == loadingId)
|
||||
val bitmap = imageRepository.jpegBytes(it.key())?.toBitmap()
|
||||
CurrentPageUiState(it.key(), bitmap, it.colorMode, isLoading)
|
||||
val canBeCropped = page.metadata != null
|
||||
CurrentPageUiState(it.key(), bitmap, it.colorMode, canBeCropped, isLoading)
|
||||
}
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
@@ -212,4 +220,47 @@ class MainViewModel(val imageRepository: ImageRepository): ViewModel() {
|
||||
_pages.value = pages
|
||||
}
|
||||
}
|
||||
|
||||
private val _cropInitState = MutableStateFlow<CropInitState>(CropInitState.Loading)
|
||||
val cropInitState: StateFlow<CropInitState> = _cropInitState
|
||||
|
||||
private var cropInitialStateJob: Job? = null
|
||||
fun loadCropInitialState(pageId: String) {
|
||||
cropInitialStateJob?.cancel()
|
||||
cropInitialStateJob = viewModelScope.launch {
|
||||
_cropInitState.value = CropInitState.Loading
|
||||
|
||||
val page = _pages.value.find { it.id == pageId }
|
||||
?: return@launch
|
||||
|
||||
val metadata = page.metadata
|
||||
val baseRotation = metadata?.baseRotation ?: Rotation.R0
|
||||
val rotation = baseRotation.add(page.manualRotation)
|
||||
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
val source = imageRepository.source(page.id)
|
||||
val bytes = source?.bytes ?: return@withContext null
|
||||
|
||||
val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
if (original != null && rotation != Rotation.R0) {
|
||||
val matrix = Matrix().apply { postRotate(rotation.degrees.toFloat()) }
|
||||
Bitmap.createBitmap(
|
||||
original, 0, 0, original.width, original.height, matrix, true
|
||||
)
|
||||
} else {
|
||||
original
|
||||
}
|
||||
}
|
||||
|
||||
val quad = metadata?.normalizedQuad?.rotate90(
|
||||
rotation.degrees / 90,
|
||||
ImageSize(1, 1)
|
||||
)
|
||||
|
||||
_cropInitState.value = if (bitmap == null || quad == null)
|
||||
CropInitState.Error
|
||||
else
|
||||
CropInitState.Ready(page.id, bitmap, quad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ package org.fairscan.app.ui
|
||||
sealed class Screen {
|
||||
sealed class Main : Screen() {
|
||||
object Camera : Main()
|
||||
data class EditImage(val pageIndex: Int) : Main()
|
||||
object EditImage : Main()
|
||||
data class Document(val initialPage: Int = 0) : Main()
|
||||
object Export : Main()
|
||||
}
|
||||
@@ -30,7 +30,7 @@ sealed class Screen {
|
||||
|
||||
data class Navigation(
|
||||
val toCameraScreen: () -> Unit,
|
||||
val toEditImageScreen: (Int) -> Unit,
|
||||
val toEditImageScreen: () -> Unit,
|
||||
val toDocumentScreen: () -> Unit,
|
||||
val toExportScreen: () -> Unit,
|
||||
val toAboutScreen: () -> Unit,
|
||||
@@ -64,7 +64,7 @@ data class NavigationState private constructor(val stack: List<Screen>, val root
|
||||
root -> this // Back handled by system
|
||||
is Screen.Main.Camera -> this // Back handled by system
|
||||
is Screen.Main.Document -> copy(stack = listOf(Screen.Main.Camera))
|
||||
is Screen.Main.EditImage -> copy(stack = listOf(Screen.Main.Document(initialPage = (current as Screen.Main.EditImage).pageIndex)))
|
||||
is Screen.Main.EditImage -> copy(stack = listOf(Screen.Main.Document()))
|
||||
is Screen.Main.Export -> copy(stack = listOf(Screen.Main.Camera))
|
||||
is Screen.Overlay -> copy(stack = stack.dropLast(1))
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AutoFixHigh
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Contrast
|
||||
import androidx.compose.material.icons.filled.Crop
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.RotateLeft
|
||||
@@ -132,6 +133,7 @@ fun DocumentScreen(
|
||||
{ showDeletePageDialog.value = true },
|
||||
onRotateImage,
|
||||
onToggleColorMode,
|
||||
navigation,
|
||||
modifier
|
||||
)
|
||||
if (showDeletePageDialog.value) {
|
||||
@@ -150,6 +152,7 @@ private fun DocumentPreview(
|
||||
onDeleteImage: () -> Unit,
|
||||
onRotateImage: (Boolean) -> Unit,
|
||||
onToggleColorMode: () -> Unit,
|
||||
navigation: Navigation,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val currentPageIndex = uiState.currentPageIndex
|
||||
@@ -194,15 +197,12 @@ private fun DocumentPreview(
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
uiState.currentPage?.colorMode?.let {
|
||||
ColorModeButton(
|
||||
currentColorMode = it,
|
||||
onToggle = { onToggleColorMode() },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
EditButtons(
|
||||
uiState,
|
||||
onToggleColorMode,
|
||||
navigation,
|
||||
modifier = Modifier.align(Alignment.BottomStart)
|
||||
)
|
||||
RotationButtons(onRotateImage, Modifier.align(Alignment.BottomCenter))
|
||||
SecondaryActionButton(
|
||||
Icons.Outlined.Delete,
|
||||
@@ -253,6 +253,31 @@ fun RotationButtons(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditButtons(
|
||||
uiState: DocumentUiState,
|
||||
onToggleColorMode: () -> Unit,
|
||||
navigation: Navigation,
|
||||
modifier: Modifier
|
||||
) {
|
||||
Row(modifier = modifier.padding(8.dp)) {
|
||||
uiState.currentPage?.colorMode?.let {
|
||||
ColorModeButton(
|
||||
currentColorMode = it,
|
||||
onToggle = { onToggleColorMode() },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (uiState.currentPage?.canBeCropped ?: false) {
|
||||
SecondaryActionButton(
|
||||
icon = Icons.Default.Crop,
|
||||
contentDescription = "Crop", // TODO externalize string
|
||||
onClick = navigation.toEditImageScreen,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColorModeButton(
|
||||
currentColorMode: ColorMode,
|
||||
@@ -348,7 +373,7 @@ fun DocumentScreenPreview() {
|
||||
)
|
||||
val key = PageViewKey("123", Rotation.R0, null)
|
||||
DocumentScreen(
|
||||
uiState = DocumentUiState(1, CurrentPageUiState(key,image, COLOR), document),
|
||||
uiState = DocumentUiState(1, CurrentPageUiState(key,image, COLOR, true), document),
|
||||
navigation = dummyNavigation(),
|
||||
onExportClick = {},
|
||||
onDeleteImage = { },
|
||||
|
||||
@@ -29,5 +29,6 @@ data class CurrentPageUiState(
|
||||
val key: PageViewKey,
|
||||
val bitmap: Bitmap?,
|
||||
val colorMode: ColorMode?,
|
||||
val canBeCropped: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -16,8 +16,8 @@ package org.fairscan.app.ui.screens.edit
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
@@ -39,7 +39,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@@ -50,100 +49,43 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.R
|
||||
import org.fairscan.app.data.ImageRepository
|
||||
import org.fairscan.app.data.ImageTransformations
|
||||
import org.fairscan.app.domain.Rotation
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.components.AppOverflowMenu
|
||||
import org.fairscan.app.ui.components.BackButton
|
||||
import org.fairscan.app.ui.components.ConfirmationDialog
|
||||
import org.fairscan.app.ui.components.MainActionButton
|
||||
import org.fairscan.app.ui.components.isLandscape
|
||||
import org.fairscan.app.ui.dummyNavigation
|
||||
import org.fairscan.app.ui.theme.FairScanTheme
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.Point
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
import java.io.File
|
||||
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditPageScreen(
|
||||
pageId: String,
|
||||
imageRepository: ImageRepository,
|
||||
onLoad: (String) -> Unit,
|
||||
initState: CropInitState,
|
||||
navigation: Navigation,
|
||||
onUpdatePageQuad: (String, Quad, onComplete: () -> Unit) -> Unit,
|
||||
onReportProblem: () -> Unit = {},
|
||||
) {
|
||||
val showDiscardChangesDialog = rememberSaveable { mutableStateOf(false) }
|
||||
val state = remember { EditPageScreenState() }
|
||||
val quadHandler = remember { QuadEditingHandler() }
|
||||
|
||||
val handleBack = {
|
||||
if (state.hasUnsavedChanges()) {
|
||||
showDiscardChangesDialog.value = true
|
||||
} else {
|
||||
navigation.back()
|
||||
}
|
||||
if (initState is CropInitState.Ready && initState.pageId == pageId) {
|
||||
state.bitmap = initState.bitmap
|
||||
state.setInitialQuad(initState.quad)
|
||||
}
|
||||
|
||||
BackHandler { handleBack() }
|
||||
|
||||
val isPreview = LocalInspectionMode.current
|
||||
if (isPreview) {
|
||||
val dummyImage = LocalContext.current.assets.open("gallica.bnf.fr-bpt6k5530456s-1.jpg").use { input ->
|
||||
BitmapFactory.decodeStream(input)
|
||||
}
|
||||
state.bitmap = dummyImage
|
||||
state.setInitialQuad(Quad(Point(.1, .1), Point(.9, .1), Point(.9, .9), Point(.1, .9)))
|
||||
}
|
||||
|
||||
val totalRotation = remember { mutableStateOf(Rotation.R0) }
|
||||
BackHandler { navigation.back() }
|
||||
|
||||
LaunchedEffect(pageId) {
|
||||
val metadata = imageRepository.getPageMetadata(pageId)
|
||||
val baseRotation = metadata?.baseRotation ?: Rotation.R0
|
||||
val manualRotation = imageRepository.getManualRotation(pageId)
|
||||
val rotation = baseRotation.add(manualRotation)
|
||||
totalRotation.value = rotation
|
||||
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
val sourceJpegBytes = imageRepository.sourceJpegBytes(pageId)
|
||||
if (sourceJpegBytes != null) {
|
||||
val original = BitmapFactory.decodeByteArray(sourceJpegBytes, 0, sourceJpegBytes.size)
|
||||
if (original != null && rotation != Rotation.R0) {
|
||||
// Adjust the displayed bitmap's rotation to what is in the metadata
|
||||
val matrix = Matrix().apply { postRotate(rotation.degrees.toFloat()) }
|
||||
val rotated = android.graphics.Bitmap.createBitmap(
|
||||
original, 0, 0, original.width, original.height, matrix, true
|
||||
)
|
||||
if (rotated !== original) {
|
||||
original.recycle()
|
||||
}
|
||||
rotated
|
||||
} else {
|
||||
original
|
||||
}
|
||||
} else null
|
||||
}
|
||||
state.bitmap = bitmap // assigned on the main thread after withContext returns
|
||||
if (metadata?.normalizedQuad != null) {
|
||||
// Rotate the quad to match the rotated bitmap display
|
||||
val rotatedQuad = metadata.normalizedQuad.rotate90(
|
||||
rotation.degrees / 90,
|
||||
ImageSize(1, 1)
|
||||
)
|
||||
state.setInitialQuad(rotatedQuad)
|
||||
}
|
||||
onLoad(pageId)
|
||||
}
|
||||
|
||||
val isLandscape = isLandscape(LocalConfiguration.current)
|
||||
@@ -178,7 +120,7 @@ fun EditPageScreen(
|
||||
}
|
||||
|
||||
BackButton(
|
||||
onClick = handleBack,
|
||||
onClick = navigation.back,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
@@ -198,6 +140,7 @@ fun EditPageScreen(
|
||||
.padding(16.dp)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||
onConfirm = {
|
||||
/*
|
||||
val quad = state.editableQuad
|
||||
if (quad != null) {
|
||||
// Reverse the total rotation to get back to original source image coordinates
|
||||
@@ -210,21 +153,11 @@ fun EditPageScreen(
|
||||
} else {
|
||||
navigation.back()
|
||||
}
|
||||
*/
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDiscardChangesDialog.value) {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(R.string.discard_changes),
|
||||
message = stringResource(R.string.discard_changes_warning),
|
||||
showDialog = showDiscardChangesDialog
|
||||
) {
|
||||
state.revertToInitial()
|
||||
navigation.back()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -246,7 +179,7 @@ private fun ActionButtons(
|
||||
private fun DragQuadOverlay(
|
||||
state: EditPageScreenState,
|
||||
quadHandler: QuadEditingHandler,
|
||||
bmp: android.graphics.Bitmap
|
||||
bmp: Bitmap
|
||||
) {
|
||||
if (state.editableQuad == null || state.containerSize == null) return
|
||||
|
||||
@@ -409,22 +342,16 @@ private fun DragMagnifyingGlass(state: EditPageScreenState) {
|
||||
@Preview(name = "RTL", locale = "ar", showSystemUi = true)
|
||||
fun EditPageScreenPreview() {
|
||||
FairScanTheme {
|
||||
|
||||
// Minimal no-op ImageTransformations implementation used only for preview.
|
||||
val dummyTransformations = object : ImageTransformations {
|
||||
override fun rotate(inputFile: File, outputFile: File, rotationDegrees: Int, jpegQuality: Int) = Unit
|
||||
override fun resize(inputFile: File, outputFile: File, maxSize: Int) = Unit
|
||||
val dummyImage = LocalContext.current.assets.open("gallica.bnf.fr-bpt6k5530456s-1.jpg").use { input ->
|
||||
BitmapFactory.decodeStream(input)
|
||||
}
|
||||
|
||||
// Use a temporary directory for the repository in preview.
|
||||
val tempDir = File(System.getProperty("java.io.tmpdir") ?: "/tmp")
|
||||
val dummyImageRepo = ImageRepository(tempDir, dummyTransformations, 128)
|
||||
|
||||
val quad = Quad(Point(.1, .1), Point(.9, .1), Point(.9, .9), Point(.1, .9))
|
||||
EditPageScreen(
|
||||
pageId = "preview-page-id",
|
||||
imageRepository = dummyImageRepo,
|
||||
pageId = "123",
|
||||
onLoad = {},
|
||||
initState = CropInitState.Ready("123",dummyImage, quad),
|
||||
navigation = dummyNavigation(),
|
||||
onUpdatePageQuad = { _, _, onComplete -> onComplete() }
|
||||
onUpdatePageQuad = { _,_,_ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
package org.fairscan.app.ui.screens.edit
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -23,6 +24,16 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import org.fairscan.imageprocessing.Quad
|
||||
|
||||
sealed interface CropInitState {
|
||||
object Loading : CropInitState
|
||||
object Error : CropInitState
|
||||
data class Ready(
|
||||
val pageId: String,
|
||||
val bitmap: Bitmap,
|
||||
val quad: Quad
|
||||
) : CropInitState
|
||||
}
|
||||
|
||||
class EditPageScreenState {
|
||||
companion object {
|
||||
val LIFT_WIGGLE_MAX_DISTANCE = 8.dp
|
||||
@@ -116,12 +127,4 @@ class EditPageScreenState {
|
||||
initialQuad = quad
|
||||
editableQuad = quad
|
||||
}
|
||||
|
||||
fun hasUnsavedChanges(): Boolean {
|
||||
return editableQuad != initialQuad
|
||||
}
|
||||
|
||||
fun revertToInitial() {
|
||||
editableQuad = initialQuad
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
<string name="delete_page">Delete page</string>
|
||||
<string name="delete_page_warning">Do you want to delete this page?</string>
|
||||
<string name="developer">Developer</string>
|
||||
<string name="discard_changes">Discard changes</string>
|
||||
<string name="discard_changes_warning">You have unsaved changes. Do you want to discard them?</string>
|
||||
<string name="discard_scan">Discard scan</string>
|
||||
<string name="download_dirname">Downloads</string>
|
||||
<string name="error">Error: %1$s</string>
|
||||
|
||||
Reference in New Issue
Block a user