Skip to content

Commit f65ef09

Browse files
feat(core-ui): implement cross-platform file and text sharing (#2974)
1 parent 0cc8a02 commit f65ef09

File tree

10 files changed

+379
-123
lines changed

10 files changed

+379
-123
lines changed

build-logic/convention/src/main/kotlin/org/mifos/mobile/Detekt.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ internal fun Project.configureDetekt(extension: DetektExtension) = extension.app
1717
exclude("**/generated/**")
1818
exclude("**/build-logic/**")
1919
exclude("**/spotless/**")
20+
exclude("core-base/designsystem/**")
2021
reports {
2122
xml.required.set(true)
2223
html.required.set(true)

core/ui/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ kotlin{
4343
implementation(compose.components.resources)
4444
implementation(compose.components.uiToolingPreview)
4545
implementation(libs.jb.composeNavigation)
46-
implementation(libs.filekit.compose)
4746
implementation(libs.filekit.core)
47+
implementation(libs.filekit.dialog.compose)
4848
implementation(libs.compottie.resources)
4949
implementation(libs.compottie.lite)
5050
}

core/ui/src/androidMain/kotlin/org/mifos/mobile/core/ui/utils/ShareUtils.android.kt

Lines changed: 109 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -13,25 +13,32 @@ import android.app.Activity
1313
import android.content.ActivityNotFoundException
1414
import android.content.Context
1515
import android.content.Intent
16-
import android.graphics.Bitmap
1716
import android.net.Uri
1817
import android.provider.Settings
19-
import android.util.Log
2018
import android.widget.Toast
21-
import androidx.compose.ui.graphics.ImageBitmap
22-
import androidx.compose.ui.graphics.asAndroidBitmap
2319
import androidx.core.content.FileProvider
20+
import androidx.core.net.toUri
2421
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
22+
import io.github.vinceglb.filekit.FileKit
23+
import io.github.vinceglb.filekit.ImageFormat
24+
import io.github.vinceglb.filekit.compressImage
2525
import kotlinx.coroutines.Dispatchers
2626
import kotlinx.coroutines.withContext
2727
import org.jetbrains.compose.resources.ExperimentalResourceApi
28-
import org.jetbrains.compose.resources.decodeToImageBitmap
2928
import java.io.File
30-
import java.io.FileOutputStream
31-
import java.io.IOException
3229

30+
/**
31+
* Actual implementation of [ShareUtils] for Android platform.
32+
*
33+
* This utility enables sharing of text and files (PDF, image, text) through Android's
34+
* native `Intent`-based sharing system.
35+
*/
3336
actual object ShareUtils {
3437

38+
/**
39+
* Provider function to retrieve the current [Activity].
40+
* This must be set before using [shareText] or [shareFile].
41+
*/
3542
private var activityProvider: () -> Activity = {
3643
throw IllegalArgumentException(
3744
"You need to implement the 'activityProvider' to provide the required Activity. " +
@@ -40,11 +47,23 @@ actual object ShareUtils {
4047
)
4148
}
4249

50+
/**
51+
* Sets the activity provider function to be used internally for context retrieval.
52+
*
53+
* This is required to initialize before calling any sharing methods.
54+
*
55+
* @param provider A lambda that returns the current [Activity].
56+
*/
4357
fun setActivityProvider(provider: () -> Activity) {
4458
activityProvider = provider
4559
}
4660

47-
actual fun shareText(text: String) {
61+
/**
62+
* Shares plain text content using an Android share sheet (`Intent.ACTION_SEND`).
63+
*
64+
* @param text The text content to share.
65+
*/
66+
actual suspend fun shareText(text: String) {
4867
val intent = Intent(Intent.ACTION_SEND).apply {
4968
type = "text/plain"
5069
putExtra(Intent.EXTRA_TEXT, text)
@@ -53,65 +72,99 @@ actual object ShareUtils {
5372
activityProvider.invoke().startActivity(intentChooser)
5473
}
5574

56-
actual suspend fun shareImage(title: String, image: ImageBitmap) {
75+
/**
76+
* Shares a file (e.g. PDF, text, image) using Android's file sharing mechanism.
77+
*
78+
* If the file is an image, it is compressed before sharing.
79+
* The file is temporarily saved to internal cache and shared using a `FileProvider`.
80+
*
81+
* @param file A [ShareFileModel] containing file metadata and binary content.
82+
*/
83+
@OptIn(ExperimentalResourceApi::class)
84+
actual suspend fun shareFile(file: ShareFileModel) {
5785
val context = activityProvider.invoke().application.baseContext
5886

59-
val uri = saveImage(image.asAndroidBitmap(), context)
60-
61-
val sendIntent: Intent = Intent().apply {
62-
action = Intent.ACTION_SEND
63-
putExtra(Intent.EXTRA_STREAM, uri)
64-
setDataAndType(uri, "image/png")
65-
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
87+
try {
88+
withContext(Dispatchers.IO) {
89+
val compressedBytes = if (file.mime == MimeType.IMAGE) {
90+
compressImage(file.bytes)
91+
} else {
92+
file.bytes
93+
}
94+
95+
val savedFile = saveFile(file.fileName, compressedBytes, context = context)
96+
val uri = FileProvider.getUriForFile(
97+
context,
98+
"${context.packageName}.provider",
99+
savedFile,
100+
)
101+
102+
withContext(Dispatchers.Main) {
103+
val intent = Intent(Intent.ACTION_SEND).apply {
104+
putExtra(Intent.EXTRA_STREAM, uri)
105+
flags += Intent.FLAG_ACTIVITY_NEW_TASK
106+
flags += Intent.FLAG_GRANT_READ_URI_PERMISSION
107+
setDataAndType(uri, file.mime.toAndroidMimeType())
108+
}
109+
val chooser = Intent.createChooser(intent, null)
110+
activityProvider.invoke().startActivity(chooser)
111+
}
112+
}
113+
} catch (e: Exception) {
114+
println("Failed to share file: ${e.message}")
66115
}
67-
68-
val shareIntent = Intent.createChooser(sendIntent, title)
69-
activityProvider.invoke().startActivity(shareIntent)
70116
}
71117

72-
@OptIn(ExperimentalResourceApi::class)
73-
actual suspend fun shareImage(title: String, byte: ByteArray) {
74-
Log.d("Sharing QR Code", " $title, size: ${byte.size} bytes")
75-
val context = activityProvider.invoke().application.baseContext
76-
val imageBitmap = byte.decodeToImageBitmap()
77-
78-
val uri = saveImage(imageBitmap.asAndroidBitmap(), context)
79-
80-
val sendIntent: Intent = Intent().apply {
81-
action = Intent.ACTION_SEND
82-
putExtra(Intent.EXTRA_STREAM, uri)
83-
setDataAndType(uri, "image/png")
84-
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
85-
}
118+
/**
119+
* Saves the provided byte array as a temporary file in the internal cache directory.
120+
*
121+
* @param name The name of the file to be saved.
122+
* @param data Byte array representing the file content.
123+
* @param context Android [Context] used to access the cache directory.
124+
* @return The saved [File] object.
125+
*/
126+
private fun saveFile(name: String, data: ByteArray, context: Context): File {
127+
val cache = context.cacheDir
128+
val savedFile = File(cache, name)
129+
savedFile.writeBytes(data)
130+
return savedFile
131+
}
86132

87-
val shareIntent = Intent.createChooser(sendIntent, title)
88-
activityProvider.invoke().startActivity(shareIntent)
133+
/**
134+
* Maps [MimeType] to a corresponding Android MIME type string.
135+
*
136+
* @return Android-compatible MIME type string.
137+
*/
138+
private fun MimeType.toAndroidMimeType(): String = when (this) {
139+
MimeType.PDF -> "application/pdf"
140+
MimeType.TEXT -> "text/plain"
141+
MimeType.IMAGE -> "image/*"
89142
}
90143

91-
private suspend fun saveImage(image: Bitmap, context: Context): Uri? {
92-
return withContext(Dispatchers.IO) {
93-
try {
94-
val imagesFolder = File(context.cacheDir, "images")
95-
imagesFolder.mkdirs()
96-
val file = File(imagesFolder, "shared_image.png")
97-
98-
val stream = FileOutputStream(file)
99-
image.compress(Bitmap.CompressFormat.PNG, 100, stream)
100-
stream.flush()
101-
stream.close()
102-
103-
FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
104-
} catch (e: IOException) {
105-
Log.d("saving bitmap", "saving bitmap error ${e.message}")
106-
null
107-
}
108-
}
144+
/**
145+
* Compresses an image file using [FileKit] logic.
146+
*
147+
* @param imageBytes The original image byte array.
148+
* @return A compressed image as a byte array.
149+
*/
150+
private suspend fun compressImage(imageBytes: ByteArray): ByteArray {
151+
return FileKit.compressImage(
152+
bytes = imageBytes,
153+
// Compression quality (0–100)
154+
quality = 100,
155+
// Max width in pixels
156+
maxWidth = 1024,
157+
// Max height in pixels
158+
maxHeight = 1024,
159+
// Image format (e.g., PNG or JPEG)
160+
imageFormat = ImageFormat.PNG,
161+
)
109162
}
110163

111164
actual fun callHelpline() {
112165
val context = activityProvider.invoke().application.baseContext
113166
val intent = Intent(Intent.ACTION_DIAL).apply {
114-
data = Uri.parse("tel:8000000000")
167+
data = "tel:8000000000".toUri()
115168
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
116169
}
117170

@@ -122,7 +175,7 @@ actual object ShareUtils {
122175
val context = activityProvider.invoke().application.baseContext
123176

124177
val intent = Intent(Intent.ACTION_SENDTO).apply {
125-
data = Uri.parse("mailto:")
178+
data = "mailto:".toUri()
126179
putExtra(Intent.EXTRA_EMAIL, arrayOf("[email protected]"))
127180
putExtra(Intent.EXTRA_SUBJECT, "User Query")
128181
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -162,7 +215,7 @@ actual object ShareUtils {
162215

163216
actual fun openUrl(url: String) {
164217
val context = activityProvider.invoke().application.baseContext
165-
val uri = url.let { Uri.parse(url) } ?: return
218+
val uri = url.let { url.toUri() }
166219
val intent = Intent(Intent.ACTION_VIEW).apply {
167220
data = uri
168221
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)

core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/component/MifosDashboardCard.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,10 @@ fun MifosDashboardCard(
170170
@Composable
171171
fun MifosAccountApplyDashboard(
172172
onOpenAccountClick: () -> Unit,
173+
modifier: Modifier = Modifier,
173174
) {
174175
MifosCustomCard(
175-
modifier = Modifier
176+
modifier = modifier
176177
.padding(horizontal = DesignToken.padding.largeIncreased)
177178
.border(
178179
0.5.dp,

core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/ShareUtils.kt

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,30 @@
99
*/
1010
package org.mifos.mobile.core.ui.utils
1111

12-
import androidx.compose.ui.graphics.ImageBitmap
13-
12+
/**
13+
* Platform-specific utilities for sharing content such as text and files.
14+
*
15+
* This expect declaration should be implemented for each platform (e.g., Android, iOS) to handle
16+
* the specifics of sharing functionality.
17+
*/
1418
expect object ShareUtils {
1519

16-
fun shareText(text: String)
17-
18-
suspend fun shareImage(title: String, image: ImageBitmap)
20+
/**
21+
* Shares plain text content using the platform's native sharing mechanism.
22+
*
23+
* @param text The text content to be shared.
24+
*/
25+
suspend fun shareText(text: String)
1926

20-
suspend fun shareImage(title: String, byte: ByteArray)
27+
/**
28+
* Shares a file using the platform's native sharing mechanism.
29+
*
30+
* This is a suspend function, allowing for asynchronous operations such as file preparation
31+
* or permission handling if needed.
32+
*
33+
* @param file A [ShareFileModel] containing the file's metadata and content.
34+
*/
35+
suspend fun shareFile(file: ShareFileModel)
2136

2237
fun openAppInfo()
2338

@@ -31,3 +46,45 @@ expect object ShareUtils {
3146

3247
fun ossLicensesMenuActivity()
3348
}
49+
50+
/**
51+
* Represents supported MIME types for file sharing.
52+
*/
53+
enum class MimeType {
54+
PDF,
55+
TEXT,
56+
IMAGE,
57+
}
58+
59+
/**
60+
* Model representing a file to be shared.
61+
*
62+
* @property mime The MIME type of the file. Defaults to [MimeType.PDF].
63+
* @property fileName The name of the file, including its extension.
64+
* @property bytes The binary content of the file.
65+
*/
66+
data class ShareFileModel(
67+
val mime: MimeType = MimeType.PDF,
68+
val fileName: String,
69+
val bytes: ByteArray,
70+
) {
71+
override fun equals(other: Any?): Boolean {
72+
if (this === other) return true
73+
if (other == null || this::class != other::class) return false
74+
75+
other as ShareFileModel
76+
77+
if (mime != other.mime) return false
78+
if (fileName != other.fileName) return false
79+
if (!bytes.contentEquals(other.bytes)) return false
80+
81+
return true
82+
}
83+
84+
override fun hashCode(): Int {
85+
var result = mime.hashCode()
86+
result = 31 * result + fileName.hashCode()
87+
result = 31 * result + bytes.contentHashCode()
88+
return result
89+
}
90+
}

0 commit comments

Comments
 (0)