diff --git a/android/app/build.gradle b/android/app/build.gradle
index 800e8eb..95dc739 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -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.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 2c6d96c..6509eb6 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -22,6 +22,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(64)
+ /** In-memory cache of MXID → display name to avoid redundant API calls per service run. */
+ private val displayNameCache = HashMap(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)
diff --git a/android/app/src/main/java/com/paarrot/app/ShareHandlerPlugin.kt b/android/app/src/main/java/com/paarrot/app/ShareHandlerPlugin.kt
new file mode 100644
index 0000000..0ac7975
--- /dev/null
+++ b/android/app/src/main/java/com/paarrot/app/ShareHandlerPlugin.kt
@@ -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,
+ 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()
+ 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()
+ }
+}
diff --git a/android/app/src/main/res/drawable/ic_stat_paarrot.xml b/android/app/src/main/res/drawable/ic_stat_paarrot.xml
index 8ea915e..6501ff5 100644
--- a/android/app/src/main/res/drawable/ic_stat_paarrot.xml
+++ b/android/app/src/main/res/drawable/ic_stat_paarrot.xml
@@ -2,15 +2,30 @@
-
-
-
+ android:viewportWidth="18"
+ android:viewportHeight="18">
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cinny b/cinny
index 7b69ed6..ef0aa2a 160000
--- a/cinny
+++ b/cinny
@@ -1 +1 @@
-Subproject commit 7b69ed6df85fdbfa22df5957c268fd874a10f749
+Subproject commit ef0aa2a8478950466933065d0f71ab33e762caec
diff --git a/full_logcat.txt b/full_logcat.txt
new file mode 100644
index 0000000..b538b5c
Binary files /dev/null and b/full_logcat.txt differ
diff --git a/overlay/src/app/features/ShareRoomPicker.tsx b/overlay/src/app/features/ShareRoomPicker.tsx
new file mode 100644
index 0000000..bd10d0f
--- /dev/null
+++ b/overlay/src/app/features/ShareRoomPicker.tsx
@@ -0,0 +1,255 @@
+import { useAtomValue } from 'jotai';
+import React, { ChangeEventHandler, useCallback, useMemo, useRef, useState } from 'react';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import FocusTrap from 'focus-trap-react';
+import {
+ Avatar,
+ Box,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ MenuItem,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Scroll,
+ Text,
+ config,
+} from 'folds';
+import { useMatrixClient } from '../hooks/useMatrixClient';
+import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
+import { allRoomsAtom } from '../state/room-list/roomList';
+import { mDirectAtom } from '../state/mDirectList';
+import { useDirects, useRooms } from '../state/hooks/roomList';
+import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../utils/room';
+import { RoomAvatar, RoomIcon } from '../components/room-avatar';
+import { nameInitials } from '../utils/common';
+import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
+import { factoryRoomIdByActivity } from '../utils/sort';
+import {
+ SearchItemStrGetter,
+ useAsyncSearch,
+ UseAsyncSearchOptions,
+} from '../hooks/useAsyncSearch';
+import { VirtualTile } from '../components/virtualizer';
+import { AndroidSharePayload } from '../utils/androidShare';
+import { stopPropagation } from '../utils/keyboard';
+
+const SEARCH_OPTS: UseAsyncSearchOptions = {
+ limit: 200,
+ matchOptions: {
+ contain: true,
+ },
+};
+
+type ShareRoomPickerProps = {
+ /** The pending share payload to display a preview of. */
+ share: AndroidSharePayload;
+ /** Called when the user picks a room. */
+ onPick: (roomId: string) => void;
+ /** Called when the user dismisses without picking. */
+ onDismiss: () => void;
+};
+
+/**
+ * Full-screen modal that lets the user pick a room to send
+ * an incoming Android share intent into.
+ */
+export function ShareRoomPicker({ share, onPick, onDismiss }: ShareRoomPickerProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const scrollRef = useRef(null);
+
+ const mDirects = useAtomValue(mDirectAtom);
+ const allRoomsSet = useAllJoinedRoomsSet();
+ const getRoom = useGetRoom(allRoomsSet);
+ const rooms = useRooms(mx, allRoomsAtom, mDirects);
+ const directs = useDirects(mx, allRoomsAtom, mDirects);
+
+ const allItems = useMemo(
+ () => [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)),
+ [mx, rooms, directs]
+ );
+
+ const getRoomNameStr: SearchItemStrGetter = useCallback(
+ (rId) => getRoom(rId)?.name ?? rId,
+ [getRoom]
+ );
+
+ const [searchResult, searchRoom, resetSearch] = useAsyncSearch(
+ allItems,
+ getRoomNameStr,
+ SEARCH_OPTS
+ );
+
+ const items = searchResult ? searchResult.items : allItems;
+
+ const virtualizer = useVirtualizer({
+ count: items.length,
+ getScrollElement: () => scrollRef.current,
+ estimateSize: () => 48,
+ overscan: 8,
+ });
+ const vItems = virtualizer.getVirtualItems();
+
+ const handleSearchChange: ChangeEventHandler = (evt) => {
+ const value = evt.currentTarget.value.trim();
+ if (!value) {
+ resetSearch();
+ return;
+ }
+ searchRoom(value);
+ };
+
+ const handleRoomClick: React.MouseEventHandler = (evt) => {
+ const roomId = evt.currentTarget.getAttribute('data-room-id');
+ if (roomId) {
+ onPick(roomId);
+ }
+ };
+
+ const previewText = share.text?.slice(0, 80) ?? share.subject?.slice(0, 80);
+ const fileCount = share.files.length;
+
+ return (
+ }>
+
+
+
+
+
+
+ Share to Room
+ {(previewText || fileCount > 0) && (
+
+ {previewText
+ ? `"${previewText}${(share.text?.length ?? 0) > 80 ? '…' : ''}"`
+ : `${fileCount} file${fileCount !== 1 ? 's' : ''}`}
+
+ )}
+
+
+
+
+
+
+
+
+
+ }
+ placeholder="Search rooms…"
+ size="400"
+ variant="Background"
+ outlined
+ autoFocus
+ />
+
+
+
+
+ {vItems.length === 0 && (
+
+
+ {searchResult ? 'No Match Found' : 'No Rooms'}
+
+
+ {searchResult
+ ? `No rooms found for "${searchResult.query}".`
+ : 'You have no rooms to share into.'}
+
+
+ )}
+
+ {vItems.map((vItem) => {
+ const roomId = items[vItem.index];
+ const room = getRoom(roomId);
+ if (!room) return null;
+ const isDm = mDirects.has(roomId);
+
+ const avatarSrc = isDm
+ ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
+ : getRoomAvatarUrl(mx, room, 96, useAuthentication);
+
+ return (
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/overlay/src/app/features/settings/notifications/SystemNotification.tsx b/overlay/src/app/features/settings/notifications/SystemNotification.tsx
index f8f5764..4b31548 100644
--- a/overlay/src/app/features/settings/notifications/SystemNotification.tsx
+++ b/overlay/src/app/features/settings/notifications/SystemNotification.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
import { IPusherRequest } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card';
@@ -11,6 +11,12 @@ import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { isCapacitorNative, requestSystemNotificationPermission } from '../../../utils/tauri';
+import {
+ isBackgroundSyncSupported,
+ getBackgroundSyncStatus,
+ requestResetPushRegistration,
+ triggerBackgroundSyncPing,
+} from '../../../utils/backgroundSync';
function EmailNotification() {
const mx = useMatrixClient();
@@ -147,6 +153,7 @@ export function SystemNotification() {
after={}
/>
+ {isBackgroundSyncSupported() && }
);
}
+
+type PushStatus = {
+ registered: boolean;
+ distributor: string;
+ endpoint: string;
+};
+
+/** Android-only section showing UnifiedPush registration status and controls. */
+function AndroidPushNotifications() {
+ const [status, setStatus] = useState(undefined);
+ const [loading, setLoading] = useState(true);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ const s = await getBackgroundSyncStatus();
+ setStatus(
+ s
+ ? {
+ registered: s.registered,
+ distributor: s.distributor || '',
+ endpoint: s.endpoint || '',
+ }
+ : undefined
+ );
+ setLoading(false);
+ }, []);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ const [resetState, reset] = useAsyncCallback(
+ useCallback(async () => {
+ await requestResetPushRegistration();
+ await refresh();
+ }, [refresh])
+ );
+
+ const [pingState, ping] = useAsyncCallback(
+ useCallback(async () => {
+ await triggerBackgroundSyncPing('manual-test');
+ }, [])
+ );
+
+ const isBusy =
+ loading ||
+ resetState.status === AsyncStatus.Loading ||
+ pingState.status === AsyncStatus.Loading;
+
+ return (
+
+ Android Push (UnifiedPush)
+
+
+ Failed to read push status.
+
+ ) : status.registered ? (
+ <>
+
+ {`Distributor: ${status.distributor || 'unknown'}`}
+
+ >
+ ) : (
+
+ Not registered. Tap "Reset" to choose a distributor app.
+
+ )
+ }
+ after={loading ? : undefined}
+ />
+ void reset()}
+ >
+ {resetState.status === AsyncStatus.Loading ? (
+
+ ) : (
+ Reset
+ )}
+
+ }
+ />
+ void ping()}
+ >
+ {pingState.status === AsyncStatus.Loading ? (
+
+ ) : (
+ Send Test
+ )}
+
+ }
+ />
+
+
+ );
+}
diff --git a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx
index d675ae8..8f6072f 100644
--- a/overlay/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/overlay/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -1,5 +1,6 @@
import { useAtomValue } from 'jotai';
-import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
+import { useAtom, useSetAtom } from 'jotai';
+import React, { ReactNode, useCallback, useEffect, useRef, useState, Fragment } from 'react';
import { useNavigate } from 'react-router-dom';
import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
@@ -28,6 +29,8 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { plainToEditorInput, toPlainText } from '../../components/editor';
+import { ShareRoomPicker } from '../../features/ShareRoomPicker';
import {
isTauri,
isElectron,
@@ -41,6 +44,20 @@ import {
stopBackgroundSync,
setAppForegroundState,
} from '../../utils/backgroundSync';
+import {
+ TUploadItem,
+ roomIdToMsgDraftAtomFamily,
+ roomIdToUploadItemsAtomFamily,
+} from '../../state/room/roomInputDrafts';
+import { encryptFile } from '../../utils/matrix';
+import {
+ AndroidSharePayload,
+ clearPendingAndroidShare,
+ getPendingAndroidShare,
+ isAndroidShareSupported,
+ listenForAndroidShares,
+ materializeSharedFile,
+} from '../../utils/androidShare';
/**
* Applies the selected emoji style font to the document.
@@ -426,8 +443,13 @@ function MessageNotifications() {
*/
function BackgroundSyncSetup() {
const mx = useMatrixClient();
+ // Try to make a simple fetch to verify component is rendering
+ try {
+ fetch('/_matrix/client/v3/sync', { method: 'HEAD' }).catch(() => {});
+ } catch {}
useEffect(() => {
+ console.log('BackgroundSyncSetup: Starting background sync for', mx.getUserId());
startBackgroundSync(mx);
const onVisibility = () => setAppForegroundState(!document.hidden);
@@ -493,6 +515,135 @@ function TaskbarFlashStopper() {
return null;
}
+function AndroidShareIntentHandler() {
+ const mx = useMatrixClient();
+ const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
+ const [pendingShare, setPendingShare] = useState(null);
+ const [pickedRoomId, setPickedRoomId] = useState(null);
+ const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(pickedRoomId ?? '__android_share__'));
+ const setUploadItems = useSetAtom(roomIdToUploadItemsAtomFamily(pickedRoomId ?? '__android_share__'));
+
+ const applyPendingShare = useCallback(
+ async (share: AndroidSharePayload, roomId: string) => {
+ const room = mx.getRoom(roomId);
+ if (!room) return false;
+
+ const nextParts = [share.subject?.trim(), share.text?.trim()].filter(
+ (value): value is string => !!value
+ );
+ if (nextParts.length > 0) {
+ const existingText = toPlainText(msgDraft, isMarkdown).trim();
+ const nextText = existingText ? `${existingText}\n${nextParts.join('\n')}` : nextParts.join('\n');
+ setMsgDraft(plainToEditorInput(nextText, isMarkdown));
+ }
+
+ if (share.files.length > 0) {
+ const uploadItems: TUploadItem[] = [];
+ for (const sharedFile of share.files) {
+ const originalFile = await materializeSharedFile(sharedFile, share.receivedAt);
+ if (room.hasEncryptionStateEvent()) {
+ const encrypted = await encryptFile(originalFile);
+ uploadItems.push({
+ file: encrypted.file,
+ originalFile,
+ metadata: { markedAsSpoiler: false },
+ encInfo: encrypted.encInfo,
+ });
+ continue;
+ }
+
+ uploadItems.push({
+ file: originalFile,
+ originalFile,
+ metadata: { markedAsSpoiler: false },
+ encInfo: undefined,
+ });
+ }
+
+ setUploadItems({
+ type: 'PUT',
+ item: uploadItems,
+ });
+ }
+
+ await clearPendingAndroidShare();
+ return true;
+ },
+ [isMarkdown, msgDraft, mx, setMsgDraft, setUploadItems]
+ );
+
+ const handlePickRoom = useCallback(
+ (roomId: string) => {
+ setPickedRoomId(roomId);
+ if (!pendingShare) return;
+
+ applyPendingShare(pendingShare, roomId)
+ .then((applied) => {
+ if (applied) {
+ setPendingShare(null);
+ setPickedRoomId(null);
+ }
+ })
+ .catch((err) => {
+ console.error('[AndroidShare] Failed to apply share after pick:', err);
+ setPendingShare(null);
+ setPickedRoomId(null);
+ });
+ },
+ [applyPendingShare, pendingShare]
+ );
+
+ const handleDismiss = useCallback(() => {
+ clearPendingAndroidShare().catch(() => {});
+ setPendingShare(null);
+ setPickedRoomId(null);
+ }, []);
+
+ useEffect(() => {
+ if (!isAndroidShareSupported()) return;
+
+ let mounted = true;
+ let listenerHandle: { remove: () => Promise } | undefined;
+
+ getPendingAndroidShare()
+ .then((share) => {
+ if (mounted && share) {
+ setPendingShare(share);
+ }
+ })
+ .catch((err) => {
+ console.error('[AndroidShare] Failed to get pending share:', err);
+ });
+
+ listenForAndroidShares((share) => {
+ if (mounted) {
+ setPendingShare(share);
+ }
+ })
+ .then((handle) => {
+ listenerHandle = handle;
+ })
+ .catch((err) => {
+ console.error('[AndroidShare] Failed to listen for shares:', err);
+ });
+
+ return () => {
+ mounted = false;
+ void listenerHandle?.remove();
+ };
+ }, []);
+
+ if (!pendingShare) return null;
+
+ return (
+
+ );
+}
+
type ClientNonUIFeaturesProps = {
children: ReactNode;
};
@@ -508,6 +659,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
+
{children}
>
);
diff --git a/overlay/src/app/utils/androidShare.ts b/overlay/src/app/utils/androidShare.ts
new file mode 100644
index 0000000..445cc96
--- /dev/null
+++ b/overlay/src/app/utils/androidShare.ts
@@ -0,0 +1,73 @@
+import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
+
+/** Metadata for a file received from an Android share intent. */
+export type AndroidSharedFile = {
+ path: string;
+ name: string;
+ mimeType: string;
+ size: number;
+};
+
+/** Payload persisted by the native Android share handler. */
+export type AndroidSharePayload = {
+ text?: string;
+ subject?: string;
+ files: AndroidSharedFile[];
+ receivedAt: number;
+};
+
+interface AndroidShareHandlerPlugin {
+ /** Returns the last pending share payload, if one exists. */
+ getPendingShare(): Promise<{ share: AndroidSharePayload | null }>;
+ /** Clears the last pending share payload after it has been consumed. */
+ clearPendingShare(): Promise;
+ addListener(
+ eventName: 'shareReceived',
+ listenerFunc: (payload: AndroidSharePayload) => void
+ ): Promise;
+}
+
+const AndroidShareHandler = registerPlugin('AndroidShareHandler');
+
+/** Returns true when the Android share bridge is available. */
+export const isAndroidShareSupported = (): boolean =>
+ Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android';
+
+/** Fetches the current pending native share payload. */
+export const getPendingAndroidShare = async (): Promise => {
+ if (!isAndroidShareSupported()) return null;
+ const result = await AndroidShareHandler.getPendingShare();
+ return result.share;
+};
+
+/** Clears the native pending share payload. */
+export const clearPendingAndroidShare = async (): Promise => {
+ if (!isAndroidShareSupported()) return;
+ await AndroidShareHandler.clearPendingShare();
+};
+
+/** Subscribes to new incoming native share payloads. */
+export const listenForAndroidShares = async (
+ listener: (payload: AndroidSharePayload) => void
+): Promise => {
+ if (!isAndroidShareSupported()) return undefined;
+ return AndroidShareHandler.addListener('shareReceived', listener);
+};
+
+/** Converts a cached native shared file into a browser File for upload. */
+export const materializeSharedFile = async (
+ sharedFile: AndroidSharedFile,
+ receivedAt: number
+): Promise => {
+ const fileUrl = Capacitor.convertFileSrc(sharedFile.path);
+ const response = await fetch(fileUrl);
+ if (!response.ok) {
+ throw new Error(`Failed to read shared file: ${sharedFile.name}`);
+ }
+
+ const blob = await response.blob();
+ return new File([blob], sharedFile.name, {
+ type: sharedFile.mimeType || blob.type,
+ lastModified: receivedAt,
+ });
+};
\ No newline at end of file
diff --git a/overlay/src/app/utils/backgroundSync.ts b/overlay/src/app/utils/backgroundSync.ts
index f4cdea7..9f31676 100644
--- a/overlay/src/app/utils/backgroundSync.ts
+++ b/overlay/src/app/utils/backgroundSync.ts
@@ -7,7 +7,7 @@ type UnifiedPushStatus = {
instance: string;
registered: boolean;
distributor: string;
- distributors: string[];
+ distributors: string[] | string;
};
type UnifiedPushEndpointEvent = {
@@ -36,6 +36,8 @@ interface MatrixBackgroundSyncPlugin {
}): Promise;
/** Trigger a one-shot fetch as if a push ping arrived. */
triggerPing(options: { reason?: string }): Promise;
+ /** Re-open distributor setup flow and retry registration. */
+ requestDistributorSetup(): Promise<{ success: boolean }>;
/** Stop any in-flight fetch, clear credentials, and unregister UnifiedPush. */
stop(): Promise;
/**
@@ -112,6 +114,24 @@ const clearStoredPusherState = (userId: string | null, deviceId: string | null):
window.localStorage.removeItem(getStoredPusherKey(userId, deviceId));
};
+const normalizeDistributors = (raw: UnifiedPushStatus['distributors']): string[] => {
+ if (Array.isArray(raw)) return raw;
+ if (typeof raw !== 'string') return [];
+
+ const trimmed = raw.trim();
+ if (!trimmed || trimmed === '[]') return [];
+
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
+ return trimmed
+ .slice(1, -1)
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter(Boolean);
+ }
+
+ return [trimmed];
+};
+
const resolveUnifiedPushGateway = async (endpoint: string): Promise => {
try {
const discoveryUrl = new URL(endpoint);
@@ -143,31 +163,52 @@ class AndroidUnifiedPushManager {
private listenersReady = false;
+ private distributorSetupAttempted = false;
+
+ private distributorPromptShown = false;
+
/** Start native UnifiedPush registration and synchronize the Matrix pusher. */
async start(mx: MatrixClient): Promise {
- if (!isBackgroundSyncSupported()) return;
+ console.log('[BackgroundSync] start() called, platform:', Capacitor.getPlatform(), 'isNative:', Capacitor.isNativePlatform());
+
+ if (!isBackgroundSyncSupported()) {
+ console.log('[BackgroundSync] Background sync not supported on this platform');
+ return;
+ }
const homeserverUrl = mx.getHomeserverUrl();
const accessToken = mx.getAccessToken();
const userId = mx.getUserId();
const deviceId = mx.getDeviceId();
+ console.log('[BackgroundSync] Credentials check:', { userId, deviceId, hasToken: !!accessToken, hasUrl: !!homeserverUrl });
+
if (!homeserverUrl || !accessToken || !userId) {
console.warn('[BackgroundSync] Missing credentials, not starting');
return;
}
this.client = mx;
+ this.distributorSetupAttempted = false;
+ this.distributorPromptShown = false;
await this.ensureListeners();
- await MatrixBackgroundSync.start({
- homeserverUrl,
- accessToken,
- userId,
- deviceId: deviceId ?? '',
- });
+ try {
+ console.log('[BackgroundSync] Calling plugin.start()...');
+ await MatrixBackgroundSync.start({
+ homeserverUrl,
+ accessToken,
+ userId,
+ deviceId: deviceId ?? '',
+ });
+ console.log('[BackgroundSync] plugin.start() completed');
+ } catch (err) {
+ console.error('[BackgroundSync] plugin.start() failed:', err);
+ throw err;
+ }
await this.syncExistingEndpoint();
+ await this.ensureDistributorPromptFromStatus();
console.log('[BackgroundSync] UnifiedPush registration requested');
}
@@ -227,6 +268,67 @@ class AndroidUnifiedPushManager {
/** Log native registration failures so the missing distributor path is visible. */
private handleRegistrationFailed(event: UnifiedPushRegistrationFailedEvent): void {
console.warn('[BackgroundSync] UnifiedPush registration failed:', event.reason);
+
+ if (event.reason !== 'ACTION_REQUIRED' || this.distributorSetupAttempted) {
+ return;
+ }
+
+ this.distributorSetupAttempted = true;
+ void this.tryRequestDistributorSetup();
+ }
+
+ /** Re-opens distributor selection once after ACTION_REQUIRED and logs actionable status. */
+ private async tryRequestDistributorSetup(): Promise {
+ try {
+ const setupResult = await MatrixBackgroundSync.requestDistributorSetup();
+ console.warn('[BackgroundSync] requestDistributorSetup result:', setupResult);
+ const status = await this.safeGetStatus();
+ console.warn('[BackgroundSync] UnifiedPush status after setup attempt:', status);
+ const distributors = normalizeDistributors(status?.distributors ?? []);
+ if (distributors.length === 0) {
+ console.error(
+ '[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
+ );
+ this.showNoDistributorPrompt();
+ }
+ } catch (err) {
+ console.warn('[BackgroundSync] requestDistributorSetup failed:', err);
+ }
+ }
+
+ /** Fallback check so users still get prompted even when ACTION_REQUIRED event is missed. */
+ private async ensureDistributorPromptFromStatus(): Promise {
+ const status = await this.safeGetStatus();
+ if (!status) return;
+
+ const distributors = normalizeDistributors(status.distributors);
+ if (!status.registered && distributors.length === 0) {
+ console.error(
+ '[BackgroundSync] No UnifiedPush distributor installed. Install one (for example ntfy) to enable Android background notifications.'
+ );
+ this.showNoDistributorPrompt();
+ }
+ }
+
+ /** Show a one-time actionable prompt when no distributor app is installed. */
+ private showNoDistributorPrompt(): void {
+ if (this.distributorPromptShown) return;
+ this.distributorPromptShown = true;
+
+ const message =
+ 'Android background notifications need a UnifiedPush distributor app. Install one (for example ntfy), then reopen Paarrot.';
+ const docsUrl = 'https://unifiedpush.org/users/distributors/';
+
+ if (typeof window === 'undefined') return;
+
+ try {
+ const openDocs = window.confirm(`${message}\n\nOpen distributor list now?`);
+ if (openDocs) {
+ window.open(docsUrl, '_blank', 'noopener,noreferrer');
+ }
+ } catch {
+ window.alert(message);
+ }
}
/** Install plugin listeners once for the active Matrix client. */
@@ -235,7 +337,9 @@ class AndroidUnifiedPushManager {
this.listenerHandles = [
await MatrixBackgroundSync.addListener('unifiedPushNewEndpoint', (event) => {
- void this.handleNewEndpoint(event);
+ void this.handleNewEndpoint(event).catch((err) => {
+ console.error('[BackgroundSync] handleNewEndpoint failed:', err);
+ });
}),
await MatrixBackgroundSync.addListener('unifiedPushUnregistered', (event) => {
void this.handleUnregistered(event);
@@ -260,6 +364,7 @@ class AndroidUnifiedPushManager {
if (!client) return;
const status = await this.safeGetStatus();
+ console.log('[BackgroundSync] Native status before pusher sync:', status);
if (status?.registered && status.endpoint) {
await this.upsertPusher(client, status.endpoint);
}
@@ -272,20 +377,40 @@ class AndroidUnifiedPushManager {
const appId = buildPusherAppId(deviceId);
const gatewayUrl = await resolveUnifiedPushGateway(endpoint);
- await mx.setPusher({
- kind: 'http',
- app_id: appId,
- pushkey: endpoint,
- app_display_name: 'Paarrot',
- device_display_name: deviceId ?? 'Android',
- lang: 'en',
- data: {
- url: gatewayUrl,
- format: 'event_id_only',
- },
- append: false,
- device_id: deviceId ?? undefined,
- } as unknown as IPusherRequest);
+ console.log('[BackgroundSync] Upserting Matrix pusher', {
+ appId,
+ endpoint,
+ gatewayUrl,
+ deviceId,
+ userId,
+ });
+
+ try {
+ await mx.setPusher({
+ kind: 'http',
+ app_id: appId,
+ pushkey: endpoint,
+ app_display_name: 'Paarrot',
+ device_display_name: deviceId ?? 'Android',
+ lang: 'en',
+ data: {
+ url: gatewayUrl,
+ format: 'event_id_only',
+ },
+ append: false,
+ device_id: deviceId ?? undefined,
+ } as unknown as IPusherRequest);
+
+ const pushers = (await mx.getPushers())?.pushers ?? [];
+ const thisPusher = pushers.find((p) => p.pushkey === endpoint && p.app_id === appId);
+ console.log('[BackgroundSync] Matrix pusher upserted successfully', {
+ totalPushers: pushers.length,
+ foundThisPusher: Boolean(thisPusher),
+ });
+ } catch (err) {
+ console.error('[BackgroundSync] Matrix pusher upsert failed:', err);
+ throw err;
+ }
saveStoredPusherState(userId, deviceId, { endpoint, appId });
}
@@ -359,3 +484,35 @@ export const setAppForegroundState = async (foreground: boolean): Promise
console.warn('[BackgroundSync] setAppForeground failed:', err);
}
};
+
+/**
+ * Re-opens the UnifiedPush distributor selection dialog.
+ * Allows users to re-select or change their push notification distributor without terminal access.
+ * Useful when the current endpoint becomes unavailable.
+ */
+export const requestResetPushRegistration = async (): Promise<{ success: boolean }> => {
+ if (!isBackgroundSyncSupported()) {
+ console.warn('[BackgroundSync] Background sync not supported on this platform');
+ return { success: false };
+ }
+
+ try {
+ const result = await MatrixBackgroundSync.requestDistributorSetup();
+ console.log('[BackgroundSync] requestDistributorSetup completed:', result);
+ return result ?? { success: false };
+ } catch (err) {
+ console.error('[BackgroundSync] requestDistributorSetup failed:', err);
+ return { success: false };
+ }
+};
+
+/** Returns the current native UnifiedPush status, or undefined if unavailable. */
+export const getBackgroundSyncStatus = async (): Promise => {
+ if (!isBackgroundSyncSupported()) return undefined;
+ try {
+ return await MatrixBackgroundSync.getStatus();
+ } catch (err) {
+ console.warn('[BackgroundSync] getStatus failed:', err);
+ return undefined;
+ }
+};
diff --git a/scripts/apply-overlay.mjs b/scripts/apply-overlay.mjs
index c866503..f379c6b 100644
--- a/scripts/apply-overlay.mjs
+++ b/scripts/apply-overlay.mjs
@@ -30,6 +30,14 @@ const STASH_FLAG = join(ROOT, '.cinny-stash-pending');
const cinnyStatus = execSync('git status --porcelain', { cwd: CINNY, encoding: 'utf8' });
if (cinnyStatus.trim()) {
console.log('[apply-overlay] Stashing pre-existing cinny changes...');
+ // Ensure android/ junction is excluded from the cinny git view — otherwise
+ // `git stash -u` would traverse the junction into the root android/ directory
+ // and delete files there when the stash is created.
+ const cinnyExclude = join(CINNY, '.git', 'info', 'exclude');
+ const excludeContent = existsSync(cinnyExclude) ? readFileSync(cinnyExclude, 'utf8') : '';
+ if (!excludeContent.includes('android/')) {
+ writeFileSync(cinnyExclude, excludeContent + (excludeContent.endsWith('\n') || !excludeContent ? '' : '\n') + 'android/\n', 'utf8');
+ }
execSync('git stash push -u -m "pre-overlay stash"', { cwd: CINNY, stdio: 'inherit' });
writeFileSync(STASH_FLAG, '1', 'utf8');
console.log('[apply-overlay] Stash saved.');
@@ -120,21 +128,19 @@ if (!existsSync(androidJunction)) {
console.log(` android/ link already exists, skipping`);
}
-// 8. Sync version from root package.json into android/app/build.gradle
-console.log('[apply-overlay] Syncing version into build.gradle...');
-const rootPkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
-const version = rootPkg.version ?? '1.0.0';
-const parts = version.split('.').map(Number);
-// versionCode: major*10000 + minor*100 + patch (e.g. 4.11.78 -> 41178)
-const versionCode = (parts[0] ?? 0) * 10000 + (parts[1] ?? 0) * 100 + (parts[2] ?? 0);
+// 8. Sync time-based version into android/app/build.gradle
+console.log('[apply-overlay] Syncing time-based version into build.gradle...');
+const buildTimestampMs = Date.now();
+const versionCode = Math.floor(buildTimestampMs / 1000);
+const versionName = new Date(buildTimestampMs).toISOString().replace('T', '.').replaceAll(':', '').replace('Z', '');
const buildGradlePath = join(androidTarget, 'app', 'build.gradle');
let buildGradle = readFileSync(buildGradlePath, 'utf8');
buildGradle = buildGradle
.replace(/versionCode\s+\d+/, `versionCode ${versionCode}`)
- .replace(/versionName\s+"[^"]+"/, `versionName "${version}"`);
+ .replace(/versionName\s+"[^"]+"/, `versionName "${versionName}"`);
writeFileSync(buildGradlePath, buildGradle, 'utf8');
-console.log(` versionName "${version}", versionCode ${versionCode}`);
+console.log(` versionName "${versionName}", versionCode ${versionCode}`);
// 9. Write android/local.properties with the current machine's Android SDK path
const androidHome = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;