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 };