Files
cinny-mobile/android/app/src/main/java/com/paarrot/app/ShareHandlerPlugin.kt
Max Litruv Boonzaayer c263b681d4 feat: add ShareRoomPicker component for selecting rooms to share Android content
- Implemented ShareRoomPicker component to allow users to pick a room for sharing content received from Android intents.
- Integrated search functionality to filter rooms and directs.
- Added virtualized list for efficient rendering of room options.
- Created utility functions for handling Android share payloads, including fetching and clearing pending shares.

Co-authored-by: Copilot <copilot@github.com>
2026-04-26 14:32:19 +10:00

197 lines
6.6 KiB
Kotlin

package com.paarrot.app
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import java.io.File
import java.io.FileOutputStream
/** Payload for a single shared file. */
data class SharedFilePayload(
val path: String,
val name: String,
val mimeType: String,
val size: Long,
)
/** Full payload delivered to JS when a share intent is received. */
data class SharePayload(
val text: String?,
val subject: String?,
val files: List<SharedFilePayload>,
val receivedAt: Long,
) {
/** Serialize to a [JSObject] for the Capacitor bridge. */
fun toJsObject(): JSObject {
val obj = JSObject()
obj.put("text", text)
obj.put("subject", subject)
val filesArray = JSArray()
for (f in files) {
val fileObj = JSObject()
fileObj.put("path", f.path)
fileObj.put("name", f.name)
fileObj.put("mimeType", f.mimeType)
fileObj.put("size", f.size)
filesArray.put(fileObj)
}
obj.put("files", filesArray)
obj.put("receivedAt", receivedAt)
return obj
}
}
/** Singleton that bridges between Android activity lifecycle and the Capacitor plugin. */
object ShareIntentStore {
var plugin: ShareHandlerPlugin? = null
var pendingShare: SharePayload? = null
/** Parse [intent] and, if it is a share action, store the payload and notify JS. */
fun handleIntent(context: Context, intent: Intent?) {
if (intent == null) return
val action = intent.action ?: return
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return
val receivedAt = System.currentTimeMillis()
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
val uris = mutableListOf<Uri>()
if (action == Intent.ACTION_SEND_MULTIPLE) {
@Suppress("UNCHECKED_CAST")
val list = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
list?.let { uris.addAll(it) }
} else {
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
uri?.let { uris.add(it) }
}
val copiedFiles = uris.mapNotNull { copySharedUri(context, it, receivedAt) }
val payload = SharePayload(
text = text,
subject = subject,
files = copiedFiles,
receivedAt = receivedAt,
)
pendingShare = payload
plugin?.emitShare(payload)
}
private fun copySharedUri(context: Context, uri: Uri, receivedAt: Long): SharedFilePayload? {
return try {
val resolver = context.contentResolver
val mimeType = resolver.getType(uri) ?: "application/octet-stream"
val originalName = resolver.query(uri, arrayOf("_display_name"), null, null, null)
?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
} ?: uri.lastPathSegment ?: "shared_file"
val safeName = sanitizeFileName(originalName)
val destDir = File(context.cacheDir, "shared-intents/$receivedAt")
destDir.mkdirs()
var destFile = File(destDir, safeName)
var counter = 1
while (destFile.exists()) {
val dot = safeName.lastIndexOf('.')
val base = if (dot >= 0) safeName.substring(0, dot) else safeName
val ext = if (dot >= 0) safeName.substring(dot) else ""
destFile = File(destDir, "${base}_$counter$ext")
counter++
}
resolver.openInputStream(uri)?.use { input ->
FileOutputStream(destFile).use { output -> input.copyTo(output) }
}
SharedFilePayload(
path = destFile.absolutePath,
name = destFile.name,
mimeType = mimeType,
size = destFile.length(),
)
} catch (e: Exception) {
android.util.Log.e("ShareHandlerPlugin", "Failed to copy shared URI: $uri", e)
null
}
}
private fun sanitizeFileName(name: String): String {
return name.replace(Regex("[^a-zA-Z0-9._\\-]"), "_").take(128).ifEmpty { "file" }
}
/** Remove cached files from a previous share and clear the stored payload. */
fun clearIntent(receivedAt: Long?) {
if (receivedAt != null) {
val dir = plugin?.context?.let { File(it.cacheDir, "shared-intents/$receivedAt") }
dir?.deleteRecursively()
}
pendingShare = null
}
}
/** Capacitor plugin that exposes Android share intents to JavaScript. */
@CapacitorPlugin(name = "AndroidShareHandler")
class ShareHandlerPlugin : Plugin() {
override fun load() {
ShareIntentStore.plugin = this
ShareIntentStore.handleIntent(context, activity.intent)
}
override fun handleOnNewIntent(intent: Intent?) {
super.handleOnNewIntent(intent)
ShareIntentStore.handleIntent(context, intent)
}
/** Fire the `shareReceived` event toward the JS layer. */
fun emitShare(payload: SharePayload) {
notifyListeners("shareReceived", payload.toJsObject())
}
/** Returns the pending share payload (if any) as a JS object. */
@PluginMethod
fun getPendingShare(call: PluginCall) {
val pending = ShareIntentStore.pendingShare
if (pending == null) {
val result = JSObject()
result.put("share", JSObject.NULL)
call.resolve(result)
} else {
val result = JSObject()
result.put("share", pending.toJsObject())
call.resolve(result)
}
}
/** Clears the pending share and removes any copied temp files. */
@PluginMethod
fun clearPendingShare(call: PluginCall) {
ShareIntentStore.clearIntent(ShareIntentStore.pendingShare?.receivedAt)
call.resolve()
}
}