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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user