feat(core-ui): implement cross-platform file and text sharing (#2974)

This commit is contained in:
Nagarjuna 2025-10-01 17:40:39 +05:30 committed by GitHub
parent 0cc8a020c0
commit f65ef09d8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 379 additions and 123 deletions

View File

@ -17,6 +17,7 @@ internal fun Project.configureDetekt(extension: DetektExtension) = extension.app
exclude("**/generated/**")
exclude("**/build-logic/**")
exclude("**/spotless/**")
exclude("core-base/designsystem/**")
reports {
xml.required.set(true)
html.required.set(true)

View File

@ -43,8 +43,8 @@ kotlin{
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation(libs.jb.composeNavigation)
implementation(libs.filekit.compose)
implementation(libs.filekit.core)
implementation(libs.filekit.dialog.compose)
implementation(libs.compottie.resources)
implementation(libs.compottie.lite)
}

View File

@ -13,25 +13,32 @@ import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.ImageFormat
import io.github.vinceglb.filekit.compressImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.decodeToImageBitmap
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
/**
* Actual implementation of [ShareUtils] for Android platform.
*
* This utility enables sharing of text and files (PDF, image, text) through Android's
* native `Intent`-based sharing system.
*/
actual object ShareUtils {
/**
* Provider function to retrieve the current [Activity].
* This must be set before using [shareText] or [shareFile].
*/
private var activityProvider: () -> Activity = {
throw IllegalArgumentException(
"You need to implement the 'activityProvider' to provide the required Activity. " +
@ -40,11 +47,23 @@ actual object ShareUtils {
)
}
/**
* Sets the activity provider function to be used internally for context retrieval.
*
* This is required to initialize before calling any sharing methods.
*
* @param provider A lambda that returns the current [Activity].
*/
fun setActivityProvider(provider: () -> Activity) {
activityProvider = provider
}
actual fun shareText(text: String) {
/**
* Shares plain text content using an Android share sheet (`Intent.ACTION_SEND`).
*
* @param text The text content to share.
*/
actual suspend fun shareText(text: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text)
@ -53,65 +72,99 @@ actual object ShareUtils {
activityProvider.invoke().startActivity(intentChooser)
}
actual suspend fun shareImage(title: String, image: ImageBitmap) {
val context = activityProvider.invoke().application.baseContext
val uri = saveImage(image.asAndroidBitmap(), context)
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_STREAM, uri)
setDataAndType(uri, "image/png")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val shareIntent = Intent.createChooser(sendIntent, title)
activityProvider.invoke().startActivity(shareIntent)
}
/**
* Shares a file (e.g. PDF, text, image) using Android's file sharing mechanism.
*
* If the file is an image, it is compressed before sharing.
* The file is temporarily saved to internal cache and shared using a `FileProvider`.
*
* @param file A [ShareFileModel] containing file metadata and binary content.
*/
@OptIn(ExperimentalResourceApi::class)
actual suspend fun shareImage(title: String, byte: ByteArray) {
Log.d("Sharing QR Code", " $title, size: ${byte.size} bytes")
actual suspend fun shareFile(file: ShareFileModel) {
val context = activityProvider.invoke().application.baseContext
val imageBitmap = byte.decodeToImageBitmap()
val uri = saveImage(imageBitmap.asAndroidBitmap(), context)
try {
withContext(Dispatchers.IO) {
val compressedBytes = if (file.mime == MimeType.IMAGE) {
compressImage(file.bytes)
} else {
file.bytes
}
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_STREAM, uri)
setDataAndType(uri, "image/png")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
val savedFile = saveFile(file.fileName, compressedBytes, context = context)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
savedFile,
)
withContext(Dispatchers.Main) {
val intent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
flags += Intent.FLAG_ACTIVITY_NEW_TASK
flags += Intent.FLAG_GRANT_READ_URI_PERMISSION
setDataAndType(uri, file.mime.toAndroidMimeType())
}
val chooser = Intent.createChooser(intent, null)
activityProvider.invoke().startActivity(chooser)
}
}
} catch (e: Exception) {
println("Failed to share file: ${e.message}")
}
val shareIntent = Intent.createChooser(sendIntent, title)
activityProvider.invoke().startActivity(shareIntent)
}
private suspend fun saveImage(image: Bitmap, context: Context): Uri? {
return withContext(Dispatchers.IO) {
try {
val imagesFolder = File(context.cacheDir, "images")
imagesFolder.mkdirs()
val file = File(imagesFolder, "shared_image.png")
/**
* Saves the provided byte array as a temporary file in the internal cache directory.
*
* @param name The name of the file to be saved.
* @param data Byte array representing the file content.
* @param context Android [Context] used to access the cache directory.
* @return The saved [File] object.
*/
private fun saveFile(name: String, data: ByteArray, context: Context): File {
val cache = context.cacheDir
val savedFile = File(cache, name)
savedFile.writeBytes(data)
return savedFile
}
val stream = FileOutputStream(file)
image.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.flush()
stream.close()
/**
* Maps [MimeType] to a corresponding Android MIME type string.
*
* @return Android-compatible MIME type string.
*/
private fun MimeType.toAndroidMimeType(): String = when (this) {
MimeType.PDF -> "application/pdf"
MimeType.TEXT -> "text/plain"
MimeType.IMAGE -> "image/*"
}
FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
} catch (e: IOException) {
Log.d("saving bitmap", "saving bitmap error ${e.message}")
null
}
}
/**
* Compresses an image file using [FileKit] logic.
*
* @param imageBytes The original image byte array.
* @return A compressed image as a byte array.
*/
private suspend fun compressImage(imageBytes: ByteArray): ByteArray {
return FileKit.compressImage(
bytes = imageBytes,
// Compression quality (0100)
quality = 100,
// Max width in pixels
maxWidth = 1024,
// Max height in pixels
maxHeight = 1024,
// Image format (e.g., PNG or JPEG)
imageFormat = ImageFormat.PNG,
)
}
actual fun callHelpline() {
val context = activityProvider.invoke().application.baseContext
val intent = Intent(Intent.ACTION_DIAL).apply {
data = Uri.parse("tel:8000000000")
data = "tel:8000000000".toUri()
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
@ -122,7 +175,7 @@ actual object ShareUtils {
val context = activityProvider.invoke().application.baseContext
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = Uri.parse("mailto:")
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("support@mifos.org"))
putExtra(Intent.EXTRA_SUBJECT, "User Query")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@ -162,7 +215,7 @@ actual object ShareUtils {
actual fun openUrl(url: String) {
val context = activityProvider.invoke().application.baseContext
val uri = url.let { Uri.parse(url) } ?: return
val uri = url.let { url.toUri() }
val intent = Intent(Intent.ACTION_VIEW).apply {
data = uri
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)

View File

@ -170,9 +170,10 @@ fun MifosDashboardCard(
@Composable
fun MifosAccountApplyDashboard(
onOpenAccountClick: () -> Unit,
modifier: Modifier = Modifier,
) {
MifosCustomCard(
modifier = Modifier
modifier = modifier
.padding(horizontal = DesignToken.padding.largeIncreased)
.border(
0.5.dp,

View File

@ -9,15 +9,30 @@
*/
package org.mifos.mobile.core.ui.utils
import androidx.compose.ui.graphics.ImageBitmap
/**
* Platform-specific utilities for sharing content such as text and files.
*
* This expect declaration should be implemented for each platform (e.g., Android, iOS) to handle
* the specifics of sharing functionality.
*/
expect object ShareUtils {
fun shareText(text: String)
/**
* Shares plain text content using the platform's native sharing mechanism.
*
* @param text The text content to be shared.
*/
suspend fun shareText(text: String)
suspend fun shareImage(title: String, image: ImageBitmap)
suspend fun shareImage(title: String, byte: ByteArray)
/**
* Shares a file using the platform's native sharing mechanism.
*
* This is a suspend function, allowing for asynchronous operations such as file preparation
* or permission handling if needed.
*
* @param file A [ShareFileModel] containing the file's metadata and content.
*/
suspend fun shareFile(file: ShareFileModel)
fun openAppInfo()
@ -31,3 +46,45 @@ expect object ShareUtils {
fun ossLicensesMenuActivity()
}
/**
* Represents supported MIME types for file sharing.
*/
enum class MimeType {
PDF,
TEXT,
IMAGE,
}
/**
* Model representing a file to be shared.
*
* @property mime The MIME type of the file. Defaults to [MimeType.PDF].
* @property fileName The name of the file, including its extension.
* @property bytes The binary content of the file.
*/
data class ShareFileModel(
val mime: MimeType = MimeType.PDF,
val fileName: String,
val bytes: ByteArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as ShareFileModel
if (mime != other.mime) return false
if (fileName != other.fileName) return false
if (!bytes.contentEquals(other.bytes)) return false
return true
}
override fun hashCode(): Int {
var result = mime.hashCode()
result = 31 * result + fileName.hashCode()
result = 31 * result + bytes.contentHashCode()
return result
}
}

View File

@ -9,30 +9,48 @@
*/
package org.mifos.mobile.core.ui.utils
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asSkiaBitmap
import io.github.vinceglb.filekit.core.FileKit
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.dialogs.openFileSaver
import io.github.vinceglb.filekit.write
import java.awt.Desktop
import java.net.URI
/**
* JVM-specific implementation of [ShareUtils] for desktop platforms.
*
* This object simulates file sharing by prompting the user with a "Save As" dialog
* using [FileKit.openFileSaver]. It allows the user to save the content locally,
* which is the most suitable alternative to "sharing" in desktop environments.
*/
actual object ShareUtils {
actual fun shareText(text: String) {
/**
* Prompts the user to save the given text content as a file on their system.
*
* This method uses [FileKit.openFileSaver] to open a native "Save As" dialog,
* and writes the provided text to the selected file location.
*
* @param text The plain text content the user will save to disk.
*/
actual suspend fun shareText(text: String) {
val newFile = FileKit.openFileSaver(
suggestedName = "text.txt",
)
newFile?.write(text.encodeToByteArray())
}
actual suspend fun shareImage(title: String, image: ImageBitmap) {
FileKit.saveFile(
bytes = image.asSkiaBitmap().readPixels(),
baseName = "MifosQrCode",
extension = "png",
)
}
actual suspend fun shareImage(title: String, byte: ByteArray) {
FileKit.saveFile(
bytes = byte,
baseName = "MifosQrCode",
extension = "png",
/**
* Prompts the user to save a binary file (e.g., image, PDF) to their local system.
*
* This is used as a desktop-friendly alternative to file sharing, using
* [FileKit.openFileSaver] to let the user choose the destination file path.
*
* @param file The file to be "shared", including its filename and byte content.
*/
actual suspend fun shareFile(file: ShareFileModel) {
val newFile = FileKit.openFileSaver(
suggestedName = file.fileName,
)
newFile?.write(file.bytes)
}
actual fun callHelpline() {

View File

@ -9,31 +9,42 @@
*/
package org.mifos.mobile.core.ui.utils
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asSkiaBitmap
import io.github.vinceglb.filekit.core.FileKit
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.download
import kotlinx.browser.window
/**
* Provides utility functions for sharing content on JS and WASM platforms.
*
* This implementation uses [FileKit.download] to trigger file downloads
* in web environments (JS), as native share dialogs are not supported
* on these platforms.
*/
actual object ShareUtils {
actual fun shareText(text: String) {
/**
* Shares plain text content by triggering a file download.
*
* The text is saved to a file named `text.txt` and offered to the user
* as a downloadable file in the browser.
*
* @param text The plain text content to be shared.
*/
actual suspend fun shareText(text: String) {
FileKit.download(bytes = text.encodeToByteArray(), fileName = "text.txt")
}
actual suspend fun shareImage(title: String, image: ImageBitmap) {
FileKit.saveFile(
bytes = image.asSkiaBitmap().readPixels(),
baseName = "MifosQrCode",
extension = "png",
)
/**
* Shares a file by triggering a download of the file's byte content.
*
* This method creates a download link in the browser for the given
* [ShareFileModel.bytes], using the provided [ShareFileModel.fileName]
* as the download file name.
*
* @param file The [ShareFileModel] containing file name and content to be downloaded.
*/
actual suspend fun shareFile(file: ShareFileModel) {
FileKit.download(bytes = file.bytes, fileName = file.fileName)
}
actual suspend fun shareImage(title: String, byte: ByteArray) {
FileKit.saveFile(
bytes = byte,
baseName = "MifosQrCode",
extension = "png",
)
}
actual fun callHelpline() {
window.alert("Calling is not supported on Web. Please contact support at 8000000000.")
}

View File

@ -9,18 +9,109 @@
*/
package org.mifos.mobile.core.ui.utils
import androidx.compose.ui.graphics.ImageBitmap
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.ImageFormat
import io.github.vinceglb.filekit.PlatformFile
import io.github.vinceglb.filekit.absolutePath
import io.github.vinceglb.filekit.cacheDir
import io.github.vinceglb.filekit.compressImage
import io.github.vinceglb.filekit.dialogs.shareFile
import io.github.vinceglb.filekit.write
import platform.Foundation.NSURL
import platform.UIKit.UIActivityViewController
import platform.UIKit.UIApplication
import platform.UIKit.UIApplicationOpenSettingsURLString
import platform.UIKit.UIViewController
/**
* Actual implementation of [ShareUtils] for iOS platform.
*
* Provides functionality to share text and files using iOS native `UIActivityViewController`.
*/
actual object ShareUtils {
actual fun shareText(text: String) {
/**
* Shares plain text using the iOS share sheet (`UIActivityViewController`).
*
* @param text The text content to be shared.
*/
actual suspend fun shareText(text: String) {
val currentViewController = UIApplication.sharedApplication().keyWindow?.rootViewController
val activityViewController = UIActivityViewController(listOf(text), null)
currentViewController?.presentViewController(
viewControllerToPresent = activityViewController,
animated = true,
completion = null,
)
}
actual suspend fun shareImage(title: String, image: ImageBitmap) {
/**
* Shares a file (image or other binary) using the iOS share sheet.
*
* If the file is an image, it will be compressed before sharing.
*
* @param file The file metadata and byte content to share.
*/
actual suspend fun shareFile(file: ShareFileModel) {
try {
val compressedBytes = if (file.mime == MimeType.IMAGE) {
compressImage(file.bytes)
} else {
file.bytes
}
val fileToShare = saveFile(data = compressedBytes, fileName = file.fileName)
FileKit.shareFile(fileToShare)
} catch (e: Exception) {
println("Failed to share file: ${e.message}")
}
}
actual suspend fun shareImage(title: String, byte: ByteArray) {
/**
* Saves a byte array as a file inside the iOS app's cache directory.
*
* Converts the resulting file path to a properly scoped [NSURL],
* which is necessary for iOS to allow sharing via `UIActivityViewController`.
*
* @param data The file content to write.
* @param fileName The name of the file to create.
* @return A [PlatformFile] backed by a scoped `NSURL`, ready for sharing.
*/
private suspend fun saveFile(data: ByteArray, fileName: String): PlatformFile {
val tempFile = PlatformFile(FileKit.cacheDir, fileName)
tempFile.write(data)
/**
* iOS requires file URLs used in `UIActivityViewController` to be created
* with `NSURL.fileURLWithPath(...)` to ensure they have proper sandbox access.
*
* If the file is created from a raw path string, the system may reject it
* with a sandbox extension error (e.g., "Cannot issue sandbox extension for URL").
*
* Wrapping the path in `NSURL` ensures the file is treated as a valid
* security-scoped resource.
*/
val nsUrl = NSURL.fileURLWithPath(tempFile.absolutePath())
return PlatformFile(nsUrl)
}
/**
* Compresses an image file using [FileKit] logic.
*
* @param imageBytes The original image byte array.
* @return A compressed image as a byte array.
*/
private suspend fun compressImage(imageBytes: ByteArray): ByteArray {
return FileKit.compressImage(
bytes = imageBytes,
// Compression quality (0100)
quality = 100,
// Max width in pixels
maxWidth = 1024,
// Max height in pixels
maxHeight = 1024,
// Image format (e.g., PNG or JPEG)
imageFormat = ImageFormat.PNG,
)
}
actual fun callHelpline() {
@ -40,9 +131,17 @@ actual object ShareUtils {
}
actual fun openAppInfo() {
val url = NSURL.URLWithString(UIApplicationOpenSettingsURLString)
if (url != null && UIApplication.sharedApplication.canOpenURL(url)) {
UIApplication.sharedApplication.openURL(url)
}
}
actual fun shareApp() {
// TODO Replace with app store link
val appStoreUrl = "https://apps.apple.com/app/idXXXXXXXX"
val activityVC = UIActivityViewController(activityItems = listOf(appStoreUrl), applicationActivities = null)
present(activityVC)
}
actual fun openUrl(url: String) {
@ -54,4 +153,9 @@ actual object ShareUtils {
actual fun ossLicensesMenuActivity() {
}
private fun present(controller: UIViewController) {
val rootController = UIApplication.sharedApplication.keyWindow?.rootViewController
rootController?.presentViewController(controller, animated = true, completion = null)
}
}

View File

@ -9,31 +9,42 @@
*/
package org.mifos.mobile.core.ui.utils
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asSkiaBitmap
import io.github.vinceglb.filekit.core.FileKit
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.download
import kotlinx.browser.window
/**
* Provides utility functions for sharing content on JS and WASM platforms.
*
* This implementation uses [FileKit.download] to trigger file downloads
* in web environments (WASM), as native share dialogs are not supported
* on these platforms.
*/
actual object ShareUtils {
actual fun shareText(text: String) {
/**
* Shares plain text content by triggering a file download.
*
* The text is saved to a file named `text.txt` and offered to the user
* as a downloadable file in the browser.
*
* @param text The plain text content to be shared.
*/
actual suspend fun shareText(text: String) {
FileKit.download(bytes = text.encodeToByteArray(), fileName = "text.txt")
}
actual suspend fun shareImage(title: String, image: ImageBitmap) {
FileKit.saveFile(
bytes = image.asSkiaBitmap().readPixels(),
baseName = "MifosQrCode",
extension = "png",
)
/**
* Shares a file by triggering a download of the file's byte content.
*
* This method creates a download link in the browser for the given
* [ShareFileModel.bytes], using the provided [ShareFileModel.fileName]
* as the download file name.
*
* @param file The [ShareFileModel] containing file name and content to be downloaded.
*/
actual suspend fun shareFile(file: ShareFileModel) {
FileKit.download(bytes = file.bytes, fileName = file.fileName)
}
actual suspend fun shareImage(title: String, byte: ByteArray) {
FileKit.saveFile(
bytes = byte,
baseName = "MifosQrCode",
extension = "png",
)
}
actual fun callHelpline() {
window.alert("Calling is not supported on Web. Please contact support at 8000000000.")
}

View File

@ -89,7 +89,7 @@ internal fun BottomSheetContent(
internal fun BottomSheetIconContainer(
onClick: (BottomSheetItemType) -> Unit,
) {
BottomSheetItemType.entries.forEach { it ->
BottomSheetItemType.entries.forEach {
MifosCustomCard(
modifier = Modifier
.fillMaxWidth()