TP-67637 | PixelCopy Capture (#195)
Co-authored-by: Varun Jain <varun.jain@navi.com>
This commit is contained in:
@@ -43,7 +43,6 @@ import com.navi.alfred.utils.ScreenShotStorageHelper
|
||||
import com.navi.alfred.utils.buildAppPerformanceEvent
|
||||
import com.navi.alfred.utils.buildEvent
|
||||
import com.navi.alfred.utils.buildNegativeCaseEvent
|
||||
import com.navi.alfred.utils.captureBottomSheet
|
||||
import com.navi.alfred.utils.captureScreen
|
||||
import com.navi.alfred.utils.captureScreenshotOfCustomView
|
||||
import com.navi.alfred.utils.checkDbAndDeleteCorruptScreenshots
|
||||
@@ -78,7 +77,8 @@ object AlfredManager {
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, _ -> }
|
||||
private var previousTouchEvent: NaviMotionEvent = NaviMotionEvent()
|
||||
private var screenShotCaptureDelay: Long = 1000L
|
||||
private var reactBottomSheetView: WeakReference<View>? = null
|
||||
private var screenShotTimerStartDelay: Long = 500L
|
||||
internal var reactBottomSheetView: WeakReference<View>? = null
|
||||
private var screenShotTimer: Timer? = null
|
||||
private var viewLayoutDelay: Long = 1000
|
||||
internal val mutex = Mutex()
|
||||
@@ -113,6 +113,11 @@ object AlfredManager {
|
||||
internal var hasCheckedDb: Boolean = true
|
||||
internal var criticalJourneyResponseCode: Int = 0
|
||||
internal var criticalJourneyResponseMessage: String = ""
|
||||
var bmpForCanvas: Pair<Canvas, Bitmap>? = null
|
||||
private var isCruiseApiCalled: Boolean = false
|
||||
private var isActivityResumed: Boolean = false
|
||||
internal var currentView: WeakReference<View>? = null
|
||||
internal var bmpForThirdPartySdkScreen: Bitmap? = null
|
||||
|
||||
fun init(
|
||||
config: AlfredConfig,
|
||||
@@ -133,18 +138,32 @@ object AlfredManager {
|
||||
return config.getAlfredStatus() && config.getEnableRecordingStatus()
|
||||
}
|
||||
|
||||
fun startRecording(
|
||||
context: Context,
|
||||
view: View,
|
||||
fun onActivityResumed(
|
||||
screenName: String? = null,
|
||||
moduleName: String,
|
||||
activity: Activity? = null
|
||||
activity: Activity? = null,
|
||||
applicationContext: Context
|
||||
) {
|
||||
isActivityResumed = true
|
||||
if (isAlfredRecordingEnabled() || !isCruiseApiCalled) {
|
||||
currentActivity = WeakReference(activity)
|
||||
setCurrentScreenName(screenName)
|
||||
currentModuleName = moduleName
|
||||
currentView = WeakReference(activity?.window?.decorView?.rootView)
|
||||
if (moduleName == THIRD_PARTY_MODULE) {
|
||||
handleScreenTransitionEvent(activity?.localClassName.toString(), moduleName)
|
||||
}
|
||||
}
|
||||
if (isCruiseApiCalled && isAlfredRecordingEnabled()) {
|
||||
initAlfredRecording(applicationContext)
|
||||
startRecording()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initAlfredRecording(context: Context) {
|
||||
if (config.getEnableRecordingStatus().not()) {
|
||||
return
|
||||
}
|
||||
screenShotTimer?.cancel()
|
||||
screenShotTimer = Timer()
|
||||
if (!hasRecordingStarted) {
|
||||
checkDbAndDeleteCorruptScreenshots()
|
||||
config.setAlfredSessionId()
|
||||
@@ -155,28 +174,32 @@ object AlfredManager {
|
||||
val startRecordingEvent =
|
||||
buildEvent(
|
||||
AlfredConstants.START_RECORDING_EVENT,
|
||||
screenName = screenName,
|
||||
moduleName = moduleName
|
||||
screenName = currentScreenName,
|
||||
moduleName = currentModuleName
|
||||
)
|
||||
AlfredDispatcher.addTaskToQueue(AddEventTask(startRecordingEvent, context))
|
||||
hasRecordingStarted = true
|
||||
hasCheckedDb = false
|
||||
}
|
||||
currentActivity = WeakReference(activity)
|
||||
setCurrentScreenName(screenName)
|
||||
currentModuleName = moduleName
|
||||
hasRecordingStarted = true
|
||||
var bmpForCanvas: Pair<Canvas, Bitmap>? = null
|
||||
var bmpForThirdPartySdkScreen: Bitmap? = null
|
||||
if (moduleName == THIRD_PARTY_MODULE) {
|
||||
handleScreenTransitionEvent(activity?.localClassName.toString(), moduleName)
|
||||
}
|
||||
|
||||
private fun startRecording() {
|
||||
if (
|
||||
config.getEnableRecordingStatus().not() ||
|
||||
currentActivity == null ||
|
||||
currentScreenName == null ||
|
||||
currentModuleName == null
|
||||
) {
|
||||
return
|
||||
}
|
||||
screenShotTimer?.cancel()
|
||||
screenShotTimer = Timer()
|
||||
val timerTask: TimerTask =
|
||||
object : TimerTask() {
|
||||
override fun run() {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
if (moduleName == THIRD_PARTY_MODULE) {
|
||||
currentScreenName = activity?.localClassName.toString()
|
||||
if (isScreenDisabled(currentScreenName, moduleName)) {
|
||||
coroutineScope.launch {
|
||||
if (currentModuleName == THIRD_PARTY_MODULE) {
|
||||
if (isScreenDisabled(currentScreenName, currentModuleName)) {
|
||||
if (bmpForThirdPartySdkScreen == null) {
|
||||
val thirdPartyScreenView =
|
||||
LayoutInflater.from(applicationContext)
|
||||
@@ -184,7 +207,7 @@ object AlfredManager {
|
||||
measureInflatedView(thirdPartyScreenView)
|
||||
thirdPartyScreenView
|
||||
.findViewById<AppCompatTextView>(R.id.tv_third_party_name)
|
||||
.text = screenName
|
||||
.text = currentScreenName
|
||||
bmpForThirdPartySdkScreen =
|
||||
thirdPartyScreenView?.let {
|
||||
captureScreenshotOfCustomView(it)
|
||||
@@ -198,18 +221,20 @@ object AlfredManager {
|
||||
} else {
|
||||
if (bmpForCanvas == null) {
|
||||
delay(viewLayoutDelay)
|
||||
bmpForCanvas = createBitmapForView(view)
|
||||
bmpForCanvas =
|
||||
currentView?.get()?.let { createBitmapForView(it) }
|
||||
}
|
||||
try {
|
||||
captureScreen(
|
||||
view,
|
||||
context,
|
||||
screenName = screenName,
|
||||
scope = coroutineScope,
|
||||
canvas = bmpForCanvas?.first,
|
||||
bmp = bmpForCanvas?.second,
|
||||
moduleName = moduleName
|
||||
)
|
||||
currentView?.get()?.let { view ->
|
||||
captureScreen(
|
||||
view,
|
||||
screenName = currentScreenName,
|
||||
canvas = bmpForCanvas?.first,
|
||||
bmp = bmpForCanvas?.second,
|
||||
moduleName = currentModuleName,
|
||||
activity = currentActivity?.get()
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.log()
|
||||
}
|
||||
@@ -217,31 +242,17 @@ object AlfredManager {
|
||||
} else {
|
||||
if (bmpForCanvas == null) {
|
||||
delay(viewLayoutDelay)
|
||||
bmpForCanvas = createBitmapForView(view)
|
||||
bmpForCanvas = currentView?.get()?.let { createBitmapForView(it) }
|
||||
}
|
||||
try {
|
||||
val bottomSheetView =
|
||||
reactBottomSheetView?.get()
|
||||
?: dialog?.window?.decorView?.rootView
|
||||
if (bottomSheetView != null) {
|
||||
captureBottomSheet(
|
||||
view,
|
||||
bottomSheetView,
|
||||
context,
|
||||
screenName,
|
||||
bmpForCanvas?.first,
|
||||
rootBmp = bmpForCanvas?.second,
|
||||
moduleName = moduleName
|
||||
)
|
||||
} else {
|
||||
currentView?.get()?.let { view ->
|
||||
captureScreen(
|
||||
view,
|
||||
context,
|
||||
screenName = screenName,
|
||||
scope = coroutineScope,
|
||||
screenName = currentScreenName,
|
||||
canvas = bmpForCanvas?.first,
|
||||
bmp = bmpForCanvas?.second,
|
||||
moduleName = moduleName
|
||||
moduleName = currentModuleName,
|
||||
activity = currentActivity?.get()
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -253,13 +264,14 @@ object AlfredManager {
|
||||
}
|
||||
}
|
||||
screenShotCaptureDelay = (1000 / config.getSnapshotPerSecond().toLong())
|
||||
screenShotTimer?.schedule(timerTask, 0, screenShotCaptureDelay)
|
||||
screenShotTimer?.schedule(timerTask, screenShotTimerStartDelay, screenShotCaptureDelay)
|
||||
}
|
||||
|
||||
fun stopRecording() {
|
||||
if (isAlfredRecordingEnabled()) {
|
||||
isAppInBackground = true
|
||||
hasRecordingStarted = false
|
||||
isActivityResumed = false
|
||||
screenShotTimer?.cancel()
|
||||
isCriticalUserJourneyActive.set(false)
|
||||
val appBackgroundView =
|
||||
@@ -280,25 +292,34 @@ object AlfredManager {
|
||||
}
|
||||
|
||||
fun getAlfredCruiseInfo(cruiseApiSuccessful: (response: CruiseResponse) -> Unit) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
getCruiseConfig(
|
||||
cruiseApiSuccessful = { response ->
|
||||
alfredDataBase =
|
||||
AlfredDatabaseHelper.getAnalyticsDatabase(applicationContext)
|
||||
analyticsDao = alfredDataBase.analyticsDao()
|
||||
screenShotDao = alfredDataBase.screenShotDao()
|
||||
zipDetailsDao = alfredDataBase.zipDetailsDao()
|
||||
apiMetricDao = alfredDataBase.apiMetricDao()
|
||||
negativeCaseDao = alfredDataBase.negativeCaseDao()
|
||||
failureEventDao = alfredDataBase.failureEventDao()
|
||||
sensitiveComposeRepository = ComposeMaskingRepoImpl()
|
||||
startSyncEvents()
|
||||
cruiseApiSuccessful(response)
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.log()
|
||||
if (!isCruiseApiCalled) {
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
getCruiseConfig(
|
||||
cruiseApiSuccessful = { response ->
|
||||
isCruiseApiCalled = true
|
||||
alfredDataBase =
|
||||
AlfredDatabaseHelper.getAnalyticsDatabase(applicationContext)
|
||||
analyticsDao = alfredDataBase.analyticsDao()
|
||||
screenShotDao = alfredDataBase.screenShotDao()
|
||||
zipDetailsDao = alfredDataBase.zipDetailsDao()
|
||||
apiMetricDao = alfredDataBase.apiMetricDao()
|
||||
negativeCaseDao = alfredDataBase.negativeCaseDao()
|
||||
failureEventDao = alfredDataBase.failureEventDao()
|
||||
sensitiveComposeRepository = ComposeMaskingRepoImpl()
|
||||
startSyncEvents()
|
||||
cruiseApiSuccessful(response)
|
||||
if (isAlfredRecordingEnabled() && isActivityResumed) {
|
||||
if (!hasRecordingStarted) {
|
||||
initAlfredRecording(context = applicationContext)
|
||||
}
|
||||
startRecording()
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.log()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,7 +420,7 @@ object AlfredManager {
|
||||
}
|
||||
|
||||
fun handleScreenTransitionEvent(screenName: String?, moduleName: String? = null) {
|
||||
if (isAlfredRecordingEnabled()) {
|
||||
if (isAlfredRecordingEnabled() && screenName != null && moduleName != null) {
|
||||
if (config.getAlfredSessionId().isNotEmpty()) {
|
||||
coroutineDispatcher.executor.execute {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
*
|
||||
* * Copyright © 2024 by Navi Technologies Limited
|
||||
* * All rights reserved. Strictly confidential
|
||||
*
|
||||
*/
|
||||
|
||||
package com.navi.alfred.utils
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.view.View
|
||||
import com.navi.alfred.AlfredManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal suspend fun getScreenShotUsingCanvasDraw(
|
||||
view: View,
|
||||
bitmap: Bitmap,
|
||||
canvas: Canvas,
|
||||
shouldMaskComposeScreen: Boolean? = false,
|
||||
shouldMaskXmlScreen: Boolean? = false
|
||||
) {
|
||||
try {
|
||||
withContext(Dispatchers.Main) { view.draw(canvas) }
|
||||
if (shouldMaskComposeScreen == true) {
|
||||
val sensitiveCoordinates = AlfredManager.sensitiveComposeRepository.sensitiveCoordinates
|
||||
captureComposeViewWithMasking(
|
||||
canvas = canvas,
|
||||
rootView = view,
|
||||
sensitiveCoordinates = sensitiveCoordinates
|
||||
)
|
||||
}
|
||||
if (shouldMaskXmlScreen == true) {
|
||||
captureXmlViewWithMasking(canvas = canvas, view = view)
|
||||
}
|
||||
|
||||
val bottomSheetView =
|
||||
AlfredManager.reactBottomSheetView?.get()
|
||||
?: AlfredManager.dialog?.window?.decorView?.rootView
|
||||
if (bottomSheetView != null && !AlfredManager.config.getDisableDialogScreenShot()) {
|
||||
val bottomSheetCanvasForBitmap = createBitmapForView(bottomSheetView)
|
||||
withContext(Dispatchers.Main) {
|
||||
bottomSheetCanvasForBitmap?.first?.let { bottomSheetView.draw(it) }
|
||||
}
|
||||
|
||||
if (bottomSheetCanvasForBitmap?.second != null) {
|
||||
combineScreenshots(
|
||||
backgroundScreenshot = bitmap,
|
||||
bottomSheetScreenshot = bottomSheetCanvasForBitmap.second,
|
||||
context = AlfredManager.applicationContext,
|
||||
moduleName = AlfredManager.currentModuleName,
|
||||
screenName = AlfredManager.currentScreenName,
|
||||
scope = AlfredManager.coroutineScope
|
||||
)
|
||||
}
|
||||
} else {
|
||||
insertScreenShotPathInDb(
|
||||
scope = AlfredManager.coroutineScope,
|
||||
context = AlfredManager.applicationContext,
|
||||
bitmap = bitmap,
|
||||
bottomSheetFlow = false
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.log()
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,6 @@ import java.lang.reflect.Type
|
||||
import java.util.UUID
|
||||
import kotlin.concurrent.fixedRateTimer
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
internal suspend fun sendEventsToServer(
|
||||
@@ -498,7 +497,7 @@ internal fun startSyncEvents() {
|
||||
AlfredConstants.DEFAULT_INITIAL_DELAY,
|
||||
AlfredManager.config.getEventsDelayInMilliseconds()
|
||||
) {
|
||||
runBlocking {
|
||||
AlfredManager.coroutineScope.launch {
|
||||
AddMetricTask.resetEventCount()
|
||||
sendIngestMetric()
|
||||
AddEventTask.resetEventCount()
|
||||
|
||||
@@ -10,20 +10,19 @@ package com.navi.alfred.utils
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
||||
internal fun findViewWithTagRecursive(
|
||||
view: View,
|
||||
tag: String,
|
||||
maskedViews: MutableList<View>
|
||||
): List<View> {
|
||||
internal fun findViewWithTagRecursive(view: View, tag: String, maskedViews: MutableList<View>) {
|
||||
if (view.tag == tag) {
|
||||
maskedViews.add(view)
|
||||
return
|
||||
}
|
||||
|
||||
if (view is ViewGroup) {
|
||||
for (i in 0 until view.childCount) {
|
||||
val childView = view.getChildAt(i)
|
||||
findViewWithTagRecursive(childView, tag, maskedViews)
|
||||
if (childView != null) {
|
||||
findViewWithTagRecursive(childView, tag, maskedViews)
|
||||
}
|
||||
}
|
||||
}
|
||||
return maskedViews
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
*
|
||||
* * Copyright © 2024 by Navi Technologies Limited
|
||||
* * All rights reserved. Strictly confidential
|
||||
*
|
||||
*/
|
||||
|
||||
package com.navi.alfred.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.view.PixelCopy
|
||||
import android.view.View
|
||||
import com.navi.alfred.AlfredManager
|
||||
import com.navi.alfred.model.MaskingBounds
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
fun getScreenShotUsingPixelCopy(
|
||||
view: View,
|
||||
bitmap: Bitmap?,
|
||||
isBottomSheet: Boolean? = false,
|
||||
activity: Activity?,
|
||||
shouldMaskComposeScreen: Boolean? = false,
|
||||
shouldMaskXmlScreen: Boolean? = false,
|
||||
bitmapForBackground: Bitmap? = null,
|
||||
canvas: Canvas
|
||||
) {
|
||||
if (isViewCaptureNotReady(activity, view)) return
|
||||
|
||||
val handlerThread = HandlerThread("PixelCopyThread")
|
||||
handlerThread.start()
|
||||
|
||||
val captureWindow =
|
||||
if (isBottomSheet == false) {
|
||||
activity?.window
|
||||
} else {
|
||||
AlfredManager.dialog?.window
|
||||
}
|
||||
captureWindow?.let { window ->
|
||||
val locationOfViewInWindow = IntArray(2)
|
||||
view.getLocationInWindow(locationOfViewInWindow)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
if (bitmap != null) {
|
||||
var sensitiveCoordinates: Map<String, MaskingBounds> = emptyMap()
|
||||
if (shouldMaskComposeScreen == true) {
|
||||
sensitiveCoordinates =
|
||||
AlfredManager.sensitiveComposeRepository.sensitiveCoordinates
|
||||
}
|
||||
PixelCopy.request(
|
||||
window,
|
||||
Rect(
|
||||
locationOfViewInWindow[0],
|
||||
locationOfViewInWindow[1],
|
||||
locationOfViewInWindow[0] + view.width,
|
||||
locationOfViewInWindow[1] + view.height
|
||||
),
|
||||
bitmap,
|
||||
{ copyResult ->
|
||||
if (copyResult == PixelCopy.SUCCESS) {
|
||||
if (shouldMaskComposeScreen == true) {
|
||||
captureComposeViewWithMasking(
|
||||
canvas,
|
||||
view,
|
||||
sensitiveCoordinates
|
||||
)
|
||||
}
|
||||
onPixelCopySuccess(
|
||||
bitmap = bitmap,
|
||||
isBottomSheetCaptured = isBottomSheet,
|
||||
activity = activity,
|
||||
scope = AlfredManager.coroutineScope,
|
||||
bitmapForBackground = bitmapForBackground,
|
||||
shouldMaskComposeScreen = shouldMaskComposeScreen,
|
||||
shouldMaskXmlScreen = shouldMaskXmlScreen,
|
||||
canvas = canvas,
|
||||
view = view
|
||||
)
|
||||
} else {
|
||||
onPixelCopyError()
|
||||
}
|
||||
handlerThread.quitSafely()
|
||||
},
|
||||
Handler(handlerThread.looper)
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.log()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onPixelCopySuccess(
|
||||
bitmap: Bitmap?,
|
||||
isBottomSheetCaptured: Boolean?,
|
||||
activity: Activity?,
|
||||
scope: CoroutineScope,
|
||||
bitmapForBackground: Bitmap?,
|
||||
shouldMaskComposeScreen: Boolean? = false,
|
||||
shouldMaskXmlScreen: Boolean? = false,
|
||||
canvas: Canvas,
|
||||
view: View
|
||||
) {
|
||||
if (shouldMaskComposeScreen == true) {
|
||||
val sensitiveCoordinates = AlfredManager.sensitiveComposeRepository.sensitiveCoordinates
|
||||
captureComposeViewWithMasking(canvas, view, sensitiveCoordinates)
|
||||
}
|
||||
|
||||
if (shouldMaskXmlScreen == true) {
|
||||
captureXmlViewWithMasking(view, canvas)
|
||||
}
|
||||
|
||||
if (isBottomSheetCaptured == true) {
|
||||
combineScreenshots(
|
||||
backgroundScreenshot = bitmapForBackground,
|
||||
bottomSheetScreenshot = bitmap,
|
||||
context = AlfredManager.applicationContext,
|
||||
moduleName = AlfredManager.currentModuleName,
|
||||
screenName = AlfredManager.currentScreenName,
|
||||
scope = scope
|
||||
)
|
||||
} else {
|
||||
val bottomSheetView =
|
||||
AlfredManager.reactBottomSheetView?.get()
|
||||
?: AlfredManager.dialog?.window?.decorView?.rootView
|
||||
if (!AlfredManager.config.getDisableDialogScreenShot() && bottomSheetView != null) {
|
||||
captureBottomSheetUsingPixelCopy(
|
||||
bitmap = bitmap,
|
||||
activity = activity,
|
||||
bottomSheetView = bottomSheetView,
|
||||
canvas = canvas
|
||||
)
|
||||
} else {
|
||||
insertScreenShotPathInDb(
|
||||
scope = scope,
|
||||
context = AlfredManager.applicationContext,
|
||||
bitmap = bitmap,
|
||||
bottomSheetFlow = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun captureBottomSheetUsingPixelCopy(
|
||||
bitmap: Bitmap?,
|
||||
activity: Activity?,
|
||||
bottomSheetView: View,
|
||||
canvas: Canvas
|
||||
) {
|
||||
val bottomSheetCanvasForBitmap = createBitmapForView(bottomSheetView)
|
||||
getScreenShotUsingPixelCopy(
|
||||
view = bottomSheetView,
|
||||
bitmap = bottomSheetCanvasForBitmap?.second,
|
||||
isBottomSheet = true,
|
||||
activity = activity,
|
||||
bitmapForBackground = bitmap,
|
||||
canvas = canvas
|
||||
)
|
||||
}
|
||||
|
||||
fun onPixelCopyError() {
|
||||
return
|
||||
}
|
||||
|
||||
internal fun captureComposeViewWithMasking(
|
||||
canvas: Canvas,
|
||||
rootView: View,
|
||||
sensitiveCoordinates: Map<String, MaskingBounds>
|
||||
) {
|
||||
try {
|
||||
val paint = Paint()
|
||||
paint.color = Color.BLACK
|
||||
if (sensitiveCoordinates.isNotEmpty()) {
|
||||
sensitiveCoordinates.forEach { (key, bounds) ->
|
||||
if (
|
||||
bounds.bottom > 0 &&
|
||||
bounds.top < rootView.height &&
|
||||
bounds.height > 0 &&
|
||||
bounds.width > 0
|
||||
) {
|
||||
canvas.drawRect(bounds.left, bounds.top, bounds.right, bounds.bottom, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.log()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun captureXmlViewWithMasking(view: View, canvas: Canvas) {
|
||||
runBlocking {
|
||||
try {
|
||||
val maskedViewsList: MutableList<View> = mutableListOf()
|
||||
findViewWithTagRecursive(view, AlfredConstants.SENSITIVE_VIEW_TAG, maskedViewsList)
|
||||
|
||||
val sensitiveCoordinatesList: MutableList<MaskingBounds> = mutableListOf()
|
||||
maskedViewsList.forEach { view ->
|
||||
val bounds =
|
||||
MaskingBounds(
|
||||
left = view.left.toFloat(),
|
||||
top = view.top.toFloat(),
|
||||
right = view.right.toFloat(),
|
||||
bottom = view.bottom.toFloat()
|
||||
)
|
||||
sensitiveCoordinatesList.add(bounds)
|
||||
}
|
||||
|
||||
sensitiveCoordinatesList.forEach {
|
||||
canvas.drawRect(
|
||||
it.left,
|
||||
it.top,
|
||||
it.right,
|
||||
it.bottom,
|
||||
Paint().apply { color = Color.BLACK }
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.log()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,17 @@
|
||||
|
||||
package com.navi.alfred.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import com.navi.alfred.AlfredManager
|
||||
import com.navi.alfred.db.AlfredDatabaseHelper
|
||||
import com.navi.alfred.db.model.ScreenShotPathHelper
|
||||
@@ -24,7 +27,6 @@ import java.io.IOException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal suspend fun handleScreenShot() {
|
||||
if (
|
||||
@@ -133,7 +135,7 @@ internal fun insertScreenShotPathInDb(
|
||||
)
|
||||
ScreenShotStorageHelper.addImage(path)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
e.log()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -203,117 +205,95 @@ enum class VideoQuality {
|
||||
|
||||
internal suspend fun captureScreen(
|
||||
v: View,
|
||||
context: Context,
|
||||
bottomSheetFlow: Boolean? = false,
|
||||
screenName: String? = null,
|
||||
scope: CoroutineScope,
|
||||
canvas: Canvas? = null,
|
||||
bmp: Bitmap? = null,
|
||||
moduleName: String? = null
|
||||
moduleName: String? = null,
|
||||
activity: Activity? = null
|
||||
): Bitmap? {
|
||||
if (
|
||||
isScreenDisabled(screenName, moduleName) ||
|
||||
isScreenDisabled(AlfredManager.currentScreenName, moduleName) ||
|
||||
canvas == null ||
|
||||
bmp == null
|
||||
bmp == null ||
|
||||
activity == null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
val rootView = AlfredManager.sensitiveComposeRepository.getRootViewOfComposeScreen()
|
||||
if (isMaskingEnabled(screenName)) {
|
||||
AlfredManager.coroutineScope.launch {
|
||||
if (isMaskingEnabled(screenName) || isMaskingEnabled(AlfredManager.currentScreenName)) {
|
||||
try {
|
||||
val rootView = AlfredManager.sensitiveComposeRepository.getRootViewOfComposeScreen()
|
||||
if (rootView != null) {
|
||||
if (AlfredManager.sensitiveComposeRepository.getBlurSensitiveScreenStatus()) {
|
||||
blurScreen(canvas, rootView)
|
||||
} else {
|
||||
captureComposeViewWithMasking(canvas, rootView)
|
||||
captureScreenManager(
|
||||
view = rootView,
|
||||
bitmap = bmp,
|
||||
canvas = canvas,
|
||||
shouldMaskComposeScreen = true,
|
||||
activity = activity
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (v.tag == AlfredConstants.SENSITIVE_VIEW_TAG) {
|
||||
captureXmlViewWithMasking(canvas, v)
|
||||
captureScreenManager(
|
||||
view = v,
|
||||
bitmap = bmp,
|
||||
canvas = canvas,
|
||||
shouldMaskXmlScreen = true,
|
||||
activity = activity
|
||||
)
|
||||
} else {
|
||||
v.draw(canvas)
|
||||
captureScreenManager(
|
||||
view = v,
|
||||
bitmap = bmp,
|
||||
canvas = canvas,
|
||||
activity = activity
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.log()
|
||||
}
|
||||
} else {
|
||||
v.draw(canvas)
|
||||
captureScreenManager(view = v, bitmap = bmp, canvas = canvas, activity = activity)
|
||||
}
|
||||
}
|
||||
insertScreenShotPathInDb(scope, context, bmp, bottomSheetFlow)
|
||||
return bmp
|
||||
}
|
||||
|
||||
internal suspend fun captureBottomSheet(
|
||||
internal suspend fun captureScreenManager(
|
||||
view: View,
|
||||
bottomSheetView: View,
|
||||
context: Context,
|
||||
screenName: String? = null,
|
||||
rootCanvas: Canvas? = null,
|
||||
rootBmp: Bitmap? = null,
|
||||
moduleName: String? = null
|
||||
bitmap: Bitmap,
|
||||
canvas: Canvas,
|
||||
shouldMaskComposeScreen: Boolean? = false,
|
||||
shouldMaskXmlScreen: Boolean? = false,
|
||||
activity: Activity?
|
||||
) {
|
||||
if (AlfredManager.config.getDisableDialogScreenShot()) {
|
||||
return
|
||||
}
|
||||
val bottomSheetCanvasForBitmap = createBitmapForView(bottomSheetView)
|
||||
val bottomSheetScreenShot =
|
||||
captureScreen(
|
||||
bottomSheetView,
|
||||
context,
|
||||
true,
|
||||
screenName,
|
||||
AlfredManager.coroutineScope,
|
||||
bottomSheetCanvasForBitmap?.first,
|
||||
bottomSheetCanvasForBitmap?.second,
|
||||
moduleName = moduleName
|
||||
)
|
||||
val backgroundScreenShot =
|
||||
captureScreen(
|
||||
view,
|
||||
context,
|
||||
true,
|
||||
screenName,
|
||||
AlfredManager.coroutineScope,
|
||||
rootCanvas,
|
||||
rootBmp,
|
||||
moduleName = moduleName
|
||||
)
|
||||
if (bottomSheetScreenShot != null && backgroundScreenShot != null) {
|
||||
combineScreenshots(
|
||||
backgroundScreenShot,
|
||||
bottomSheetScreenShot,
|
||||
context,
|
||||
screenName,
|
||||
moduleName = moduleName,
|
||||
scope = AlfredManager.coroutineScope
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun captureComposeViewWithMasking(canvas: Canvas, rootView: View) {
|
||||
rootView.draw(canvas)
|
||||
|
||||
val sensitiveCoordinates = AlfredManager.sensitiveComposeRepository.sensitiveCoordinates
|
||||
|
||||
val paint = Paint()
|
||||
paint.color = Color.BLACK
|
||||
|
||||
if (sensitiveCoordinates.isNotEmpty()) {
|
||||
sensitiveCoordinates.forEach { (key, bounds) ->
|
||||
if (
|
||||
bounds.bottom > 0 &&
|
||||
bounds.top < rootView.height &&
|
||||
bounds.height > 0 &&
|
||||
bounds.width > 0
|
||||
) {
|
||||
canvas.drawRect(bounds.left, bounds.top, bounds.right, bounds.bottom, paint)
|
||||
}
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
getScreenShotUsingPixelCopy(
|
||||
view = view,
|
||||
bitmap = bitmap,
|
||||
activity = activity,
|
||||
canvas = canvas,
|
||||
shouldMaskComposeScreen = shouldMaskComposeScreen,
|
||||
shouldMaskXmlScreen = shouldMaskXmlScreen
|
||||
)
|
||||
} else {
|
||||
getScreenShotUsingCanvasDraw(
|
||||
view = view,
|
||||
bitmap = bitmap,
|
||||
canvas = canvas,
|
||||
shouldMaskComposeScreen = shouldMaskComposeScreen,
|
||||
shouldMaskXmlScreen = shouldMaskXmlScreen
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.log()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,33 +310,6 @@ internal fun blurScreen(canvas: Canvas, rootView: View) {
|
||||
)
|
||||
}
|
||||
|
||||
internal fun captureXmlViewWithMasking(canvas: Canvas, v: View) {
|
||||
val maskedViewsList =
|
||||
findViewWithTagRecursive(v, AlfredConstants.SENSITIVE_VIEW_TAG, mutableListOf())
|
||||
try {
|
||||
if (maskedViewsList.isEmpty()) {
|
||||
v.draw(canvas)
|
||||
} else {
|
||||
val alphaList = mutableListOf<Pair<View, Float>>()
|
||||
|
||||
for (maskedView in maskedViewsList) {
|
||||
alphaList.add(Pair(maskedView, maskedView.alpha))
|
||||
maskedView.alpha = 0.0f
|
||||
}
|
||||
|
||||
v.draw(canvas)
|
||||
|
||||
for (alphaView in alphaList) {
|
||||
val view = alphaView.first
|
||||
view.alpha = alphaView.second
|
||||
}
|
||||
alphaList.clear()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.log()
|
||||
}
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
internal fun measureInflatedView(view: View, width: Int = 1080, height: Int = 1920) {
|
||||
view.layoutParams = ViewGroup.LayoutParams(width, height)
|
||||
@@ -386,3 +339,13 @@ internal fun createBitmapForView(view: View): Pair<Canvas, Bitmap>? {
|
||||
internal fun isResolutionEven(width: Int, height: Int): Boolean {
|
||||
return width.mod(2) == 0 && height.mod(2) == 0
|
||||
}
|
||||
|
||||
fun isViewCaptureNotReady(activity: Activity?, view: View): Boolean {
|
||||
return activity == null ||
|
||||
!view.isVisible ||
|
||||
!view.isAttachedToWindow ||
|
||||
view.width == 0 ||
|
||||
view.height == 0 ||
|
||||
activity.isFinishing ||
|
||||
activity.isDestroyed
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ internal suspend fun uploadFile(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildWorkManagerForZipUploadRetry(requestData: Data) {
|
||||
internal fun buildWorkManagerForZipUploadRetry() {
|
||||
val constraints =
|
||||
Constraints.Builder()
|
||||
.setRequiresBatteryNotLow(false)
|
||||
@@ -239,19 +239,13 @@ internal fun buildWorkManagerForZipUploadRetry(requestData: Data) {
|
||||
.get()
|
||||
if (uniqueWorkStatus.isNullOrEmpty()) {
|
||||
val uniqueWorkRequest =
|
||||
OneTimeWorkRequestBuilder<ZipUploadRetryWorker>()
|
||||
.setConstraints(constraints)
|
||||
.setInputData(requestData)
|
||||
.build()
|
||||
OneTimeWorkRequestBuilder<ZipUploadRetryWorker>().setConstraints(constraints).build()
|
||||
WorkManager.getInstance(AlfredManager.applicationContext)
|
||||
.beginUniqueWork(uniqueWorkName, ExistingWorkPolicy.KEEP, uniqueWorkRequest)
|
||||
.enqueue()
|
||||
} else {
|
||||
val workRequest =
|
||||
OneTimeWorkRequestBuilder<ZipUploadRetryWorker>()
|
||||
.setConstraints(constraints)
|
||||
.setInputData(requestData)
|
||||
.build()
|
||||
OneTimeWorkRequestBuilder<ZipUploadRetryWorker>().setConstraints(constraints).build()
|
||||
WorkManager.getInstance(AlfredManager.applicationContext).enqueue(workRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import android.graphics.Bitmap
|
||||
import android.view.View
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkManager
|
||||
import com.google.gson.Gson
|
||||
import com.navi.alfred.AlfredManager
|
||||
import com.navi.alfred.db.model.ScreenShotPathHelper
|
||||
import com.navi.alfred.db.model.ZipDetailsHelper
|
||||
@@ -218,15 +217,7 @@ internal fun startAnrCrashZipUpload(view: View) {
|
||||
}
|
||||
AlfredManager.coroutineScope.launch(Dispatchers.IO) {
|
||||
if (AlfredManager.zipDetailsDao.getZipFilesDetailsCount() > 0) {
|
||||
val zipFileDetailList = AlfredManager.zipDetailsDao.fetchAllZipFilesDetails()
|
||||
val requestData =
|
||||
Data.Builder()
|
||||
.putString(
|
||||
AlfredConstants.ZIP_FILE_DETAIL_LIST,
|
||||
Gson().toJson(zipFileDetailList)
|
||||
)
|
||||
.build()
|
||||
buildWorkManagerForZipUploadRetry(requestData)
|
||||
buildWorkManagerForZipUploadRetry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class AddEventTask(private val event: AnalyticsEvent, private val context: Conte
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun execute(): Boolean {
|
||||
override suspend fun execute(): Boolean {
|
||||
val db = AlfredDatabaseHelper.getAnalyticsDatabase(context)
|
||||
val analyticsDao = db.analyticsDao()
|
||||
return try {
|
||||
|
||||
@@ -27,7 +27,7 @@ class AddFailureTask(private val event: FailureEvent, private val context: Conte
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun execute(): Boolean {
|
||||
override suspend fun execute(): Boolean {
|
||||
val db = AlfredDatabaseHelper.getAnalyticsDatabase(context)
|
||||
val failureDao = db.failureEventDao()
|
||||
return try {
|
||||
|
||||
@@ -27,7 +27,7 @@ class AddMetricTask(private val event: ApiMetricHelper, private val context: Con
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun execute(): Boolean {
|
||||
override suspend fun execute(): Boolean {
|
||||
val db = AlfredDatabaseHelper.getAnalyticsDatabase(context)
|
||||
val analyticsDao = db.apiMetricDao()
|
||||
return try {
|
||||
|
||||
@@ -27,7 +27,7 @@ class AddNegativeCase(private val negativeCase: NegativeCase, private val contex
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun execute(): Boolean {
|
||||
override suspend fun execute(): Boolean {
|
||||
val db = AlfredDatabaseHelper.getAnalyticsDatabase(context)
|
||||
val negativeCaseDao = db.negativeCaseDao()
|
||||
return try {
|
||||
|
||||
@@ -10,7 +10,7 @@ package com.navi.alfred.worker
|
||||
import androidx.annotation.WorkerThread
|
||||
|
||||
interface AnalyticsTask {
|
||||
@WorkerThread fun execute(): Boolean
|
||||
@WorkerThread suspend fun execute(): Boolean
|
||||
|
||||
fun getTaskName(): String
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
|
||||
package com.navi.alfred.worker
|
||||
|
||||
import com.navi.alfred.AlfredManager
|
||||
import java.util.concurrent.BlockingDeque
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.LinkedBlockingDeque
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AnalyticsTaskProcessor {
|
||||
private val taskQueue: BlockingDeque<AnalyticsTask> = LinkedBlockingDeque<AnalyticsTask>()
|
||||
@@ -43,14 +45,16 @@ class AnalyticsTaskProcessor {
|
||||
if (taskQueue.poll().also { active = it } != null) {
|
||||
coroutineDispatcher.executor.execute {
|
||||
active?.let {
|
||||
executeTask(it)
|
||||
scheduleNext()
|
||||
AlfredManager.coroutineScope.launch {
|
||||
executeTask(it)
|
||||
scheduleNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeTask(task: AnalyticsTask) {
|
||||
private suspend fun executeTask(task: AnalyticsTask) {
|
||||
task.execute()
|
||||
}
|
||||
|
||||
@@ -58,8 +62,10 @@ class AnalyticsTaskProcessor {
|
||||
if (isSyncEventTaskExecuted) {
|
||||
isSyncEventTaskExecuted = false
|
||||
singleThreadExecutorForSync.execute {
|
||||
task.execute()
|
||||
isSyncEventTaskExecuted = true
|
||||
AlfredManager.coroutineScope.launch {
|
||||
task.execute()
|
||||
isSyncEventTaskExecuted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,15 +11,11 @@ import androidx.annotation.WorkerThread
|
||||
import com.navi.alfred.utils.AlfredConstants.SYNC_EVENT_TASK
|
||||
import com.navi.alfred.utils.sendEventsToServer
|
||||
import com.navi.alfred.utils.sendIngestMetric
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class SyncDataTask() : AnalyticsTask {
|
||||
@WorkerThread
|
||||
override fun execute(): Boolean {
|
||||
return runBlocking {
|
||||
sendIngestMetric()
|
||||
sendEventsToServer()
|
||||
}
|
||||
override suspend fun execute(): Boolean {
|
||||
return sendEventsToServer() && sendIngestMetric()
|
||||
}
|
||||
|
||||
override fun getTaskName(): String {
|
||||
|
||||
@@ -10,12 +10,11 @@ package com.navi.alfred.worker
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.navi.alfred.utils.AlfredConstants.SYNC_FAILURE_TASK
|
||||
import com.navi.alfred.utils.sendFailureEventsToServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class SyncFailureTask() : AnalyticsTask {
|
||||
@WorkerThread
|
||||
override fun execute(): Boolean {
|
||||
return runBlocking { sendFailureEventsToServer() }
|
||||
override suspend fun execute(): Boolean {
|
||||
return sendFailureEventsToServer()
|
||||
}
|
||||
|
||||
override fun getTaskName(): String {
|
||||
|
||||
@@ -10,12 +10,11 @@ package com.navi.alfred.worker
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.navi.alfred.utils.AlfredConstants.SYNC_NEGATIVE_CASE_TASK
|
||||
import com.navi.alfred.utils.sendNegativeCaseToServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class SyncNegativeCaseTask() : AnalyticsTask {
|
||||
@WorkerThread
|
||||
override fun execute(): Boolean {
|
||||
return runBlocking { sendNegativeCaseToServer() }
|
||||
override suspend fun execute(): Boolean {
|
||||
return sendNegativeCaseToServer()
|
||||
}
|
||||
|
||||
override fun getTaskName(): String {
|
||||
|
||||
@@ -53,7 +53,6 @@ class UploadEventsWorker(context: Context, workerParams: WorkerParameters) :
|
||||
Result.success()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,6 @@ class UploadFileWorker(context: Context, workerParams: WorkerParameters) :
|
||||
Result.success()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,12 @@ import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkerParameters
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.navi.alfred.AlfredManager
|
||||
import com.navi.alfred.db.AlfredDatabaseHelper
|
||||
import com.navi.alfred.db.model.ZipDetailsHelper
|
||||
import com.navi.alfred.utils.AlfredConstants
|
||||
import com.navi.alfred.utils.buildWorkManagerForZip
|
||||
import com.navi.alfred.utils.sendAlfredSessionEvent
|
||||
import java.lang.reflect.Type
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -27,14 +26,11 @@ class ZipUploadRetryWorker(context: Context, workerParams: WorkerParameters) :
|
||||
override suspend fun doWork(): Result =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val zipDetailList = inputData.getString(AlfredConstants.ZIP_FILE_DETAIL_LIST)
|
||||
val listType: Type = object : TypeToken<List<ZipDetailsHelper?>?>() {}.type
|
||||
AlfredManager.alfredDataBase =
|
||||
AlfredDatabaseHelper.getAnalyticsDatabase(AlfredManager.applicationContext)
|
||||
AlfredManager.zipDetailsDao = AlfredManager.alfredDataBase.zipDetailsDao()
|
||||
val zipFileDetailList: List<ZipDetailsHelper> =
|
||||
if (zipDetailList != null) {
|
||||
Gson().fromJson(zipDetailList, listType)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
AlfredManager.zipDetailsDao.fetchAllZipFilesDetails()
|
||||
|
||||
zipFileDetailList.forEach {
|
||||
if (it.zipUploadStatus) {
|
||||
@@ -62,7 +58,6 @@ class ZipUploadRetryWorker(context: Context, workerParams: WorkerParameters) :
|
||||
}
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user