Skip to content

Commit 8b166d7

Browse files
committed
feat(feature:send-money): implement share screenshot
1 parent 0436ea0 commit 8b166d7

File tree

14 files changed

+895
-41
lines changed

14 files changed

+895
-41
lines changed

cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ internal fun MifosNavHost(
328328
navigateToPayeeDetailsScreen = navController::navigateToPayeeDetailsScreen,
329329
navigateToScanQrScreen = navController::navigateToScanQr,
330330
)
331-
// Already in paise from PayeeDetailsState
331+
332332
payeeDetailsScreen(
333333
onBackClick = navController::popBackStack,
334334
onNavigateToUpiPin = { state ->
@@ -346,7 +346,6 @@ internal fun MifosNavHost(
346346
upiPinScreen(
347347
onBackClick = navController::popBackStack,
348348
onNavigateToPaymentProcessing = { payeeName, amount, isUpiCode ->
349-
// Convert rupees to paise for navigation
350349
val amountInPaise = AmountUtils.rupeesToPaise(amount)
351350
navController.navigateToPaymentProcessingScreen(
352351
payeeName = payeeName,
@@ -355,7 +354,7 @@ internal fun MifosNavHost(
355354
)
356355
},
357356
)
358-
// Already in paise from PaymentProcessingViewModel
357+
359358
paymentProcessingScreen(
360359
onPaymentComplete = { payeeName, amount, upiName, transactionTimestamp ->
361360
navController.navigateToPaymentSuccessScreen(
@@ -372,7 +371,9 @@ internal fun MifosNavHost(
372371

373372
paymentSuccessScreen(
374373
onShareScreenshot = {
375-
// TODO: Implement screenshot sharing functionality
374+
// Screenshot functionality is handled by the Android-specific PaymentSuccessScreen implementation
375+
// The actual screenshot and sharing is done within the screen itself
376+
// This callback is used by the Android-specific implementation to trigger the screenshot
376377
},
377378
onDone = {
378379
navController.navigate(HOME_ROUTE) {
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
/*
2+
* Copyright 2024 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
7+
*
8+
* See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md
9+
*/
10+
package org.mifospay.core.ui.utils
11+
12+
import android.app.Activity
13+
import android.graphics.Bitmap
14+
import android.graphics.Canvas
15+
import android.graphics.Paint
16+
import android.graphics.PorterDuff
17+
import android.graphics.PorterDuffXfermode
18+
import android.graphics.RectF
19+
import android.view.ViewGroup
20+
import androidx.core.view.drawToBitmap
21+
import io.github.vinceglb.filekit.FileKit
22+
import io.github.vinceglb.filekit.ImageFormat
23+
import io.github.vinceglb.filekit.compressImage
24+
import kotlinx.coroutines.Dispatchers
25+
import kotlinx.coroutines.withContext
26+
import java.io.ByteArrayOutputStream
27+
28+
// TODO enhance content presentation image shared in the screenshot utils
29+
/**
30+
* Specialized screenshot utility for PaymentSuccessScreen.
31+
*
32+
* This utility is designed specifically for taking screenshots of the payment success screen
33+
* while excluding the advertisement banner and action buttons. It captures only the content
34+
* above the advertisement banner for clean, professional screenshots.
35+
*/
36+
object PaymentSuccessScreenshotUtils {
37+
38+
private const val CONTENT_PADDING_DP = 48
39+
private const val EXTRA_BOTTOM_PADDING_DP = 16
40+
private const val BUTTON_AREA_HEIGHT_RATIO = 0.25f
41+
private const val BANNER_START_RATIO = 0.60f
42+
private const val TOP_PADDING_RATIO = 0.03f
43+
private const val COMPRESSION_QUALITY = 90
44+
private const val MAX_WIDTH = 1080
45+
private const val MAX_HEIGHT = 1920
46+
private const val TEST_TAG_PAYMENT_SUCCESS_CONTENT = "payment-success-content"
47+
48+
/**
49+
* Provider function to retrieve the current [Activity].
50+
* This must be set before using screenshot functionality.
51+
*/
52+
private var activityProvider: () -> Activity = {
53+
throw IllegalArgumentException(
54+
"You need to implement the 'activityProvider' to provide the required Activity. " +
55+
"Just make sure to set a valid activity using " +
56+
"the 'setActivityProvider()' method.",
57+
)
58+
}
59+
60+
/**
61+
* Sets the activity provider function to be used internally for context retrieval.
62+
*
63+
* This is required to initialize before calling any screenshot methods.
64+
*
65+
* @param provider A lambda that returns the current [Activity].
66+
*/
67+
fun setActivityProvider(provider: () -> Activity) {
68+
activityProvider = provider
69+
}
70+
71+
/**
72+
* Takes a screenshot of the PaymentSuccessScreen with context details and immediately shares it.
73+
*
74+
* @param contextDetails Payment context details for better file naming
75+
* @param onError Callback when screenshot or sharing fails
76+
*/
77+
suspend fun takePaymentSuccessScreenshotAndShare(
78+
contextDetails: ScreenshotContextDetails,
79+
onError: (String) -> Unit,
80+
) {
81+
try {
82+
val screenshotBytes = takePaymentSuccessScreenshotSync()
83+
val fileName = contextDetails.generateFileName()
84+
val shareFile = ShareFileModel(
85+
mime = MimeType.IMAGE,
86+
fileName = fileName,
87+
bytes = screenshotBytes,
88+
)
89+
90+
ShareUtils.shareFile(shareFile)
91+
} catch (e: Exception) {
92+
onError("Failed to share payment success screenshot: ${e.message}")
93+
}
94+
}
95+
96+
/**
97+
* Takes a screenshot of the PaymentSuccessScreen synchronously and returns the bytes.
98+
*
99+
* @return Byte array of the compressed screenshot
100+
*/
101+
private suspend fun takePaymentSuccessScreenshotSync(): ByteArray {
102+
return withContext(Dispatchers.Main) {
103+
val activity = activityProvider.invoke()
104+
val rootView = activity.findViewById<ViewGroup>(android.R.id.content)
105+
106+
val screenshot = capturePaymentSuccessScreenshot(rootView)
107+
compressScreenshot(screenshot)
108+
}
109+
}
110+
111+
/**
112+
* Captures a screenshot of the PaymentSuccessScreen while excluding the advertisement banner and action buttons.
113+
*
114+
* @param rootView The root view to capture
115+
* @return Bitmap of the screenshot with only content above the advertisement banner
116+
*/
117+
private fun capturePaymentSuccessScreenshot(rootView: ViewGroup): Bitmap {
118+
val bitmap = rootView.drawToBitmap()
119+
val contentArea = findContentArea(rootView)
120+
121+
return if (contentArea != null && contentArea.width() > 0 && contentArea.height() > 0) {
122+
captureContentAreaScreenshot(bitmap, contentArea, rootView.resources.displayMetrics.density)
123+
} else {
124+
captureFallbackScreenshot(bitmap)
125+
}
126+
}
127+
128+
/**
129+
* Captures screenshot using the identified content area with proper padding.
130+
*/
131+
private fun captureContentAreaScreenshot(
132+
bitmap: Bitmap,
133+
contentArea: android.graphics.Rect,
134+
density: Float,
135+
): Bitmap {
136+
val paddingPx = (CONTENT_PADDING_DP * density).toInt()
137+
val extraBottomPaddingPx = (EXTRA_BOTTOM_PADDING_DP * density).toInt()
138+
139+
val paddedLeft = maxOf(0, contentArea.left - paddingPx)
140+
val paddedTop = maxOf(0, contentArea.top - paddingPx)
141+
val paddedRight = minOf(bitmap.width, contentArea.right + paddingPx)
142+
val paddedBottom = minOf(bitmap.height, contentArea.bottom + paddingPx + extraBottomPaddingPx)
143+
144+
val paddedWidth = paddedRight - paddedLeft
145+
val paddedHeight = paddedBottom - paddedTop
146+
147+
val contentBitmap = Bitmap.createBitmap(
148+
bitmap,
149+
paddedLeft,
150+
paddedTop,
151+
paddedWidth,
152+
paddedHeight,
153+
)
154+
bitmap.recycle()
155+
return contentBitmap
156+
}
157+
158+
/**
159+
* Captures screenshot using fallback method when content area is not found.
160+
*/
161+
private fun captureFallbackScreenshot(bitmap: Bitmap): Bitmap {
162+
val canvas = Canvas(bitmap)
163+
val paint = Paint().apply {
164+
color = android.graphics.Color.TRANSPARENT
165+
xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
166+
}
167+
168+
val screenHeight = bitmap.height
169+
val screenWidth = bitmap.width
170+
171+
val buttonAreaHeight = (screenHeight * BUTTON_AREA_HEIGHT_RATIO).toInt()
172+
val bannerStartY = (screenHeight * BANNER_START_RATIO).toFloat()
173+
val topPadding = (screenHeight * TOP_PADDING_RATIO).toInt()
174+
175+
val excludeBottomRect = RectF(
176+
0f,
177+
(screenHeight - buttonAreaHeight).toFloat(),
178+
screenWidth.toFloat(),
179+
screenHeight.toFloat(),
180+
)
181+
canvas.drawRect(excludeBottomRect, paint)
182+
183+
val excludeBannerRect = RectF(
184+
0f,
185+
bannerStartY,
186+
screenWidth.toFloat(),
187+
screenHeight.toFloat(),
188+
)
189+
canvas.drawRect(excludeBannerRect, paint)
190+
191+
val excludeTopRect = RectF(
192+
0f,
193+
0f,
194+
screenWidth.toFloat(),
195+
topPadding.toFloat(),
196+
)
197+
canvas.drawRect(excludeTopRect, paint)
198+
199+
return bitmap
200+
}
201+
202+
/**
203+
* Compresses the screenshot bitmap to reduce file size.
204+
*
205+
* @param bitmap The screenshot bitmap to compress
206+
* @return Compressed image as byte array
207+
*/
208+
private suspend fun compressScreenshot(bitmap: Bitmap): ByteArray {
209+
return withContext(Dispatchers.IO) {
210+
val outputStream = ByteArrayOutputStream()
211+
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
212+
val originalBytes = outputStream.toByteArray()
213+
214+
FileKit.compressImage(
215+
bytes = originalBytes,
216+
quality = COMPRESSION_QUALITY,
217+
maxWidth = MAX_WIDTH,
218+
maxHeight = MAX_HEIGHT,
219+
imageFormat = ImageFormat.PNG,
220+
)
221+
}
222+
}
223+
224+
/**
225+
* Finds the content area above the advertisement banner using the testTag.
226+
*
227+
* @param rootView The root view to search in
228+
* @return Rect representing the content area bounds, or null if not found
229+
*/
230+
private fun findContentArea(rootView: ViewGroup): android.graphics.Rect? {
231+
return findViewWithTestTag(rootView, TEST_TAG_PAYMENT_SUCCESS_CONTENT)?.let { contentView ->
232+
val location = IntArray(2)
233+
contentView.getLocationInWindow(location)
234+
235+
var viewWidth = contentView.width
236+
var viewHeight = contentView.height
237+
238+
if (viewWidth <= 0 || viewHeight <= 0) {
239+
contentView.measure(
240+
android.view.View.MeasureSpec.makeMeasureSpec(0, android.view.View.MeasureSpec.UNSPECIFIED),
241+
android.view.View.MeasureSpec.makeMeasureSpec(0, android.view.View.MeasureSpec.UNSPECIFIED),
242+
)
243+
viewWidth = contentView.measuredWidth
244+
viewHeight = contentView.measuredHeight
245+
}
246+
247+
if (contentView is ViewGroup) {
248+
contentView.layout(0, 0, viewWidth, viewHeight)
249+
}
250+
251+
if (viewWidth > 0 && viewHeight > 0) {
252+
android.graphics.Rect(
253+
location[0],
254+
location[1],
255+
location[0] + viewWidth,
256+
location[1] + viewHeight,
257+
)
258+
} else {
259+
null
260+
}
261+
}
262+
}
263+
264+
/**
265+
* Recursively searches for a view with the specified test tag.
266+
*
267+
* @param viewGroup The view group to search in
268+
* @param testTag The test tag to search for
269+
* @return The view with the test tag, or null if not found
270+
*/
271+
private fun findViewWithTestTag(viewGroup: ViewGroup, testTag: String): android.view.View? {
272+
for (i in 0 until viewGroup.childCount) {
273+
val child = viewGroup.getChildAt(i)
274+
275+
if (child.tag == testTag) {
276+
return child
277+
}
278+
279+
if (child is ViewGroup) {
280+
val found = findViewWithTestTag(child, testTag)
281+
if (found != null) {
282+
return found
283+
}
284+
}
285+
}
286+
return null
287+
}
288+
}

0 commit comments

Comments
 (0)