Skip to content

Commit 75f2c92

Browse files
feat(feature:ui): implement iOS file sharing and improve Android logic (#1884)
Co-authored-by: Sk Niyaj Ali <[email protected]>
1 parent 627d878 commit 75f2c92

File tree

27 files changed

+731
-515
lines changed

27 files changed

+731
-515
lines changed

core/ui/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ kotlin {
4040
implementation(compose.components.resources)
4141
implementation(compose.components.uiToolingPreview)
4242
implementation(libs.jb.composeNavigation)
43-
implementation(libs.filekit.compose)
4443
implementation(libs.filekit.core)
44+
implementation(libs.filekit.dialogs.compose)
4545
}
4646
androidInstrumentedTest.dependencies {
4747
implementation(libs.bundles.androidx.compose.ui.test)

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

Lines changed: 107 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,28 @@ package org.mifospay.core.ui.utils
1212
import android.app.Activity
1313
import android.content.Context
1414
import android.content.Intent
15-
import android.graphics.Bitmap
16-
import android.net.Uri
17-
import android.util.Log
18-
import androidx.compose.ui.graphics.ImageBitmap
19-
import androidx.compose.ui.graphics.asAndroidBitmap
2015
import androidx.core.content.FileProvider
16+
import co.touchlab.kermit.Logger
17+
import io.github.vinceglb.filekit.FileKit
18+
import io.github.vinceglb.filekit.ImageFormat
19+
import io.github.vinceglb.filekit.compressImage
2120
import kotlinx.coroutines.Dispatchers
2221
import kotlinx.coroutines.withContext
2322
import org.jetbrains.compose.resources.ExperimentalResourceApi
24-
import org.jetbrains.compose.resources.decodeToImageBitmap
2523
import java.io.File
26-
import java.io.FileOutputStream
27-
import java.io.IOException
2824

25+
/**
26+
* Actual implementation of [ShareUtils] for Android platform.
27+
*
28+
* This utility enables sharing of text and files (PDF, image, text) through Android's
29+
* native `Intent`-based sharing system.
30+
*/
2931
actual object ShareUtils {
3032

33+
/**
34+
* Provider function to retrieve the current [Activity].
35+
* This must be set before using [shareText] or [shareFile].
36+
*/
3137
private var activityProvider: () -> Activity = {
3238
throw IllegalArgumentException(
3339
"You need to implement the 'activityProvider' to provide the required Activity. " +
@@ -36,11 +42,23 @@ actual object ShareUtils {
3642
)
3743
}
3844

45+
/**
46+
* Sets the activity provider function to be used internally for context retrieval.
47+
*
48+
* This is required to initialize before calling any sharing methods.
49+
*
50+
* @param provider A lambda that returns the current [Activity].
51+
*/
3952
fun setActivityProvider(provider: () -> Activity) {
4053
activityProvider = provider
4154
}
4255

43-
actual fun shareText(text: String) {
56+
/**
57+
* Shares plain text content using an Android share sheet (`Intent.ACTION_SEND`).
58+
*
59+
* @param text The text content to share.
60+
*/
61+
actual suspend fun shareText(text: String) {
4462
val intent = Intent(Intent.ACTION_SEND).apply {
4563
type = "text/plain"
4664
putExtra(Intent.EXTRA_TEXT, text)
@@ -49,57 +67,93 @@ actual object ShareUtils {
4967
activityProvider.invoke().startActivity(intentChooser)
5068
}
5169

52-
actual suspend fun shareImage(title: String, image: ImageBitmap) {
53-
val context = activityProvider.invoke().application.baseContext
54-
55-
val uri = saveImage(image.asAndroidBitmap(), context)
56-
57-
val sendIntent: Intent = Intent().apply {
58-
action = Intent.ACTION_SEND
59-
putExtra(Intent.EXTRA_STREAM, uri)
60-
setDataAndType(uri, "image/png")
61-
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
62-
}
63-
64-
val shareIntent = Intent.createChooser(sendIntent, title)
65-
activityProvider.invoke().startActivity(shareIntent)
66-
}
67-
70+
/**
71+
* Shares a file (e.g. PDF, text, image) using Android's file sharing mechanism.
72+
*
73+
* If the file is an image, it is compressed before sharing.
74+
* The file is temporarily saved to internal cache and shared using a `FileProvider`.
75+
*
76+
* @param file A [ShareFileModel] containing file metadata and binary content.
77+
*/
6878
@OptIn(ExperimentalResourceApi::class)
69-
actual suspend fun shareImage(title: String, byte: ByteArray) {
79+
actual suspend fun shareFile(file: ShareFileModel) {
7080
val context = activityProvider.invoke().application.baseContext
71-
val imageBitmap = byte.decodeToImageBitmap()
7281

73-
val uri = saveImage(imageBitmap.asAndroidBitmap(), context)
74-
75-
val sendIntent: Intent = Intent().apply {
76-
action = Intent.ACTION_SEND
77-
putExtra(Intent.EXTRA_STREAM, uri)
78-
setDataAndType(uri, "image/png")
79-
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
82+
try {
83+
withContext(Dispatchers.IO) {
84+
val compressedBytes = if (file.mime == MimeType.IMAGE) {
85+
compressImage(file.bytes)
86+
} else {
87+
file.bytes
88+
}
89+
90+
val savedFile = saveFile(file.fileName, compressedBytes, context = context)
91+
val uri = FileProvider.getUriForFile(
92+
context,
93+
"${context.packageName}.provider",
94+
savedFile,
95+
)
96+
97+
withContext(Dispatchers.Main) {
98+
val intent = Intent(Intent.ACTION_SEND).apply {
99+
putExtra(Intent.EXTRA_STREAM, uri)
100+
flags += Intent.FLAG_ACTIVITY_NEW_TASK
101+
flags += Intent.FLAG_GRANT_READ_URI_PERMISSION
102+
setDataAndType(uri, file.mime.toAndroidMimeType())
103+
}
104+
val chooser = Intent.createChooser(intent, null)
105+
activityProvider.invoke().startActivity(chooser)
106+
}
107+
}
108+
} catch (e: Exception) {
109+
e.printStackTrace()
110+
Logger.e(e) { "Failed to share file: ${e.message}" }
80111
}
81-
82-
val shareIntent = Intent.createChooser(sendIntent, title)
83-
activityProvider.invoke().startActivity(shareIntent)
84112
}
85113

86-
private suspend fun saveImage(image: Bitmap, context: Context): Uri? {
87-
return withContext(Dispatchers.IO) {
88-
try {
89-
val imagesFolder = File(context.cacheDir, "images")
90-
imagesFolder.mkdirs()
91-
val file = File(imagesFolder, "shared_image.png")
114+
/**
115+
* Saves the provided byte array as a temporary file in the internal cache directory.
116+
*
117+
* @param name The name of the file to be saved.
118+
* @param data Byte array representing the file content.
119+
* @param context Android [Context] used to access the cache directory.
120+
* @return The saved [File] object.
121+
*/
122+
private fun saveFile(name: String, data: ByteArray, context: Context): File {
123+
val cache = context.cacheDir
124+
val savedFile = File(cache, name)
125+
savedFile.writeBytes(data)
126+
return savedFile
127+
}
92128

93-
val stream = FileOutputStream(file)
94-
image.compress(Bitmap.CompressFormat.PNG, 100, stream)
95-
stream.flush()
96-
stream.close()
129+
/**
130+
* Maps [MimeType] to a corresponding Android MIME type string.
131+
*
132+
* @return Android-compatible MIME type string.
133+
*/
134+
private fun MimeType.toAndroidMimeType(): String = when (this) {
135+
MimeType.PDF -> "application/pdf"
136+
MimeType.TEXT -> "text/plain"
137+
MimeType.IMAGE -> "image/*"
138+
}
97139

98-
FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
99-
} catch (e: IOException) {
100-
Log.d("saving bitmap", "saving bitmap error ${e.message}")
101-
null
102-
}
103-
}
140+
/**
141+
* Compresses an image file using [FileKit] logic.
142+
*
143+
* @param imageBytes The original image byte array.
144+
* @return A compressed image as a byte array.
145+
*/
146+
private suspend fun compressImage(imageBytes: ByteArray): ByteArray {
147+
return FileKit.compressImage(
148+
bytes = imageBytes,
149+
// Compression quality (0–100)
150+
quality = 100,
151+
// Max width in pixels
152+
maxWidth = 1024,
153+
// Max height in pixels
154+
maxHeight = 1024,
155+
// Image format (e.g., PNG or JPEG)
156+
imageFormat = ImageFormat.PNG,
157+
)
104158
}
105159
}

core/ui/src/commonMain/kotlin/org/mifospay/core/ui/utils/ShareUtils.kt

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,70 @@
99
*/
1010
package org.mifospay.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)
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)
26+
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)
36+
}
37+
38+
/**
39+
* Represents supported MIME types for file sharing.
40+
*/
41+
enum class MimeType {
42+
PDF,
43+
TEXT,
44+
IMAGE,
45+
}
46+
47+
/**
48+
* Model representing a file to be shared.
49+
*
50+
* @property mime The MIME type of the file. Defaults to [MimeType.PDF].
51+
* @property fileName The name of the file, including its extension.
52+
* @property bytes The binary content of the file.
53+
*/
54+
data class ShareFileModel(
55+
val mime: MimeType = MimeType.PDF,
56+
val fileName: String,
57+
val bytes: ByteArray,
58+
) {
59+
override fun equals(other: Any?): Boolean {
60+
if (this === other) return true
61+
if (other == null || this::class != other::class) return false
62+
63+
other as ShareFileModel
64+
65+
if (mime != other.mime) return false
66+
if (fileName != other.fileName) return false
67+
if (!bytes.contentEquals(other.bytes)) return false
1768

18-
suspend fun shareImage(title: String, image: ImageBitmap)
69+
return true
70+
}
1971

20-
suspend fun shareImage(title: String, byte: ByteArray)
72+
override fun hashCode(): Int {
73+
var result = mime.hashCode()
74+
result = 31 * result + fileName.hashCode()
75+
result = 31 * result + bytes.contentHashCode()
76+
return result
77+
}
2178
}

core/ui/src/desktopMain/kotlin/org/mifospay/core/ui/utils/ShareUtils.desktop.kt

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,46 @@
99
*/
1010
package org.mifospay.core.ui.utils
1111

12-
import androidx.compose.ui.graphics.ImageBitmap
13-
import androidx.compose.ui.graphics.asSkiaBitmap
14-
import io.github.vinceglb.filekit.core.FileKit
12+
import io.github.vinceglb.filekit.FileKit
13+
import io.github.vinceglb.filekit.dialogs.openFileSaver
14+
import io.github.vinceglb.filekit.write
1515

16+
/**
17+
* JVM-specific implementation of [ShareUtils] for desktop platforms.
18+
*
19+
* This object simulates file sharing by prompting the user with a "Save As" dialog
20+
* using [FileKit.openFileSaver]. It allows the user to save the content locally,
21+
* which is the most suitable alternative to "sharing" in desktop environments.
22+
*/
1623
actual object ShareUtils {
17-
actual fun shareText(text: String) {
18-
}
1924

20-
actual suspend fun shareImage(title: String, image: ImageBitmap) {
21-
FileKit.saveFile(
22-
bytes = image.asSkiaBitmap().readPixels(),
23-
baseName = "MifosQrCode",
24-
extension = "png",
25+
/**
26+
* Prompts the user to save the given text content as a file on their system.
27+
*
28+
* This method uses [FileKit.openFileSaver] to open a native "Save As" dialog,
29+
* and writes the provided text to the selected file location.
30+
*
31+
* @param text The plain text content the user will save to disk.
32+
*/
33+
actual suspend fun shareText(text: String) {
34+
val newFile = FileKit.openFileSaver(
35+
suggestedName = "text.txt",
2536
)
37+
newFile?.write(text.encodeToByteArray())
2638
}
2739

28-
actual suspend fun shareImage(title: String, byte: ByteArray) {
29-
FileKit.saveFile(
30-
bytes = byte,
31-
baseName = "MifosQrCode",
32-
extension = "png",
40+
/**
41+
* Prompts the user to save a binary file (e.g., image, PDF) to their local system.
42+
*
43+
* This is used as a desktop-friendly alternative to file sharing, using
44+
* [FileKit.openFileSaver] to let the user choose the destination file path.
45+
*
46+
* @param file The file to be "shared", including its filename and byte content.
47+
*/
48+
actual suspend fun shareFile(file: ShareFileModel) {
49+
val newFile = FileKit.openFileSaver(
50+
suggestedName = file.fileName,
3351
)
52+
newFile?.write(file.bytes)
3453
}
3554
}

0 commit comments

Comments
 (0)