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 ( + + + {avatarSrc ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + } + > + + + {room.name} + + + + + ); + })} + + + +
+
+
+
+
+ ); +} 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;