forked from fekt/hush-android-wallet
fekt
1 year ago
6 changed files with 285 additions and 159 deletions
@ -1,216 +1,341 @@ |
|||||
package cash.z.ecc.android.ui.scan |
package cash.z.ecc.android.ui.scan |
||||
|
|
||||
import android.content.Context |
|
||||
import android.content.pm.PackageManager |
|
||||
import android.os.Bundle |
import android.os.Bundle |
||||
import android.util.DisplayMetrics |
import android.util.Log |
||||
import android.view.LayoutInflater |
import android.view.LayoutInflater |
||||
import android.view.View |
import android.view.View |
||||
|
import android.view.ViewGroup |
||||
|
import android.widget.Toast |
||||
import androidx.camera.core.* |
import androidx.camera.core.* |
||||
import androidx.camera.lifecycle.ProcessCameraProvider |
import androidx.camera.lifecycle.ProcessCameraProvider |
||||
import androidx.core.content.ContextCompat |
import androidx.core.content.ContextCompat |
||||
import androidx.fragment.app.activityViewModels |
import androidx.fragment.app.activityViewModels |
||||
import androidx.fragment.app.viewModels |
import androidx.fragment.app.viewModels |
||||
|
import androidx.window.layout.WindowInfoTracker |
||||
|
import androidx.window.layout.WindowMetricsCalculator |
||||
import cash.z.ecc.android.R |
import cash.z.ecc.android.R |
||||
import cash.z.ecc.android.databinding.FragmentScanBinding |
import cash.z.ecc.android.databinding.FragmentScanBinding |
||||
import cash.z.ecc.android.ext.onClickNavBack |
import cash.z.ecc.android.ext.onClickNavBack |
||||
import cash.z.ecc.android.feedback.Report |
|
||||
import cash.z.ecc.android.feedback.Report.Tap.SCAN_BACK |
|
||||
import cash.z.ecc.android.ui.base.BaseFragment |
import cash.z.ecc.android.ui.base.BaseFragment |
||||
import cash.z.ecc.android.ui.send.SendViewModel |
import cash.z.ecc.android.ui.send.SendViewModel |
||||
import cash.z.ecc.android.util.twig |
|
||||
import com.google.common.util.concurrent.ListenableFuture |
|
||||
import kotlinx.coroutines.launch |
import kotlinx.coroutines.launch |
||||
import java.util.concurrent.ExecutorService |
import java.util.concurrent.ExecutorService |
||||
import java.util.concurrent.Executors |
import java.util.concurrent.Executors |
||||
|
import kotlin.math.abs |
||||
|
import kotlin.math.max |
||||
|
import kotlin.math.min |
||||
|
|
||||
class ScanFragment : BaseFragment<FragmentScanBinding>() { |
|
||||
|
|
||||
override val screen = Report.Screen.SCAN |
class ScanFragment : BaseFragment<FragmentScanBinding>() { |
||||
|
|
||||
|
private var _fragmentCameraBinding: FragmentScanBinding? = null |
||||
|
private val fragmentCameraBinding get() = _fragmentCameraBinding!! |
||||
|
private var displayId: Int = -1 |
||||
|
private var lensFacing: Int = CameraSelector.LENS_FACING_BACK |
||||
|
private var preview: Preview? = null |
||||
|
private var imageCapture: ImageCapture? = null |
||||
|
private var imageAnalyzer: ImageAnalysis? = null |
||||
|
private var camera: Camera? = null |
||||
|
private var cameraProvider: ProcessCameraProvider? = null |
||||
private val viewModel: ScanViewModel by viewModels() |
private val viewModel: ScanViewModel by viewModels() |
||||
|
|
||||
private val sendViewModel: SendViewModel by activityViewModels() |
private val sendViewModel: SendViewModel by activityViewModels() |
||||
|
private lateinit var windowManager: WindowInfoTracker |
||||
|
|
||||
private lateinit var cameraProviderFuture: ListenableFuture<ProcessCameraProvider> |
/** Blocking camera operations are performed using this executor */ |
||||
|
private lateinit var cameraExecutor: ExecutorService |
||||
private var cameraExecutor: ExecutorService? = null |
|
||||
|
|
||||
override fun inflate(inflater: LayoutInflater): FragmentScanBinding = |
override fun inflate(inflater: LayoutInflater): FragmentScanBinding = |
||||
FragmentScanBinding.inflate(inflater) |
FragmentScanBinding.inflate(inflater) |
||||
|
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { |
||||
super.onViewCreated(view, savedInstanceState) |
super.onViewCreated(view, savedInstanceState) |
||||
if (cameraExecutor != null) cameraExecutor?.shutdown() |
|
||||
|
// Initialize our background executor |
||||
cameraExecutor = Executors.newSingleThreadExecutor() |
cameraExecutor = Executors.newSingleThreadExecutor() |
||||
|
|
||||
binding.backButtonHitArea.onClickNavBack() { tapped(SCAN_BACK) } |
//Initialize WindowManager to retrieve display metrics |
||||
} |
windowManager = WindowInfoTracker.getOrCreate(view.context) |
||||
|
|
||||
override fun onCreate(savedInstanceState: Bundle?) { |
// Wait for the views to be properly laid out |
||||
super.onCreate(savedInstanceState) |
fragmentCameraBinding.viewFinder.post { |
||||
if (!allPermissionsGranted()) getRuntimePermissions() |
|
||||
} |
|
||||
|
|
||||
override fun onAttach(context: Context) { |
// Keep track of the display in which this view is attached |
||||
super.onAttach(context) |
displayId = fragmentCameraBinding.viewFinder.display.displayId |
||||
cameraProviderFuture = ProcessCameraProvider.getInstance(context) |
|
||||
cameraProviderFuture.addListener( |
// Set up the camera and its use cases |
||||
Runnable { |
setUpCamera() |
||||
bindPreview(cameraProviderFuture.get()) |
} |
||||
}, |
|
||||
ContextCompat.getMainExecutor(context) |
// Initialize back button |
||||
) |
_fragmentCameraBinding?.backButtonHitArea?.onClickNavBack() |
||||
} |
} |
||||
|
|
||||
override fun onDestroyView() { |
override fun onDestroyView() { |
||||
|
_fragmentCameraBinding = null |
||||
super.onDestroyView() |
super.onDestroyView() |
||||
cameraExecutor?.shutdown() |
|
||||
cameraExecutor = null |
// Shut down our background executor |
||||
|
cameraExecutor.shutdown() |
||||
|
} |
||||
|
|
||||
|
override fun onCreateView( |
||||
|
inflater: LayoutInflater, |
||||
|
container: ViewGroup?, |
||||
|
savedInstanceState: Bundle? |
||||
|
): View { |
||||
|
_fragmentCameraBinding = FragmentScanBinding.inflate(inflater, container, false) |
||||
|
return fragmentCameraBinding.root |
||||
|
} |
||||
|
|
||||
|
/** Initialize CameraX, and prepare to bind the camera use cases */ |
||||
|
private fun setUpCamera() { |
||||
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext()) |
||||
|
cameraProviderFuture.addListener(Runnable { |
||||
|
|
||||
|
// CameraProvider |
||||
|
cameraProvider = cameraProviderFuture.get() |
||||
|
|
||||
|
// Select lensFacing depending on the available cameras |
||||
|
lensFacing = when { |
||||
|
hasBackCamera() -> CameraSelector.LENS_FACING_BACK |
||||
|
hasFrontCamera() -> CameraSelector.LENS_FACING_FRONT |
||||
|
else -> throw IllegalStateException("Back and front camera are unavailable") |
||||
|
} |
||||
|
|
||||
|
// Build and bind the camera use cases |
||||
|
bindCameraUseCases() |
||||
|
}, ContextCompat.getMainExecutor(requireContext())) |
||||
} |
} |
||||
|
|
||||
private fun bindPreview(cameraProvider: ProcessCameraProvider) { |
/** Declare and bind preview, capture and analysis use cases */ |
||||
// Most of the code here is adapted from: https://github.com/android/camera-samples/blob/master/CameraXBasic/app/src/main/java/com/android/example/cameraxbasic/fragments/CameraFragment.kt |
private fun bindCameraUseCases() { |
||||
// it's worth keeping tabs on that implementation because they keep making breaking changes to these APIs! |
|
||||
|
|
||||
// Get screen metrics used to setup camera for full screen resolution |
// Get screen metrics used to setup camera for full screen resolution |
||||
val metrics = DisplayMetrics().also { binding.preview.display.getRealMetrics(it) } |
/* |
||||
val screenAspectRatio = aspectRatio(metrics.widthPixels, metrics.heightPixels) |
val metrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(requireActivity()).bounds |
||||
val rotation = binding.preview.display.rotation |
Log.d("SilentDragon", "Screen metrics: ${metrics.width()} x ${metrics.height()}") |
||||
|
val screenAspectRatio = aspectRatio(metrics.width(), metrics.height()) |
||||
|
*/ |
||||
|
|
||||
|
// Hardcode to square for now otherwise scanning doesn't work |
||||
|
val screenAspectRatio = aspectRatio(1, 1) |
||||
|
Log.d("SilentDragon", "Preview aspect ratio: $screenAspectRatio") |
||||
|
|
||||
|
val rotation = fragmentCameraBinding.viewFinder.display.rotation |
||||
|
|
||||
|
// CameraProvider |
||||
|
val cameraProvider = cameraProvider |
||||
|
?: throw IllegalStateException("Camera initialization failed.") |
||||
|
|
||||
val preview = |
// CameraSelector |
||||
Preview.Builder().setTargetName("Preview").setTargetAspectRatio(screenAspectRatio) |
val cameraSelector = CameraSelector.Builder().requireLensFacing(lensFacing).build() |
||||
.setTargetRotation(rotation).build() |
|
||||
|
|
||||
val cameraSelector = CameraSelector.Builder() |
// Preview |
||||
.requireLensFacing(CameraSelector.LENS_FACING_BACK) |
preview = Preview.Builder() |
||||
|
// We request aspect ratio but no resolution |
||||
|
.setTargetAspectRatio(screenAspectRatio) |
||||
|
// Set initial target rotation |
||||
|
.setTargetRotation(rotation) |
||||
|
.build() |
||||
|
|
||||
|
// ImageCapture |
||||
|
imageCapture = ImageCapture.Builder() |
||||
|
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) |
||||
|
// We request aspect ratio but no resolution to match preview config, but letting |
||||
|
// CameraX optimize for whatever specific resolution best fits our use cases |
||||
|
.setTargetAspectRatio(screenAspectRatio) |
||||
|
// Set initial target rotation, we will have to call this again if rotation changes |
||||
|
// during the lifecycle of this use case |
||||
|
.setTargetRotation(rotation) |
||||
.build() |
.build() |
||||
|
|
||||
val imageAnalysis = ImageAnalysis.Builder().setTargetAspectRatio(screenAspectRatio) |
// ImageAnalysis |
||||
|
imageAnalyzer = ImageAnalysis.Builder() |
||||
|
// We request aspect ratio but no resolution |
||||
|
.setTargetAspectRatio(screenAspectRatio) |
||||
|
// Set initial target rotation, we will have to call this again if rotation changes |
||||
|
// during the lifecycle of this use case |
||||
.setTargetRotation(rotation) |
.setTargetRotation(rotation) |
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) |
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) |
||||
.build() |
.build() |
||||
|
|
||||
imageAnalysis.setAnalyzer( |
|
||||
cameraExecutor!!, |
// The analyzer can then be assigned to the instance |
||||
QrAnalyzer { q, i -> |
.also { |
||||
onQrScanned(q, i) |
it.setAnalyzer(cameraExecutor, |
||||
|
QrAnalyzer { q, i -> |
||||
|
onQrScanned(q, i) |
||||
|
} |
||||
|
) |
||||
} |
} |
||||
) |
|
||||
|
|
||||
// Must unbind the use-cases before rebinding them |
// Must unbind the use-cases before rebinding them |
||||
cameraProvider.unbindAll() |
cameraProvider.unbindAll() |
||||
|
|
||||
|
if (camera != null) { |
||||
|
// Must remove observers from the previous camera instance |
||||
|
removeCameraStateObservers(camera!!.cameraInfo) |
||||
|
} |
||||
|
|
||||
try { |
try { |
||||
cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageAnalysis) |
// A variable number of use-cases can be passed here - |
||||
preview.setSurfaceProvider(binding.preview.surfaceProvider) |
// camera provides access to CameraControl & CameraInfo |
||||
} catch (t: Throwable) { |
camera = cameraProvider.bindToLifecycle( |
||||
// TODO: consider bubbling this up to the user |
this, cameraSelector, preview, imageCapture, imageAnalyzer) |
||||
mainActivity?.feedback?.report(t) |
|
||||
twig("Error while opening the camera: $t") |
// Attach the viewfinder's surface provider to preview use case |
||||
|
preview?.setSurfaceProvider(fragmentCameraBinding.viewFinder.surfaceProvider) |
||||
|
observeCameraState(camera?.cameraInfo!!) |
||||
|
} catch (exc: Exception) { |
||||
|
Log.e(TAG, "Use case binding failed", exc) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private fun removeCameraStateObservers(cameraInfo: CameraInfo) { |
||||
|
cameraInfo.cameraState.removeObservers(viewLifecycleOwner) |
||||
|
} |
||||
|
|
||||
|
private fun observeCameraState(cameraInfo: CameraInfo) { |
||||
|
cameraInfo.cameraState.observe(viewLifecycleOwner) { cameraState -> |
||||
|
run { |
||||
|
when (cameraState.type) { |
||||
|
CameraState.Type.PENDING_OPEN -> { |
||||
|
// Ask the user to close other camera apps |
||||
|
Toast.makeText(context, |
||||
|
"CameraState: Pending Open", |
||||
|
Toast.LENGTH_SHORT).show() |
||||
|
} |
||||
|
CameraState.Type.OPENING -> { |
||||
|
// Show the Camera UI |
||||
|
Toast.makeText(context, |
||||
|
"CameraState: Opening", |
||||
|
Toast.LENGTH_SHORT).show() |
||||
|
} |
||||
|
CameraState.Type.OPEN -> { |
||||
|
// Setup Camera resources and begin processing |
||||
|
Toast.makeText(context, |
||||
|
"CameraState: Open", |
||||
|
Toast.LENGTH_SHORT).show() |
||||
|
} |
||||
|
CameraState.Type.CLOSING -> { |
||||
|
// Close camera UI |
||||
|
Toast.makeText(context, |
||||
|
"CameraState: Closing", |
||||
|
Toast.LENGTH_SHORT).show() |
||||
|
} |
||||
|
CameraState.Type.CLOSED -> { |
||||
|
// Free camera resources |
||||
|
Toast.makeText(context, |
||||
|
"CameraState: Closed", |
||||
|
Toast.LENGTH_SHORT).show() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
cameraState.error?.let { error -> |
||||
|
when (error.code) { |
||||
|
// Open errors |
||||
|
CameraState.ERROR_STREAM_CONFIG -> { |
||||
|
// Make sure to setup the use cases properly |
||||
|
Toast.makeText(context, |
||||
|
"Stream config error", |
||||
|
Toast.LENGTH_SHORT).show() |
||||
|
} |
||||
|
// Opening errors |
||||
|
CameraState.ERROR_CAMERA_IN_USE -> { |
||||
|
// Close the camera or ask user to close another camera app that's using the |
||||
|
// camera |
||||
|
Toast.makeText(context, |
||||
|
"Camera in use", |
||||
|
Toast.LENGTH_SHORT).show() |
||||
|
} |
||||
|
CameraState.ERROR_MAX_CAMERAS_IN_USE -> { |
||||
|
// Close another open camera in the app, or ask the user to close another |
||||
|
// camera app that's using the camera |
||||
|
Toast.makeText(context, |
||||
|
"Max cameras in use", |
||||
|
Toast.LENGTH_SHORT).show() |
||||
|
} |
||||
|
CameraState.ERROR_OTHER_RECOVERABLE_ERROR -> { |
||||
|
Toast.makeText(context, |
||||
|
"Other recoverable error", |
||||
|
Toast.LENGTH_SHORT).show() |
||||
|
} |
||||
|
// Closing errors |
||||
|
CameraState.ERROR_CAMERA_DISABLED -> { |
||||
|
// Ask the user to enable the device's cameras |
||||
|
Toast.makeText(context, |
||||
|
"Camera disabled", |
||||
|
Toast.LENGTH_SHORT).show() |
||||
|
} |
||||
|
CameraState.ERROR_CAMERA_FATAL_ERROR -> { |
||||
|
// Ask the user to reboot the device to restore camera function |
||||
|
Toast.makeText(context, |
||||
|
"Fatal error", |
||||
|
Toast.LENGTH_SHORT).show() |
||||
|
} |
||||
|
// Closed errors |
||||
|
CameraState.ERROR_DO_NOT_DISTURB_MODE_ENABLED -> { |
||||
|
// Ask the user to disable the "Do Not Disturb" mode, then reopen the camera |
||||
|
Toast.makeText(context, |
||||
|
"Do not disturb mode enabled", |
||||
|
Toast.LENGTH_SHORT).show() |
||||
|
} |
||||
|
} |
||||
|
} |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
/** |
/** |
||||
* Adapted from: https://github.com/android/camera-samples/blob/master/CameraXBasic/app/src/main/java/com/android/example/cameraxbasic/fragments/CameraFragment.kt#L350 |
* [androidx.camera.core.ImageAnalysis.Builder] requires enum value of |
||||
|
* [androidx.camera.core.AspectRatio]. Currently it has values of 4:3 & 16:9. |
||||
|
* |
||||
|
* Detecting the most suitable ratio for dimensions provided in @params by counting absolute |
||||
|
* of preview ratio to one of the provided values. |
||||
|
* |
||||
|
* @param width - preview width |
||||
|
* @param height - preview height |
||||
|
* @return suitable aspect ratio |
||||
*/ |
*/ |
||||
private fun aspectRatio(width: Int, height: Int): Int { |
private fun aspectRatio(width: Int, height: Int): Int { |
||||
val previewRatio = kotlin.math.max(width, height).toDouble() / kotlin.math.min( |
val previewRatio = max(width, height).toDouble() / min(width, height) |
||||
width, |
if (abs(previewRatio - RATIO_4_3_VALUE) <= abs(previewRatio - RATIO_16_9_VALUE)) { |
||||
height |
|
||||
) |
|
||||
if (kotlin.math.abs(previewRatio - (4.0 / 3.0)) |
|
||||
<= kotlin.math.abs(previewRatio - (16.0 / 9.0)) |
|
||||
) { |
|
||||
return AspectRatio.RATIO_4_3 |
return AspectRatio.RATIO_4_3 |
||||
} |
} |
||||
return AspectRatio.RATIO_16_9 |
return AspectRatio.RATIO_16_9 |
||||
} |
} |
||||
|
|
||||
|
/** Returns true if the device has an available back camera. False otherwise */ |
||||
|
private fun hasBackCamera(): Boolean { |
||||
|
return cameraProvider?.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA) ?: false |
||||
|
} |
||||
|
|
||||
|
/** Returns true if the device has an available front camera. False otherwise */ |
||||
|
private fun hasFrontCamera(): Boolean { |
||||
|
return cameraProvider?.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) ?: false |
||||
|
} |
||||
|
|
||||
|
companion object { |
||||
|
|
||||
|
private const val TAG = "SilentDragon" |
||||
|
private const val RATIO_4_3_VALUE = 4.0 / 3.0 |
||||
|
private const val RATIO_16_9_VALUE = 16.0 / 9.0 |
||||
|
} |
||||
|
|
||||
private fun onQrScanned(qrContent: String, image: ImageProxy) { |
private fun onQrScanned(qrContent: String, image: ImageProxy) { |
||||
|
//Log.d("SilentDragon", "QR scanned: $qrContent") |
||||
resumedScope.launch { |
resumedScope.launch { |
||||
val parsed = viewModel.parse(qrContent) |
val parsed = viewModel.parse(qrContent) |
||||
if (parsed == null) { |
if (parsed == null) { |
||||
val network = viewModel.networkName |
val network = viewModel.networkName |
||||
binding.textScanError.text = getString(R.string.scan_invalid_address, network, qrContent) |
_fragmentCameraBinding?.textScanError?.text = |
||||
|
getString(R.string.scan_invalid_address, network, qrContent) |
||||
image.close() |
image.close() |
||||
} else { /* continue scanning*/ |
} else { /* continue scanning*/ |
||||
binding.textScanError.text = "" |
_fragmentCameraBinding?.textScanError?.text = "" |
||||
sendViewModel.toAddress = parsed |
sendViewModel.toAddress = parsed |
||||
mainActivity?.safeNavigate(R.id.action_nav_scan_to_nav_send) |
mainActivity?.safeNavigate(R.id.action_nav_scan_to_nav_send) |
||||
} |
} |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
// private fun updateOverlay(detectedObjects: DetectedObjects) { |
|
||||
// if (detectedObjects.objects.isEmpty()) { |
|
||||
// return |
|
||||
// } |
|
||||
// |
|
||||
// overlay.setSize(detectedObjects.imageWidth, detectedObjects.imageHeight) |
|
||||
// val list = mutableListOf<BoxData>() |
|
||||
// for (obj in detectedObjects.objects) { |
|
||||
// val box = obj.boundingBox |
|
||||
// val name = "${categoryNames[obj.classificationCategory]}" |
|
||||
// val confidence = |
|
||||
// if (obj.classificationCategory != FirebaseVisionObject.CATEGORY_UNKNOWN) { |
|
||||
// val confidence: Int = obj.classificationConfidence!!.times(100).toInt() |
|
||||
// "$confidence%" |
|
||||
// } else { |
|
||||
// "" |
|
||||
// } |
|
||||
// list.add(BoxData("$name $confidence", box)) |
|
||||
// } |
|
||||
// overlay.set(list) |
|
||||
// } |
|
||||
|
|
||||
// |
|
||||
// Permissions |
|
||||
// |
|
||||
|
|
||||
private val requiredPermissions: Array<String?> |
|
||||
get() { |
|
||||
return try { |
|
||||
val info = mainActivity?.packageManager |
|
||||
?.getPackageInfo(mainActivity?.packageName ?: "", PackageManager.GET_PERMISSIONS) |
|
||||
val ps = info?.requestedPermissions |
|
||||
if (ps != null && ps.isNotEmpty()) { |
|
||||
ps |
|
||||
} else { |
|
||||
arrayOfNulls(0) |
|
||||
} |
|
||||
} catch (e: Exception) { |
|
||||
arrayOfNulls(0) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private fun allPermissionsGranted(): Boolean { |
|
||||
for (permission in requiredPermissions) { |
|
||||
if (!isPermissionGranted(mainActivity!!, permission!!)) { |
|
||||
return false |
|
||||
} |
|
||||
} |
|
||||
return true |
|
||||
} |
|
||||
|
|
||||
private fun getRuntimePermissions() { |
|
||||
val allNeededPermissions = arrayListOf<String>() |
|
||||
for (permission in requiredPermissions) { |
|
||||
if (!isPermissionGranted(mainActivity!!, permission!!)) { |
|
||||
allNeededPermissions.add(permission) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if (allNeededPermissions.isNotEmpty()) { |
|
||||
requestPermissions(allNeededPermissions.toTypedArray(), CAMERA_PERMISSION_REQUEST) |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
companion object { |
|
||||
private const val CAMERA_PERMISSION_REQUEST = 1002 |
|
||||
|
|
||||
private fun isPermissionGranted(context: Context, permission: String): Boolean { |
|
||||
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED |
|
||||
} |
|
||||
} |
|
||||
} |
} |
||||
|
Loading…
Reference in new issue