feat(app): Teilen-Ziel — Bilder/Videos/Dateien aus anderen Apps an ARIA

'Teilen -> ARIA' aus WhatsApp, Galerie, Browser & Co. Ablauf wie gewuenscht:
sehen WAS geteilt wurde -> WOHIN (Projekt oder Hauptchat) -> optional Text dazu.
Ohne Text geht genauso.

- Manifest: intent-filter fuer ACTION_SEND (text/image/video/audio/application)
  und ACTION_SEND_MULTIPLE. launchMode=singleTask war bereits gesetzt.
- ShareIntentModule (Kotlin): liest den Intent und kopiert die Inhalte SOFORT in
  den App-Cache — die Leseerlaubnis fuer content://-URIs gilt nur fuer diesen
  Intent und waere nach der Projektauswahl womoeglich erloschen. Liefert echte
  Dateipfade, dadurch laeuft Teilen durch exakt dieselbe Upload-Strecke wie die
  Bueroklammer (RNFS -> Base64 -> Anhang im Chat).
- MainActivity.onNewIntent: setIntent + Event an JS (best effort). Zweitweg in
  JS ueber AppState 'active' -> getSharedData(), falls das Event je nach
  RN-Architektur nicht durchkommt; getSharedData markiert den Intent als
  verbraucht, also kein doppeltes Aufpoppen.
- ShareModal: Vorschau der geteilten Inhalte, Projektliste (inkl. Hauptchat),
  optionales Textfeld. Senden erst nach Zielwahl moeglich.
- sendPendingAttachments nimmt die Anhaenge jetzt optional als Parameter — beim
  Teilen waere der State zum Aufrufzeitpunkt noch leer.
