diff --git a/android/android/app/src/main/AndroidManifest.xml b/android/android/app/src/main/AndroidManifest.xml index 1aeb11d..c52bb4f 100644 --- a/android/android/app/src/main/AndroidManifest.xml +++ b/android/android/app/src/main/AndroidManifest.xml @@ -49,6 +49,28 @@ + + + + + + + + + + + + + + + + + + + + + () + if (intent.action == Intent.ACTION_SEND) { + @Suppress("DEPRECATION") + (intent.getParcelableExtra(Intent.EXTRA_STREAM))?.let { uris.add(it) } + } else { + @Suppress("DEPRECATION") + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + ?.forEach { uris.add(it) } + } + + val items: WritableArray = Arguments.createArray() + for (uri in uris) { + copyToCache(ctx, uri)?.let { items.pushMap(it) } + } + out.putArray("items", items) + return out + } + + /** + * Kopiert den geteilten Inhalt in den Cache. Noetig, weil die Leseerlaubnis + * fuer die content://-URI nur fuer diesen Intent gilt — spaeter (nach der + * Projektauswahl) waere sie womoeglich schon erloschen. + */ + private fun copyToCache(ctx: ReactApplicationContext, uri: Uri): WritableMap? { + return try { + val cr = ctx.contentResolver + var name = "geteilt" + var size = 0L + cr.query(uri, null, null, null, null)?.use { c -> + val ni = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val si = c.getColumnIndex(OpenableColumns.SIZE) + if (c.moveToFirst()) { + if (ni >= 0) c.getString(ni)?.let { name = it } + if (si >= 0) size = c.getLong(si) + } + } + val type = cr.getType(uri) ?: "application/octet-stream" + val dir = File(ctx.cacheDir, "shared").apply { mkdirs() } + // Eindeutiger Name, damit zwei gleichnamige Bilder sich nicht ueberschreiben. + val safe = name.replace(Regex("[^A-Za-z0-9._-]"), "_").take(80) + val dst = File(dir, "${System.currentTimeMillis()}_$safe") + cr.openInputStream(uri)?.use { input -> + dst.outputStream().use { output -> input.copyTo(output) } + } ?: return null + + Arguments.createMap().apply { + putString("path", dst.absolutePath) + putString("name", name) + putString("type", type) + putDouble("size", (if (size > 0) size else dst.length()).toDouble()) + } + } catch (_: Throwable) { + null + } + } + } + + /** + * Holt den Inhalt, mit dem die App GESTARTET wurde (App war zu). + * Gibt {text, items:[]} zurueck — items leer, wenn nichts geteilt wurde. + */ + @ReactMethod + fun getSharedData(promise: Promise) { + try { + val activity = currentActivity + val intent = activity?.intent + if (intent == null || !isShare(intent)) { + promise.resolve(Arguments.createMap().apply { + putString("text", "") + putArray("items", Arguments.createArray()) + }) + return + } + val data = extract(reactContext, intent) + // Verbraucht markieren, damit ein Rotieren/Zurueckkehren den Dialog + // nicht erneut oeffnet. + activity.intent = Intent(Intent.ACTION_MAIN) + promise.resolve(data) + } catch (e: Throwable) { + promise.reject("SHARE_READ_FAILED", e.message, e) + } + } + + /** Aufgeraeumte Kopien loeschen (nach erfolgreichem Senden). */ + @ReactMethod + fun clearCache(promise: Promise) { + try { + File(reactContext.cacheDir, "shared").listFiles()?.forEach { it.delete() } + promise.resolve(true) + } catch (e: Throwable) { + promise.resolve(false) + } + } + + // Fuer NativeEventEmitter (RN verlangt diese beiden Methoden). + @ReactMethod fun addListener(eventName: String) {} + @ReactMethod fun removeListeners(count: Int) {} +} diff --git a/android/android/app/src/main/java/com/ariacockpit/ShareIntentPackage.kt b/android/android/app/src/main/java/com/ariacockpit/ShareIntentPackage.kt new file mode 100644 index 0000000..9d35a43 --- /dev/null +++ b/android/android/app/src/main/java/com/ariacockpit/ShareIntentPackage.kt @@ -0,0 +1,16 @@ +package com.ariacockpit + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class ShareIntentPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(ShareIntentModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} diff --git a/android/src/components/ShareModal.tsx b/android/src/components/ShareModal.tsx new file mode 100644 index 0000000..2ede425 --- /dev/null +++ b/android/src/components/ShareModal.tsx @@ -0,0 +1,183 @@ +/** + * Teilen-Dialog — was aus anderen Apps per "Teilen → ARIA" hereinkommt. + * + * Ablauf wie gewuenscht: erst sehen WAS geteilt wurde, dann WOHIN (Projekt + * oder Hauptchat), dann optional ein Text dazu. Ohne Text geht genauso. + */ +import React, { useCallback, useEffect, useState } from 'react'; +import { + ActivityIndicator, FlatList, Image, Modal, StyleSheet, Text, + TextInput, TouchableOpacity, View, +} from 'react-native'; +import brainApi, { Project } from '../services/brainApi'; + +export interface SharedItem { + path: string; + name: string; + type: string; + size: number; +} + +interface Props { + visible: boolean; + items: SharedItem[]; + /** Text, den die sendende App mitgegeben hat (z.B. geteilter Link). */ + initialText?: string; + onCancel: () => void; + /** projectId: '' = Hauptchat. text: optional, darf leer sein. */ + onSend: (projectId: string, text: string) => void; +} + +const fmtSize = (n: number) => + n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` + : n > 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`; + +const ShareModal: React.FC = ({ visible, items, initialText, onCancel, onSend }) => { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(false); + const [pid, setPid] = useState(null); // null = noch nichts gewaehlt + const [text, setText] = useState(''); + const [sending, setSending] = useState(false); + + useEffect(() => { + if (!visible) return; + setPid(null); + setText(initialText || ''); + setSending(false); + setLoading(true); + brainApi.listProjects(false) + .then(ps => setProjects(ps || [])) + .catch(() => setProjects([])) + .finally(() => setLoading(false)); + }, [visible, initialText]); + + const send = useCallback(() => { + if (pid === null || sending) return; + setSending(true); + onSend(pid, text.trim()); + }, [pid, text, sending, onSend]); + + const preview = items.find(i => i.type?.startsWith('image/')); + + return ( + + + + Mit ARIA teilen + + {/* Was wird geteilt */} + + {preview ? ( + + ) : null} + + {items.length === 0 ? ( + Nur Text + ) : items.slice(0, 3).map((it, i) => ( + + {it.name} ({fmtSize(it.size)}) + + ))} + {items.length > 3 ? ( + + {items.length - 3} weitere + ) : null} + + + + {/* Wohin */} + Wohin soll das? + {loading ? ( + + ) : ( + p.id || '__main__'} + keyboardShouldPersistTaps="handled" + renderItem={({ item }) => { + const sel = pid === item.id; + return ( + setPid(item.id)}> + + {item.id ? '📁 ' : '💬 '}{item.name} + + {sel ? ✓ : null} + + ); + }} + /> + )} + + {/* Optionaler Text */} + Text dazu (optional) + + + + + Abbrechen + + + + {sending ? 'Senden …' : pid === null ? 'Ziel wählen' : 'Senden'} + + + + + + + ); +}; + +const s = StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.75)', justifyContent: 'flex-end' }, + box: { + backgroundColor: '#0D0D1A', borderTopLeftRadius: 16, borderTopRightRadius: 16, + padding: 18, maxHeight: '88%', + }, + title: { color: '#E0E0F0', fontSize: 19, fontWeight: '700', marginBottom: 12 }, + itemsBox: { + flexDirection: 'row', gap: 10, alignItems: 'center', + backgroundColor: '#12121E', borderRadius: 10, padding: 10, marginBottom: 14, + }, + thumb: { width: 56, height: 56, borderRadius: 8, backgroundColor: '#1E1E2E' }, + itemLine: { color: '#E0E0F0', fontSize: 13 }, + dim: { color: '#8888AA', fontSize: 12 }, + label: { color: '#8888AA', fontSize: 12, marginBottom: 6 }, + list: { maxHeight: 190, marginBottom: 14 }, + row: { + flexDirection: 'row', alignItems: 'center', paddingVertical: 10, + paddingHorizontal: 12, borderRadius: 8, marginBottom: 4, backgroundColor: '#12121E', + }, + rowSel: { backgroundColor: '#1E2A1E', borderWidth: 1, borderColor: '#3FFF9F' }, + rowText: { color: '#E0E0F0', fontSize: 14, flex: 1 }, + rowTextSel: { color: '#3FFF9F', fontWeight: '600' }, + check: { color: '#3FFF9F', fontSize: 16 }, + input: { + backgroundColor: '#12121E', borderRadius: 8, color: '#E0E0F0', + paddingHorizontal: 12, paddingVertical: 10, minHeight: 56, + textAlignVertical: 'top', marginBottom: 16, fontSize: 14, + }, + actions: { flexDirection: 'row', gap: 10, justifyContent: 'flex-end' }, + btnGhost: { paddingVertical: 11, paddingHorizontal: 18 }, + btnGhostText: { color: '#8888AA', fontSize: 15 }, + btn: { + backgroundColor: '#FFD60A', borderRadius: 8, + paddingVertical: 11, paddingHorizontal: 24, + }, + btnDisabled: { backgroundColor: '#3A3A2A' }, + btnText: { color: '#0D0D1A', fontSize: 15, fontWeight: '700' }, +}); + +export default ShareModal; diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index c9248f7..e2e0257 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -49,6 +49,8 @@ import updateService from '../services/updater'; import VoiceButton from '../components/VoiceButton'; import FileUpload, { FileData } from '../components/FileUpload'; import CameraUpload, { PhotoData } from '../components/CameraUpload'; +import ShareModal, { SharedItem } from '../components/ShareModal'; +import shareIntent from '../services/shareIntent'; import MessageText from '../components/MessageText'; import { loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio'; import Geolocation from '@react-native-community/geolocation'; @@ -348,6 +350,10 @@ const ChatScreen: React.FC = () => { } | null>(null); const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]); + // Teilen-Ziel: Inhalte, die aus anderen Apps hereingereicht wurden. + const [shareItems, setShareItems] = useState([]); + const [shareText, setShareText] = useState(''); + const [shareVisible, setShareVisible] = useState(false); const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''}); // Multi-Threading: Activity pro Kontext (key = projectId, '' = Hauptchat). // Der Indikator zeigt nur den fokussierten Kontext — nicht global. @@ -2471,16 +2477,47 @@ const ChatScreen: React.FC = () => { setPendingAttachments(prev => [...prev, { file: photo, isPhoto: true }]); }, []); + // ── Teilen-Ziel ──────────────────────────────────────────────── + // Beim Start pruefen (App war zu) und auf spaetere Shares lauschen + // (App laeuft schon → onNewIntent im MainActivity). + useEffect(() => { + let alive = true; + const open = (p: { text: string; items: SharedItem[] }) => { + if (!alive) return; + if (!p.items.length && !p.text) return; // nichts dabei + setShareItems(p.items); + setShareText(p.text); + setShareVisible(true); + }; + shareIntent.getShared().then(open).catch(() => {}); + const unsub = shareIntent.onShare(open); + // Zweitweg: Teilen holt die App in den Vordergrund. Beim Wechsel nach + // 'active' nochmal nachfragen — das greift auch, wenn das native Event + // (je nach RN-Architektur) nicht durchkommt. getSharedData() markiert den + // Intent als verbraucht, es kann also nicht doppelt aufpoppen. + const appSub = AppState.addEventListener('change', st => { + if (st === 'active') shareIntent.getShared().then(open).catch(() => {}); + }); + return () => { alive = false; unsub(); try { appSub.remove(); } catch (_) {} }; + }, []); + + // Alle Pending Anhaenge + Text senden - const sendPendingAttachments = useCallback(async (messageText: string) => { - if (pendingAttachments.length === 0) return; - console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length); + const sendPendingAttachments = useCallback(async ( + messageText: string, + // Explizite Liste (Teilen-Weg): der State waere beim direkten Aufruf noch + // nicht aktualisiert. Ohne Angabe wie bisher die Pending-Liste. + explicitAttachments?: { file: any; isPhoto: boolean }[], + ) => { + const atts = explicitAttachments ?? pendingAttachments; + if (atts.length === 0) return; + console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', atts.length); const location = await getCurrentLocation(); const msgId = nextId(); // Alle Attachments fuer die Chat-Nachricht sammeln const attachments: Attachment[] = []; - for (const { file, isPhoto } of pendingAttachments) { + for (const { file, isPhoto } of atts) { const isImage = isPhoto || (file.type && file.type.startsWith('image/')); const name = isPhoto ? file.fileName : file.name; const base64 = file.base64 || ''; @@ -2503,7 +2540,7 @@ const ChatScreen: React.FC = () => { const userMsg: ChatMessage = { id: msgId, sender: 'user', - text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`, + text: messageText || `${atts.length} Anhang/Anhaenge`, timestamp: Date.now(), attachments, projectId: activePid, @@ -2516,7 +2553,7 @@ const ChatScreen: React.FC = () => { setMessages(prev => capMessages([...prev, userMsg])); // Alle Dateien an RVS senden + auf Disk speichern - for (const { file, isPhoto } of pendingAttachments) { + for (const { file, isPhoto } of atts) { const name = isPhoto ? file.fileName : file.name; const base64 = file.base64 || ''; const mimeType = file.type || ''; @@ -2571,6 +2608,33 @@ const ChatScreen: React.FC = () => { // sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es // davor deklariert waere, laendeten sie dort in der Temporal Dead Zone // (tsc TS2448/2454; lief nur zufaellig, weil Babel const→var hebt). + /** Geteiltes senden: Ziel setzen, Dateien anhaengen, abschicken. */ + const handleShareSend = useCallback(async (projectId: string, text: string) => { + try { + // Chat-Focus aufs gewaehlte Ziel — die Nachricht landet dort, und Stefan + // steht nach dem Senden gleich im richtigen Kontext. + setFocusedProjectId(projectId); + focusedProjectIdRef.current = projectId; + + const files = await shareIntent.toFileData(shareItems); + if (files.length) { + // Liste explizit uebergeben: der State waere hier noch leer. + await sendPendingAttachments(text, files.map(f => ({ file: f, isPhoto: false }))); + } else if (text) { + // Reiner Text (z.B. geteilter Link): ins Eingabefeld, Stefan schickt ab. + setInputText(text); + } + shareIntent.clearShareCache(); + } catch (e) { + console.warn('[Share] Senden fehlgeschlagen:', e); + Alert.alert('Teilen fehlgeschlagen', String((e as any)?.message || e)); + } finally { + setShareVisible(false); + setShareItems([]); + setShareText(''); + } + }, [shareItems, sendPendingAttachments]); + const sendTextMessage = useCallback(async () => { const text = inputText.trim(); @@ -3708,6 +3772,13 @@ const ChatScreen: React.FC = () => { {/* Datei-Upload Modal */} + { setShareVisible(false); setShareItems([]); setShareText(''); }} + onSend={handleShareSend} + /> setShowFileUpload(false)} diff --git a/android/src/services/shareIntent.ts b/android/src/services/shareIntent.ts new file mode 100644 index 0000000..c4dcd7b --- /dev/null +++ b/android/src/services/shareIntent.ts @@ -0,0 +1,77 @@ +/** + * Teilen-Ziel — Bruecke zum nativen ShareIntent-Modul. + * + * Zwei Faelle: + * App war ZU → der Inhalt liegt beim Start am Intent → getShared() + * App LAEUFT → onNewIntent feuert das Event 'ShareReceived' → onShare() + * + * Die Dateien hat die native Seite schon in den App-Cache kopiert und liefert + * echte Pfade; content://-URIs waeren nach dem Dialog womoeglich nicht mehr + * lesbar (die Leseerlaubnis gilt nur fuer den Intent). + */ +import { NativeEventEmitter, NativeModules } from 'react-native'; +import RNFS from 'react-native-fs'; +import type { FileData } from '../components/FileUpload'; +import type { SharedItem } from '../components/ShareModal'; + +const { ShareIntent } = NativeModules; + +export interface SharedPayload { + text: string; + items: SharedItem[]; +} + +const EMPTY: SharedPayload = { text: '', items: [] }; + +/** Inhalt, mit dem die App gestartet wurde (leer, wenn normal geoeffnet). */ +export async function getShared(): Promise { + if (!ShareIntent?.getSharedData) return EMPTY; + try { + const r = await ShareIntent.getSharedData(); + return { text: r?.text || '', items: Array.isArray(r?.items) ? r.items : [] }; + } catch (e) { + console.warn('[Share] getSharedData fehlgeschlagen:', e); + return EMPTY; + } +} + +/** Abonniert Shares in die bereits laufende App. Gibt Unsubscribe zurueck. */ +export function onShare(cb: (p: SharedPayload) => void): () => void { + if (!ShareIntent) return () => {}; + const emitter = new NativeEventEmitter(ShareIntent); + const sub = emitter.addListener('ShareReceived', (r: any) => { + cb({ text: r?.text || '', items: Array.isArray(r?.items) ? r.items : [] }); + }); + return () => { try { sub.remove(); } catch (_) {} }; +} + +/** Zwischenkopien loeschen (nach erfolgreichem Senden). */ +export async function clearShareCache(): Promise { + try { await ShareIntent?.clearCache?.(); } catch (_) {} +} + +/** + * Macht aus geteilten Dateien das Format, das der bestehende Upload-Weg + * erwartet (Base64) — so laeuft Teilen durch exakt dieselbe Strecke wie die + * Bueroklammer, inklusive Anhang-Darstellung im Chat. + */ +export async function toFileData(items: SharedItem[]): Promise { + const out: FileData[] = []; + for (const it of items) { + try { + const base64 = await RNFS.readFile(it.path, 'base64'); + out.push({ + name: it.name || 'geteilt', + type: it.type || 'application/octet-stream', + size: it.size || 0, + base64, + uri: 'file://' + it.path, + }); + } catch (e) { + console.warn('[Share] Datei nicht lesbar (%s): %s', it.name, e); + } + } + return out; +} + +export default { getShared, onShare, clearShareCache, toFileData };