Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b5591aa72 | ||
|
|
b70623bb78 | ||
|
|
73b6827f6b | ||
|
|
a7998bcd8b | ||
|
|
07df538763 | ||
|
|
5761c5f6b9 | ||
|
|
87755b9c58 | ||
|
|
b6195d3a53 |
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 20502
|
versionCode 20504
|
||||||
versionName "0.2.5.2"
|
versionName "0.2.5.4"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,28 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.ariacockpit
|
package com.ariacockpit
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import com.facebook.react.ReactActivity
|
import com.facebook.react.ReactActivity
|
||||||
import com.facebook.react.ReactActivityDelegate
|
import com.facebook.react.ReactActivityDelegate
|
||||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
||||||
@@ -19,4 +20,26 @@ class MainActivity : ReactActivity() {
|
|||||||
*/
|
*/
|
||||||
override fun createReactActivityDelegate(): ReactActivityDelegate =
|
override fun createReactActivityDelegate(): ReactActivityDelegate =
|
||||||
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
|
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(OpenWakeWordPackage())
|
||||||
add(PhoneCallPackage())
|
add(PhoneCallPackage())
|
||||||
add(BackgroundAudioPackage())
|
add(BackgroundAudioPackage())
|
||||||
|
add(ShareIntentPackage())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getJSMainModuleName(): String = "index"
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.2.5.2",
|
"version": "0.2.5.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -49,6 +49,8 @@ import updateService from '../services/updater';
|
|||||||
import VoiceButton from '../components/VoiceButton';
|
import VoiceButton from '../components/VoiceButton';
|
||||||
import FileUpload, { FileData } from '../components/FileUpload';
|
import FileUpload, { FileData } from '../components/FileUpload';
|
||||||
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
import CameraUpload, { PhotoData } from '../components/CameraUpload';
|
||||||
|
import ShareModal, { SharedItem } from '../components/ShareModal';
|
||||||
|
import shareIntent from '../services/shareIntent';
|
||||||
import MessageText from '../components/MessageText';
|
import MessageText from '../components/MessageText';
|
||||||
import { loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
|
import { loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
|
||||||
import Geolocation from '@react-native-community/geolocation';
|
import Geolocation from '@react-native-community/geolocation';
|
||||||
@@ -198,6 +200,28 @@ function formatDur(sec: number): string {
|
|||||||
return `${m}:${r.toString().padStart(2, '0')}`;
|
return `${m}:${r.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Grenzen fuer den Anhang-Re-Download ────────────────────────────
|
||||||
|
//
|
||||||
|
// Ein Re-Download kommt als base64 in EINER WebSocket-Nachricht. Die muss die
|
||||||
|
// App komplett im Speicher puffern und dann durch JSON.parse schicken — bei
|
||||||
|
// einem 150-MB-Video sind das ~200 MB base64, und das Handy stirbt daran mit
|
||||||
|
// einem nativen OOM (kein JS-Fehler, darum tauchte nie ein Stacktrace auf).
|
||||||
|
//
|
||||||
|
// Genau das war die Absturzschleife: App startet → Auto-Re-Download fordert
|
||||||
|
// ungefragt das 150-MB-Video an → OOM → Android startet den Vordergrund-
|
||||||
|
// Dienst neu → App startet → von vorn. Alle ~70s, stundenlang.
|
||||||
|
//
|
||||||
|
// Darum zwei Grenzen:
|
||||||
|
// AUTO — was die App OHNE Nachfrage holt. Klein halten; Bilder und
|
||||||
|
// Dokumente passen rein, Videos nicht.
|
||||||
|
// TAP — was sie auf ausdruecklichen Tipp holt. Hoeher, aber immer noch
|
||||||
|
// weit unter der Schmerzgrenze. Drueber gibt's eine ehrliche
|
||||||
|
// Meldung statt eines Absturzes.
|
||||||
|
const AUTO_DL_MAX_BYTES = 25 * 1024 * 1024;
|
||||||
|
const TAP_DL_MAX_BYTES = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
const fmtMB = (n: number) => `${(n / 1024 / 1024).toFixed(0)} MB`;
|
||||||
|
|
||||||
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
|
||||||
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';
|
||||||
|
|
||||||
@@ -348,6 +372,10 @@ const ChatScreen: React.FC = () => {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
const [searchIndex, setSearchIndex] = useState(0); // welcher Treffer aktiv ist
|
||||||
const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
|
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: ''});
|
const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
|
||||||
// Multi-Threading: Activity pro Kontext (key = projectId, '' = Hauptchat).
|
// Multi-Threading: Activity pro Kontext (key = projectId, '' = Hauptchat).
|
||||||
// Der Indikator zeigt nur den fokussierten Kontext — nicht global.
|
// Der Indikator zeigt nur den fokussierten Kontext — nicht global.
|
||||||
@@ -419,6 +447,33 @@ const ChatScreen: React.FC = () => {
|
|||||||
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
|
// Intent geoeffnet (PDF-Viewer, Galerie, etc.).
|
||||||
const autoOpenPaths = useRef<Set<string>>(new Set());
|
const autoOpenPaths = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-Download anfordern — mit Groessen-Bremse.
|
||||||
|
* Gibt false zurueck (und sagt warum), wenn die Datei zu gross fuer einen
|
||||||
|
* Transfer durch die WebSocket ist. Ohne das holt sich die App ein 150-MB-
|
||||||
|
* Video als ~200 MB base64 und stirbt am nativen OOM (siehe TAP_DL_MAX_BYTES).
|
||||||
|
*/
|
||||||
|
const requestAttachment = useCallback((att: Attachment, reqId: string): boolean => {
|
||||||
|
if (!att.serverPath) {
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof att.size === 'number' && att.size > TAP_DL_MAX_BYTES) {
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
ToastAndroid.show(
|
||||||
|
`${att.name}: ${fmtMB(att.size)} — zu gross fuers Handy. `
|
||||||
|
+ `Liegt auf dem Server unter ${att.serverPath}`,
|
||||||
|
ToastAndroid.LONG,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: reqId });
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Eindeutige Message-ID generieren
|
// Eindeutige Message-ID generieren
|
||||||
const nextId = (): string => {
|
const nextId = (): string => {
|
||||||
messageIdCounter.current += 1;
|
messageIdCounter.current += 1;
|
||||||
@@ -854,13 +909,26 @@ const ChatScreen: React.FC = () => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const missing: {id: string, serverPath: string}[] = [];
|
const missing: {id: string, serverPath: string}[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
for (const msg of prev) {
|
for (const msg of prev) {
|
||||||
for (const att of msg.attachments || []) {
|
for (const att of msg.attachments || []) {
|
||||||
if (att.serverPath && !att.uri) {
|
if (!att.serverPath || att.uri) continue;
|
||||||
missing.push({ id: msg.id, serverPath: att.serverPath });
|
// Grosse Dateien NICHT ungefragt holen (siehe AUTO_DL_MAX_BYTES).
|
||||||
|
// Sie bleiben als "tippen zum Laden" stehen — der User
|
||||||
|
// entscheidet, ob er sie wirklich aufs Handy zieht.
|
||||||
|
if (typeof att.size === 'number' && att.size > AUTO_DL_MAX_BYTES) {
|
||||||
|
skipped.push(`${att.name} (${fmtMB(att.size)})`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
missing.push({ id: msg.id, serverPath: att.serverPath });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (skipped.length > 0) {
|
||||||
|
console.log('[Chat] Auto-Download uebersprungen (zu gross): %s', skipped.join(', '));
|
||||||
|
import('../services/logger')
|
||||||
|
.then(m => m.reportAppDebug('chat.autodl', `uebersprungen: ${skipped.join(', ')}`))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
console.log(`[Chat] ${missing.length} fehlende Anhaenge — lade nach...`);
|
console.log(`[Chat] ${missing.length} fehlende Anhaenge — lade nach...`);
|
||||||
for (const m of missing) {
|
for (const m of missing) {
|
||||||
@@ -2471,16 +2539,47 @@ const ChatScreen: React.FC = () => {
|
|||||||
setPendingAttachments(prev => [...prev, { file: photo, isPhoto: true }]);
|
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
|
// Alle Pending Anhaenge + Text senden
|
||||||
const sendPendingAttachments = useCallback(async (messageText: string) => {
|
const sendPendingAttachments = useCallback(async (
|
||||||
if (pendingAttachments.length === 0) return;
|
messageText: string,
|
||||||
console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length);
|
// 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 location = await getCurrentLocation();
|
||||||
const msgId = nextId();
|
const msgId = nextId();
|
||||||
|
|
||||||
// Alle Attachments fuer die Chat-Nachricht sammeln
|
// Alle Attachments fuer die Chat-Nachricht sammeln
|
||||||
const attachments: Attachment[] = [];
|
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 isImage = isPhoto || (file.type && file.type.startsWith('image/'));
|
||||||
const name = isPhoto ? file.fileName : file.name;
|
const name = isPhoto ? file.fileName : file.name;
|
||||||
const base64 = file.base64 || '';
|
const base64 = file.base64 || '';
|
||||||
@@ -2503,7 +2602,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: msgId,
|
id: msgId,
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
|
text: messageText || `${atts.length} Anhang/Anhaenge`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
attachments,
|
attachments,
|
||||||
projectId: activePid,
|
projectId: activePid,
|
||||||
@@ -2516,7 +2615,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
setMessages(prev => capMessages([...prev, userMsg]));
|
setMessages(prev => capMessages([...prev, userMsg]));
|
||||||
|
|
||||||
// Alle Dateien an RVS senden + auf Disk speichern
|
// 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 name = isPhoto ? file.fileName : file.name;
|
||||||
const base64 = file.base64 || '';
|
const base64 = file.base64 || '';
|
||||||
const mimeType = file.type || '';
|
const mimeType = file.type || '';
|
||||||
@@ -2571,6 +2670,33 @@ const ChatScreen: React.FC = () => {
|
|||||||
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
|
// sendPendingAttachments — beide stehen in seinem deps-Array, und wenn es
|
||||||
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
|
// davor deklariert waere, laendeten sie dort in der Temporal Dead Zone
|
||||||
// (tsc TS2448/2454; lief nur zufaellig, weil Babel const→var hebt).
|
// (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 sendTextMessage = useCallback(async () => {
|
||||||
const text = inputText.trim();
|
const text = inputText.trim();
|
||||||
|
|
||||||
@@ -2805,16 +2931,15 @@ const ChatScreen: React.FC = () => {
|
|||||||
) : att.type === 'image' && !att.uri ? (
|
) : att.type === 'image' && !att.uri ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.attachmentFile}
|
style={styles.attachmentFile}
|
||||||
onPress={() => {
|
onPress={() => { requestAttachment(att, item.id); }}
|
||||||
if (att.serverPath) {
|
|
||||||
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text style={styles.attachmentFileIcon}>{'\uD83D\uDDBC\uFE0F'}</Text>
|
<Text style={styles.attachmentFileIcon}>{'\uD83D\uDDBC\uFE0F'}</Text>
|
||||||
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
|
<Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
|
||||||
<Text style={styles.attachmentFileSize}>
|
<Text style={styles.attachmentFileSize}>
|
||||||
{att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'}
|
{!att.serverPath ? '(nicht verfuegbar)'
|
||||||
|
: typeof att.size === 'number' && att.size > AUTO_DL_MAX_BYTES
|
||||||
|
? `(${fmtMB(att.size)} — tippen zum Laden)`
|
||||||
|
: '(tippen zum Laden)'}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : att.type === 'audio' ? (
|
) : att.type === 'audio' ? (
|
||||||
@@ -2851,11 +2976,9 @@ const ChatScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
// Re-Download via file_request \u2192 bei file_response wird die
|
// Re-Download via file_request \u2192 bei file_response wird die
|
||||||
// Datei gespeichert UND geoeffnet (autoOpenPaths-Tracking).
|
// Datei gespeichert UND geoeffnet (autoOpenPaths-Tracking).
|
||||||
if (att.serverPath) {
|
if (att.serverPath) autoOpenPaths.current.add(att.serverPath);
|
||||||
autoOpenPaths.current.add(att.serverPath);
|
if (!requestAttachment(att, item.id) && att.serverPath) {
|
||||||
rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
|
autoOpenPaths.current.delete(att.serverPath);
|
||||||
} else if (Platform.OS === 'android') {
|
|
||||||
ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -3708,6 +3831,13 @@ const ChatScreen: React.FC = () => {
|
|||||||
{/* Datei-Upload Modal */}
|
{/* Datei-Upload Modal */}
|
||||||
<Modal visible={showFileUpload} transparent animationType="slide">
|
<Modal visible={showFileUpload} transparent animationType="slide">
|
||||||
<View style={styles.modalOverlay}>
|
<View style={styles.modalOverlay}>
|
||||||
|
<ShareModal
|
||||||
|
visible={shareVisible}
|
||||||
|
items={shareItems}
|
||||||
|
initialText={shareText}
|
||||||
|
onCancel={() => { setShareVisible(false); setShareItems([]); setShareText(''); }}
|
||||||
|
onSend={handleShareSend}
|
||||||
|
/>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
onFileSelected={handleFileSelected}
|
onFileSelected={handleFileSelected}
|
||||||
onCancel={() => setShowFileUpload(false)}
|
onCancel={() => setShowFileUpload(false)}
|
||||||
|
|||||||
@@ -1161,8 +1161,12 @@ class AudioService {
|
|||||||
// start, Chunks und end waren weg, die Bubble verschwand, und erst der
|
// start, Chunks und end waren weg, die Bubble verschwand, und erst der
|
||||||
// zweite Versuch klappte (da war der Reconnect durch). Nachtraeglich
|
// zweite Versuch klappte (da war der Reconnect durch). Nachtraeglich
|
||||||
// gepuffertes Audio waere sinnlos: die STT-Sitzung laeuft in Echtzeit.
|
// gepuffertes Audio waere sinnlos: die STT-Sitzung laeuft in Echtzeit.
|
||||||
if (rvs.getState() !== 'connected') {
|
// WICHTIG: den SOCKET fragen, nicht den gemerkten Zustand. Der kann
|
||||||
console.warn('[Audio] startStreamingRecording: RVS nicht verbunden (state=%s)',
|
// faelschlich 'disconnected' sein, obwohl die Verbindung laeuft (Zombie) —
|
||||||
|
// dann wuerde hier jede Aufnahme abgelehnt, bevor sie beginnt, und der
|
||||||
|
// Nutzer kann gar nicht mehr sprechen.
|
||||||
|
if (!rvs.isSocketOpen()) {
|
||||||
|
console.warn('[Audio] startStreamingRecording: Socket nicht offen (state=%s)',
|
||||||
rvs.getState());
|
rvs.getState());
|
||||||
return { requestId: '', ok: false, error: 'not-connected' };
|
return { requestId: '', ok: false, error: 'not-connected' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,8 +49,15 @@ const BREADCRUMB_KEY = 'aria_last_breadcrumb';
|
|||||||
let _breadcrumb: { ts: number; scope: string; message: string } = { ts: 0, scope: '', message: '' };
|
let _breadcrumb: { ts: number; scope: string; message: string } = { ts: 0, scope: '', message: '' };
|
||||||
let _breadcrumbDirty = false;
|
let _breadcrumbDirty = false;
|
||||||
|
|
||||||
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot. */
|
/** Letzte App-Aktivitaet merken — Crash-Kontext fuer den naechsten Boot.
|
||||||
|
*
|
||||||
|
* Die Crash-Meldung selbst darf den Brotkrumen NICHT ueberschreiben: sonst
|
||||||
|
* merkt sich der zweite Absturz nur noch "letzte Aktivitaet: Crash-Meldung",
|
||||||
|
* der dritte "letzte Aktivitaet: Crash-Meldung ueber Crash-Meldung" — und bei
|
||||||
|
* einer Absturzschleife ist nach dem ersten Durchlauf jede Spur weg. Genau
|
||||||
|
* das ist passiert; die eigentliche Ursache stand nur in der ERSTEN Meldung. */
|
||||||
export function noteBreadcrumb(scope: string, message: string): void {
|
export function noteBreadcrumb(scope: string, message: string): void {
|
||||||
|
if (scope === 'app.crash-detected') return;
|
||||||
_breadcrumb = { ts: Date.now(), scope: scope || '', message: String(message || '').slice(0, 120) };
|
_breadcrumb = { ts: Date.now(), scope: scope || '', message: String(message || '').slice(0, 120) };
|
||||||
_breadcrumbDirty = true;
|
_breadcrumbDirty = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,15 @@ class RVSConnection {
|
|||||||
this.setState('disconnected');
|
this.setState('disconnected');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ist die Verbindung WIRKLICH offen? Fragt den Socket selbst, nicht den
|
||||||
|
* gemerkten Zustand — der kann (z.B. nach einem Zombie) falsch sein. Wer
|
||||||
|
* davon abhaengig macht, ob ueberhaupt gesendet wird, muss das Echte wissen.
|
||||||
|
*/
|
||||||
|
isSocketOpen(): boolean {
|
||||||
|
return !!this.ws && this.ws.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
/** Nachricht an den RVS senden */
|
/** Nachricht an den RVS senden */
|
||||||
send(type: MessageType, payload: Record<string, unknown>): void {
|
send(type: MessageType, payload: Record<string, unknown>): void {
|
||||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
+20
-6
@@ -3550,6 +3550,10 @@ class ARIABridge:
|
|||||||
elif msg_type == "sat_hello":
|
elif msg_type == "sat_hello":
|
||||||
sid = (payload.get("id") or "").strip()
|
sid = (payload.get("id") or "").strip()
|
||||||
if sid:
|
if sid:
|
||||||
|
# sat_hello kommt alle ~25s erneut. Nur Neuanmeldungen und echte
|
||||||
|
# Aenderungen loggen — sonst ersaeuft das Bridge-Log in
|
||||||
|
# "Satellit online", und die interessanten Zeilen sind weg.
|
||||||
|
prev = self._satellites.get(sid)
|
||||||
self._satellites[sid] = {
|
self._satellites[sid] = {
|
||||||
"id": sid,
|
"id": sid,
|
||||||
"location": payload.get("location") or sid,
|
"location": payload.get("location") or sid,
|
||||||
@@ -3557,9 +3561,12 @@ class ARIABridge:
|
|||||||
"control": bool(payload.get("control")),
|
"control": bool(payload.get("control")),
|
||||||
"last_seen": time.time(),
|
"last_seen": time.time(),
|
||||||
}
|
}
|
||||||
logger.info("[sat] Satellit online: %s (%s) caps=%s control=%s",
|
cur = self._satellites[sid]
|
||||||
sid, self._satellites[sid]["location"],
|
if prev is None or any(prev.get(k) != cur.get(k)
|
||||||
self._satellites[sid]["caps"], self._satellites[sid]["control"])
|
for k in ("location", "caps", "control")):
|
||||||
|
logger.info("[sat] Satellit %s: %s (%s) caps=%s control=%s",
|
||||||
|
"online" if prev is None else "aktualisiert",
|
||||||
|
sid, cur["location"], cur["caps"], cur["control"])
|
||||||
return
|
return
|
||||||
|
|
||||||
elif msg_type == "host_hello":
|
elif msg_type == "host_hello":
|
||||||
@@ -3625,9 +3632,16 @@ class ARIABridge:
|
|||||||
"busy": bool(prev.get("busy", False)),
|
"busy": bool(prev.get("busy", False)),
|
||||||
"last_seen": time.time(),
|
"last_seen": time.time(),
|
||||||
}
|
}
|
||||||
logger.info("[worker] online: %s (service=%s node=%s gpus=%s model=%s)",
|
# Wie bei sat_hello: worker_hello wiederholt sich im Takt von
|
||||||
iid, self._workers[iid]["service"], self._workers[iid]["node"],
|
# ~30s. Nur neue Boxen und echte Aenderungen (anderes Modell,
|
||||||
self._workers[iid]["gpus"] or "?", self._workers[iid]["model"] or "?")
|
# andere GPUs) ins Log — der Rest ist Rauschen.
|
||||||
|
cur = self._workers[iid]
|
||||||
|
if not prev or any(prev.get(k) != cur.get(k)
|
||||||
|
for k in ("service", "node", "gpus", "model", "models")):
|
||||||
|
logger.info("[worker] %s: %s (service=%s node=%s gpus=%s model=%s)",
|
||||||
|
"online" if not prev else "aktualisiert",
|
||||||
|
iid, cur["service"], cur["node"],
|
||||||
|
cur["gpus"] or "?", cur["model"] or "?")
|
||||||
return
|
return
|
||||||
|
|
||||||
elif msg_type == "stt_lease_request":
|
elif msg_type == "stt_lease_request":
|
||||||
|
|||||||
+106
@@ -124,6 +124,86 @@ const ALLOWED_TYPES = new Set([
|
|||||||
// Token-Raum: token -> { clients: Set<ws> }
|
// Token-Raum: token -> { clients: Set<ws> }
|
||||||
const rooms = new Map();
|
const rooms = new Map();
|
||||||
|
|
||||||
|
// ── Punkt-zu-Punkt-Antworten ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Der RVS ist ein Broadcast-Relay: normalerweise bekommt JEDER Client im Raum
|
||||||
|
// jede Nachricht. Bei Datei-Antworten ist das fatal. Ein 150-MB-Video wird als
|
||||||
|
// base64 ~200 MB gross und ging bisher an ALLE Clients im Raum — auch an die
|
||||||
|
// STT/TTS-Worker, die daran mit Code 1009 (message too big) gestorben sind und
|
||||||
|
// die gerade laufende Sprachaufnahme mitgerissen haben ("erste Sprachnachricht
|
||||||
|
// kommt nicht durch"). Die Worker-max_size-Angleichung verhindert den Absturz,
|
||||||
|
// aber 200 MB an zehn Clients zu schicken bleibt Unsinn: nur EINER hat gefragt.
|
||||||
|
//
|
||||||
|
// Darum: wer eine Anfrage mit requestId stellt, wird gemerkt — die Antwort mit
|
||||||
|
// derselben requestId geht nur an ihn zurueck. Unbekannte requestId → wie
|
||||||
|
// bisher broadcasten (nichts geht verloren, falls ein Client mithoeren muss,
|
||||||
|
// dessen Anfrage der RVS nie gesehen hat).
|
||||||
|
const TARGETED = new Map([
|
||||||
|
["file_request", "file_response"],
|
||||||
|
["file_zip_request", "file_zip_response"],
|
||||||
|
["file_version_download_request", "file_version_download_response"],
|
||||||
|
["brain_request", "brain_response"],
|
||||||
|
]);
|
||||||
|
const TARGETED_REPLIES = new Set(TARGETED.values());
|
||||||
|
|
||||||
|
// `antwortTyp \0 requestId` -> { clients: Set<ws>, at }
|
||||||
|
// Mehrere Clients koennen dieselbe requestId benutzen (die App nimmt die
|
||||||
|
// Nachrichten-ID) — darum ein Set, sonst bekaeme nur der letzte die Antwort.
|
||||||
|
const pendingReplies = new Map();
|
||||||
|
const PENDING_TTL_MS = 180_000;
|
||||||
|
|
||||||
|
function pendingKey(replyType, requestId) {
|
||||||
|
return `${replyType} | ||||||