tsc --noEmit: 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 01:19:55 +02:00
co-authored by Claude Opus 4.8
parent b9354283af
commit b6195d3a53
8 changed files with 553 additions and 6 deletions
@@ -49,6 +49,28 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Teilen-Ziel: ARIA erscheint im Teilen-Menue anderer Apps (WhatsApp,
Galerie, Browser ...). EINE Datei bzw. reiner Text. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="application/*" />
</intent-filter>
<!-- Mehrere Dateien auf einmal. -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="audio/*" />
<data android:mimeType="application/*" />
</intent-filter>
</activity>
<provider
@@ -1,5 +1,6 @@
package com.ariacockpit
import android.content.Intent
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
@@ -19,4 +20,26 @@ class MainActivity : ReactActivity() {
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
/**
* Teilen in die LAUFENDE App (launchMode=singleTask liefert das hier ab).
* setIntent() ist wichtig, damit getSharedData() denselben Inhalt sieht, und
* das Event weckt die JS-Seite sofort — ohne es wuerde der geteilte Inhalt
* erst beim naechsten Kaltstart auftauchen.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
// Best effort: je nach RN-Architektur ist der Kontext anders erreichbar.
// Scheitert es, greift der zuverlaessige Zweitweg — die JS-Seite fragt beim
// Zurueckkehren in den Vordergrund ohnehin nochmal via getSharedData() nach
// (setIntent() oben sorgt dafuer, dass sie denselben Inhalt sieht).
try {
val ctx = (application as? com.facebook.react.ReactApplication)
?.reactNativeHost?.reactInstanceManager?.currentReactContext
ShareIntentModule.notifyNewIntent(
ctx as? com.facebook.react.bridge.ReactApplicationContext, intent)
} catch (_: Throwable) {
}
}
}
@@ -25,6 +25,7 @@ class MainApplication : Application(), ReactApplication {
add(OpenWakeWordPackage())
add(PhoneCallPackage())
add(BackgroundAudioPackage())
add(ShareIntentPackage())
}
override fun getJSMainModuleName(): String = "index"
@@ -0,0 +1,154 @@
package com.ariacockpit
import android.content.Intent
import android.net.Uri
import android.provider.OpenableColumns
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.WritableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import java.io.File
/**
* Teilen-Ziel: nimmt Inhalte entgegen, die aus anderen Apps (WhatsApp, Galerie,
* Browser ...) per "Teilen → ARIA" geschickt werden.
*
* Die geteilten Dateien kommen als content://-URI, auf die nur DIESE App und nur
* JETZT zugreifen darf. Deshalb kopieren wir sie sofort in den App-Cache und
* geben einen echten Dateipfad zurueck — den kann die JS-Seite wie eine normal
* ausgewaehlte Datei weiterverarbeiten (RNFS.readFile → Base64 → Upload).
*
* Ablauf:
* App geschlossen → Intent liegt beim Start an → getSharedData()
* App laeuft → onNewIntent (launchMode=singleTask) → Event 'ShareReceived'
*/
class ShareIntentModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "ShareIntent"
companion object {
private const val EVENT = "ShareReceived"
/** Vom MainActivity.onNewIntent gerufen, wenn die App schon laeuft. */
@JvmStatic
fun notifyNewIntent(ctx: ReactApplicationContext?, intent: Intent?) {
if (ctx == null || intent == null || !isShare(intent)) return
try {
val data = extract(ctx, intent)
ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(EVENT, data)
} catch (_: Throwable) {
}
}
private fun isShare(i: Intent): Boolean =
i.action == Intent.ACTION_SEND || i.action == Intent.ACTION_SEND_MULTIPLE
/** Baut {text, items:[{path,name,type,size}]} aus dem Intent. */
private fun extract(ctx: ReactApplicationContext, intent: Intent): WritableMap {
val out = Arguments.createMap()
out.putString("text", intent.getStringExtra(Intent.EXTRA_TEXT) ?: "")
val uris = mutableListOf<Uri>()
if (intent.action == Intent.ACTION_SEND) {
@Suppress("DEPRECATION")
(intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))?.let { uris.add(it) }
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra<Uri>(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) {}
}
@@ -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<NativeModule> {
return listOf(ShareIntentModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
+183
View File
@@ -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<Props> = ({ visible, items, initialText, onCancel, onSend }) => {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false);
const [pid, setPid] = useState<string | null>(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 (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onCancel}>
<View style={s.backdrop}>
<View style={s.box}>
<Text style={s.title}>Mit ARIA teilen</Text>
{/* Was wird geteilt */}
<View style={s.itemsBox}>
{preview ? (
<Image source={{ uri: 'file://' + preview.path }} style={s.thumb} resizeMode="cover" />
) : null}
<View style={{ flex: 1 }}>
{items.length === 0 ? (
<Text style={s.dim}>Nur Text</Text>
) : items.slice(0, 3).map((it, i) => (
<Text key={i} style={s.itemLine} numberOfLines={1}>
{it.name} <Text style={s.dim}>({fmtSize(it.size)})</Text>
</Text>
))}
{items.length > 3 ? (
<Text style={s.dim}>+ {items.length - 3} weitere</Text>
) : null}
</View>
</View>
{/* Wohin */}
<Text style={s.label}>Wohin soll das?</Text>
{loading ? (
<ActivityIndicator color="#FFD60A" style={{ marginVertical: 12 }} />
) : (
<FlatList
style={s.list}
data={[{ id: '', name: 'Hauptchat', description: '' } as Project, ...projects]}
keyExtractor={p => p.id || '__main__'}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => {
const sel = pid === item.id;
return (
<TouchableOpacity
style={[s.row, sel && s.rowSel]}
onPress={() => setPid(item.id)}>
<Text style={[s.rowText, sel && s.rowTextSel]} numberOfLines={1}>
{item.id ? '📁 ' : '💬 '}{item.name}
</Text>
{sel ? <Text style={s.check}>✓</Text> : null}
</TouchableOpacity>
);
}}
/>
)}
{/* Optionaler Text */}
<Text style={s.label}>Text dazu <Text style={s.dim}>(optional)</Text></Text>
<TextInput
style={s.input}
value={text}
onChangeText={setText}
placeholder="z.B. „Schau dir das mal an“ — kann auch leer bleiben"
placeholderTextColor="#666680"
multiline
/>
<View style={s.actions}>
<TouchableOpacity style={s.btnGhost} onPress={onCancel} disabled={sending}>
<Text style={s.btnGhostText}>Abbrechen</Text>
</TouchableOpacity>
<TouchableOpacity
style={[s.btn, (pid === null || sending) && s.btnDisabled]}
onPress={send}
disabled={pid === null || sending}>
<Text style={s.btnText}>
{sending ? 'Senden …' : pid === null ? 'Ziel wählen' : 'Senden'}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
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;
+77 -6
View File
@@ -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<SharedItem[]>([]);
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 */}
<Modal visible={showFileUpload} transparent animationType="slide">
<View style={styles.modalOverlay}>
<ShareModal
visible={shareVisible}
items={shareItems}
initialText={shareText}
onCancel={() => { setShareVisible(false); setShareItems([]); setShareText(''); }}
onSend={handleShareSend}
/>
<FileUpload
onFileSelected={handleFileSelected}
onCancel={() => setShowFileUpload(false)}
+77
View File
@@ -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<SharedPayload> {
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<void> {
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<FileData[]> {
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 };