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>
This commit is contained in:
@@ -27,8 +27,8 @@ android {
|
||||
applicationId "com.paarrot.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionCode 1777176952
|
||||
versionName "2026-04-26.041552.151"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -22,6 +22,24 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
|
||||
@@ -7,6 +7,7 @@ public class MainActivity extends BridgeActivity {
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
registerPlugin(SyncServicePlugin.class);
|
||||
registerPlugin(ShareHandlerPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,9 @@ class MatrixSyncService : Service() {
|
||||
/** Event IDs already shown as notifications this session — prevents duplicates on restart. */
|
||||
private val shownEventIds = HashSet<String>(64)
|
||||
|
||||
/** In-memory cache of MXID → display name to avoid redundant API calls per service run. */
|
||||
private val displayNameCache = HashMap<String, String>(16)
|
||||
|
||||
override fun onBind(intent: Intent?) = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -140,6 +143,9 @@ class MatrixSyncService : Service() {
|
||||
}
|
||||
|
||||
private fun processRoomEvents(sync: JSONObject, myUserId: String) {
|
||||
val prefs = applicationContext.getSharedPreferences(SyncServicePlugin.PREFS, Context.MODE_PRIVATE)
|
||||
val homeserver = prefs.getString(EXTRA_HOMESERVER, null)?.trimEnd('/') ?: return
|
||||
val token = prefs.getString(EXTRA_TOKEN, null) ?: return
|
||||
val joinedRooms = sync.optJSONObject("rooms")?.optJSONObject("join") ?: return
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
ensureMessageChannel(nm)
|
||||
@@ -161,13 +167,44 @@ class MatrixSyncService : Service() {
|
||||
val content = event.optJSONObject("content") ?: continue
|
||||
val body = content.optString("body").takeIf { it.isNotBlank() } ?: continue
|
||||
val sender = event.optString("sender")
|
||||
val senderName = sender.substringAfter("@").substringBefore(":")
|
||||
val senderName = resolveDisplayName(sender, homeserver, token)
|
||||
|
||||
showMessageNotification(nm, senderName, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveDisplayName(mxid: String, homeserver: String, token: String): String {
|
||||
displayNameCache[mxid]?.let { return it }
|
||||
|
||||
val fallback = mxid.substringAfter("@").substringBefore(":")
|
||||
return try {
|
||||
val encodedId = URLEncoder.encode(mxid, "UTF-8")
|
||||
val url = "$homeserver/_matrix/client/v3/profile/$encodedId/displayname"
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
val name = try {
|
||||
conn.requestMethod = "GET"
|
||||
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||
conn.setRequestProperty("Accept", "application/json")
|
||||
conn.connectTimeout = 4_000
|
||||
conn.readTimeout = 4_000
|
||||
if (conn.responseCode == 200) {
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
JSONObject(body).optString("displayname").takeIf { it.isNotBlank() } ?: fallback
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
displayNameCache[mxid] = name
|
||||
name
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Could not resolve display name for $mxid: ${e.message}")
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
private fun showMessageNotification(nm: NotificationManager, sender: String, body: String) {
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
?: Intent(this, MainActivity::class.java)
|
||||
|
||||
196
android/app/src/main/java/com/paarrot/app/ShareHandlerPlugin.kt
Normal file
196
android/app/src/main/java/com/paarrot/app/ShareHandlerPlugin.kt
Normal file
@@ -0,0 +1,196 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M2,9.1C3.8,5.7 7.2,4 11.6,4c4.4,0 7.8,1.7 10.4,5.1l-2,1.6c-1.7,-1.5 -3.8,-2.4 -6.4,-2.7c-0.2,1.6 -1.6,2.9 -3.3,2.9c-1.7,0 -3.1,-1.2 -3.3,-2.8C5.1,8.5 3.5,9.1 2,10z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M8.7,11.5c-2.8,0 -5.1,2.3 -5.1,5.1c0,3 2.4,5.4 5.5,5.4c2.9,0 5.4,-1.3 7,-3.7c-0.5,0.1 -0.9,0.2 -1.4,0.2c-3.5,0 -6.4,-2.8 -6.4,-6.3c0,-0.2 0,-0.4 0,-0.7z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M14.7,12.3c3,0.1 5.3,1.6 7.3,4.2c-1.8,1.5 -4.1,2.4 -6.9,2.8c1,-1 1.6,-2.3 1.6,-3.8c0,-1.2 -0.3,-2.3 -0.9,-3.2z" />
|
||||
android:viewportWidth="18"
|
||||
android:viewportHeight="18">
|
||||
<!--
|
||||
Converted from public/res/svg/notification.svg.
|
||||
Transforms flattened: outer(1.05922,0,0,1.05922,-0.5628,-0.4805)
|
||||
x inner(1.6762,0,0,1.6762,-4.5071,-10.7901)
|
||||
= scale 1.7754, translate(-5.337,-11.908)
|
||||
-->
|
||||
<group
|
||||
android:scaleX="1.7754"
|
||||
android:scaleY="1.7754"
|
||||
android:translateX="-5.337"
|
||||
android:translateY="-11.908">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M4.624,14.382C4.624,14.382 1.358,10.79 5.108,8.336C5.501,8.316 7.14,8.899 7.18,10.368C7.22,11.836 5.641,11.172 5.108,11.856C4.575,12.54 4.454,14.121 4.624,14.382Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M5.08,13.547C5.08,13.547 7.28,14.842 7.291,10.74C7.592,10.971 8.588,12.107 7.713,13.777C6.838,15.446 5.392,14.322 5.08,13.547Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M5.921,14.763C5.584,14.449 6.374,15.403 6.704,16.642C6.873,16.232 8.282,17.562 11.166,14.247C12.324,12.581 13.244,11.279 11.249,8.874C9.254,6.469 6.507,6.615 5.469,7.581C4.431,8.546 5.35,7.566 6.653,8.667C7.52,9.399 7.444,10.431 7.444,10.431C7.774,10.551 9.12,12.332 7.935,14.099C7.205,15.187 6.259,15.076 5.921,14.763Z" />
|
||||
</group>
|
||||
</vector>
|
||||
Reference in New Issue
Block a user