Compare commits
562 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72277098af | |||
| 80d2fe3e93 | |||
| b5ca3cd371 | |||
| d939fc4ac3 | |||
| 13e87fb083 | |||
| 30c1dd7473 | |||
| 9ed9c99b0e | |||
| 1ea614c26b | |||
| acaa9fc3f2 | |||
| 0887674497 | |||
| f5243b1abb | |||
| eb5c178139 | |||
| 31b0bfaac1 | |||
| 1d3c45fdda | |||
| 84a59d7b4f | |||
| 8ad3e39453 | |||
| afa96b1d44 | |||
| 0407c5bc3c | |||
| 2d348aeec7 | |||
| 7e53dcfed3 | |||
| 33d5be781f | |||
| 785f5d0805 | |||
| fac87474ec | |||
| 8227266aea | |||
| 5d24e01d4b | |||
| 4fe72cc4a8 | |||
| eeeb1d43f5 | |||
| 0044e222db | |||
| 048d231b60 | |||
| 2bac9c26ca | |||
| c758727345 | |||
| cb0e879118 | |||
| ce6f5b551e | |||
| b6a68b7658 | |||
| 03edee8881 | |||
| 7093ebaf0b | |||
| b4923bc221 | |||
| 7a66752655 | |||
| b510ccd93a | |||
| bbd51406a9 | |||
| 2cd436f6e9 | |||
| 22adc91c1e | |||
| 61cf8e3bcc | |||
| 3e38f1dad3 | |||
| 635944299e | |||
| b2ac013765 | |||
| 93db6a3156 | |||
| 579a466402 | |||
| 5133f0bc2d | |||
| a476a4b734 | |||
| 11b205ddaf | |||
| 71c60ade8a | |||
| bf3dc635d9 | |||
| 8ca899aaf5 | |||
| 15facf48eb | |||
| 71fc90fcb8 | |||
| 856701fb6f | |||
| 6037b62612 | |||
| 8f88cb0030 | |||
| c224562423 | |||
| 5c07aef526 | |||
| d54d37061f | |||
| a6afec0e11 | |||
| 205112021b | |||
| 853f2737f1 | |||
| 7c61107f87 | |||
| 7a22474efd | |||
| f2cf4e0d58 | |||
| db4bebfa57 | |||
| 435b77e1df | |||
| 6f80e442cf | |||
| 0fcbf5e3ed | |||
| 3cf6308b79 | |||
| 7e5a4da659 | |||
| d27fcaf342 | |||
| 5b28a065c0 | |||
| e74e1eaf70 | |||
| ff7c6333bb | |||
| 2c85df3499 | |||
| 6f11f28448 | |||
| 21a315ca71 | |||
| d8b05082d6 | |||
| de91073b2e | |||
| e88b5f57bf | |||
| 64a17c8c19 | |||
| ebeacba8b5 | |||
| 58251b26a2 | |||
| 5c10990cbc | |||
| f71936da86 | |||
| 62f394b2aa | |||
| 6239037fa7 | |||
| 4b3f8cded2 | |||
| 16ebaa652f | |||
| 27c04a2874 | |||
| 31a1370050 | |||
| 933dd50367 | |||
| d5531521fa | |||
| de9b7b46f9 | |||
| da4e970a31 | |||
| c677cfed24 | |||
| 331c1437be | |||
| 1e754910ee | |||
| 351c58e88e | |||
| df60bb6d74 | |||
| 24cf40293a | |||
| 5f96ace469 | |||
| 9dd95709b9 | |||
| a2dee3164a | |||
| 01f0ad3a40 | |||
| 6549fcbce8 | |||
| 3c41f11997 | |||
| 3f2499b528 | |||
| daf0d44dd7 | |||
| 051d629cb3 | |||
| 1a19b362d7 | |||
| 6ebee21bf0 | |||
| 3e35c0853b | |||
| 39eec25828 | |||
| 517bc7ca8e | |||
| 9ea7908fe4 | |||
| 7237f05344 | |||
| e26226f370 | |||
| 0d13118f7e | |||
| b1796520b8 | |||
| 0ff44d99c4 | |||
| 8c74b3fed8 | |||
| c3fefc60c0 | |||
| 7107ce4fdd | |||
| fa47068d6d | |||
| 07c761fc72 | |||
| 6821eaaa38 | |||
| 31aa86a2a9 | |||
| 87cb687610 | |||
| eb4059a887 | |||
| 415706036b | |||
| e2dd47255e | |||
| 3497aa23f8 | |||
| 8491fb2af7 | |||
| f61864282e | |||
| b2f7d6dda2 | |||
| eeedcc4781 | |||
| 5cf8cab5bd | |||
| 3ae9e19524 | |||
| 0ec4b00879 | |||
| b6b4b1b4d9 | |||
| 950a9d009c | |||
| 693542ef19 | |||
| d12f356ebe | |||
| 7b55d6a91f | |||
| aa077f60e6 | |||
| 094bd6e4f1 | |||
| 5b0b5eeac6 | |||
| 96a1f90ac3 | |||
| bfa06d78a7 | |||
| d16dcd34cc | |||
| dc2f4eb6d2 | |||
| 0f9a029269 | |||
| 70d1500096 | |||
| d0cb7acd10 | |||
| 0b58feee1e | |||
| 8be34e7284 | |||
| b56cef6298 | |||
| 0d203af8fb | |||
| 0468d0e603 | |||
| 7cfc2ba058 | |||
| da795d14f5 | |||
| d60c7e9110 | |||
| 83c99a5e65 | |||
| e438bb11ff | |||
| 8b4f75bf91 | |||
| d7e7386954 | |||
| 2100c64b91 | |||
| 74ebf59c6f | |||
| 53b49eacad | |||
| 0f11d23c75 | |||
| 311030bdaa | |||
| 1e05c66baa | |||
| 4082a6bf2a | |||
| 3485642b3e | |||
| 1240ae3829 | |||
| 2dd4d38dce | |||
| 7f862ce1f4 | |||
| 528fe97b59 | |||
| 3483d1bfce | |||
| 158423c155 | |||
| 087e91dca1 | |||
| 2de4cbc00f | |||
| 03fc465057 | |||
| b696b47feb | |||
| 6aae565541 | |||
| 214bd218a0 | |||
| 2afeee29ee | |||
| c8dee4c416 | |||
| f49f3c3b08 | |||
| c4bbb06710 | |||
| 4411cc4fff | |||
| 24a91887ef | |||
| 4e62b2919f | |||
| fa774156fe | |||
| 3b19f05c5b | |||
| fc3ecaacca | |||
| 08857093b5 | |||
| 62018b3e51 | |||
| 89e3a195a3 | |||
| f023ba0ac5 | |||
| a0570ef8f7 | |||
| facde1fef7 | |||
| 38106a2096 | |||
| a476afb311 | |||
| db4c7b9b72 | |||
| 3bc490b485 | |||
| dd6d70c46e | |||
| b1eaf42fef | |||
| fb9e5dcd10 | |||
| f95e71463f | |||
| 1088bff43d | |||
| cad68db2a2 | |||
| 50b10c8ac0 | |||
| a8b586ec92 | |||
| 632e1e4fa1 | |||
| 7e12816ebd | |||
| 8f64f8fb30 | |||
| b3ff3991c4 | |||
| a4ea387c98 | |||
| 68fbf74a23 | |||
| b857f778e9 | |||
| 31aa82b68c | |||
| de8eeb69e2 | |||
| f5970ce700 | |||
| ef1a4436ca | |||
| 981779cd9e | |||
| 3dcd2ae0b4 | |||
| 2750b867a3 | |||
| f6424add6c | |||
| 2dfd21d1d0 | |||
| 9d9ddc730b | |||
| 77ccee8331 | |||
| 175dcdf225 | |||
| 1549e9cd4f | |||
| 910e74b497 | |||
| 160c5c34b6 | |||
| a6638c0108 | |||
| 43c21d3ddc | |||
| b73c6c346e | |||
| b91ddc5bdf | |||
| 7d08c06720 | |||
| f066a2a555 | |||
| b55b0e7c42 | |||
| 70f806ef80 | |||
| 0773d9496d | |||
| 1a4857ed62 | |||
| 962d814318 | |||
| 9276a92c83 | |||
| d16896c4b4 | |||
| 20050d4077 | |||
| 79760d1b2e | |||
| 13f1103604 | |||
| 73b7a76ea8 | |||
| 17f3d8870e | |||
| 4feaacc7e4 | |||
| af7b2674f3 | |||
| 97442198ec | |||
| e3e841f2ab | |||
| 33185de42b | |||
| dbe547d4ea | |||
| 1a982c0d45 | |||
| dfba5ceb1f | |||
| 1a6f633836 | |||
| 7f7db100af | |||
| d646e9d58e | |||
| bef59ba134 | |||
| dbebfd44ff | |||
| 4d0b9e0d78 | |||
| 0c43a18402 | |||
| 5bdcc3c65b | |||
| 52795530f9 | |||
| 2eb0b4df90 | |||
| 0c18090351 | |||
| d6b54d3247 | |||
| ead28cf09a | |||
| f682aad4ff | |||
| e0c1a4bcd5 | |||
| a648dad96d | |||
| da5579038e | |||
| 4ba48940b9 | |||
| 568ef9ed10 | |||
| 7682a0ce58 | |||
| 3ca834e633 | |||
| 55ef207454 | |||
| 6651f5937d | |||
| e9e7dd804f | |||
| ec9530f17f | |||
| 97cb7be313 | |||
| 77e927ffcd | |||
| a9a87f12df | |||
| 2a56ac0290 | |||
| edc65ce645 | |||
| d7efaf93b3 | |||
| 31ff20c846 | |||
| 406f4cb3cc | |||
| fa0667088a | |||
| f55329706e | |||
| 6c7fd1d0e3 | |||
| 9d8db111ac | |||
| 482cb6ace3 | |||
| 69c1c49a7d | |||
| b1ccf29295 | |||
| 4cd9faece2 | |||
| fec8aa977b | |||
| 20123de827 | |||
| 8761d1a1b7 | |||
| abc5b971f4 | |||
| b588dd7e3b | |||
| 309df9d851 | |||
| f2e643d1fb | |||
| 6ac374621c | |||
| efbd306597 | |||
| 4454613a98 | |||
| 55cfb752a2 | |||
| a4d3449e3a | |||
| 44d2c6b4fe | |||
| 0309c95aa5 | |||
| 2aa2cc70c9 | |||
| 9d0776c819 | |||
| f031fa159e | |||
| be373466a3 | |||
| bbf9aed3ba | |||
| 745b4a07c0 | |||
| 23ca815cb2 | |||
| cc3fac8142 | |||
| cd89e36ec2 | |||
| f5b4285d15 | |||
| 248e7c9ae4 | |||
| 7058cc8d8d | |||
| 7919489543 | |||
| feac7f2479 | |||
| b80b813703 | |||
| e7bb6c37cb | |||
| d146ca92c4 | |||
| fd95af2c40 | |||
| 9e12e0001c | |||
| 1d34143be5 | |||
| 0fc11e33c8 | |||
| dae603541b | |||
| 87b4cd305c | |||
| 190352820c | |||
| 2264f4e3bc | |||
| 58fd8721e3 | |||
| 4f494daffb | |||
| 958c8d6fc6 | |||
| 5ba89c7191 | |||
| b373f915b5 | |||
| 7748834a0f | |||
| 8b52f4c92b | |||
| dc20570f6d | |||
| 744a27cfd1 | |||
| 37c5f6c368 | |||
| a361015ff4 | |||
| d83b555209 | |||
| a029267d9d | |||
| 8ba6a71a49 | |||
| 2f625572fc | |||
| ac56916eb0 | |||
| ae08a5051c | |||
| d372cd638e | |||
| 60c5cb7e59 | |||
| 607a4c9ff8 | |||
| 4ea16cfa8f | |||
| 6ce9880bc0 | |||
| 187ffad7ee | |||
| 467f95424e | |||
| c1a5518fb7 | |||
| 22fa4b3ccf | |||
| 1b8a51aad0 | |||
| 578ade3544 | |||
| ed2f1bb5ee | |||
| 0a04972455 | |||
| 2a4379eb64 | |||
| e64df23bb7 | |||
| 576ae925dd | |||
| e170991222 | |||
| a1343ee18f | |||
| b2d3c935d8 | |||
| 49089eee4b | |||
| e544992c9f | |||
| 97a1a3089a | |||
| 64f18e97a0 | |||
| 9cbea27455 | |||
| c8881f9e4d | |||
| 028e3b2240 | |||
| c042f27106 | |||
| 4ceadf8be5 | |||
| ddd30b3059 | |||
| 6c8ba5fe2d | |||
| 32ddac002f | |||
| bbbe69d928 | |||
| 23c39d5bba | |||
| 5328dc8595 | |||
| 0c03b4f161 | |||
| 31fe70bab5 | |||
| 39251b3d32 | |||
| 0623de32a0 | |||
| cd5e6e7ee6 | |||
| ee3e0a0af6 | |||
| 0783b1b99d | |||
| 5492c7a46f | |||
| 4cbe184faa | |||
| 647a1cb726 | |||
| 73263b69a6 | |||
| c62ceafdc2 | |||
| 9b5a35cb4a | |||
| 5ac1a0a522 | |||
| a28b46a809 | |||
| 59c8d36a3d | |||
| 79ba7b8487 | |||
| ba62cec78c | |||
| f15b3f583f | |||
| 402bddc18a | |||
| 350069d371 | |||
| 019c078393 | |||
| d411df4074 | |||
| 763e0d79ab | |||
| 47fe4ad655 | |||
| 99cb83202e | |||
| fc2438be2d | |||
| 40e48b046b | |||
| f801d99748 | |||
| 6ab6196739 | |||
| eb12281dfc | |||
| 1fb1fdef9e | |||
| 593d26e0ff | |||
| 394abb58be | |||
| fc3bee6d05 | |||
| b203503fd8 | |||
| 8b0a72dc9b | |||
| 23add7a107 | |||
| caf84196fb | |||
| 099b9651a6 | |||
| 76d72a1eef | |||
| 87deede078 | |||
| 6fec8588c1 | |||
| aafdbcd57a | |||
| 08da28f475 | |||
| 8c1014d281 | |||
| 271fc4edf6 | |||
| cd390a4115 | |||
| a65ed579d2 | |||
| 2ad1f57382 | |||
| 58e3cfd3e6 | |||
| 7de4ee8f5b | |||
| 213edac3a7 | |||
| acc13aef6b | |||
| 4bbc6f7787 | |||
| 20f2ea1829 | |||
| 2d23f0668b | |||
| d6030a06b7 | |||
| 0df76e2af6 | |||
| f80fe1df93 | |||
| cff421bc53 | |||
| bca925d385 | |||
| 9abde89805 | |||
| ea4f639fcb | |||
| 64cd5f7d52 | |||
| 843ebe1d8f | |||
| 764619f076 | |||
| e3a0cfb55a | |||
| 2929749314 | |||
| 51b9512f4e | |||
| ffcfa44eef | |||
| 6363da97b1 | |||
| 07ed2cdcf6 | |||
| 5ad68b7dfc | |||
| 8a6ee018ea | |||
| b42590ff95 | |||
| 056b579c47 | |||
| 576e612cd0 | |||
| c2faa06a15 | |||
| d3ed3556eb | |||
| d960d125c0 | |||
| 89d5d7ec0a | |||
| ea0c13936b | |||
| 773c976822 | |||
| cd05ed2379 | |||
| 054e4057d8 | |||
| 3943e79bb1 | |||
| 87f4317c15 | |||
| 50aa793910 | |||
| 5efc9865a8 | |||
| 949c573c49 | |||
| f7f450a09d | |||
| 81f7c38383 | |||
| 2c785cb37a | |||
| 57e65b061c | |||
| aa54765b03 | |||
| 8929bc99bb | |||
| 0428c06612 | |||
| a7eb3cf433 | |||
| e4e0e793a8 | |||
| b3d3b8b6bc | |||
| 06bc456221 | |||
| 3461f45207 | |||
| a17d4acc13 | |||
| 62fd9193a1 | |||
| 2329645df4 | |||
| 8a435ddf6c | |||
| 25b754ba31 | |||
| b734593bf2 | |||
| 16847ce6f7 | |||
| 6300829317 | |||
| a1e1ee31bd | |||
| 7ed70b876d | |||
| 3ca85da906 | |||
| d6a89168ef | |||
| cb33a20694 | |||
| a242693751 | |||
| 81ca3cc7a7 | |||
| 1a32098c9e | |||
| fa4c32270b | |||
| 9c43b875f4 | |||
| 63560e290b | |||
| 1ab8a6a2fe | |||
| a2c0196e05 | |||
| 680f7a64e2 | |||
| 4893616a5a | |||
| 04e8c0245d | |||
| 10cefaf1cd | |||
| adbb1fe80a | |||
| 79c50aedcc | |||
| eb72b35e23 | |||
| bbd02d46a6 | |||
| 3d3c8ce973 | |||
| 562f929056 | |||
| ff03d8ce62 | |||
| 8281131432 | |||
| 8a6bd4e0e7 | |||
| 1b4df0565a | |||
| eb3692ef81 | |||
| 46a9ac9f84 | |||
| a012ec65ef | |||
| b86c4a0d1a | |||
| 11de9a01b9 | |||
| 80dec2daf9 | |||
| da591bb53c | |||
| 7545c9c823 | |||
| ecc3d59a8f | |||
| b8862f025b | |||
| db20a07b27 | |||
| 8dadd5c9fe | |||
| b7cecb2a8b | |||
| 6c7b631cb7 | |||
| 892c6403eb | |||
| f6834f49d4 | |||
| 75752eefc0 | |||
| fbdd4274ac | |||
| 867b03aa1e | |||
| 457b469c96 | |||
| 94691f12ab | |||
| 5c8d11824e | |||
| db053c2dbd | |||
| 8c1dac86d5 | |||
| 8fb95b884f | |||
| f1f297b3a7 |
@@ -0,0 +1,15 @@
|
||||
# Wo erreicht die Dev-Maschine die aria-wohnung VM?
|
||||
# Kopiere diese Datei nach .claude/aria-vm.env und passe die IP an.
|
||||
# .claude/aria-vm.env ist gitignored (lokal pro Maschine).
|
||||
#
|
||||
# Verwendung in Bash:
|
||||
# source .claude/aria-vm.env
|
||||
# curl -s "$ARIA_BRAIN_URL/memory/stats"
|
||||
#
|
||||
# Im docker-compose-Netz aria-net laufen die Hostnamen ohnehin direkt
|
||||
# (aria-brain, aria-bridge, aria-qdrant). Diese Datei brauchen nur
|
||||
# Hosts AUSSERHALB der VM (z.B. die Dev-Maschine wo Claude Code laeuft).
|
||||
|
||||
ARIA_VM_HOST=192.0.2.1
|
||||
ARIA_DIAG_URL=http://192.0.2.1:3001
|
||||
ARIA_BRAIN_URL=http://192.0.2.1:3001/api/brain
|
||||
+62
-7
@@ -1,20 +1,75 @@
|
||||
# ARIA Environment Configuration
|
||||
# Copy to .env and fill in values
|
||||
# ════════════════════════════════════════════════
|
||||
# ARIA — Umgebungsvariablen
|
||||
# Kopieren nach .env und Werte eintragen
|
||||
# ════════════════════════════════════════════════
|
||||
|
||||
# Auth token for ARIA Core (generate a long random string)
|
||||
# openssl rand -hex 32
|
||||
# ── ARIA Auth Token ──────────────────────────────
|
||||
# Authentifizierung fuer den OpenClaw Gateway (aria-core).
|
||||
# Wird von Diagnostic, Bridge und App genutzt um sich am Gateway anzumelden.
|
||||
# Alle Services die mit aria-core kommunizieren brauchen diesen Token.
|
||||
# Generieren: openssl rand -hex 32
|
||||
ARIA_AUTH_TOKEN=change-me-to-a-long-random-string
|
||||
|
||||
# RVS — Rendezvous-Server (Bridge + App verbinden sich hierüber)
|
||||
# ── RVS — Rendezvous-Server ─────────────────────
|
||||
# Der RVS ist ein WebSocket-Relay im Rechenzentrum.
|
||||
# App, Bridge, Diagnostic und XTTS-Bridge verbinden sich hierueber.
|
||||
# Alle muessen den gleichen Host, Port und Token nutzen.
|
||||
|
||||
# Hostname des RVS-Servers (z.B. rvs.example.de oder mobil.hacker-net.de)
|
||||
# WICHTIG: muss oeffentlich aufloesbar sein (DNS), nicht nur intern.
|
||||
# Wird auch fuer OAuth-Callback-URLs verwendet — Spotify/Google/etc.
|
||||
# redirecten Stefan im Browser an https://{RVS_HOST}/oauth/callback/{service}.
|
||||
RVS_HOST=rvs.example.de
|
||||
|
||||
# Port auf dem der RVS laeuft (muss mit rvs/docker-compose.yml uebereinstimmen)
|
||||
RVS_PORT=443
|
||||
|
||||
# Oeffentlich erreichbarer TLS-Port — was Browser/Provider von aussen sehen.
|
||||
# Meist identisch mit RVS_PORT, kann aber abweichen wenn ein TLS-Terminator
|
||||
# (Caddy/Nginx) davor steht der z.B. 444 auf intern 3000 mappt. Wird fuer
|
||||
# die OAuth-Callback-URL benutzt; muss zu dem Eintrag im Provider-Dashboard
|
||||
# passen. Leer/ungesetzt = RVS_PORT wird verwendet.
|
||||
RVS_PORT_PUBLIC=
|
||||
|
||||
# TLS (wss://) verwenden? true = verschluesselt, false = unverschluesselt (ws://)
|
||||
RVS_TLS=true
|
||||
|
||||
# Bei TLS-Fehler automatisch auf ws:// (ohne TLS) fallback?
|
||||
# true = Fallback erlaubt, false = nur mit TLS verbinden
|
||||
# Nuetzlich wenn kein TLS-Zertifikat vorhanden (z.B. Entwicklung)
|
||||
RVS_TLS_FALLBACK=true
|
||||
|
||||
# Pairing-Token: Wer den gleichen Token hat, landet im gleichen RVS-Room.
|
||||
# Wird von generate-token.sh automatisch generiert und hier eingetragen.
|
||||
# Die Android App bekommt den Token per QR-Code beim Pairing.
|
||||
# WICHTIG: Muss auf ARIA-VM, Gaming-PC (xtts/.env) und App identisch sein!
|
||||
# Generieren: ./generate-token.sh (traegt den Token automatisch ein)
|
||||
RVS_TOKEN=
|
||||
|
||||
# Gitea (for release.sh — Kennwort wird interaktiv abgefragt)
|
||||
# ── Brain-Timeouts ───────────────────────────────
|
||||
# Brain redet via HTTP mit dem Proxy-Container. Da der Proxy non-streaming
|
||||
# antwortet (Response kommt erst nach subprocess-close), kann ein Brain-Call
|
||||
# bei langen Agent-Sessions (Pentests, Multi-Step-Tasks) >1h dauern.
|
||||
# PROXY_TIMEOUT_SEC ist der httpx-Read-Timeout im Brain — wir setzen ihn
|
||||
# bewusst hoch (24h), der Proxy hat einen eigenen Idle-Watchdog
|
||||
# (ARIA_IDLE_TIMEOUT_MS in der proxy-Logik, default 20min Inaktivitaet)
|
||||
# der den Subprocess killt wenn wirklich was haengt.
|
||||
# Connect/Write/Pool bleiben klein damit toter Proxy in 10s erkannt wird.
|
||||
PROXY_TIMEOUT_SEC=86400
|
||||
# Diese drei sind defensive Defaults — aendern nur wenn netzwerk-bedingt noetig.
|
||||
# PROXY_CONNECT_TIMEOUT_SEC=10
|
||||
# PROXY_WRITE_TIMEOUT_SEC=30
|
||||
# PROXY_POOL_TIMEOUT_SEC=10
|
||||
|
||||
# ── Gitea — Release-Verwaltung ───────────────────
|
||||
# Wird von release.sh genutzt um APKs auf Gitea zu veroeffentlichen.
|
||||
# Kennwort wird beim Release interaktiv abgefragt (nicht in .env!).
|
||||
GITEA_URL=https://git.hacker-net.de
|
||||
GITEA_REPO=Hacker-Software/ARIA-AGENT
|
||||
GITEA_USER=duffyduck
|
||||
|
||||
# ── Auto-Update — APK auf RVS-Server kopieren ───
|
||||
# SSH-Ziel fuer scp: release.sh kopiert die APK dorthin.
|
||||
# Der RVS-Server stellt sie dann per WebSocket an die App bereit.
|
||||
# Format: user@host (z.B. root@aria-rvs oder root@rvs.example.de)
|
||||
# Leer lassen = Auto-Update ueberspringen, APK manuell auf RVS kopieren.
|
||||
RVS_UPDATE_HOST=
|
||||
|
||||
+30
-8
@@ -9,15 +9,33 @@
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
aria-data/config/*.env
|
||||
!aria-data/config/*.env.example
|
||||
!aria-data/config/openclaw.env
|
||||
|
||||
# ── ARIAs Gedächtnis (nur per tar gesichert) ────
|
||||
aria-data/brain/
|
||||
# Lokale Dev-Maschinen-Settings fuer Claude Code (z.B. wie erreicht die
|
||||
# Dev-Maschine die aria-wohnung-VM). .example ist Repo-Inhalt, echte
|
||||
# Werte pro Maschine selbst pflegen.
|
||||
.claude/*.env
|
||||
!.claude/*.env.example
|
||||
|
||||
# ── Stimmen (große Binärdateien) ─────────────────
|
||||
aria-data/voices/
|
||||
# brain-import/ ist nur ein Drop-Folder: Stefan packt MDs rein wenn er
|
||||
# was migrieren will, klickt im Diagnostic „Migration aus brain-import/",
|
||||
# fertig. Die MDs gehoeren NICHT ins Repo (koennen private Daten enthalten,
|
||||
# sind eh ephemeral). Verzeichnis selbst bleibt im Git via .gitkeep,
|
||||
# README erklaert den Zweck.
|
||||
aria-data/brain-import/*
|
||||
!aria-data/brain-import/.gitkeep
|
||||
!aria-data/brain-import/README.md
|
||||
|
||||
# .aria-debug/ — App-Crash-Logs die tools/fetch-app-logs.sh hier ablegt.
|
||||
# Komplett lokal, enthaelt potentiell private Stacktraces / Daten.
|
||||
.aria-debug/
|
||||
|
||||
# ── ARIAs Gedächtnis (Vector-DB, Skills, Models) ──
|
||||
# Backup via Diagnostic → Gehirn-Export (tar.gz), nicht via Git.
|
||||
aria-data/brain/data/
|
||||
aria-data/brain/qdrant/
|
||||
|
||||
# Diagnostic-State (aktive Session etc.)
|
||||
aria-data/config/diag-state/
|
||||
|
||||
# ── Node / npm ──────────────────────────────────
|
||||
node_modules/
|
||||
@@ -29,9 +47,14 @@ yarn-error.log*
|
||||
android/build/
|
||||
android/.gradle/
|
||||
android/app/build/
|
||||
android/android/.gradle/
|
||||
android/android/app/build/
|
||||
android/android/local.properties
|
||||
android/local.properties
|
||||
android/package-lock.json
|
||||
*.apk
|
||||
*.aab
|
||||
rvs/updates/*.apk
|
||||
|
||||
# ── Tauri / Desktop Build ───────────────────────
|
||||
desktop/src-tauri/target/
|
||||
@@ -41,7 +64,6 @@ desktop/dist/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
bridge/__pycache__/
|
||||
|
||||
# ── macOS ────────────────────────────────────────
|
||||
.DS_Store
|
||||
|
||||
Binary file not shown.
+55
-1
@@ -6,13 +6,17 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { StatusBar, StyleSheet } from 'react-native';
|
||||
import { PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
|
||||
import ChatScreen from './src/screens/ChatScreen';
|
||||
import SettingsScreen from './src/screens/SettingsScreen';
|
||||
import rvs from './src/services/rvs';
|
||||
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
|
||||
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
|
||||
import gpsTrackingService from './src/services/gpsTracking';
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
@@ -44,6 +48,13 @@ const TAB_ICONS: Record<string, { active: string; inactive: string }> = {
|
||||
const App: React.FC = () => {
|
||||
// Beim Start: gespeicherte RVS-Konfiguration laden und verbinden
|
||||
useEffect(() => {
|
||||
// Verbose-Logging-Setting laden BEVOR andere Module loslegen.
|
||||
// initLogger ist async aber blockt nichts — solange er noch laueft,
|
||||
// loggen wir normal (Default an), danach respektiert console.log das Setting.
|
||||
initLogger().catch(() => {});
|
||||
// Crash-Reporter installieren — ungefangene JS-Errors landen via RVS
|
||||
// bei der Bridge (sichtbar in /shared/logs/app.log + Diagnostic-API)
|
||||
installGlobalCrashReporter();
|
||||
const initConnection = async () => {
|
||||
const config = await rvs.loadConfig();
|
||||
if (config) {
|
||||
@@ -53,6 +64,49 @@ const App: React.FC = () => {
|
||||
};
|
||||
initConnection();
|
||||
|
||||
// Hintergrund-Modus: Foreground-Service starten damit JS-Engine +
|
||||
// WebSocket auch ueberleben wenn die App im Hintergrund ist.
|
||||
// Trigger-Replies, Reconnects, Timer-Erinnerungen kommen sonst nicht
|
||||
// durch weil Android nach ~30s die JS-Engine pausiert.
|
||||
//
|
||||
// Default an, kann in Settings → Hintergrund-Modus deaktiviert werden.
|
||||
// Braucht POST_NOTIFICATIONS Permission ab Android 13.
|
||||
const initBackground = async () => {
|
||||
const setting = await AsyncStorage.getItem('aria_background_mode');
|
||||
if (setting === 'false') {
|
||||
console.log('[App] Hintergrund-Modus deaktiviert (Settings)');
|
||||
return;
|
||||
}
|
||||
// Permission fuer die persistente Notification
|
||||
if (Platform.OS === 'android' && Platform.Version >= 33) {
|
||||
try {
|
||||
await PermissionsAndroid.request(
|
||||
'android.permission.POST_NOTIFICATIONS' as any,
|
||||
{
|
||||
title: 'Hintergrund-Modus',
|
||||
message: 'ARIA zeigt eine Notification damit Trigger und Reconnects auch laufen wenn die App im Hintergrund ist.',
|
||||
buttonPositive: 'Erlauben',
|
||||
buttonNegative: 'Spaeter',
|
||||
},
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
await acquireBackgroundAudio('background');
|
||||
console.log('[App] Hintergrund-Modus aktiv');
|
||||
} catch (err: any) {
|
||||
console.warn('[App] Hintergrund-Modus konnte nicht starten:', err?.message || err);
|
||||
}
|
||||
};
|
||||
initBackground();
|
||||
|
||||
// GPS-Tracking-Status aus AsyncStorage wiederherstellen (war
|
||||
// bisher nur an SettingsScreen-Mount gekoppelt; wenn Stefan
|
||||
// direkt im Chat startete blieb GPS aus bis er Settings oeffnete).
|
||||
gpsTrackingService.restoreFromStorage().catch((err) => {
|
||||
console.warn('[App] GPS-Tracking restore fehlgeschlagen:', err?.message || err);
|
||||
});
|
||||
|
||||
// Beim Beenden: Verbindung sauber trennen
|
||||
return () => {
|
||||
rvs.disconnect();
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
-245
@@ -1,245 +0,0 @@
|
||||
package org.gradle.accessors.dm;
|
||||
|
||||
import org.gradle.api.NonNullApi;
|
||||
import org.gradle.api.artifacts.MinimalExternalModuleDependency;
|
||||
import org.gradle.plugin.use.PluginDependency;
|
||||
import org.gradle.api.artifacts.ExternalModuleDependencyBundle;
|
||||
import org.gradle.api.artifacts.MutableVersionConstraint;
|
||||
import org.gradle.api.provider.Provider;
|
||||
import org.gradle.api.model.ObjectFactory;
|
||||
import org.gradle.api.provider.ProviderFactory;
|
||||
import org.gradle.api.internal.catalog.AbstractExternalDependencyFactory;
|
||||
import org.gradle.api.internal.catalog.DefaultVersionCatalog;
|
||||
import java.util.Map;
|
||||
import org.gradle.api.internal.attributes.ImmutableAttributesFactory;
|
||||
import org.gradle.api.internal.artifacts.dsl.CapabilityNotationParser;
|
||||
import javax.inject.Inject;
|
||||
|
||||
/**
|
||||
* A catalog of dependencies accessible via the `libs` extension.
|
||||
*/
|
||||
@NonNullApi
|
||||
public class LibrariesForLibs extends AbstractExternalDependencyFactory {
|
||||
|
||||
private final AbstractExternalDependencyFactory owner = this;
|
||||
private final AndroidLibraryAccessors laccForAndroidLibraryAccessors = new AndroidLibraryAccessors(owner);
|
||||
private final KotlinLibraryAccessors laccForKotlinLibraryAccessors = new KotlinLibraryAccessors(owner);
|
||||
private final VersionAccessors vaccForVersionAccessors = new VersionAccessors(providers, config);
|
||||
private final BundleAccessors baccForBundleAccessors = new BundleAccessors(objects, providers, config, attributesFactory, capabilityNotationParser);
|
||||
private final PluginAccessors paccForPluginAccessors = new PluginAccessors(providers, config);
|
||||
|
||||
@Inject
|
||||
public LibrariesForLibs(DefaultVersionCatalog config, ProviderFactory providers, ObjectFactory objects, ImmutableAttributesFactory attributesFactory, CapabilityNotationParser capabilityNotationParser) {
|
||||
super(config, providers, objects, attributesFactory, capabilityNotationParser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dependency provider for gson (com.google.code.gson:gson)
|
||||
* This dependency was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<MinimalExternalModuleDependency> getGson() {
|
||||
return create("gson");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dependency provider for guava (com.google.guava:guava)
|
||||
* This dependency was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<MinimalExternalModuleDependency> getGuava() {
|
||||
return create("guava");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dependency provider for javapoet (com.squareup:javapoet)
|
||||
* This dependency was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<MinimalExternalModuleDependency> getJavapoet() {
|
||||
return create("javapoet");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dependency provider for junit (junit:junit)
|
||||
* This dependency was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<MinimalExternalModuleDependency> getJunit() {
|
||||
return create("junit");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the group of libraries at android
|
||||
*/
|
||||
public AndroidLibraryAccessors getAndroid() {
|
||||
return laccForAndroidLibraryAccessors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the group of libraries at kotlin
|
||||
*/
|
||||
public KotlinLibraryAccessors getKotlin() {
|
||||
return laccForKotlinLibraryAccessors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the group of versions at versions
|
||||
*/
|
||||
public VersionAccessors getVersions() {
|
||||
return vaccForVersionAccessors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the group of bundles at bundles
|
||||
*/
|
||||
public BundleAccessors getBundles() {
|
||||
return baccForBundleAccessors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the group of plugins at plugins
|
||||
*/
|
||||
public PluginAccessors getPlugins() {
|
||||
return paccForPluginAccessors;
|
||||
}
|
||||
|
||||
public static class AndroidLibraryAccessors extends SubDependencyFactory {
|
||||
private final AndroidGradleLibraryAccessors laccForAndroidGradleLibraryAccessors = new AndroidGradleLibraryAccessors(owner);
|
||||
|
||||
public AndroidLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
|
||||
|
||||
/**
|
||||
* Returns the group of libraries at android.gradle
|
||||
*/
|
||||
public AndroidGradleLibraryAccessors getGradle() {
|
||||
return laccForAndroidGradleLibraryAccessors;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class AndroidGradleLibraryAccessors extends SubDependencyFactory {
|
||||
|
||||
public AndroidGradleLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
|
||||
|
||||
/**
|
||||
* Creates a dependency provider for plugin (com.android.tools.build:gradle)
|
||||
* This dependency was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<MinimalExternalModuleDependency> getPlugin() {
|
||||
return create("android.gradle.plugin");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class KotlinLibraryAccessors extends SubDependencyFactory {
|
||||
private final KotlinGradleLibraryAccessors laccForKotlinGradleLibraryAccessors = new KotlinGradleLibraryAccessors(owner);
|
||||
|
||||
public KotlinLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
|
||||
|
||||
/**
|
||||
* Returns the group of libraries at kotlin.gradle
|
||||
*/
|
||||
public KotlinGradleLibraryAccessors getGradle() {
|
||||
return laccForKotlinGradleLibraryAccessors;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class KotlinGradleLibraryAccessors extends SubDependencyFactory {
|
||||
|
||||
public KotlinGradleLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
|
||||
|
||||
/**
|
||||
* Creates a dependency provider for plugin (org.jetbrains.kotlin:kotlin-gradle-plugin)
|
||||
* This dependency was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<MinimalExternalModuleDependency> getPlugin() {
|
||||
return create("kotlin.gradle.plugin");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class VersionAccessors extends VersionFactory {
|
||||
|
||||
public VersionAccessors(ProviderFactory providers, DefaultVersionCatalog config) { super(providers, config); }
|
||||
|
||||
/**
|
||||
* Returns the version associated to this alias: agp (8.1.1)
|
||||
* If the version is a rich version and that its not expressible as a
|
||||
* single version string, then an empty string is returned.
|
||||
* This version was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<String> getAgp() { return getVersion("agp"); }
|
||||
|
||||
/**
|
||||
* Returns the version associated to this alias: gson (2.8.9)
|
||||
* If the version is a rich version and that its not expressible as a
|
||||
* single version string, then an empty string is returned.
|
||||
* This version was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<String> getGson() { return getVersion("gson"); }
|
||||
|
||||
/**
|
||||
* Returns the version associated to this alias: guava (31.0.1-jre)
|
||||
* If the version is a rich version and that its not expressible as a
|
||||
* single version string, then an empty string is returned.
|
||||
* This version was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<String> getGuava() { return getVersion("guava"); }
|
||||
|
||||
/**
|
||||
* Returns the version associated to this alias: javapoet (1.13.0)
|
||||
* If the version is a rich version and that its not expressible as a
|
||||
* single version string, then an empty string is returned.
|
||||
* This version was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<String> getJavapoet() { return getVersion("javapoet"); }
|
||||
|
||||
/**
|
||||
* Returns the version associated to this alias: junit (4.13.2)
|
||||
* If the version is a rich version and that its not expressible as a
|
||||
* single version string, then an empty string is returned.
|
||||
* This version was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<String> getJunit() { return getVersion("junit"); }
|
||||
|
||||
/**
|
||||
* Returns the version associated to this alias: kotlin (1.8.0)
|
||||
* If the version is a rich version and that its not expressible as a
|
||||
* single version string, then an empty string is returned.
|
||||
* This version was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<String> getKotlin() { return getVersion("kotlin"); }
|
||||
|
||||
}
|
||||
|
||||
public static class BundleAccessors extends BundleFactory {
|
||||
|
||||
public BundleAccessors(ObjectFactory objects, ProviderFactory providers, DefaultVersionCatalog config, ImmutableAttributesFactory attributesFactory, CapabilityNotationParser capabilityNotationParser) { super(objects, providers, config, attributesFactory, capabilityNotationParser); }
|
||||
|
||||
}
|
||||
|
||||
public static class PluginAccessors extends PluginFactory {
|
||||
private final KotlinPluginAccessors paccForKotlinPluginAccessors = new KotlinPluginAccessors(providers, config);
|
||||
|
||||
public PluginAccessors(ProviderFactory providers, DefaultVersionCatalog config) { super(providers, config); }
|
||||
|
||||
/**
|
||||
* Returns the group of plugins at plugins.kotlin
|
||||
*/
|
||||
public KotlinPluginAccessors getKotlin() {
|
||||
return paccForKotlinPluginAccessors;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class KotlinPluginAccessors extends PluginFactory {
|
||||
|
||||
public KotlinPluginAccessors(ProviderFactory providers, DefaultVersionCatalog config) { super(providers, config); }
|
||||
|
||||
/**
|
||||
* Creates a plugin provider for kotlin.jvm to the plugin id 'org.jetbrains.kotlin.jvm'
|
||||
* This plugin was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<PluginDependency> getJvm() { return createPlugin("kotlin.jvm"); }
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
-298
@@ -1,298 +0,0 @@
|
||||
package org.gradle.accessors.dm;
|
||||
|
||||
import org.gradle.api.NonNullApi;
|
||||
import org.gradle.api.artifacts.MinimalExternalModuleDependency;
|
||||
import org.gradle.plugin.use.PluginDependency;
|
||||
import org.gradle.api.artifacts.ExternalModuleDependencyBundle;
|
||||
import org.gradle.api.artifacts.MutableVersionConstraint;
|
||||
import org.gradle.api.provider.Provider;
|
||||
import org.gradle.api.model.ObjectFactory;
|
||||
import org.gradle.api.provider.ProviderFactory;
|
||||
import org.gradle.api.internal.catalog.AbstractExternalDependencyFactory;
|
||||
import org.gradle.api.internal.catalog.DefaultVersionCatalog;
|
||||
import java.util.Map;
|
||||
import org.gradle.api.internal.attributes.ImmutableAttributesFactory;
|
||||
import org.gradle.api.internal.artifacts.dsl.CapabilityNotationParser;
|
||||
import javax.inject.Inject;
|
||||
|
||||
/**
|
||||
* A catalog of dependencies accessible via the `libs` extension.
|
||||
*/
|
||||
@NonNullApi
|
||||
public class LibrariesForLibsInPluginsBlock extends AbstractExternalDependencyFactory {
|
||||
|
||||
private final AbstractExternalDependencyFactory owner = this;
|
||||
private final AndroidLibraryAccessors laccForAndroidLibraryAccessors = new AndroidLibraryAccessors(owner);
|
||||
private final KotlinLibraryAccessors laccForKotlinLibraryAccessors = new KotlinLibraryAccessors(owner);
|
||||
private final VersionAccessors vaccForVersionAccessors = new VersionAccessors(providers, config);
|
||||
private final BundleAccessors baccForBundleAccessors = new BundleAccessors(objects, providers, config, attributesFactory, capabilityNotationParser);
|
||||
private final PluginAccessors paccForPluginAccessors = new PluginAccessors(providers, config);
|
||||
|
||||
@Inject
|
||||
public LibrariesForLibsInPluginsBlock(DefaultVersionCatalog config, ProviderFactory providers, ObjectFactory objects, ImmutableAttributesFactory attributesFactory, CapabilityNotationParser capabilityNotationParser) {
|
||||
super(config, providers, objects, attributesFactory, capabilityNotationParser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dependency provider for gson (com.google.code.gson:gson)
|
||||
* This dependency was declared in catalog libs.versions.toml
|
||||
* @deprecated Will be removed in Gradle 9.0.
|
||||
*/
|
||||
@Deprecated
|
||||
public Provider<MinimalExternalModuleDependency> getGson() {
|
||||
org.gradle.internal.deprecation.DeprecationLogger.deprecateBehaviour("Accessing libraries or bundles from version catalogs in the plugins block.").withAdvice("Only use versions or plugins from catalogs in the plugins block.").willBeRemovedInGradle9().withUpgradeGuideSection(8, "kotlin_dsl_deprecated_catalogs_plugins_block").nagUser();
|
||||
return create("gson");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dependency provider for guava (com.google.guava:guava)
|
||||
* This dependency was declared in catalog libs.versions.toml
|
||||
* @deprecated Will be removed in Gradle 9.0.
|
||||
*/
|
||||
@Deprecated
|
||||
public Provider<MinimalExternalModuleDependency> getGuava() {
|
||||
org.gradle.internal.deprecation.DeprecationLogger.deprecateBehaviour("Accessing libraries or bundles from version catalogs in the plugins block.").withAdvice("Only use versions or plugins from catalogs in the plugins block.").willBeRemovedInGradle9().withUpgradeGuideSection(8, "kotlin_dsl_deprecated_catalogs_plugins_block").nagUser();
|
||||
return create("guava");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dependency provider for javapoet (com.squareup:javapoet)
|
||||
* This dependency was declared in catalog libs.versions.toml
|
||||
* @deprecated Will be removed in Gradle 9.0.
|
||||
*/
|
||||
@Deprecated
|
||||
public Provider<MinimalExternalModuleDependency> getJavapoet() {
|
||||
org.gradle.internal.deprecation.DeprecationLogger.deprecateBehaviour("Accessing libraries or bundles from version catalogs in the plugins block.").withAdvice("Only use versions or plugins from catalogs in the plugins block.").willBeRemovedInGradle9().withUpgradeGuideSection(8, "kotlin_dsl_deprecated_catalogs_plugins_block").nagUser();
|
||||
return create("javapoet");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dependency provider for junit (junit:junit)
|
||||
* This dependency was declared in catalog libs.versions.toml
|
||||
* @deprecated Will be removed in Gradle 9.0.
|
||||
*/
|
||||
@Deprecated
|
||||
public Provider<MinimalExternalModuleDependency> getJunit() {
|
||||
org.gradle.internal.deprecation.DeprecationLogger.deprecateBehaviour("Accessing libraries or bundles from version catalogs in the plugins block.").withAdvice("Only use versions or plugins from catalogs in the plugins block.").willBeRemovedInGradle9().withUpgradeGuideSection(8, "kotlin_dsl_deprecated_catalogs_plugins_block").nagUser();
|
||||
return create("junit");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the group of libraries at android
|
||||
* @deprecated Will be removed in Gradle 9.0.
|
||||
*/
|
||||
@Deprecated
|
||||
public AndroidLibraryAccessors getAndroid() {
|
||||
org.gradle.internal.deprecation.DeprecationLogger.deprecateBehaviour("Accessing libraries or bundles from version catalogs in the plugins block.").withAdvice("Only use versions or plugins from catalogs in the plugins block.").willBeRemovedInGradle9().withUpgradeGuideSection(8, "kotlin_dsl_deprecated_catalogs_plugins_block").nagUser();
|
||||
return laccForAndroidLibraryAccessors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the group of libraries at kotlin
|
||||
* @deprecated Will be removed in Gradle 9.0.
|
||||
*/
|
||||
@Deprecated
|
||||
public KotlinLibraryAccessors getKotlin() {
|
||||
org.gradle.internal.deprecation.DeprecationLogger.deprecateBehaviour("Accessing libraries or bundles from version catalogs in the plugins block.").withAdvice("Only use versions or plugins from catalogs in the plugins block.").willBeRemovedInGradle9().withUpgradeGuideSection(8, "kotlin_dsl_deprecated_catalogs_plugins_block").nagUser();
|
||||
return laccForKotlinLibraryAccessors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the group of versions at versions
|
||||
*/
|
||||
public VersionAccessors getVersions() {
|
||||
return vaccForVersionAccessors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the group of bundles at bundles
|
||||
* @deprecated Will be removed in Gradle 9.0.
|
||||
*/
|
||||
@Deprecated
|
||||
public BundleAccessors getBundles() {
|
||||
org.gradle.internal.deprecation.DeprecationLogger.deprecateBehaviour("Accessing libraries or bundles from version catalogs in the plugins block.").withAdvice("Only use versions or plugins from catalogs in the plugins block.").willBeRemovedInGradle9().withUpgradeGuideSection(8, "kotlin_dsl_deprecated_catalogs_plugins_block").nagUser();
|
||||
return baccForBundleAccessors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the group of plugins at plugins
|
||||
*/
|
||||
public PluginAccessors getPlugins() {
|
||||
return paccForPluginAccessors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Will be removed in Gradle 9.0.
|
||||
*/
|
||||
@Deprecated
|
||||
public static class AndroidLibraryAccessors extends SubDependencyFactory {
|
||||
private final AndroidGradleLibraryAccessors laccForAndroidGradleLibraryAccessors = new AndroidGradleLibraryAccessors(owner);
|
||||
|
||||
public AndroidLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
|
||||
|
||||
/**
|
||||
* Returns the group of libraries at android.gradle
|
||||
* @deprecated Will be removed in Gradle 9.0.
|
||||
*/
|
||||
@Deprecated
|
||||
public AndroidGradleLibraryAccessors getGradle() {
|
||||
org.gradle.internal.deprecation.DeprecationLogger.deprecateBehaviour("Accessing libraries or bundles from version catalogs in the plugins block.").withAdvice("Only use versions or plugins from catalogs in the plugins block.").willBeRemovedInGradle9().withUpgradeGuideSection(8, "kotlin_dsl_deprecated_catalogs_plugins_block").nagUser();
|
||||
return laccForAndroidGradleLibraryAccessors;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Will be removed in Gradle 9.0.
|
||||
*/
|
||||
@Deprecated
|
||||
public static class AndroidGradleLibraryAccessors extends SubDependencyFactory {
|
||||
|
||||
public AndroidGradleLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
|
||||
|
||||
/**
|
||||
* Creates a dependency provider for plugin (com.android.tools.build:gradle)
|
||||
* This dependency was declared in catalog libs.versions.toml
|
||||
* @deprecated Will be removed in Gradle 9.0.
|
||||
*/
|
||||
@Deprecated
|
||||
public Provider<MinimalExternalModuleDependency> getPlugin() {
|
||||
org.gradle.internal.deprecation.DeprecationLogger.deprecateBehaviour("Accessing libraries or bundles from version catalogs in the plugins block.").withAdvice("Only use versions or plugins from catalogs in the plugins block.").willBeRemovedInGradle9().withUpgradeGuideSection(8, "kotlin_dsl_deprecated_catalogs_plugins_block").nagUser();
|
||||
return create("android.gradle.plugin");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Will be removed in Gradle 9.0.
|
||||
*/
|
||||
@Deprecated
|
||||
public static class KotlinLibraryAccessors extends SubDependencyFactory {
|
||||
private final KotlinGradleLibraryAccessors laccForKotlinGradleLibraryAccessors = new KotlinGradleLibraryAccessors(owner);
|
||||
|
||||
public KotlinLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
|
||||
|
||||
/**
|
||||
* Returns the group of libraries at kotlin.gradle
|
||||
* @deprecated Will be removed in Gradle 9.0.
|
||||
*/
|
||||
@Deprecated
|
||||
public KotlinGradleLibraryAccessors getGradle() {
|
||||
org.gradle.internal.deprecation.DeprecationLogger.deprecateBehaviour("Accessing libraries or bundles from version catalogs in the plugins block.").withAdvice("Only use versions or plugins from catalogs in the plugins block.").willBeRemovedInGradle9().withUpgradeGuideSection(8, "kotlin_dsl_deprecated_catalogs_plugins_block").nagUser();
|
||||
return laccForKotlinGradleLibraryAccessors;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Will be removed in Gradle 9.0.
|
||||
*/
|
||||
@Deprecated
|
||||
public static class KotlinGradleLibraryAccessors extends SubDependencyFactory {
|
||||
|
||||
public KotlinGradleLibraryAccessors(AbstractExternalDependencyFactory owner) { super(owner); }
|
||||
|
||||
/**
|
||||
* Creates a dependency provider for plugin (org.jetbrains.kotlin:kotlin-gradle-plugin)
|
||||
* This dependency was declared in catalog libs.versions.toml
|
||||
* @deprecated Will be removed in Gradle 9.0.
|
||||
*/
|
||||
@Deprecated
|
||||
public Provider<MinimalExternalModuleDependency> getPlugin() {
|
||||
org.gradle.internal.deprecation.DeprecationLogger.deprecateBehaviour("Accessing libraries or bundles from version catalogs in the plugins block.").withAdvice("Only use versions or plugins from catalogs in the plugins block.").willBeRemovedInGradle9().withUpgradeGuideSection(8, "kotlin_dsl_deprecated_catalogs_plugins_block").nagUser();
|
||||
return create("kotlin.gradle.plugin");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class VersionAccessors extends VersionFactory {
|
||||
|
||||
public VersionAccessors(ProviderFactory providers, DefaultVersionCatalog config) { super(providers, config); }
|
||||
|
||||
/**
|
||||
* Returns the version associated to this alias: agp (8.1.1)
|
||||
* If the version is a rich version and that its not expressible as a
|
||||
* single version string, then an empty string is returned.
|
||||
* This version was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<String> getAgp() { return getVersion("agp"); }
|
||||
|
||||
/**
|
||||
* Returns the version associated to this alias: gson (2.8.9)
|
||||
* If the version is a rich version and that its not expressible as a
|
||||
* single version string, then an empty string is returned.
|
||||
* This version was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<String> getGson() { return getVersion("gson"); }
|
||||
|
||||
/**
|
||||
* Returns the version associated to this alias: guava (31.0.1-jre)
|
||||
* If the version is a rich version and that its not expressible as a
|
||||
* single version string, then an empty string is returned.
|
||||
* This version was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<String> getGuava() { return getVersion("guava"); }
|
||||
|
||||
/**
|
||||
* Returns the version associated to this alias: javapoet (1.13.0)
|
||||
* If the version is a rich version and that its not expressible as a
|
||||
* single version string, then an empty string is returned.
|
||||
* This version was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<String> getJavapoet() { return getVersion("javapoet"); }
|
||||
|
||||
/**
|
||||
* Returns the version associated to this alias: junit (4.13.2)
|
||||
* If the version is a rich version and that its not expressible as a
|
||||
* single version string, then an empty string is returned.
|
||||
* This version was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<String> getJunit() { return getVersion("junit"); }
|
||||
|
||||
/**
|
||||
* Returns the version associated to this alias: kotlin (1.8.0)
|
||||
* If the version is a rich version and that its not expressible as a
|
||||
* single version string, then an empty string is returned.
|
||||
* This version was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<String> getKotlin() { return getVersion("kotlin"); }
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Will be removed in Gradle 9.0.
|
||||
*/
|
||||
@Deprecated
|
||||
public static class BundleAccessors extends BundleFactory {
|
||||
|
||||
public BundleAccessors(ObjectFactory objects, ProviderFactory providers, DefaultVersionCatalog config, ImmutableAttributesFactory attributesFactory, CapabilityNotationParser capabilityNotationParser) { super(objects, providers, config, attributesFactory, capabilityNotationParser); }
|
||||
|
||||
}
|
||||
|
||||
public static class PluginAccessors extends PluginFactory {
|
||||
private final KotlinPluginAccessors paccForKotlinPluginAccessors = new KotlinPluginAccessors(providers, config);
|
||||
|
||||
public PluginAccessors(ProviderFactory providers, DefaultVersionCatalog config) { super(providers, config); }
|
||||
|
||||
/**
|
||||
* Returns the group of plugins at plugins.kotlin
|
||||
*/
|
||||
public KotlinPluginAccessors getKotlin() {
|
||||
return paccForKotlinPluginAccessors;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class KotlinPluginAccessors extends PluginFactory {
|
||||
|
||||
public KotlinPluginAccessors(ProviderFactory providers, DefaultVersionCatalog config) { super(providers, config); }
|
||||
|
||||
/**
|
||||
* Creates a plugin provider for kotlin.jvm to the plugin id 'org.jetbrains.kotlin.jvm'
|
||||
* This plugin was declared in catalog libs.versions.toml
|
||||
*/
|
||||
public Provider<PluginDependency> getJvm() { return createPlugin("kotlin.jvm"); }
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
#Sun Mar 29 11:32:18 CEST 2026
|
||||
gradle.version=8.3
|
||||
Binary file not shown.
Binary file not shown.
@@ -79,8 +79,8 @@ android {
|
||||
applicationId "com.ariacockpit"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionCode 10602
|
||||
versionName "0.1.6.2"
|
||||
// Fallback fuer Libraries mit Product Flavors
|
||||
missingDimensionStrategy 'react-native-camera', 'general'
|
||||
}
|
||||
@@ -104,6 +104,19 @@ android {
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
}
|
||||
}
|
||||
|
||||
// ABI-Split: nur arm64-v8a (jedes Android-Phone seit ~2017). Bringt die
|
||||
// APK von ~136 MB auf ~35 MB — relevant weil ONNX Runtime + die anderen
|
||||
// Native-Libs sonst pro Architektur dazukommen. Wer 32-bit oder Emulator
|
||||
// braucht, kann hier "armeabi-v7a", "x86_64" etc. ergaenzen.
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
include "arm64-v8a"
|
||||
universalApk false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -111,6 +124,9 @@ dependencies {
|
||||
implementation("com.facebook.react:react-android")
|
||||
implementation("com.facebook.react:flipper-integration")
|
||||
|
||||
// ONNX Runtime fuer on-device Wake-Word (openWakeWord ONNX-Modelle in assets/openwakeword/)
|
||||
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.17.1")
|
||||
|
||||
if (hermesEnabled.toBoolean()) {
|
||||
implementation("com.facebook.react:hermes-android")
|
||||
} else {
|
||||
|
||||
-97
@@ -1,97 +0,0 @@
|
||||
|
||||
package com.facebook.react;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.content.res.Resources;
|
||||
|
||||
import com.facebook.react.ReactPackage;
|
||||
import com.facebook.react.shell.MainPackageConfig;
|
||||
import com.facebook.react.shell.MainReactPackage;
|
||||
import java.util.Arrays;
|
||||
import java.util.ArrayList;
|
||||
|
||||
// react-native-screens
|
||||
import com.swmansion.rnscreens.RNScreensPackage;
|
||||
// react-native-safe-area-context
|
||||
import com.th3rdwave.safeareacontext.SafeAreaContextPackage;
|
||||
// react-native-document-picker
|
||||
import com.reactnativedocumentpicker.RNDocumentPickerPackage;
|
||||
// react-native-sound
|
||||
import com.zmxv.RNSound.RNSoundPackage;
|
||||
// @react-native-community/geolocation
|
||||
import com.reactnativecommunity.geolocation.GeolocationPackage;
|
||||
// react-native-image-picker
|
||||
import com.imagepicker.ImagePickerPackage;
|
||||
// react-native-permissions
|
||||
import com.zoontek.rnpermissions.RNPermissionsPackage;
|
||||
// react-native-camera-kit
|
||||
import com.rncamerakit.RNCameraKitPackage;
|
||||
// @react-native-async-storage/async-storage
|
||||
import com.reactnativecommunity.asyncstorage.AsyncStoragePackage;
|
||||
// react-native-fs
|
||||
import com.rnfs.RNFSPackage;
|
||||
// react-native-audio-recorder-player
|
||||
import com.dooboolab.audiorecorderplayer.RNAudioRecorderPlayerPackage;
|
||||
// react-native-live-audio-stream
|
||||
import com.imxiqi.rnliveaudiostream.RNLiveAudioStreamPackage;
|
||||
|
||||
public class PackageList {
|
||||
private Application application;
|
||||
private ReactNativeHost reactNativeHost;
|
||||
private MainPackageConfig mConfig;
|
||||
|
||||
public PackageList(ReactNativeHost reactNativeHost) {
|
||||
this(reactNativeHost, null);
|
||||
}
|
||||
|
||||
public PackageList(Application application) {
|
||||
this(application, null);
|
||||
}
|
||||
|
||||
public PackageList(ReactNativeHost reactNativeHost, MainPackageConfig config) {
|
||||
this.reactNativeHost = reactNativeHost;
|
||||
mConfig = config;
|
||||
}
|
||||
|
||||
public PackageList(Application application, MainPackageConfig config) {
|
||||
this.reactNativeHost = null;
|
||||
this.application = application;
|
||||
mConfig = config;
|
||||
}
|
||||
|
||||
private ReactNativeHost getReactNativeHost() {
|
||||
return this.reactNativeHost;
|
||||
}
|
||||
|
||||
private Resources getResources() {
|
||||
return this.getApplication().getResources();
|
||||
}
|
||||
|
||||
private Application getApplication() {
|
||||
if (this.reactNativeHost == null) return this.application;
|
||||
return this.reactNativeHost.getApplication();
|
||||
}
|
||||
|
||||
private Context getApplicationContext() {
|
||||
return this.getApplication().getApplicationContext();
|
||||
}
|
||||
|
||||
public ArrayList<ReactPackage> getPackages() {
|
||||
return new ArrayList<>(Arrays.<ReactPackage>asList(
|
||||
new MainReactPackage(mConfig),
|
||||
new RNScreensPackage(),
|
||||
new SafeAreaContextPackage(),
|
||||
new RNDocumentPickerPackage(),
|
||||
new RNSoundPackage(),
|
||||
new GeolocationPackage(),
|
||||
new ImagePickerPackage(),
|
||||
new RNPermissionsPackage(),
|
||||
new RNCameraKitPackage(),
|
||||
new AsyncStoragePackage(),
|
||||
new RNFSPackage(),
|
||||
new RNAudioRecorderPlayerPackage(),
|
||||
new RNLiveAudioStreamPackage()
|
||||
));
|
||||
}
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Automatically generated file. DO NOT MODIFY
|
||||
*/
|
||||
package com.ariacockpit;
|
||||
|
||||
public final class BuildConfig {
|
||||
public static final boolean DEBUG = false;
|
||||
public static final String APPLICATION_ID = "com.ariacockpit";
|
||||
public static final String BUILD_TYPE = "release";
|
||||
public static final int VERSION_CODE = 1;
|
||||
public static final String VERSION_NAME = "1.0";
|
||||
// Field from default config.
|
||||
public static final boolean IS_HERMES_ENABLED = true;
|
||||
// Field from default config.
|
||||
public static final boolean IS_NEW_ARCHITECTURE_ENABLED = false;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"schemaVersion": "1.1.0",
|
||||
"buildSystem": "Gradle",
|
||||
"buildSystemVersion": "8.3",
|
||||
"buildPlugin": "org.jetbrains.kotlin.gradle.plugin.KotlinAndroidPluginWrapper",
|
||||
"buildPluginVersion": "1.8.0",
|
||||
"projectSettings": {
|
||||
"isHmppEnabled": true,
|
||||
"isCompatibilityMetadataVariantEnabled": false,
|
||||
"isKPMEnabled": false
|
||||
},
|
||||
"projectTargets": [
|
||||
{
|
||||
"target": "org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget",
|
||||
"platformType": "androidJvm",
|
||||
"extras": {
|
||||
"android": {
|
||||
"sourceCompatibility": "17",
|
||||
"targetCompatibility": "17"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,6 +3,20 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- Anruf-State lesen damit TTS bei klingelndem Telefon pausiert -->
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<!-- Optional: GPS-Position der Frage anhaengen (nur wenn User in Settings aktiviert) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<!-- Foreground-Service damit TTS auch bei minimierter App weiterlaeuft.
|
||||
FOREGROUND_SERVICE_MICROPHONE ist Pflicht ab Android 14 wenn der
|
||||
Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word,
|
||||
Aufnahme im Gespraechsmodus). -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
@@ -24,5 +38,20 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name=".AriaPlaybackService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback|microphone" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,44 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.content.FileProvider
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.Promise
|
||||
import java.io.File
|
||||
|
||||
class ApkInstallerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
override fun getName() = "ApkInstaller"
|
||||
|
||||
@ReactMethod
|
||||
fun install(filePath: String, promise: Promise) {
|
||||
try {
|
||||
val file = File(filePath)
|
||||
if (!file.exists()) {
|
||||
promise.reject("FILE_NOT_FOUND", "APK nicht gefunden: $filePath")
|
||||
return
|
||||
}
|
||||
|
||||
val context = reactApplicationContext
|
||||
val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
} else {
|
||||
Uri.fromFile(file)
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("INSTALL_ERROR", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ApkInstallerPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(ApkInstallerModule(reactContext), FileOpenerModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
* Foreground-Service der den App-Prozess waehrend TTS-Wiedergabe am Leben
|
||||
* haelt — Android killt sonst den Prozess sobald die App im Hintergrund ist
|
||||
* und ARIA verstummt mitten im Satz.
|
||||
*
|
||||
* Notification ist persistent (ongoing) waehrend der Service laeuft.
|
||||
* Tap auf die Notification bringt MainActivity zurueck nach vorne.
|
||||
*
|
||||
* foregroundServiceType="mediaPlayback" ist Pflicht ab Android 14, sonst
|
||||
* wirft startForeground() eine SecurityException.
|
||||
*/
|
||||
class AriaPlaybackService : Service() {
|
||||
companion object {
|
||||
private const val TAG = "AriaPlaybackService"
|
||||
private const val CHANNEL_ID = "aria_playback"
|
||||
private const val NOTIFICATION_ID = 1042
|
||||
const val EXTRA_REASON = "reason" // "tts" | "wake" | "rec" | ""
|
||||
}
|
||||
|
||||
private var currentReason: String = ""
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ensureNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val reason = intent?.getStringExtra(EXTRA_REASON) ?: ""
|
||||
currentReason = reason
|
||||
Log.i(TAG, "Foreground-Service start/update (reason=$reason)")
|
||||
try {
|
||||
startForeground(NOTIFICATION_ID, buildNotification(reason))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "startForeground fehlgeschlagen", e)
|
||||
stopSelf()
|
||||
}
|
||||
// START_NOT_STICKY: wenn Android den Service killt, NICHT automatisch
|
||||
// wieder starten — die App entscheidet wann der Service noetig ist.
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.i(TAG, "Foreground-Service gestoppt")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun ensureNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val nm = getSystemService(NotificationManager::class.java) ?: return
|
||||
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"ARIA Audio-Wiedergabe",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Notification waehrend ARIA spricht (haelt die App im Hintergrund am Leben)"
|
||||
setShowBadge(false)
|
||||
}
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(reason: String): Notification {
|
||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
else
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
val pendingIntent = PendingIntent.getActivity(this, 0, launchIntent, pendingFlags)
|
||||
|
||||
val (title, body) = when (reason) {
|
||||
"tts" -> "ARIA spricht" to "Antwort wird abgespielt — antippen oeffnet die App"
|
||||
"rec" -> "ARIA hoert zu" to "Sprachaufnahme laeuft — antippen oeffnet die App"
|
||||
"wake" -> "ARIA bereit" to "Wake-Word lauscht passiv — antippen oeffnet die App"
|
||||
else -> "ARIA aktiv" to "Hintergrund-Modus — antippen oeffnet die App"
|
||||
}
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setShowWhen(false)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
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.modules.core.DeviceEventManagerModule
|
||||
|
||||
/**
|
||||
* Steuert Audio-Focus fuer Ducking/Muten anderer Apps + emittiert Loss-Events
|
||||
* an JS damit ARIA bei VoIP-Anrufen (WhatsApp/Signal/Discord/...) aufhoert
|
||||
* zu sprechen — diese Anrufe gehen nicht ueber TelephonyManager, sondern
|
||||
* requestn AudioFocus_GAIN_TRANSIENT_EXCLUSIVE was wir hier mitbekommen.
|
||||
*
|
||||
* - requestDuck() → andere Apps werden leiser (ARIA spricht TTS)
|
||||
* - requestExclusive() → andere Apps werden pausiert (Mikrofon-Aufnahme)
|
||||
* - release() → Focus abgeben, andere Apps duerfen wieder
|
||||
*
|
||||
* Events:
|
||||
* - "AudioFocusChanged" mit type:
|
||||
* "loss" — endgueltiger Verlust (Anruf, andere App permanent)
|
||||
* "loss_transient" — vorruebergehender Verlust (kurze Unterbrechung)
|
||||
* "gain" — Fokus zurueck
|
||||
*/
|
||||
class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
override fun getName() = "AudioFocus"
|
||||
|
||||
companion object { private const val TAG = "AudioFocus" }
|
||||
|
||||
private var currentRequest: AudioFocusRequest? = null
|
||||
|
||||
private fun audioManager(): AudioManager? =
|
||||
reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
|
||||
private fun emitFocusChange(type: String) {
|
||||
try {
|
||||
val params = Arguments.createMap().apply { putString("type", type) }
|
||||
reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("AudioFocusChanged", params)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "emit failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private val focusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
|
||||
when (focusChange) {
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
Log.i(TAG, "AUDIOFOCUS_LOSS (z.B. Anruf, anderer Player permanent)")
|
||||
emitFocusChange("loss")
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
Log.i(TAG, "AUDIOFOCUS_LOSS_TRANSIENT (kurze Unterbrechung)")
|
||||
emitFocusChange("loss_transient")
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
// Notification-Sound o.ae. — wir ignorieren das, ARIA macht weiter
|
||||
Log.d(TAG, "AUDIOFOCUS_LOSS_CAN_DUCK ignoriert")
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
Log.i(TAG, "AUDIOFOCUS_GAIN")
|
||||
emitFocusChange("gain")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestFocus(durationHint: Int, usage: Int, promise: Promise) {
|
||||
val am = audioManager()
|
||||
if (am == null) {
|
||||
promise.reject("NO_AUDIO_MANAGER", "AudioManager nicht verfuegbar")
|
||||
return
|
||||
}
|
||||
|
||||
release()
|
||||
|
||||
val result: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val attrs = AudioAttributes.Builder()
|
||||
.setUsage(usage)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
val req = AudioFocusRequest.Builder(durationHint)
|
||||
.setAudioAttributes(attrs)
|
||||
.setOnAudioFocusChangeListener(focusListener)
|
||||
.build()
|
||||
currentRequest = req
|
||||
am.requestAudioFocus(req)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
am.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, durationHint)
|
||||
}
|
||||
|
||||
promise.resolve(result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
|
||||
}
|
||||
|
||||
/** Andere Apps werden pausiert (TTS spricht).
|
||||
*
|
||||
* TRANSIENT (statt TRANSIENT_MAY_DUCK): Spotify/YouTube pausieren komplett
|
||||
* statt nur leiser zu werden. Verhindert auch das "kommt-wieder-hoch"-
|
||||
* Problem mit MAY_DUCK, wo das System nach kurzer Zeit den Duck-Effekt
|
||||
* wieder aufgehoben hat obwohl wir den Fokus noch hielten.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun requestDuck(promise: Promise) {
|
||||
requestFocus(
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
|
||||
AudioAttributes.USAGE_ASSISTANT,
|
||||
promise,
|
||||
)
|
||||
}
|
||||
|
||||
/** Andere Apps werden pausiert (Mikrofon-Aufnahme / Gespraech). */
|
||||
@ReactMethod
|
||||
fun requestExclusive(promise: Promise) {
|
||||
requestFocus(
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
|
||||
AudioAttributes.USAGE_VOICE_COMMUNICATION,
|
||||
promise,
|
||||
)
|
||||
}
|
||||
|
||||
/** Focus abgeben — andere Apps duerfen wieder volle Lautstaerke. */
|
||||
@ReactMethod
|
||||
fun release(promise: Promise) {
|
||||
release()
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
/** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
|
||||
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
|
||||
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
|
||||
* GAIN beanspruchen — das System invalidiert dabei den haengenden Stack-
|
||||
* Eintrag des anderen Players — und sofort wieder abandonen. Spotify
|
||||
* bekommt den Focus-Gain und resumed.
|
||||
*
|
||||
* Workaround fuer das react-native-sound-Bug: Sound.stop()/release()
|
||||
* laesst den AudioFocusRequest haengen.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun kickReleaseMedia(promise: Promise) {
|
||||
val am = audioManager()
|
||||
if (am == null) {
|
||||
promise.resolve(false)
|
||||
return
|
||||
}
|
||||
// Async laufen lassen — wir wollen einen request, Pause, dann abandon.
|
||||
// Ohne Pause merkt das System (und damit Spotify) die kurze Owner-
|
||||
// Wechsel oft gar nicht. 250ms reicht erfahrungsgemaess fuer den
|
||||
// Focus-Stack-Refresh.
|
||||
Thread {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val attrs = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
|
||||
val kickReq = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(attrs)
|
||||
.setOnAudioFocusChangeListener(kickListener)
|
||||
.build()
|
||||
am.requestAudioFocus(kickReq)
|
||||
Thread.sleep(250)
|
||||
am.abandonAudioFocusRequest(kickReq)
|
||||
} else {
|
||||
val kickListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
|
||||
@Suppress("DEPRECATION")
|
||||
am.requestAudioFocus(kickListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
|
||||
Thread.sleep(250)
|
||||
@Suppress("DEPRECATION")
|
||||
am.abandonAudioFocus(kickListener)
|
||||
}
|
||||
Log.i(TAG, "kickReleaseMedia: USAGE_MEDIA-Stack aufgemischt (250ms Pause)")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "kickReleaseMedia failed: ${e.message}")
|
||||
}
|
||||
}.start()
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
private fun release() {
|
||||
val am = audioManager() ?: return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
currentRequest?.let { am.abandonAudioFocusRequest(it) }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
am.abandonAudioFocus(focusListener)
|
||||
}
|
||||
currentRequest = null
|
||||
}
|
||||
|
||||
/** Aktueller Audio-Mode: NORMAL=0, IN_CALL=2, IN_COMMUNICATION=3, CALL_SCREENING=4.
|
||||
* IN_COMMUNICATION ist der typische VoIP-Anruf-Mode (WhatsApp, Signal, etc.) —
|
||||
* kann gepollt werden um zu erkennen wann der Anruf vorbei ist (zurueck NORMAL). */
|
||||
@ReactMethod
|
||||
fun getMode(promise: Promise) {
|
||||
val am = audioManager()
|
||||
if (am == null) {
|
||||
promise.resolve(0)
|
||||
return
|
||||
}
|
||||
promise.resolve(am.mode)
|
||||
}
|
||||
|
||||
@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 AudioFocusPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(AudioFocusModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
|
||||
/**
|
||||
* RN-Bridge fuer den AriaPlaybackService.
|
||||
*
|
||||
* Wird vom JS waehrend einer TTS-Wiedergabe gestartet damit Android den
|
||||
* App-Prozess nicht killt wenn die App im Hintergrund ist (= ARIA spricht
|
||||
* weiter, auch wenn Stefan die App minimiert hat).
|
||||
*
|
||||
* Service stoppt entweder explizit per stop() oder wird von Android
|
||||
* mitgekillt wenn der Prozess weg ist (was bei Foreground-Service nur
|
||||
* passiert wenn der User die App force-stopped).
|
||||
*/
|
||||
class BackgroundAudioModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
override fun getName() = "BackgroundAudio"
|
||||
|
||||
companion object { private const val TAG = "BackgroundAudio" }
|
||||
|
||||
@ReactMethod
|
||||
fun start(reason: String, promise: Promise) {
|
||||
try {
|
||||
val ctx = reactApplicationContext
|
||||
val intent = Intent(ctx, AriaPlaybackService::class.java)
|
||||
intent.putExtra(AriaPlaybackService.EXTRA_REASON, reason ?: "")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ctx.startForegroundService(intent)
|
||||
} else {
|
||||
ctx.startService(intent)
|
||||
}
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "start fehlgeschlagen: ${e.message}")
|
||||
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun stop(promise: Promise) {
|
||||
try {
|
||||
val ctx = reactApplicationContext
|
||||
ctx.stopService(Intent(ctx, AriaPlaybackService::class.java))
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "stop fehlgeschlagen: ${e.message}")
|
||||
promise.reject("STOP_FAILED", e.message ?: "Unbekannter Fehler", e)
|
||||
}
|
||||
}
|
||||
|
||||
@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 BackgroundAudioPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(BackgroundAudioModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.content.FileProvider
|
||||
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 java.io.File
|
||||
|
||||
/**
|
||||
* Oeffnet eine beliebige Datei (PDF, Bild, Office-Doc, ...) mit der vom User
|
||||
* gewaehlten App via Android-Intent-Picker. Nutzt FileProvider damit auch
|
||||
* Android 7+ (content:// statt file://) das URI lesen darf.
|
||||
*
|
||||
* MIME-Type wird vom Caller bestimmt — App-Auswahl ist davon abhaengig (PDF
|
||||
* geht an PDF-Viewer, image/jpeg an Galerie, etc.).
|
||||
*/
|
||||
class FileOpenerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
override fun getName() = "FileOpener"
|
||||
|
||||
@ReactMethod
|
||||
fun open(filePath: String, mimeType: String, promise: Promise) {
|
||||
try {
|
||||
val cleanPath = filePath.removePrefix("file://")
|
||||
val file = File(cleanPath)
|
||||
if (!file.exists()) {
|
||||
promise.reject("FILE_NOT_FOUND", "Datei nicht gefunden: $cleanPath")
|
||||
return
|
||||
}
|
||||
val context = reactApplicationContext
|
||||
val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
} else {
|
||||
Uri.fromFile(file)
|
||||
}
|
||||
val safeMime = if (mimeType.isBlank()) "application/octet-stream" else mimeType
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, safeMime)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
// Chooser zeigt Android-Auswahl falls mehrere Apps das MIME oeffnen koennen.
|
||||
val chooser = Intent.createChooser(intent, "Oeffnen mit").apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(chooser)
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("OPEN_ERROR", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,12 @@ class MainApplication : Application(), ReactApplication {
|
||||
object : DefaultReactNativeHost(this) {
|
||||
override fun getPackages(): List<ReactPackage> =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// add(MyReactNativePackage())
|
||||
add(ApkInstallerPackage())
|
||||
add(AudioFocusPackage())
|
||||
add(PcmStreamPlayerPackage())
|
||||
add(OpenWakeWordPackage())
|
||||
add(PhoneCallPackage())
|
||||
add(BackgroundAudioPackage())
|
||||
}
|
||||
|
||||
override fun getJSMainModuleName(): String = "index"
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import ai.onnxruntime.OnnxTensor
|
||||
import ai.onnxruntime.OrtEnvironment
|
||||
import ai.onnxruntime.OrtSession
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.media.audiofx.AcousticEchoCanceler
|
||||
import android.media.audiofx.AutomaticGainControl
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
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.modules.core.DeviceEventManagerModule
|
||||
import java.nio.FloatBuffer
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Wake-Word Erkennung on-device via openWakeWord (https://github.com/dscripka/openWakeWord).
|
||||
*
|
||||
* Drei-stufige ONNX Pipeline:
|
||||
* 1. Audio (16kHz mono int16, 1280-Sample-Chunks) → Melspectrogram → 32-mel Frames
|
||||
* 2. 76 Mel-Frames Sliding Window (stride 8) → Speech-Embedding → 96-dim Vektor
|
||||
* 3. Letzte 16 Embeddings (~1.28s Kontext) → Wake-Word-Klassifikator → Sigmoid-Score
|
||||
*
|
||||
* Modelle liegen in assets/openwakeword/ (mel + embedding shared, plus pro Keyword
|
||||
* ein eigenes .onnx). Erkennung feuert nach `patience` aufeinanderfolgenden
|
||||
* Frames ueber `threshold` und unterdrueckt Wiederholungen fuer `debounceMs`.
|
||||
*
|
||||
* Emittiert "WakeWordDetected" als RN-Event wenn ein Trigger erkannt wurde.
|
||||
*/
|
||||
class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
override fun getName() = "OpenWakeWord"
|
||||
|
||||
companion object {
|
||||
private const val TAG = "OpenWakeWord"
|
||||
private const val SAMPLE_RATE = 16000
|
||||
private const val CHUNK_SAMPLES = 1280 // 80ms @ 16kHz
|
||||
private const val MEL_FRAMES_PER_EMBEDDING = 76 // Embedding-Fenster
|
||||
private const val EMBEDDING_STRIDE = 8 // Slide um 8 Mel-Frames
|
||||
private const val EMBEDDING_DIM = 96
|
||||
private const val MEL_BINS = 32
|
||||
private const val DEFAULT_WW_INPUT_FRAMES = 16 // Fallback wenn Modell-Metadata fehlt
|
||||
}
|
||||
|
||||
private val env: OrtEnvironment = OrtEnvironment.getEnvironment()
|
||||
private var melSession: OrtSession? = null
|
||||
private var embSession: OrtSession? = null
|
||||
private var wwSession: OrtSession? = null
|
||||
|
||||
private var melInputName: String = "input"
|
||||
private var embInputName: String = "input_1"
|
||||
private var wwInputName: String = "input"
|
||||
// Anzahl Embedding-Frames die der Wake-Word-Klassifikator pro Inferenz erwartet —
|
||||
// hey_jarvis hat 16, andere Community-Modelle koennen abweichen (z.B. 28).
|
||||
// Wird beim init() aus den Modell-Metadaten gelesen.
|
||||
private var wwInputFrames: Int = DEFAULT_WW_INPUT_FRAMES
|
||||
|
||||
// Konfiguration
|
||||
private var threshold: Float = 0.5f
|
||||
private var patience: Int = 2
|
||||
private var debounceMs: Long = 1500
|
||||
private var modelName: String = "hey_jarvis"
|
||||
|
||||
// Audio-Capture-Thread
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private val running = AtomicBoolean(false)
|
||||
private var captureThread: Thread? = null
|
||||
|
||||
// Audio-Effects: Echo-Cancellation (gegen ARIAs eigene TTS-Stimme die sonst
|
||||
// das Wake-Word triggern wuerde) + Noise-Suppression. Per VOICE_COMMUNICATION
|
||||
// Audio-Source schon vorhanden, aber explizites Aktivieren ist robuster.
|
||||
private var aec: AcousticEchoCanceler? = null
|
||||
private var ns: NoiseSuppressor? = null
|
||||
private var agc: AutomaticGainControl? = null
|
||||
|
||||
// Inferenz-State
|
||||
private val melBuffer: ArrayList<FloatArray> = ArrayList(256) // Liste von 32-dim Frames
|
||||
private var melProcessedIdx: Int = 0
|
||||
private val embBuffer: ArrayDeque<FloatArray> = ArrayDeque(32) // Ringpuffer letzter Embeddings
|
||||
private var consecutiveAboveThreshold: Int = 0
|
||||
private var lastDetectionMs: Long = 0L
|
||||
|
||||
/**
|
||||
* Initialisiert die ONNX-Sessions fuer ein bestimmtes Wake-Word.
|
||||
* modelName: dateiname ohne Suffix (z.B. "hey_jarvis", "alexa", "hey_mycroft", "hey_rhasspy")
|
||||
*/
|
||||
@ReactMethod
|
||||
fun init(modelName: String, threshold: Double, patience: Int, debounceMs: Int, promise: Promise) {
|
||||
try {
|
||||
disposeSessions()
|
||||
this.modelName = modelName
|
||||
this.threshold = threshold.toFloat()
|
||||
this.patience = patience.coerceAtLeast(1)
|
||||
this.debounceMs = debounceMs.toLong()
|
||||
|
||||
val ctx = reactApplicationContext
|
||||
val melBytes = ctx.assets.open("openwakeword/melspectrogram.onnx").use { it.readBytes() }
|
||||
val embBytes = ctx.assets.open("openwakeword/embedding_model.onnx").use { it.readBytes() }
|
||||
val wwBytes = ctx.assets.open("openwakeword/$modelName.onnx").use { it.readBytes() }
|
||||
|
||||
val opts = OrtSession.SessionOptions()
|
||||
melSession = env.createSession(melBytes, opts)
|
||||
embSession = env.createSession(embBytes, opts)
|
||||
wwSession = env.createSession(wwBytes, opts)
|
||||
|
||||
melInputName = melSession!!.inputNames.first()
|
||||
embInputName = embSession!!.inputNames.first()
|
||||
wwInputName = wwSession!!.inputNames.first()
|
||||
|
||||
// WW-Input-Frame-Count aus dem Modell lesen — variiert pro Keyword.
|
||||
// Erwartete Form: (1, N, 96), N steht in der Modell-Metadaten.
|
||||
val wwInputInfo = wwSession!!.inputInfo[wwInputName]
|
||||
val wwShape = (wwInputInfo?.info as? ai.onnxruntime.TensorInfo)?.shape
|
||||
wwInputFrames = wwShape?.getOrNull(1)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_WW_INPUT_FRAMES
|
||||
|
||||
Log.i(TAG, "Init OK: model=$modelName wwFrames=$wwInputFrames threshold=$threshold patience=$patience " +
|
||||
"debounce=${debounceMs}ms (inputs: mel=$melInputName emb=$embInputName ww=$wwInputName)")
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Init fehlgeschlagen: ${e.message}", e)
|
||||
disposeSessions()
|
||||
promise.reject("INIT_FAILED", e.message ?: "Unbekannter Fehler", e)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun start(promise: Promise) {
|
||||
if (running.get()) {
|
||||
promise.resolve(true)
|
||||
return
|
||||
}
|
||||
if (melSession == null || embSession == null || wwSession == null) {
|
||||
promise.reject("NOT_INITIALIZED", "init() muss vor start() aufgerufen werden")
|
||||
return
|
||||
}
|
||||
// Berechtigung pruefen — der App-Code holt die ueblicherweise schon vorher,
|
||||
// aber wir bestehen hier explizit darauf damit AudioRecord nicht stumm
|
||||
// failt.
|
||||
val perm = ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.RECORD_AUDIO)
|
||||
if (perm != PackageManager.PERMISSION_GRANTED) {
|
||||
promise.reject("NO_MIC_PERMISSION", "RECORD_AUDIO Permission fehlt")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val minBuf = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
).coerceAtLeast(CHUNK_SAMPLES * 2 * 4)
|
||||
|
||||
// VOICE_COMMUNICATION-Source: aktiviert auf den meisten Android-Geraeten
|
||||
// automatisch Echo-Cancellation + Noise-Suppression. Wichtig damit
|
||||
// ARIAs eigene Stimme nicht das Wake-Word triggert wenn parallel
|
||||
// zur TTS-Wiedergabe gelauscht wird.
|
||||
val record = AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
||||
SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
minBuf,
|
||||
)
|
||||
if (record.state != AudioRecord.STATE_INITIALIZED) {
|
||||
record.release()
|
||||
promise.reject("AUDIO_INIT", "AudioRecord nicht initialisiert (Mikro belegt?)")
|
||||
return
|
||||
}
|
||||
audioRecord = record
|
||||
|
||||
// Audio-Effects ZUSAETZLICH explizit aktivieren — manche Geraete
|
||||
// benoetigen das, obwohl VOICE_COMMUNICATION es eigentlich schon
|
||||
// mitbringt. Failure ist nicht kritisch (continue ohne Effects).
|
||||
try {
|
||||
if (AcousticEchoCanceler.isAvailable()) {
|
||||
aec = AcousticEchoCanceler.create(record.audioSessionId)?.apply { enabled = true }
|
||||
Log.i(TAG, "AEC aktiviert (enabled=${aec?.enabled})")
|
||||
}
|
||||
} catch (e: Exception) { Log.w(TAG, "AEC failed: ${e.message}") }
|
||||
try {
|
||||
if (NoiseSuppressor.isAvailable()) {
|
||||
ns = NoiseSuppressor.create(record.audioSessionId)?.apply { enabled = true }
|
||||
}
|
||||
} catch (e: Exception) { Log.w(TAG, "NS failed: ${e.message}") }
|
||||
try {
|
||||
if (AutomaticGainControl.isAvailable()) {
|
||||
agc = AutomaticGainControl.create(record.audioSessionId)?.apply { enabled = true }
|
||||
}
|
||||
} catch (e: Exception) { Log.w(TAG, "AGC failed: ${e.message}") }
|
||||
|
||||
resetInferenceState()
|
||||
running.set(true)
|
||||
record.startRecording()
|
||||
|
||||
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
|
||||
isDaemon = true
|
||||
start()
|
||||
}
|
||||
|
||||
Log.i(TAG, "Lauschen gestartet (model=$modelName)")
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "start fehlgeschlagen", e)
|
||||
running.set(false)
|
||||
audioRecord?.release()
|
||||
audioRecord = null
|
||||
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseAudioEffects() {
|
||||
try { aec?.release() } catch (_: Exception) {}
|
||||
try { ns?.release() } catch (_: Exception) {}
|
||||
try { agc?.release() } catch (_: Exception) {}
|
||||
aec = null; ns = null; agc = null
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun stop(promise: Promise) {
|
||||
running.set(false)
|
||||
try {
|
||||
captureThread?.join(1500)
|
||||
} catch (_: InterruptedException) {}
|
||||
captureThread = null
|
||||
try { audioRecord?.stop() } catch (_: Exception) {}
|
||||
try { audioRecord?.release() } catch (_: Exception) {}
|
||||
audioRecord = null
|
||||
releaseAudioEffects()
|
||||
Log.i(TAG, "Lauschen gestoppt")
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun dispose(promise: Promise) {
|
||||
running.set(false)
|
||||
try { captureThread?.join(1000) } catch (_: InterruptedException) {}
|
||||
captureThread = null
|
||||
try { audioRecord?.stop() } catch (_: Exception) {}
|
||||
try { audioRecord?.release() } catch (_: Exception) {}
|
||||
audioRecord = null
|
||||
releaseAudioEffects()
|
||||
disposeSessions()
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun isAvailable(promise: Promise) {
|
||||
// Wake-Word ist immer verfuegbar (kein API-Key, alles on-device)
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
// RN-Event-Subscriptions — RN-Konvention, sonst Warnung im Debug-Build
|
||||
@ReactMethod fun addListener(eventName: String) {}
|
||||
@ReactMethod fun removeListeners(count: Int) {}
|
||||
|
||||
private fun disposeSessions() {
|
||||
try { melSession?.close() } catch (_: Exception) {}
|
||||
try { embSession?.close() } catch (_: Exception) {}
|
||||
try { wwSession?.close() } catch (_: Exception) {}
|
||||
melSession = null
|
||||
embSession = null
|
||||
wwSession = null
|
||||
}
|
||||
|
||||
private fun resetInferenceState() {
|
||||
melBuffer.clear()
|
||||
melProcessedIdx = 0
|
||||
embBuffer.clear()
|
||||
consecutiveAboveThreshold = 0
|
||||
lastDetectionMs = 0L
|
||||
}
|
||||
|
||||
private fun emitDetected() {
|
||||
val params = com.facebook.react.bridge.Arguments.createMap().apply {
|
||||
putString("model", modelName)
|
||||
}
|
||||
try {
|
||||
reactApplicationContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("WakeWordDetected", params)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "emit fehlgeschlagen: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureLoop() {
|
||||
val buf = ShortArray(CHUNK_SAMPLES)
|
||||
val record = audioRecord ?: return
|
||||
Log.i(TAG, "Capture-Loop gestartet")
|
||||
while (running.get()) {
|
||||
var read = 0
|
||||
while (read < CHUNK_SAMPLES && running.get()) {
|
||||
val n = record.read(buf, read, CHUNK_SAMPLES - read)
|
||||
if (n <= 0) {
|
||||
Log.w(TAG, "AudioRecord.read returned $n — Loop ende")
|
||||
running.set(false)
|
||||
return
|
||||
}
|
||||
read += n
|
||||
}
|
||||
if (!running.get()) break
|
||||
try {
|
||||
processChunk(buf)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "processChunk: ${e.message}")
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Capture-Loop beendet")
|
||||
}
|
||||
|
||||
/** Verarbeitet einen 1280-Sample int16 Audio-Chunk. */
|
||||
private fun processChunk(audio: ShortArray) {
|
||||
// 1) Audio → mel (output (1, 1, frames, 32))
|
||||
val floats = FloatArray(audio.size) { audio[it].toFloat() }
|
||||
val melTensor = OnnxTensor.createTensor(
|
||||
env,
|
||||
FloatBuffer.wrap(floats),
|
||||
longArrayOf(1L, audio.size.toLong()),
|
||||
)
|
||||
val melResult = melSession!!.run(mapOf(melInputName to melTensor))
|
||||
val melOut = melResult.get(0).value
|
||||
melTensor.close()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val mel4 = melOut as Array<Array<Array<FloatArray>>>
|
||||
val frames = mel4[0][0]
|
||||
// openWakeWord wendet `mel/10 + 2` an, bevor es ans Embedding-Modell geht
|
||||
for (frame in frames) {
|
||||
val scaled = FloatArray(frame.size) { frame[it] / 10f + 2f }
|
||||
melBuffer.add(scaled)
|
||||
}
|
||||
melResult.close()
|
||||
|
||||
// 2) Sliding window: alle vollstaendigen 76-Frame-Fenster verarbeiten
|
||||
while (melBuffer.size >= melProcessedIdx + MEL_FRAMES_PER_EMBEDDING) {
|
||||
val flat = FloatArray(MEL_FRAMES_PER_EMBEDDING * MEL_BINS)
|
||||
var pos = 0
|
||||
for (i in 0 until MEL_FRAMES_PER_EMBEDDING) {
|
||||
val src = melBuffer[melProcessedIdx + i]
|
||||
System.arraycopy(src, 0, flat, pos, MEL_BINS)
|
||||
pos += MEL_BINS
|
||||
}
|
||||
val embIn = OnnxTensor.createTensor(
|
||||
env,
|
||||
FloatBuffer.wrap(flat),
|
||||
longArrayOf(1L, MEL_FRAMES_PER_EMBEDDING.toLong(), MEL_BINS.toLong(), 1L),
|
||||
)
|
||||
val embRes = embSession!!.run(mapOf(embInputName to embIn))
|
||||
val embOut = embRes.get(0).value
|
||||
embIn.close()
|
||||
// Erwartete Output-Form: (1, 1, 1, 96) — rank-4, NICHT (1, 96).
|
||||
// Die Google-Embedding-Pipeline behaelt extra Dimensionen.
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val embArr = embOut as Array<Array<Array<FloatArray>>>
|
||||
embBuffer.addLast(embArr[0][0][0].copyOf())
|
||||
while (embBuffer.size > wwInputFrames) embBuffer.removeFirst()
|
||||
embRes.close()
|
||||
|
||||
melProcessedIdx += EMBEDDING_STRIDE
|
||||
}
|
||||
// Mel-Buffer trimmen — verhindert Memory-Wachstum
|
||||
if (melProcessedIdx > MEL_FRAMES_PER_EMBEDDING) {
|
||||
val keepFrom = melProcessedIdx - MEL_FRAMES_PER_EMBEDDING
|
||||
val newList = ArrayList<FloatArray>(melBuffer.size - keepFrom)
|
||||
for (i in keepFrom until melBuffer.size) newList.add(melBuffer[i])
|
||||
melBuffer.clear()
|
||||
melBuffer.addAll(newList)
|
||||
melProcessedIdx = MEL_FRAMES_PER_EMBEDDING
|
||||
}
|
||||
|
||||
// 3) Klassifikation — sobald wir 16 Embeddings haben
|
||||
if (embBuffer.size < wwInputFrames) return
|
||||
val flatEmb = FloatArray(wwInputFrames * EMBEDDING_DIM)
|
||||
var p = 0
|
||||
// Letzte wwInputFrames Embeddings nehmen (embBuffer ist auf wwInputFrames begrenzt)
|
||||
for (e in embBuffer) {
|
||||
System.arraycopy(e, 0, flatEmb, p, EMBEDDING_DIM)
|
||||
p += EMBEDDING_DIM
|
||||
}
|
||||
val wwIn = OnnxTensor.createTensor(
|
||||
env,
|
||||
FloatBuffer.wrap(flatEmb),
|
||||
longArrayOf(1L, wwInputFrames.toLong(), EMBEDDING_DIM.toLong()),
|
||||
)
|
||||
val wwRes = wwSession!!.run(mapOf(wwInputName to wwIn))
|
||||
val wwOut = wwRes.get(0).value
|
||||
wwIn.close()
|
||||
// Erwartete Output-Form: (1, 1) → Array<FloatArray>
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val score = (wwOut as Array<FloatArray>)[0][0]
|
||||
wwRes.close()
|
||||
|
||||
if (score >= threshold) {
|
||||
consecutiveAboveThreshold++
|
||||
if (consecutiveAboveThreshold >= patience) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastDetectionMs >= debounceMs) {
|
||||
lastDetectionMs = now
|
||||
consecutiveAboveThreshold = 0
|
||||
Log.i(TAG, "Wake-Word erkannt! score=$score model=$modelName")
|
||||
emitDetected()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
consecutiveAboveThreshold = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 OpenWakeWordPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(OpenWakeWordModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.os.Build
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
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.modules.core.DeviceEventManagerModule
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
|
||||
/**
|
||||
* Streamt PCM-s16le Audio direkt via AudioTrack MODE_STREAM mit Pre-Roll.
|
||||
*
|
||||
* Pre-Roll: AudioTrack wird zwar direkt gebaut und gefuttert, aber play()
|
||||
* wird erst aufgerufen wenn PREROLL_SECONDS Audio im Buffer ist. So hat
|
||||
* der Stream Zeit einen Vorrat aufzubauen — wenn XTTS mit RTF>1 rendert
|
||||
* (langsamer als Echtzeit), laeuft der Buffer trotzdem nicht leer.
|
||||
*
|
||||
* Flow:
|
||||
* JS: start(sampleRate, channels) → öffnet AudioTrack (noch nicht play())
|
||||
* JS: writeChunk(base64) → dekodiert, queued, Writer schreibt
|
||||
* Writer: spielt los sobald PREROLL erreicht ist
|
||||
* JS: end() → wartet bis Queue leer, schließt
|
||||
* JS: stop() → Hart stoppen (Cancel)
|
||||
*/
|
||||
class PcmStreamPlayerModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
companion object {
|
||||
private const val TAG = "PcmStreamPlayer"
|
||||
// Fallback wenn JS keinen Wert uebergibt.
|
||||
private const val DEFAULT_PREROLL_SECONDS = 3.5
|
||||
// 0.0 = sofortige Wiedergabe — play() direkt beim ersten Chunk.
|
||||
// Macht Sinn fuer F5-TTS weil Render so schnell ist dass ein Puffer
|
||||
// unnoetig ist und bei kurzen Saetzen sogar stoeren kann.
|
||||
private const val MIN_PREROLL_SECONDS = 0.0
|
||||
private const val MAX_PREROLL_SECONDS = 10.0
|
||||
// Stille am Stream-Anfang, damit AudioTrack sauber anfaehrt und die
|
||||
// ersten Samples nicht abgeschnitten werden (XTTS-Warmup + play()-Latenz).
|
||||
private const val LEADING_SILENCE_SECONDS = 0.3
|
||||
// Stille am Ende — puffert das Hardware-Flushen damit die letzten
|
||||
// echten Samples garantiert ausgespielt werden bevor stop() kommt.
|
||||
private const val TRAILING_SILENCE_SECONDS = 0.3
|
||||
}
|
||||
|
||||
override fun getName() = "PcmStreamPlayer"
|
||||
|
||||
private var track: AudioTrack? = null
|
||||
private val queue = LinkedBlockingQueue<ByteArray>()
|
||||
private var writerThread: Thread? = null
|
||||
@Volatile private var writerShouldStop = false
|
||||
@Volatile private var endRequested = false
|
||||
@Volatile private var prerollBytes: Int = 0
|
||||
@Volatile private var playbackStarted = false
|
||||
@Volatile private var bytesBuffered: Long = 0
|
||||
@Volatile private var streamBytesPerFrame: Int = 2 // mono s16le default
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
@ReactMethod
|
||||
fun start(sampleRate: Int, channels: Int, prerollSeconds: Double, promise: Promise) {
|
||||
try {
|
||||
// Alte Session beenden falls vorhanden
|
||||
stopInternal()
|
||||
|
||||
// Nur NaN/Inf → Default. 0.0 ist gueltig (= sofortige Wiedergabe).
|
||||
val prerollSec = if (prerollSeconds.isFinite() && prerollSeconds >= 0.0) {
|
||||
prerollSeconds.coerceIn(MIN_PREROLL_SECONDS, MAX_PREROLL_SECONDS)
|
||||
} else {
|
||||
DEFAULT_PREROLL_SECONDS
|
||||
}
|
||||
|
||||
val channelConfig = if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO
|
||||
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
||||
val minBuf = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding)
|
||||
val bytesPerSecond = sampleRate * channels * 2 // 16-bit = 2 bytes
|
||||
val prerollTarget = (bytesPerSecond * prerollSec).toInt()
|
||||
// Buffer entkoppelt von Preroll — fester ~4s-Buffer. OnePlus A12
|
||||
// mit USAGE_ASSISTANT laeuft AudioTrack erst ab ~3s gepufferter
|
||||
// Daten an. Wir padden Kurztexte vor play() auf 3s (siehe Block
|
||||
// nach mainLoop), Buffer braucht ~1s Headroom weil write() blockt.
|
||||
val bufferSize = (bytesPerSecond * 4).coerceAtLeast(minBuf * 8)
|
||||
prerollBytes = prerollTarget
|
||||
bytesBuffered = 0
|
||||
playbackStarted = false
|
||||
streamBytesPerFrame = channels * 2 // s16 = 2 bytes per sample
|
||||
|
||||
val newTrack = AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ASSISTANT)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(channelConfig)
|
||||
.setEncoding(encoding)
|
||||
.build(),
|
||||
)
|
||||
.setBufferSizeInBytes(bufferSize)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.build()
|
||||
|
||||
// Start-Threshold runterdrehen: Default ist bufferSize/2 (= 2s bei 4s
|
||||
// Buffer). AudioTrack startet sonst nicht bevor 2s im Puffer sind —
|
||||
// bei kurzen TTS-Antworten (3 Worte ~ 1.4s) bleibt pos auf 0 stehen.
|
||||
// 0.1s reicht damit AudioTrack sofort mit dem ersten Chunk anlaeuft.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
try {
|
||||
val startFrames = (sampleRate / 10).coerceAtLeast(1) // 100ms
|
||||
newTrack.setStartThresholdInFrames(startFrames)
|
||||
Log.i(TAG, "Start-Threshold gesetzt: ${startFrames} frames (~100ms)")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "setStartThresholdInFrames failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
track = newTrack
|
||||
queue.clear()
|
||||
writerShouldStop = false
|
||||
endRequested = false
|
||||
|
||||
writerThread = Thread({
|
||||
val t = track ?: return@Thread
|
||||
try {
|
||||
// Leading-Silence in den Buffer — gibt AudioTrack Zeit anzufahren.
|
||||
val leadingBytes = ((sampleRate * channels * 2) * LEADING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE
|
||||
if (leadingBytes > 0) {
|
||||
val silence = ByteArray(leadingBytes)
|
||||
var silOff = 0
|
||||
while (silOff < silence.size && !writerShouldStop) {
|
||||
val w = t.write(silence, silOff, silence.size - silOff)
|
||||
if (w <= 0) break
|
||||
silOff += w
|
||||
}
|
||||
bytesBuffered += silence.size
|
||||
}
|
||||
// Bei preroll=0: play() SOFORT nach Leading-Silence aufrufen,
|
||||
// nicht erst bei Ankunft des ersten echten Chunks. Android's
|
||||
// AudioTrack haelt den Play-State und wartet auf neue Samples.
|
||||
// So verschluckt es keine Worte wenn der erste Chunk erst
|
||||
// nach play()-Startup-Latenz eintrifft.
|
||||
if (prerollBytes == 0 && !playbackStarted) {
|
||||
try {
|
||||
t.play()
|
||||
playbackStarted = true
|
||||
Log.i(TAG, "Playback sofort gestartet (preroll=0, ${bytesBuffered}B silence)")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "play() sofort failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
// Idle-Cutoff: wenn endRequested NICHT kam aber lange nichts mehr
|
||||
// reinkommt, brechen wir ab (Bridge-Crash, verlorener final).
|
||||
// 120s damit lange F5-TTS-Render-Pausen zwischen Saetzen (z.B. bei
|
||||
// Modell-Wechsel oder kalter GPU) nicht den Stream abreissen.
|
||||
var idleMs = 0L
|
||||
val maxIdleMs = 120_000L
|
||||
// Zielpufferfuellung — unter diesem Wasserstand fuettern wir
|
||||
// Stille rein damit AudioTrack nicht underrunt waehrend die
|
||||
// Bridge den naechsten Satz rendert. Spotify/YouTube reagieren
|
||||
// sonst mit eigenmaechtiger Wiederaufnahme nach ~10s Stille.
|
||||
val underrunGuardFrames = sampleRate / 10 // ~100ms
|
||||
val silenceFillFrames = sampleRate / 20 // ~50ms pro Refill
|
||||
|
||||
mainLoop@ while (!writerShouldStop) {
|
||||
val data = queue.poll(50, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
if (data == null) {
|
||||
if (endRequested) {
|
||||
// Falls play() noch gar nicht lief (Stream ohne data
|
||||
// ueberhaupt — sehr seltene Edge-Case): jetzt anstossen
|
||||
// damit das finally{}-Wait nicht endlos blockt.
|
||||
if (!playbackStarted) {
|
||||
try { t.play(); playbackStarted = true } catch (_: Exception) {}
|
||||
}
|
||||
break@mainLoop
|
||||
}
|
||||
// Underrun-Schutz: Stille reinfuettern wenn der AudioTrack-
|
||||
// Puffer leerzulaufen droht. Spotify resumed sonst nach
|
||||
// ~10s Pause auf eigene Faust, obwohl wir den Fokus halten.
|
||||
if (playbackStarted) {
|
||||
val framesWritten = bytesBuffered / streamBytesPerFrame
|
||||
val framesPlayed = t.playbackHeadPosition.toLong()
|
||||
val framesInBuffer = framesWritten - framesPlayed
|
||||
if (framesInBuffer < underrunGuardFrames) {
|
||||
val fillBytes = silenceFillFrames * streamBytesPerFrame
|
||||
val silence = ByteArray(fillBytes)
|
||||
var silOff = 0
|
||||
while (silOff < silence.size && !writerShouldStop) {
|
||||
val w = t.write(silence, silOff, silence.size - silOff)
|
||||
if (w <= 0) break
|
||||
silOff += w
|
||||
}
|
||||
bytesBuffered += silence.size
|
||||
}
|
||||
}
|
||||
idleMs += 50L
|
||||
if (idleMs >= maxIdleMs) {
|
||||
Log.w(TAG, "Idle-Cutoff: ${maxIdleMs}ms keine Daten — Stream wird beendet")
|
||||
break@mainLoop
|
||||
}
|
||||
continue@mainLoop
|
||||
}
|
||||
idleMs = 0L
|
||||
|
||||
// play() beim ALLERERSTEN data-chunk aufrufen — egal wie wenig
|
||||
// Daten da sind. Sonst stallt AudioTrack auf OnePlus A12 wenn
|
||||
// play() erst gerufen wird nachdem der Buffer komplett gefuellt
|
||||
// ist. Pre-Roll als "Vorrat aufbauen" passiert dann waehrend
|
||||
// der Track schon spielt — Underrun-Schutz fuettert ggf. Stille.
|
||||
if (!playbackStarted) {
|
||||
try {
|
||||
t.play()
|
||||
playbackStarted = true
|
||||
Log.i(TAG, "Playback gestartet beim 1. Chunk (${bytesBuffered}B leading + ${data.size}B data)")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "play() failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
var offset = 0
|
||||
while (offset < data.size && !writerShouldStop) {
|
||||
val written = t.write(data, offset, data.size - offset)
|
||||
if (written <= 0) break
|
||||
offset += written
|
||||
}
|
||||
bytesBuffered += data.size
|
||||
}
|
||||
// Trailing-Silence damit die letzten echten Samples garantiert
|
||||
// durch das Hardware-Buffering kommen bevor stop() sie abschneidet
|
||||
val trailingBytes = ((sampleRate * channels * 2) * TRAILING_SILENCE_SECONDS).toInt() and 0x7FFFFFFE
|
||||
if (trailingBytes > 0 && !writerShouldStop) {
|
||||
val silence = ByteArray(trailingBytes)
|
||||
var silOff = 0
|
||||
while (silOff < silence.size && !writerShouldStop) {
|
||||
val w = t.write(silence, silOff, silence.size - silOff)
|
||||
if (w <= 0) break
|
||||
silOff += w
|
||||
}
|
||||
bytesBuffered += silence.size
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Writer-Thread Fehler: ${e.message}")
|
||||
} finally {
|
||||
// Warten bis alle geschriebenen Samples tatsaechlich abgespielt sind,
|
||||
// sonst cuttet t.release() die letzten Sekunden ab.
|
||||
try {
|
||||
val totalFrames = (bytesBuffered / streamBytesPerFrame).toInt()
|
||||
var lastPos = -1
|
||||
var stalledCount = 0
|
||||
var retried = false
|
||||
while (!writerShouldStop) {
|
||||
val pos = t.playbackHeadPosition
|
||||
if (pos >= totalFrames) break
|
||||
if (pos == lastPos) {
|
||||
stalledCount++
|
||||
// Nach 500ms Stillstand: AudioTrack-Quirk auf manchen
|
||||
// Geraeten (OnePlus A12) — play() nochmal anstossen.
|
||||
if (stalledCount == 10 && pos == 0 && !retried) {
|
||||
retried = true
|
||||
Log.w(TAG, "playback nicht angefahren — retry play()")
|
||||
try { t.play() } catch (e: Exception) {
|
||||
Log.w(TAG, "retry play() failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
if (stalledCount > 40) {
|
||||
Log.w(TAG, "playback stalled at $pos/$totalFrames — give up")
|
||||
break
|
||||
}
|
||||
} else {
|
||||
stalledCount = 0
|
||||
lastPos = pos
|
||||
}
|
||||
Thread.sleep(50)
|
||||
}
|
||||
Log.i(TAG, "Playback fertig: frames=$totalFrames pos=${t.playbackHeadPosition}")
|
||||
} catch (_: Exception) {}
|
||||
try { t.stop() } catch (_: Exception) {}
|
||||
try { t.release() } catch (_: Exception) {}
|
||||
// RN-Event: AudioTrack ist wirklich durch (alle Samples gespielt).
|
||||
// JS released erst JETZT den AudioFocus — sonst spielt Spotify
|
||||
// beim end()-Cap waehrend ARIA noch redet (15s+ je nach Buffer).
|
||||
try {
|
||||
val params = Arguments.createMap()
|
||||
reactApplicationContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("PcmPlaybackFinished", params)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "PlaybackFinished emit failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}, "PcmStreamWriter").apply { start() }
|
||||
|
||||
Log.i(TAG, "Stream gestartet: ${sampleRate}Hz ch=$channels buf=${bufferSize}B preroll=${prerollBytes}B (${prerollSec}s)")
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "start fehlgeschlagen", e)
|
||||
promise.reject("START_FAILED", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun writeChunk(base64Pcm: String, promise: Promise) {
|
||||
try {
|
||||
if (base64Pcm.isEmpty()) {
|
||||
promise.resolve(true)
|
||||
return
|
||||
}
|
||||
val bytes = Base64.decode(base64Pcm, Base64.DEFAULT)
|
||||
queue.put(bytes)
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("WRITE_FAILED", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Signalisiert: keine weiteren Chunks. Writer spielt aus, dann stoppt.
|
||||
* Das Promise resolved erst wenn der Writer-Thread fertig ist —
|
||||
* wichtig damit der Aufrufer den AudioFocus erst NACH dem letzten
|
||||
* abgespielten Sample wieder freigibt (sonst dreht Spotify hoch
|
||||
* waehrend das Pre-Roll noch ausspielt).
|
||||
*/
|
||||
@ReactMethod
|
||||
fun end(promise: Promise) {
|
||||
endRequested = true
|
||||
val t = writerThread
|
||||
if (t == null || !t.isAlive) {
|
||||
promise.resolve(true)
|
||||
return
|
||||
}
|
||||
// Im Hintergrund auf den Writer warten — kein Threading-Block fuer JS-Bridge
|
||||
Thread({
|
||||
try {
|
||||
t.join(15_000) // hartes Cap, falls Writer haengt
|
||||
} catch (_: InterruptedException) {}
|
||||
promise.resolve(true)
|
||||
}, "PcmStreamEndWaiter").start()
|
||||
}
|
||||
|
||||
/** Harter Stop (Cancel) — Queue verwerfen. */
|
||||
@ReactMethod
|
||||
fun stop(promise: Promise) {
|
||||
stopInternal()
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
@ReactMethod fun addListener(eventName: String) {}
|
||||
@ReactMethod fun removeListeners(count: Int) {}
|
||||
|
||||
private fun stopInternal() {
|
||||
writerShouldStop = true
|
||||
endRequested = true
|
||||
queue.clear()
|
||||
writerThread?.interrupt()
|
||||
writerThread = null
|
||||
val t = track
|
||||
if (t != null) {
|
||||
try { t.stop() } catch (_: Exception) {}
|
||||
try { t.release() } catch (_: Exception) {}
|
||||
}
|
||||
track = null
|
||||
}
|
||||
|
||||
override fun onCatalystInstanceDestroy() {
|
||||
stopInternal()
|
||||
super.onCatalystInstanceDestroy()
|
||||
}
|
||||
}
|
||||
@@ -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 PcmStreamPlayerPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(PcmStreamPlayerModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.ariacockpit
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.telephony.PhoneStateListener
|
||||
import android.telephony.TelephonyCallback
|
||||
import android.telephony.TelephonyManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
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.modules.core.DeviceEventManagerModule
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Lauscht auf Anruf-Statusaenderungen — wenn das Telefon klingelt oder ein
|
||||
* Anruf laeuft, sendet das Modul ein "PhoneCallStateChanged"-Event an JS.
|
||||
*
|
||||
* JS-Side stoppt dann die TTS-Wiedergabe damit ARIA nicht mitten ins Gespraech
|
||||
* weiterredet. Ohne READ_PHONE_STATE-Permission failt start() leise — der Rest
|
||||
* der App funktioniert wie bisher.
|
||||
*
|
||||
* State-Strings: "idle" | "ringing" | "offhook"
|
||||
*/
|
||||
class PhoneCallModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
||||
override fun getName() = "PhoneCall"
|
||||
|
||||
companion object { private const val TAG = "PhoneCall" }
|
||||
|
||||
private var telephonyManager: TelephonyManager? = null
|
||||
private var legacyListener: PhoneStateListener? = null
|
||||
private var modernCallback: Any? = null // TelephonyCallback ab API 31
|
||||
private var lastState: Int = TelephonyManager.CALL_STATE_IDLE
|
||||
// Eigener Single-Thread-Executor statt mainExecutor — der wird bei
|
||||
// pausierter Activity verzoegert oder gar nicht abgearbeitet, der eigene
|
||||
// Thread laeuft unabhaengig solange der App-Prozess lebt (was er ja tut,
|
||||
// wir haben einen Foreground-Service der das garantiert).
|
||||
private val callbackExecutor = Executors.newSingleThreadExecutor()
|
||||
|
||||
@ReactMethod
|
||||
fun start(promise: Promise) {
|
||||
try {
|
||||
val perm = ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.READ_PHONE_STATE)
|
||||
if (perm != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.w(TAG, "READ_PHONE_STATE Permission fehlt — Anruf-Erkennung inaktiv")
|
||||
promise.resolve(false)
|
||||
return
|
||||
}
|
||||
val tm = reactApplicationContext.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
|
||||
if (tm == null) {
|
||||
Log.w(TAG, "TelephonyManager nicht verfuegbar")
|
||||
promise.resolve(false)
|
||||
return
|
||||
}
|
||||
telephonyManager = tm
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val cb = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
|
||||
override fun onCallStateChanged(state: Int) {
|
||||
handleStateChange(state)
|
||||
}
|
||||
}
|
||||
tm.registerTelephonyCallback(callbackExecutor, cb)
|
||||
modernCallback = cb
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val l = object : PhoneStateListener() {
|
||||
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
|
||||
handleStateChange(state)
|
||||
}
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
tm.listen(l, PhoneStateListener.LISTEN_CALL_STATE)
|
||||
legacyListener = l
|
||||
}
|
||||
Log.i(TAG, "PhoneCall-Listener aktiv")
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "start fehlgeschlagen", e)
|
||||
promise.reject("START_FAILED", e.message ?: "Unbekannter Fehler", e)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun stop(promise: Promise) {
|
||||
try {
|
||||
val tm = telephonyManager
|
||||
if (tm != null) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
(modernCallback as? TelephonyCallback)?.let { tm.unregisterTelephonyCallback(it) }
|
||||
modernCallback = null
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
legacyListener?.let { tm.listen(it, PhoneStateListener.LISTEN_NONE) }
|
||||
legacyListener = null
|
||||
}
|
||||
}
|
||||
telephonyManager = null
|
||||
lastState = TelephonyManager.CALL_STATE_IDLE
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("STOP_FAILED", e.message ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStateChange(state: Int) {
|
||||
if (state == lastState) return
|
||||
lastState = state
|
||||
val name = when (state) {
|
||||
TelephonyManager.CALL_STATE_RINGING -> "ringing"
|
||||
TelephonyManager.CALL_STATE_OFFHOOK -> "offhook"
|
||||
TelephonyManager.CALL_STATE_IDLE -> "idle"
|
||||
else -> return
|
||||
}
|
||||
Log.i(TAG, "Telefon-State: $name")
|
||||
val params = Arguments.createMap().apply { putString("state", name) }
|
||||
try {
|
||||
reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("PhoneCallStateChanged", params)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Event-emit fehlgeschlagen: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@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 PhoneCallPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(PhoneCallModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="cache" path="." />
|
||||
<files-path name="files" path="." />
|
||||
<external-path name="external" path="." />
|
||||
<external-files-path name="external_files" path="." />
|
||||
<external-cache-path name="external_cache" path="." />
|
||||
</paths>
|
||||
@@ -1,7 +1,9 @@
|
||||
buildscript {
|
||||
ext {
|
||||
buildToolsVersion = "34.0.0"
|
||||
minSdkVersion = 23
|
||||
// 24 = Android 7.0 (Nougat). Verlangt von Porcupine (Picovoice).
|
||||
// Realistisch eh das Minimum: alles unter 7.0 hat <1% Marktanteil.
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 34
|
||||
targetSdkVersion = 34
|
||||
ndkVersion = "25.1.8937393"
|
||||
|
||||
+15
-2
@@ -167,10 +167,23 @@ export CI=true
|
||||
|
||||
if [ "$MODE" = "debug" ]; then
|
||||
./gradlew assembleDebug
|
||||
APK_PATH="app/build/outputs/apk/debug/app-debug.apk"
|
||||
OUT_DIR="app/build/outputs/apk/debug"
|
||||
else
|
||||
./gradlew assembleRelease
|
||||
APK_PATH="app/build/outputs/apk/release/app-release.apk"
|
||||
OUT_DIR="app/build/outputs/apk/release"
|
||||
fi
|
||||
|
||||
# Mit ABI-Splits heisst die APK z.B. app-arm64-v8a-release.apk statt
|
||||
# app-release.apk. arm64-v8a-Variante zuerst probieren (das ist unser
|
||||
# Standard), Universal-APK als Fallback falls Splits deaktiviert sind.
|
||||
if [ -f "$OUT_DIR/app-arm64-v8a-${MODE}.apk" ]; then
|
||||
APK_PATH="$OUT_DIR/app-arm64-v8a-${MODE}.apk"
|
||||
elif [ -f "$OUT_DIR/app-${MODE}.apk" ]; then
|
||||
APK_PATH="$OUT_DIR/app-${MODE}.apk"
|
||||
else
|
||||
echo -e "${RED}Keine passende APK in $OUT_DIR gefunden${NC}"
|
||||
cd ..
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd ..
|
||||
|
||||
Generated
-12840
File diff suppressed because it is too large
Load Diff
+18
-18
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aria-cockpit",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.6.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
@@ -10,32 +10,32 @@
|
||||
"build:apk": "cd android && ./gradlew assembleRelease"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "^1.21.0",
|
||||
"@react-native-community/geolocation": "^3.2.1",
|
||||
"@react-navigation/bottom-tabs": "^6.5.11",
|
||||
"@react-navigation/native": "^6.1.9",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.73.4",
|
||||
"@react-navigation/native": "^6.1.9",
|
||||
"@react-navigation/bottom-tabs": "^6.5.11",
|
||||
"react-native-screens": "3.27.0",
|
||||
"react-native-safe-area-context": "^4.8.2",
|
||||
"react-native-audio-recorder-player": "^3.6.7",
|
||||
"react-native-camera-kit": "^13.0.0",
|
||||
"react-native-document-picker": "^9.1.1",
|
||||
"react-native-sound": "^0.11.2",
|
||||
"@react-native-community/geolocation": "^3.2.1",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-image-picker": "^7.1.0",
|
||||
"react-native-permissions": "^4.1.4",
|
||||
"react-native-camera-kit": "^13.0.0",
|
||||
"@react-native-async-storage/async-storage": "^1.21.0",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-audio-recorder-player": "^3.6.7",
|
||||
"react-native-live-audio-stream": "^1.1.1"
|
||||
"react-native-safe-area-context": "^4.8.2",
|
||||
"react-native-screens": "3.27.0",
|
||||
"react-native-sound": "^0.11.2",
|
||||
"react-native-svg": "^14.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"@react-native/eslint-config": "^0.73.2",
|
||||
"@react-native/metro-config": "^0.73.5",
|
||||
"@react-native/typescript-config": "^0.73.1",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-native": "^0.73.0",
|
||||
"@react-native/eslint-config": "^0.73.2",
|
||||
"@react-native/typescript-config": "^0.73.1",
|
||||
"@react-native/metro-config": "^0.73.5",
|
||||
"metro-react-native-babel-preset": "^0.77.0",
|
||||
"jest": "^29.7.0",
|
||||
"@types/jest": "^29.5.11"
|
||||
"metro-react-native-babel-preset": "^0.77.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* ErrorBoundary — fängt React-Render-Fehler und zeigt eine Error-Box
|
||||
* statt White-Screen-of-Death. Plus: Crash wird zum logger geschickt,
|
||||
* der das ueber RVS an die Bridge weiterleitet.
|
||||
*
|
||||
* Einsatz: kritische Komponenten/Modals damit ein Bug nicht die ganze
|
||||
* App killt.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { reportAppError } from '../services/logger';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
/** Optional: Bezeichnung der eingegrenzten Section fuer's Log. */
|
||||
scope?: string;
|
||||
/** Optional: Reset-Callback (z.B. Modal schliessen) — Button ist dann sichtbar. */
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
err: Error | null;
|
||||
info: string;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { err: null, info: '' };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(err: Error): Partial<State> {
|
||||
return { err };
|
||||
}
|
||||
|
||||
componentDidCatch(err: Error, info: any) {
|
||||
const stack = info?.componentStack || '';
|
||||
this.setState({ info: stack });
|
||||
reportAppError({
|
||||
scope: this.props.scope || 'ErrorBoundary',
|
||||
message: err?.message || String(err),
|
||||
stack: (err?.stack || '') + '\n--- componentStack ---\n' + stack,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.err) {
|
||||
return (
|
||||
<View style={s.box}>
|
||||
<Text style={s.title}>⚠️ Etwas ist schiefgegangen</Text>
|
||||
<Text style={s.scope}>{this.props.scope || 'unbekannte Komponente'}</Text>
|
||||
<ScrollView style={s.scroll}>
|
||||
<Text style={s.msg}>{this.state.err.message || String(this.state.err)}</Text>
|
||||
{this.state.info ? <Text style={s.stack}>{this.state.info}</Text> : null}
|
||||
</ScrollView>
|
||||
{this.props.onReset ? (
|
||||
<TouchableOpacity style={s.btn} onPress={() => { this.setState({err:null,info:''}); this.props.onReset?.(); }}>
|
||||
<Text style={s.btnText}>Schliessen + zurueck</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity style={s.btn} onPress={() => this.setState({err:null,info:''})}>
|
||||
<Text style={s.btnText}>Erneut versuchen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Text style={s.hint}>
|
||||
Crash wurde an die Bridge gemeldet — sichtbar in der Diagnostic-Web-UI unter /api/app-log
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
box: { flex:1, padding:16, backgroundColor:'#1A0A0A' },
|
||||
title: { color:'#FF6B6B', fontWeight:'bold', fontSize:16, marginBottom:6 },
|
||||
scope: { color:'#FF9500', fontSize:12, marginBottom:10 },
|
||||
scroll: { flex:1, backgroundColor:'#0D0D1A', borderRadius:6, padding:10, marginBottom:10 },
|
||||
msg: { color:'#FF6B6B', fontSize:13, marginBottom:8 },
|
||||
stack: { color:'#8888AA', fontSize:11, fontFamily:'monospace' },
|
||||
btn: { backgroundColor:'#0096FF', paddingVertical:10, borderRadius:6, alignItems:'center' },
|
||||
btnText: { color:'#fff', fontWeight:'600' },
|
||||
hint: { color:'#555570', fontSize:10, marginTop:8, textAlign:'center' },
|
||||
});
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import DocumentPicker, {
|
||||
DocumentPickerResponse,
|
||||
} from 'react-native-document-picker';
|
||||
import RNFS from 'react-native-fs';
|
||||
|
||||
// --- Typen ---
|
||||
|
||||
@@ -74,15 +75,17 @@ const FileUpload: React.FC<FileUploadProps> = ({ onFileSelected, onCancel }) =>
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// In Produktion: Datei lesen und zu Base64 konvertieren
|
||||
// const base64 = await RNFS.readFile(selectedFile.fileCopyUri || selectedFile.uri, 'base64');
|
||||
const base64Placeholder = '';
|
||||
// Datei lesen und zu Base64 konvertieren
|
||||
const filePath = selectedFile.fileCopyUri || selectedFile.uri;
|
||||
// URI-Schema entfernen fuer RNFS (file:// → absoluter Pfad)
|
||||
const cleanPath = filePath.replace('file://', '');
|
||||
const base64 = await RNFS.readFile(cleanPath, 'base64');
|
||||
|
||||
const fileData: FileData = {
|
||||
name: selectedFile.name || 'unbenannt',
|
||||
type: selectedFile.type || 'application/octet-stream',
|
||||
size: selectedFile.size || 0,
|
||||
base64: base64Placeholder,
|
||||
base64,
|
||||
uri: selectedFile.uri,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Memory-Browser — Liste mit Suche + Filter, Tap oeffnet MemoryDetailModal.
|
||||
*
|
||||
* Eingesetzt von:
|
||||
* - SettingsScreen → Sektion "Gedächtnis" (kompletter Editor)
|
||||
* - Inbox-Modal (Notizen-Button neben Lupe) — kann aber auch Bubbles
|
||||
* aus dem Chat als zusaetzlichen Filter zeigen
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
Alert,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
|
||||
import brainApi, { Memory } from '../services/brainApi';
|
||||
import MemoryDetailModal from './MemoryDetailModal';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
identity: 'Identität', rule: 'Regeln', preference: 'Präferenzen',
|
||||
tool: 'Tools', skill: 'Skills', fact: 'Fakten',
|
||||
conversation: 'Konversation', reminder: 'Reminder',
|
||||
};
|
||||
const TYPE_OPTIONS = ['', 'identity', 'rule', 'preference', 'tool', 'skill', 'fact', 'conversation', 'reminder'];
|
||||
|
||||
interface Props {
|
||||
/** Wenn gesetzt: nur diese IDs anzeigen (z.B. Inbox-Modal mit Chat-Bubbles-Filter). */
|
||||
restrictToIds?: string[];
|
||||
/** Headline ueber der Liste. */
|
||||
title?: string;
|
||||
/** Style-Erweiterung fuer den Container. */
|
||||
flatStyle?: boolean;
|
||||
/** Wenn gesetzt: kein eigenes DetailModal mounten — Parent kuemmert sich. */
|
||||
onOpenMemory?: (id: string) => void;
|
||||
}
|
||||
|
||||
export const MemoryBrowser: React.FC<Props> = ({ restrictToIds, title, flatStyle, onOpenMemory }) => {
|
||||
const [items, setItems] = useState<Memory[]>([]);
|
||||
const [filtered, setFiltered] = useState<Memory[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [q, setQ] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [pinnedFilter, setPinnedFilter] = useState<'all' | 'pinned' | 'cold'>('all');
|
||||
const [showTypeMenu, setShowTypeMenu] = useState(false);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true); setErr(null);
|
||||
brainApi.listMemories({ limit: 500 })
|
||||
.then(setItems)
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Filter clientseitig — bei kleiner DB (<1000) easy
|
||||
useEffect(() => {
|
||||
let out = items;
|
||||
if (restrictToIds && restrictToIds.length) {
|
||||
const set = new Set(restrictToIds);
|
||||
out = out.filter(m => set.has(m.id));
|
||||
}
|
||||
if (typeFilter) out = out.filter(m => m.type === typeFilter);
|
||||
if (pinnedFilter === 'pinned') out = out.filter(m => m.pinned);
|
||||
else if (pinnedFilter === 'cold') out = out.filter(m => !m.pinned);
|
||||
if (q.trim()) {
|
||||
const needle = q.toLowerCase();
|
||||
out = out.filter(m =>
|
||||
(m.title || '').toLowerCase().includes(needle) ||
|
||||
(m.content || '').toLowerCase().includes(needle) ||
|
||||
(m.category || '').toLowerCase().includes(needle) ||
|
||||
(m.tags || []).some(t => t.toLowerCase().includes(needle))
|
||||
);
|
||||
}
|
||||
setFiltered(out);
|
||||
}, [items, q, typeFilter, pinnedFilter, restrictToIds]);
|
||||
|
||||
const [showNewMemoryDialog, setShowNewMemoryDialog] = useState(false);
|
||||
const [newMemoryTitle, setNewMemoryTitle] = useState('');
|
||||
|
||||
const onAddNew = () => {
|
||||
setNewMemoryTitle('');
|
||||
setShowNewMemoryDialog(true);
|
||||
};
|
||||
|
||||
const confirmAddNew = async () => {
|
||||
const t = newMemoryTitle.trim();
|
||||
if (!t) { setShowNewMemoryDialog(false); return; }
|
||||
setShowNewMemoryDialog(false);
|
||||
try {
|
||||
const m = await brainApi.saveMemory({
|
||||
type: 'fact', title: t,
|
||||
content: '(noch leer — bitte editieren)',
|
||||
});
|
||||
load();
|
||||
if (onOpenMemory) onOpenMemory(m.id);
|
||||
else setOpenId(m.id);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Fehler', String(e?.message || e));
|
||||
}
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: Memory }) => {
|
||||
const attCount = (item.attachments || []).length;
|
||||
return (
|
||||
<TouchableOpacity style={s.row} onPress={() => onOpenMemory ? onOpenMemory(item.id) : setOpenId(item.id)}>
|
||||
<View style={{flex:1}}>
|
||||
<Text style={s.rowTitle} numberOfLines={1}>
|
||||
{item.pinned ? '📌 ' : ''}{item.title || '(ohne Titel)'}
|
||||
{attCount > 0 ? <Text style={s.attBadge}>{` 📎${attCount}`}</Text> : null}
|
||||
</Text>
|
||||
<Text style={s.rowMeta} numberOfLines={1}>
|
||||
{TYPE_LABELS[item.type] || item.type}
|
||||
{item.category ? ` · [${item.category}]` : ''}
|
||||
</Text>
|
||||
<Text style={s.rowPreview} numberOfLines={2}>{item.content}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[s.container, flatStyle && {padding:0,backgroundColor:'transparent'}]}>
|
||||
{title ? <Text style={s.heading}>{title}</Text> : null}
|
||||
|
||||
<View style={s.searchRow}>
|
||||
<TextInput
|
||||
style={s.search}
|
||||
value={q}
|
||||
onChangeText={setQ}
|
||||
placeholder="Suche in Titel, Inhalt, Tags…"
|
||||
placeholderTextColor="#555570"
|
||||
/>
|
||||
<TouchableOpacity style={s.iconBtn} onPress={load}>
|
||||
<Text style={{color:'#0096FF'}}>↻</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={s.filterRow}>
|
||||
<TouchableOpacity style={s.filterBtn} onPress={() => setShowTypeMenu(true)}>
|
||||
<Text style={s.filterText}>{typeFilter ? (TYPE_LABELS[typeFilter] || typeFilter) : 'Alle Typen'} ▾</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.filterBtn} onPress={() => {
|
||||
setPinnedFilter(pinnedFilter === 'all' ? 'pinned' : pinnedFilter === 'pinned' ? 'cold' : 'all');
|
||||
}}>
|
||||
<Text style={s.filterText}>
|
||||
{pinnedFilter === 'pinned' ? '📌 Nur Pinned' : pinnedFilter === 'cold' ? 'Nur Cold' : 'Alle'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[s.filterBtn,{backgroundColor:'#0096FF'}]} onPress={onAddNew}>
|
||||
<Text style={[s.filterText,{color:'#fff'}]}>+ Neu</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||
|
||||
{loading && items.length === 0 ? (
|
||||
<ActivityIndicator color="#0096FF" style={{marginTop:20}} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={filtered}
|
||||
keyExtractor={m => m.id}
|
||||
renderItem={renderItem}
|
||||
// nestedScrollEnabled: notwendig damit die FlatList auf Android
|
||||
// scrollt wenn sie in einer aeusseren ScrollView haengt (Settings-
|
||||
// Screen ist ScrollView). Ohne das frisst der aeussere ScrollView
|
||||
// alle Gesten und die innere Liste ist tot.
|
||||
nestedScrollEnabled={true}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ListEmptyComponent={
|
||||
<Text style={{color:'#555570',textAlign:'center',padding:20,fontStyle:'italic'}}>
|
||||
{items.length === 0 ? '(keine Memories in der DB)' : '(keine Treffer für diese Filter)'}
|
||||
</Text>
|
||||
}
|
||||
contentContainerStyle={{paddingBottom:20}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Text style={s.footer}>
|
||||
{filtered.length}/{items.length} Memories
|
||||
</Text>
|
||||
|
||||
{/* Type-Filter-Auswahl */}
|
||||
<Modal visible={showTypeMenu} transparent animationType="fade" onRequestClose={() => setShowTypeMenu(false)}>
|
||||
<TouchableOpacity style={s.menuBack} activeOpacity={1} onPress={() => setShowTypeMenu(false)}>
|
||||
<View style={s.menuBox}>
|
||||
{TYPE_OPTIONS.map(t => (
|
||||
<TouchableOpacity
|
||||
key={t || 'all'}
|
||||
style={s.menuItem}
|
||||
onPress={() => { setTypeFilter(t); setShowTypeMenu(false); }}
|
||||
>
|
||||
<Text style={s.menuItemText}>
|
||||
{t ? (TYPE_LABELS[t] || t) : 'Alle Typen'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
|
||||
{/* Eigenes DetailModal nur wenn der Parent kein Callback uebergibt
|
||||
(vermeidet Modal-in-Modal-Stacking auf Android). */}
|
||||
{!onOpenMemory && (
|
||||
<MemoryDetailModal
|
||||
memoryId={openId}
|
||||
visible={!!openId}
|
||||
onClose={() => { setOpenId(null); load(); }}
|
||||
onDeleted={() => { setOpenId(null); load(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* "Neue Memory"-Dialog (Alert.prompt ist iOS-only, daher eigenes Modal) */}
|
||||
<Modal visible={showNewMemoryDialog} transparent animationType="fade" onRequestClose={() => setShowNewMemoryDialog(false)}>
|
||||
<View style={s.menuBack}>
|
||||
<View style={[s.menuBox, {padding:16, minWidth:280}]}>
|
||||
<Text style={{color:'#FFD60A', fontWeight:'bold', fontSize:14, marginBottom:10}}>Neue Memory anlegen</Text>
|
||||
<Text style={{color:'#8888AA', fontSize:11, marginBottom:6}}>Titel:</Text>
|
||||
<TextInput
|
||||
value={newMemoryTitle}
|
||||
onChangeText={setNewMemoryTitle}
|
||||
autoFocus
|
||||
placeholder="z.B. Stefans Auto"
|
||||
placeholderTextColor="#555570"
|
||||
style={{backgroundColor:'#1E1E2E', color:'#E0E0F0', padding:8, borderRadius:4, fontSize:13, marginBottom:12}}
|
||||
/>
|
||||
<View style={{flexDirection:'row', gap:8, justifyContent:'flex-end'}}>
|
||||
<TouchableOpacity onPress={() => setShowNewMemoryDialog(false)} style={{padding:8}}>
|
||||
<Text style={{color:'#8888AA'}}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={confirmAddNew} style={{backgroundColor:'#0096FF', paddingHorizontal:14, paddingVertical:8, borderRadius:4}}>
|
||||
<Text style={{color:'#fff', fontWeight:'600'}}>Anlegen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const s = StyleSheet.create({
|
||||
container: { flex:1, padding:8, backgroundColor:'#0D0D1A' },
|
||||
heading: { color:'#0096FF', fontWeight:'bold', fontSize:14, marginBottom:8 },
|
||||
searchRow: { flexDirection:'row', gap:6, marginBottom:6 },
|
||||
search: { flex:1, backgroundColor:'#1E1E2E', color:'#E0E0F0', padding:8, borderRadius:6, fontSize:13 },
|
||||
iconBtn: { paddingHorizontal:12, justifyContent:'center', backgroundColor:'#1E1E2E', borderRadius:6 },
|
||||
filterRow: { flexDirection:'row', gap:6, marginBottom:8 },
|
||||
filterBtn: { backgroundColor:'#1E1E2E', paddingHorizontal:10, paddingVertical:6, borderRadius:6 },
|
||||
filterText: { color:'#E0E0F0', fontSize:12 },
|
||||
err: { color:'#FF6B6B', fontSize:12, marginVertical:6 },
|
||||
row: { backgroundColor:'#1E1E2E', padding:10, borderRadius:6, marginBottom:6 },
|
||||
rowTitle: { color:'#E0E0F0', fontWeight:'600', fontSize:13 },
|
||||
attBadge: { color:'#34C759', fontWeight:'normal', fontSize:11 },
|
||||
rowMeta: { color:'#8888AA', fontSize:11, marginTop:2 },
|
||||
rowPreview: { color:'#666680', fontSize:11, marginTop:4 },
|
||||
footer: { color:'#555570', fontSize:10, textAlign:'center', paddingVertical:6 },
|
||||
menuBack: { flex:1, backgroundColor:'rgba(0,0,0,0.7)', justifyContent:'center', alignItems:'center' },
|
||||
menuBox: { backgroundColor:'#0D0D1A', borderRadius:8, paddingVertical:4, minWidth:200 },
|
||||
menuItem: { paddingVertical:10, paddingHorizontal:14 },
|
||||
menuItemText: { color:'#E0E0F0', fontSize:13 },
|
||||
});
|
||||
|
||||
export default MemoryBrowser;
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Memory-Detail-Modal — Anzeige + Edit eines einzelnen Memory-Eintrags.
|
||||
*
|
||||
* Zwei Modi:
|
||||
* - read-only: zeigt alle Felder + Anhang-Vorschau (Klick auf Bild = Vollbild)
|
||||
* - edit: Form mit Save/Delete/Anhang-hochladen
|
||||
*
|
||||
* Memory-Daten werden beim Oeffnen aus dem Brain (via brainApi → RVS) frisch
|
||||
* gezogen. Optimistic Updates sind explizit nicht da — der DB-Stand ist die
|
||||
* Truth.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import DocumentPicker, { DocumentPickerResponse } from 'react-native-document-picker';
|
||||
import RNFS from 'react-native-fs';
|
||||
|
||||
import brainApi, { Memory, MemoryAttachment } from '../services/brainApi';
|
||||
|
||||
interface Props {
|
||||
memoryId: string | null;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDeleted?: (id: string) => void;
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'identity', label: 'identity (FEST)' },
|
||||
{ value: 'rule', label: 'rule (FEST)' },
|
||||
{ value: 'preference', label: 'preference (FEST)' },
|
||||
{ value: 'tool', label: 'tool (FEST)' },
|
||||
{ value: 'skill', label: 'skill (FEST)' },
|
||||
{ value: 'fact', label: 'fact (Cold)' },
|
||||
{ value: 'conversation', label: 'conversation (Cold)' },
|
||||
{ value: 'reminder', label: 'reminder (Cold)' },
|
||||
];
|
||||
|
||||
export const MemoryDetailModal: React.FC<Props> = ({ memoryId, visible, onClose, onDeleted }) => {
|
||||
const [memory, setMemory] = useState<Memory | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
// Edit-Felder
|
||||
const [eTitle, setETitle] = useState('');
|
||||
const [eContent, setEContent] = useState('');
|
||||
const [eCategory, setECategory] = useState('');
|
||||
const [eTags, setETags] = useState('');
|
||||
const [ePinned, setEPinned] = useState(false);
|
||||
|
||||
// Bild-Vollbild
|
||||
const [fullscreen, setFullscreen] = useState<string | null>(null);
|
||||
|
||||
// Memory laden beim Oeffnen
|
||||
useEffect(() => {
|
||||
if (!visible || !memoryId) {
|
||||
setMemory(null); setEditing(false); setErr(null); return;
|
||||
}
|
||||
setLoading(true); setErr(null);
|
||||
brainApi.getMemory(memoryId)
|
||||
.then(m => {
|
||||
setMemory(m);
|
||||
setETitle(m.title || '');
|
||||
setEContent(m.content || '');
|
||||
setECategory(m.category || '');
|
||||
setETags((m.tags || []).join(', '));
|
||||
setEPinned(!!m.pinned);
|
||||
})
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [visible, memoryId]);
|
||||
|
||||
const reload = () => {
|
||||
if (!memoryId) return;
|
||||
setLoading(true);
|
||||
brainApi.getMemory(memoryId)
|
||||
.then(m => setMemory(m))
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
if (!memoryId) return;
|
||||
setSaving(true); setErr(null);
|
||||
try {
|
||||
const tags = eTags.split(',').map(t => t.trim()).filter(Boolean);
|
||||
const m = await brainApi.updateMemory(memoryId, {
|
||||
title: eTitle.trim(),
|
||||
content: eContent.trim(),
|
||||
category: eCategory.trim(),
|
||||
tags,
|
||||
pinned: ePinned,
|
||||
});
|
||||
setMemory(m);
|
||||
setEditing(false);
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
if (!memoryId || !memory) return;
|
||||
Alert.alert(
|
||||
'Memory loeschen?',
|
||||
`"${memory.title}"\n\nWird permanent aus der DB entfernt, inkl. aller Anhaenge.`,
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Loeschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await brainApi.deleteMemory(memoryId);
|
||||
if (onDeleted) onDeleted(memoryId);
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
Alert.alert('Fehler', String(e?.message || e));
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const onPickAndUpload = async () => {
|
||||
if (!memoryId) return;
|
||||
try {
|
||||
const picked: DocumentPickerResponse[] = await DocumentPicker.pick({
|
||||
type: [DocumentPicker.types.images, DocumentPicker.types.pdf, DocumentPicker.types.allFiles],
|
||||
copyTo: 'cachesDirectory',
|
||||
});
|
||||
for (const f of picked) {
|
||||
setBusy(`Lade ${f.name}…`);
|
||||
// RNFS lesen → base64 → API
|
||||
const localPath = (f.fileCopyUri || f.uri).replace(/^file:\/\//, '');
|
||||
const b64 = await RNFS.readFile(localPath, 'base64');
|
||||
await brainApi.uploadAttachment(memoryId, f.name || 'datei', b64);
|
||||
}
|
||||
setBusy(null);
|
||||
reload();
|
||||
} catch (e: any) {
|
||||
setBusy(null);
|
||||
if (DocumentPicker.isCancel(e)) return;
|
||||
Alert.alert('Upload-Fehler', String(e?.message || e));
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteAttachment = (att: MemoryAttachment) => {
|
||||
if (!memoryId) return;
|
||||
Alert.alert(
|
||||
'Anhang loeschen?',
|
||||
`"${att.name}"`,
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Loeschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const m = await brainApi.deleteAttachment(memoryId, att.name);
|
||||
setMemory(m);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Fehler', String(e?.message || e));
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const onTapAttachment = async (att: MemoryAttachment) => {
|
||||
if (!memoryId) return;
|
||||
if ((att.mime || '').startsWith('image/')) {
|
||||
try {
|
||||
setBusy('Lade Bild…');
|
||||
const data = await brainApi.getAttachmentBytes(memoryId, att.name);
|
||||
// Temp-File schreiben damit <Image source={uri: file://...}> es zeigen kann
|
||||
const safe = att.name.replace(/[^A-Za-z0-9._-]/g, '_');
|
||||
const localPath = `${RNFS.CachesDirectoryPath}/memory_${memoryId}_${safe}`;
|
||||
await RNFS.writeFile(localPath, data.base64, 'base64');
|
||||
setBusy(null);
|
||||
setFullscreen('file://' + localPath);
|
||||
} catch (e: any) {
|
||||
setBusy(null);
|
||||
Alert.alert('Fehler', String(e?.message || e));
|
||||
}
|
||||
} else {
|
||||
Alert.alert('Anhang', `${att.name}\n${att.mime}\n${att.size} Byte\n\nPfad: ${att.path}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
|
||||
<View style={s.backdrop}>
|
||||
<View style={s.box}>
|
||||
<View style={s.header}>
|
||||
<Text style={s.title}>{editing ? 'Memory bearbeiten' : 'Memory-Detail'}</Text>
|
||||
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||||
<Text style={s.closeX}>×</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={s.body} contentContainerStyle={{paddingBottom:20}}>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#0096FF" style={{marginTop:30}} />
|
||||
) : err && !memory ? (
|
||||
<Text style={s.err}>{err}</Text>
|
||||
) : memory ? (
|
||||
editing ? (
|
||||
<View>
|
||||
<Text style={s.label}>Typ</Text>
|
||||
<Text style={{color:'#888',fontSize:12,marginBottom:8}}>{memory.type} (kann hier nicht geaendert werden)</Text>
|
||||
|
||||
<Text style={s.label}>Titel</Text>
|
||||
<TextInput style={s.input} value={eTitle} onChangeText={setETitle} />
|
||||
|
||||
<Text style={s.label}>Inhalt</Text>
|
||||
<TextInput
|
||||
style={[s.input, {minHeight:120, textAlignVertical:'top'}]}
|
||||
value={eContent}
|
||||
onChangeText={setEContent}
|
||||
multiline
|
||||
/>
|
||||
|
||||
<Text style={s.label}>Kategorie</Text>
|
||||
<TextInput style={s.input} value={eCategory} onChangeText={setECategory} />
|
||||
|
||||
<Text style={s.label}>Tags (komma-getrennt)</Text>
|
||||
<TextInput style={s.input} value={eTags} onChangeText={setETags} />
|
||||
|
||||
<View style={{flexDirection:'row',alignItems:'center',marginTop:10,gap:8}}>
|
||||
<Switch value={ePinned} onValueChange={setEPinned} />
|
||||
<Text style={{color:'#E0E0F0'}}>📌 Pinned (immer im System-Prompt)</Text>
|
||||
</View>
|
||||
|
||||
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||
|
||||
<View style={{flexDirection:'row',gap:8,marginTop:14}}>
|
||||
<TouchableOpacity style={[s.btn,s.btnSecondary]} onPress={() => setEditing(false)} disabled={saving}>
|
||||
<Text style={s.btnText}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[s.btn,s.btnPrimary,{flex:1}]} onPress={onSave} disabled={saving}>
|
||||
<Text style={s.btnText}>{saving ? 'Speichere…' : 'Speichern'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
<View style={{flexDirection:'row',alignItems:'flex-start',justifyContent:'space-between'}}>
|
||||
<Text style={s.bigTitle}>{memory.pinned ? '📌 ' : ''}{memory.title}</Text>
|
||||
<TouchableOpacity onPress={() => setEditing(true)} style={s.iconBtn}>
|
||||
<Text style={s.iconBtnText}>✎</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={s.meta}>
|
||||
{memory.type}{memory.category ? ` · [${memory.category}]` : ''}
|
||||
</Text>
|
||||
{(memory.tags || []).length > 0 ? (
|
||||
<View style={s.tagsRow}>
|
||||
{memory.tags.map(t => <Text key={t} style={s.tag}>{t}</Text>)}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Text style={s.contentBlock}>{memory.content}</Text>
|
||||
|
||||
<Text style={s.sectionHead}>📎 Anhaenge</Text>
|
||||
{(memory.attachments || []).length === 0 ? (
|
||||
<Text style={{color:'#555570',fontStyle:'italic',fontSize:12}}>(keine)</Text>
|
||||
) : (
|
||||
(memory.attachments || []).map((a) => {
|
||||
const isImage = (a.mime || '').startsWith('image/');
|
||||
return (
|
||||
<View key={a.name} style={s.attRow}>
|
||||
<TouchableOpacity style={{flexDirection:'row',alignItems:'center',gap:8,flex:1}} onPress={() => onTapAttachment(a)}>
|
||||
<Text style={{fontSize:18}}>{isImage ? '🖼️' : '📄'}</Text>
|
||||
<View style={{flex:1}}>
|
||||
<Text style={{color:'#E0E0F0',fontSize:12}} numberOfLines={1}>{a.name}</Text>
|
||||
<Text style={{color:'#555570',fontSize:10}}>{a.mime} · {Math.round(a.size/1024)} KB</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => onDeleteAttachment(a)} style={s.attDelete}>
|
||||
<Text style={{color:'#FF6B6B',fontSize:12}}>🗑</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<TouchableOpacity style={[s.btn,s.btnSecondary,{marginTop:8}]} onPress={onPickAndUpload}>
|
||||
<Text style={s.btnText}>⬆ Datei anhaengen</Text>
|
||||
</TouchableOpacity>
|
||||
{busy ? <Text style={{color:'#8888AA',fontSize:11,marginTop:4}}>{busy}</Text> : null}
|
||||
|
||||
<Text style={s.timestamps}>
|
||||
angelegt: {(memory.created_at || '').slice(0,16).replace('T',' ')}{'\n'}
|
||||
geaendert: {(memory.updated_at || '').slice(0,16).replace('T',' ')}{'\n'}
|
||||
id: {memory.id}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity style={[s.btn,s.btnDanger,{marginTop:14}]} onPress={onDelete}>
|
||||
<Text style={s.btnText}>🗑 Memory komplett loeschen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Modal visible={!!fullscreen} transparent onRequestClose={() => setFullscreen(null)}>
|
||||
<TouchableOpacity style={s.fsBack} onPress={() => setFullscreen(null)}>
|
||||
{fullscreen ? <Image source={{uri:fullscreen}} style={s.fsImg} resizeMode="contain" /> : null}
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const s = StyleSheet.create({
|
||||
backdrop: { flex:1, backgroundColor:'rgba(0,0,0,0.75)', justifyContent:'flex-end' },
|
||||
box: { backgroundColor:'#0D0D1A', borderTopLeftRadius:12, borderTopRightRadius:12, maxHeight:'92%' },
|
||||
header: { flexDirection:'row', justifyContent:'space-between', alignItems:'center', padding:14, borderBottomColor:'#1E1E2E', borderBottomWidth:1 },
|
||||
title: { color:'#FFD60A', fontWeight:'bold', fontSize:15 },
|
||||
closeX: { color:'#8888AA', fontSize:24, paddingHorizontal:6 },
|
||||
body: { padding:14 },
|
||||
err: { color:'#FF6B6B', fontSize:12, marginTop:8 },
|
||||
label: { color:'#8888AA', fontSize:11, marginBottom:3, marginTop:8 },
|
||||
input: { backgroundColor:'#080810', borderColor:'#1E1E2E', borderWidth:1, borderRadius:4, padding:8, color:'#E0E0F0', fontSize:13 },
|
||||
bigTitle: { color:'#E0E0F0', fontWeight:'bold', fontSize:16, flex:1, marginRight:6 },
|
||||
iconBtn: { padding:6, backgroundColor:'#1E1E2E', borderRadius:6 },
|
||||
iconBtnText: { color:'#0096FF', fontSize:14 },
|
||||
meta: { color:'#8888AA', fontSize:11, marginTop:4 },
|
||||
tagsRow: { flexDirection:'row', flexWrap:'wrap', gap:4, marginTop:6 },
|
||||
tag: { backgroundColor:'#1E1E2E', color:'#8888AA', fontSize:10, paddingHorizontal:6, paddingVertical:2, borderRadius:8 },
|
||||
contentBlock: { color:'#E0E0F0', fontSize:13, marginTop:12, lineHeight:18 },
|
||||
sectionHead: { color:'#0096FF', fontSize:11, marginTop:14, marginBottom:6, textTransform:'uppercase', letterSpacing:0.5 },
|
||||
attRow: { flexDirection:'row', alignItems:'center', backgroundColor:'#080810', padding:8, borderRadius:6, marginBottom:4, gap:6 },
|
||||
attDelete: { padding:4 },
|
||||
timestamps: { color:'#555570', fontSize:10, marginTop:12, fontFamily:'monospace' },
|
||||
btn: { paddingVertical:10, paddingHorizontal:14, borderRadius:6, alignItems:'center' },
|
||||
btnPrimary: { backgroundColor:'#0096FF' },
|
||||
btnSecondary: { backgroundColor:'#1E1E2E' },
|
||||
btnDanger: { backgroundColor:'#3B1010', borderWidth:1, borderColor:'#FF6B6B' },
|
||||
btnText: { color:'#fff', fontSize:13, fontWeight:'600' },
|
||||
fsBack: { flex:1, backgroundColor:'rgba(0,0,0,0.95)', justifyContent:'center', alignItems:'center' },
|
||||
fsImg: { width:'95%', height:'85%' },
|
||||
});
|
||||
|
||||
export default MemoryDetailModal;
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* MessageText — selektierbarer Chat-Text mit Android-Auto-Linkifizierung,
|
||||
* plus Inline-Image-Rendering wenn der Text Bild-URLs enthaelt.
|
||||
*
|
||||
* - Markdown-Syntax `` und plain `https://...image.png` werden
|
||||
* erkannt — die URL bleibt im Text sichtbar (klickbar via Linkify),
|
||||
* zusaetzlich wird das Bild als <Image> oder <SvgUri> drunter gerendert.
|
||||
* - Wir nutzen Androids dataDetectorType="all" (System macht Phone/URL/Email
|
||||
* automatisch klickbar) und ein einzelnes <Text selectable> ohne nested
|
||||
* <Text> mit eigenem onPress — Nested Text mit onPress fing die Long-Press-
|
||||
* Geste ab, damit war Markieren+Kopieren defekt.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, Image, TextStyle, StyleProp } from 'react-native';
|
||||
import { SvgUri } from 'react-native-svg';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
style?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
// Bild-URL-Pattern: http(s)://... endend auf gaengige Bild-Endungen.
|
||||
const IMG_URL_RE = /https?:\/\/[^\s)<"']+\.(?:jpe?g|png|gif|webp|bmp|ico|svg)(?:\?[^\s)<"']*)?/gi;
|
||||
|
||||
function extractImageUrls(text: string): string[] {
|
||||
const urls = new Set<string>();
|
||||
const matches = text.match(IMG_URL_RE);
|
||||
if (matches) matches.forEach(u => urls.add(u));
|
||||
return Array.from(urls);
|
||||
}
|
||||
|
||||
const SVG_RE = /\.svg(?:\?|$)/i;
|
||||
|
||||
/** Image mit dynamischer Aspect-Ratio aus echten Bilddimensionen.
|
||||
* SVGs werden ueber react-native-svg gerendert (kein Image.getSize). */
|
||||
const InlineImage: React.FC<{ uri: string }> = ({ uri }) => {
|
||||
const isSvg = SVG_RE.test(uri);
|
||||
const [aspectRatio, setAspectRatio] = useState<number>(1);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
if (isSvg) return; // Image.getSize geht fuer SVG nicht
|
||||
let cancelled = false;
|
||||
Image.getSize(
|
||||
uri,
|
||||
(w, h) => { if (!cancelled && w > 0 && h > 0) setAspectRatio(Math.max(0.5, Math.min(2.5, w / h))); },
|
||||
() => { if (!cancelled) setFailed(true); },
|
||||
);
|
||||
return () => { cancelled = true; };
|
||||
}, [uri, isSvg]);
|
||||
if (failed) return null;
|
||||
if (isSvg) {
|
||||
return (
|
||||
<View style={{ marginTop: 8, width: 260, height: 260, backgroundColor: '#0D0D1A', borderRadius: 8, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<SvgUri uri={uri} width="100%" height="100%" onError={() => setFailed(true)} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Image
|
||||
source={{ uri }}
|
||||
style={{ width: 260, aspectRatio, borderRadius: 8, marginTop: 8, backgroundColor: '#0D0D1A' }}
|
||||
resizeMode="cover"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const MessageText: React.FC<Props> = ({ text, style }) => {
|
||||
const imageUrls = extractImageUrls(text || '');
|
||||
if (imageUrls.length === 0) {
|
||||
return (
|
||||
<Text style={style} selectable dataDetectorType="all">
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View>
|
||||
<Text style={style} selectable dataDetectorType="all">
|
||||
{text}
|
||||
</Text>
|
||||
{imageUrls.map(u => <InlineImage key={u} uri={u} />)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageText;
|
||||
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* OAuth-Browser — Verwaltung der OAuth-Provider (Spotify + Custom) und ihrer
|
||||
* Credentials. Eingesetzt von SettingsScreen → Sektion "OAuth-Apps".
|
||||
*
|
||||
* Pro Service:
|
||||
* - Status (verbunden / konfiguriert / leer)
|
||||
* - client_id + client_secret (Passwort-Toggle)
|
||||
* - Bei Custom-Services: auch auth_url + token_url + scopes editierbar
|
||||
* - "Autorisieren ↗" oeffnet die Provider-Auth-Seite im System-Browser
|
||||
* - "Abmelden" + (bei Custom) "🗑 Service entfernen"
|
||||
*
|
||||
* Plus: "+ Custom-Service" oeffnet ein Modal fuer name/auth_url/token_url/scopes.
|
||||
*
|
||||
* Hinweis zu Credentials: client_id/client_secret laufen ueber HTTP zur
|
||||
* Bridge, von dort zum Brain. Wenn die App via RVS verbunden ist, geht alles
|
||||
* ueber TLS (wss://) — der Wert ist nie im Klartext im Netz unterwegs.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
Linking,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import brainApi, { OAuthServiceStatus, OAuthAppConfig } from '../services/brainApi';
|
||||
|
||||
const COL_OK = '#34C759';
|
||||
const COL_PENDING = '#FFD60A';
|
||||
const COL_OFF = '#666680';
|
||||
const COL_ERR = '#FF6B6B';
|
||||
|
||||
function fmtExpiry(secs: number | null | undefined): string {
|
||||
if (secs == null) return '';
|
||||
if (secs <= 0) return 'abgelaufen';
|
||||
if (secs < 60) return `${secs}s`;
|
||||
if (secs < 3600) return `${Math.round(secs / 60)} min`;
|
||||
if (secs < 86400) return `${Math.round(secs / 3600)} h`;
|
||||
return `${Math.round(secs / 86400)} Tage`;
|
||||
}
|
||||
|
||||
interface MergedService extends OAuthServiceStatus {
|
||||
app?: OAuthAppConfig;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export const OAuthBrowser: React.FC = () => {
|
||||
const [services, setServices] = useState<MergedService[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [editService, setEditService] = useState<MergedService | null>(null);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true); setErr(null);
|
||||
Promise.all([brainApi.listOAuthServices(), brainApi.getOAuthApps()])
|
||||
.then(([statusRes, appsRes]) => {
|
||||
const apps = appsRes.apps || {};
|
||||
const defaults = new Set(appsRes.defaults || []);
|
||||
const items: MergedService[] = (statusRes.services || []).map(s => ({
|
||||
...s,
|
||||
app: apps[s.service],
|
||||
isDefault: defaults.has(s.service),
|
||||
}));
|
||||
items.sort((a, b) => {
|
||||
if (a.authenticated !== b.authenticated) return a.authenticated ? -1 : 1;
|
||||
if (a.configured !== b.configured) return a.configured ? -1 : 1;
|
||||
return a.service.localeCompare(b.service);
|
||||
});
|
||||
setServices(items);
|
||||
})
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const renderItem = ({ item }: { item: MergedService }) => {
|
||||
let statusColor: string = COL_OFF;
|
||||
let statusIcon = '⚫';
|
||||
let statusText = 'nicht konfiguriert';
|
||||
if (item.authenticated) {
|
||||
statusColor = COL_OK; statusIcon = '✅';
|
||||
statusText = `verbunden${item.expiresInSec != null ? ' · noch ' + fmtExpiry(item.expiresInSec) : ''}`;
|
||||
} else if (item.configured) {
|
||||
statusColor = COL_PENDING; statusIcon = '🟡';
|
||||
statusText = 'konfiguriert, nicht autorisiert';
|
||||
}
|
||||
return (
|
||||
<TouchableOpacity style={s.row} onPress={() => setEditService(item)}>
|
||||
<View style={{flex: 1, marginRight: 8}}>
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 2}}>
|
||||
<Text style={{color: '#E0E0F0', fontWeight: '600', fontSize: 14, textTransform: 'capitalize'}}>{item.service}</Text>
|
||||
{!item.isDefault ? (
|
||||
<Text style={{color: '#8888AA', fontSize: 10}}>(custom)</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={{color: statusColor, fontSize: 12}}>{statusIcon} {statusText}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{flex: 1}}>
|
||||
<View style={s.toolbar}>
|
||||
<Text style={{color: '#8888AA', fontSize: 11, flex: 1}}>
|
||||
Verbinde ARIA mit externen Services (Spotify u.a.).
|
||||
</Text>
|
||||
<TouchableOpacity onPress={load} style={s.iconBtn}>
|
||||
<Text style={{fontSize: 16}}>{'↻'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setShowNew(true)} style={[s.iconBtn, {backgroundColor: '#0096FF'}]}>
|
||||
<Text style={{fontSize: 13, color: '#fff', fontWeight: '700'}}>+ Custom</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||
|
||||
{loading && services.length === 0 ? (
|
||||
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={services}
|
||||
keyExtractor={s => s.service}
|
||||
renderItem={renderItem}
|
||||
nestedScrollEnabled={true}
|
||||
ListEmptyComponent={
|
||||
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
|
||||
(keine OAuth-Services — frag ARIA: "verbinde mich mit X")
|
||||
</Text>
|
||||
}
|
||||
contentContainerStyle={{paddingBottom: 20}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editService ? (
|
||||
<OAuthEditModal
|
||||
service={editService}
|
||||
onClose={() => setEditService(null)}
|
||||
onReload={() => { setEditService(null); load(); }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showNew ? (
|
||||
<OAuthCustomNewModal
|
||||
onClose={() => setShowNew(false)}
|
||||
onCreated={() => { setShowNew(false); load(); }}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Edit-Modal (Credentials + Authorize + Revoke + Delete) ──────────
|
||||
|
||||
interface EditProps {
|
||||
service: MergedService;
|
||||
onClose: () => void;
|
||||
onReload: () => void;
|
||||
}
|
||||
|
||||
const OAuthEditModal: React.FC<EditProps> = ({ service: svc, onClose, onReload }) => {
|
||||
const [clientId, setClientId] = useState(svc.app?.client_id || '');
|
||||
const [clientSecret, setClientSecret] = useState('');
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [authUrl, setAuthUrl] = useState(svc.app?.auth_url || '');
|
||||
const [tokenUrl, setTokenUrl] = useState(svc.app?.token_url || '');
|
||||
const [scopes, setScopes] = useState((svc.app?.scopes || []).join(' '));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const save = async () => {
|
||||
if (!clientId.trim()) {
|
||||
Alert.alert('Fehler', 'client_id darf nicht leer sein.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const body: any = {
|
||||
service: svc.service,
|
||||
client_id: clientId.trim(),
|
||||
};
|
||||
if (clientSecret) body.client_secret = clientSecret;
|
||||
if (authUrl.trim()) body.auth_url = authUrl.trim();
|
||||
if (tokenUrl.trim()) body.token_url = tokenUrl.trim();
|
||||
if (scopes.trim()) body.scopes = scopes.trim().split(/\s+/).filter(Boolean);
|
||||
try {
|
||||
await brainApi.saveOAuthApp(body);
|
||||
onReload();
|
||||
} catch (e: any) {
|
||||
Alert.alert('Speichern fehlgeschlagen', String(e?.message || e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const authorize = async () => {
|
||||
if (!svc.configured) {
|
||||
Alert.alert('Erst Credentials eintragen', 'client_id und client_secret muessen vor dem Autorisieren gespeichert sein.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await brainApi.authorizeOAuth(svc.service);
|
||||
// Im System-Browser oeffnen — InAppBrowser wuerde z.T. von Providern blockiert
|
||||
const ok = await Linking.canOpenURL(r.url);
|
||||
if (!ok) {
|
||||
Alert.alert('Browser nicht verfuegbar', 'Konnte die Auth-URL nicht oeffnen.');
|
||||
return;
|
||||
}
|
||||
Linking.openURL(r.url);
|
||||
Alert.alert(
|
||||
'Im Browser anmelden',
|
||||
`Bitte stimme bei ${svc.service} zu. Nach dem Redirect zur Callback-Seite kannst du den Tab schliessen — ARIA bekommt das Token automatisch.\n\nDie Status-Anzeige in der App aktualisiert sich nach Refresh.`,
|
||||
[{ text: 'OK', onPress: () => setTimeout(onReload, 8000) }],
|
||||
);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Authorize fehlgeschlagen', String(e?.message || e));
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = () => {
|
||||
Alert.alert(
|
||||
'Abmelden?',
|
||||
`Token fuer ${svc.service} entfernen. Du musst danach neu autorisieren.`,
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Abmelden',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try { await brainApi.revokeOAuth(svc.service); onReload(); }
|
||||
catch (e: any) { Alert.alert('Fehler', String(e?.message || e)); }
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const removeService = () => {
|
||||
Alert.alert(
|
||||
'Service komplett entfernen?',
|
||||
`"${svc.service}" wird inkl. client_id/secret und Token geloescht.`,
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Loeschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try { await brainApi.deleteOAuthApp(svc.service); onReload(); }
|
||||
catch (e: any) { Alert.alert('Fehler', String(e?.message || e)); }
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
|
||||
<View style={s.modal}>
|
||||
<View style={s.modalHeader}>
|
||||
<Text style={s.modalTitle} numberOfLines={1}>{svc.service}</Text>
|
||||
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||||
<Text style={{color: '#8888AA', fontSize: 18}}>{'✕'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{flex: 1}} contentContainerStyle={{padding: 16}}>
|
||||
{svc.authenticated ? (
|
||||
<View style={[s.metaBox, {borderLeftWidth: 3, borderLeftColor: COL_OK, marginBottom: 12}]}>
|
||||
<Text style={[s.meta, {color: COL_OK, fontWeight: '700'}]}>
|
||||
✅ verbunden{svc.expiresInSec != null ? ` · Token noch ${fmtExpiry(svc.expiresInSec)}` : ''}
|
||||
</Text>
|
||||
{svc.hasRefresh ? <Text style={s.meta}>refresh_token vorhanden — auto-renew aktiv</Text>
|
||||
: <Text style={[s.meta, {color: COL_ERR}]}>KEIN refresh_token — Token verfaellt komplett</Text>}
|
||||
{svc.scope ? <Text style={s.meta}>scopes: {svc.scope}</Text> : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Text style={s.label}>client_id</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={clientId}
|
||||
onChangeText={setClientId}
|
||||
placeholder="aus dem Provider-Developer-Dashboard"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={s.label}>
|
||||
client_secret {svc.app?.has_client_secret ? '— gespeichert (leer = behalten)' : '— fehlt'}
|
||||
</Text>
|
||||
<View style={{flexDirection: 'row', gap: 6}}>
|
||||
<TextInput
|
||||
style={[s.input, {flex: 1}]}
|
||||
value={clientSecret}
|
||||
onChangeText={setClientSecret}
|
||||
placeholder={svc.app?.has_client_secret ? '(neuen eintragen oder leer lassen)' : 'aus dem Dashboard'}
|
||||
placeholderTextColor="#444460"
|
||||
secureTextEntry={!showSecret}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: '#1A1A2E', justifyContent: 'center'}]}
|
||||
onPress={() => setShowSecret(v => !v)}
|
||||
>
|
||||
<Text style={{color: '#8888AA', fontSize: 14}}>{showSecret ? '🙈' : '👁'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* URLs/Scopes: bei Defaults hinter "advanced" versteckt damit Stefan
|
||||
nicht ausversehen die Spotify-URLs ueberschreibt. */}
|
||||
{svc.isDefault ? (
|
||||
<TouchableOpacity onPress={() => setShowAdvanced(v => !v)} style={{marginTop: 12}}>
|
||||
<Text style={{color: '#666680', fontSize: 11, fontStyle: 'italic'}}>
|
||||
{showAdvanced ? '▼' : '▶'} Default-URLs ueberschreiben (advanced)
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
{(!svc.isDefault || showAdvanced) ? (
|
||||
<View style={{marginTop: 8}}>
|
||||
<Text style={s.label}>auth_url</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={authUrl}
|
||||
onChangeText={setAuthUrl}
|
||||
placeholder="https://provider.com/oauth/authorize"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text style={s.label}>token_url</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={tokenUrl}
|
||||
onChangeText={setTokenUrl}
|
||||
placeholder="https://provider.com/oauth/token"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text style={s.label}>scopes (space-separated)</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={scopes}
|
||||
onChangeText={setScopes}
|
||||
placeholder="read write user.email"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={{flexDirection: 'row', gap: 8, marginTop: 16}}>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: '#0096FF', flex: 1}]}
|
||||
onPress={save}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text style={{color: '#fff', textAlign: 'center', fontWeight: '700'}}>
|
||||
{saving ? 'speichert...' : 'Speichern'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: svc.configured ? '#34C759' : '#1E1E2E', flex: 1}]}
|
||||
onPress={authorize}
|
||||
disabled={!svc.configured}
|
||||
>
|
||||
<Text style={{color: svc.configured ? '#fff' : '#555570', textAlign: 'center', fontWeight: '700'}}>
|
||||
Autorisieren ↗
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{svc.authenticated ? (
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: COL_ERR, marginTop: 12}]}
|
||||
onPress={revoke}
|
||||
>
|
||||
<Text style={{color: COL_ERR, textAlign: 'center', fontWeight: '700'}}>Abmelden (Token loeschen)</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
{!svc.isDefault ? (
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: COL_ERR, marginTop: 8}]}
|
||||
onPress={removeService}
|
||||
>
|
||||
<Text style={{color: COL_ERR, textAlign: 'center', fontWeight: '700'}}>🗑 Service komplett entfernen</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
<View style={{height: 30}} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Neuer Custom-Provider ──────────────────────────────────────────
|
||||
|
||||
interface NewProps {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
const OAuthCustomNewModal: React.FC<NewProps> = ({ onClose, onCreated }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [authUrl, setAuthUrl] = useState('https://');
|
||||
const [tokenUrl, setTokenUrl] = useState('https://');
|
||||
const [scopes, setScopes] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const create = async () => {
|
||||
const svc = name.trim().toLowerCase();
|
||||
if (!/^[a-z0-9_-]+$/.test(svc)) {
|
||||
Alert.alert('Ungueltiger Name', 'Erlaubt: a-z 0-9 _ -');
|
||||
return;
|
||||
}
|
||||
if (!authUrl.startsWith('http') || !tokenUrl.startsWith('http')) {
|
||||
Alert.alert('Ungueltige URLs', 'auth_url und token_url muessen http(s):// sein.');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const body: any = { service: svc, auth_url: authUrl.trim(), token_url: tokenUrl.trim() };
|
||||
if (scopes.trim()) body.scopes = scopes.trim().split(/\s+/).filter(Boolean);
|
||||
await brainApi.saveOAuthApp(body);
|
||||
onCreated();
|
||||
} catch (e: any) {
|
||||
Alert.alert('Anlegen fehlgeschlagen', String(e?.message || e));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
|
||||
<View style={s.modal}>
|
||||
<View style={s.modalHeader}>
|
||||
<Text style={s.modalTitle}>Custom OAuth-Provider</Text>
|
||||
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||||
<Text style={{color: '#8888AA', fontSize: 18}}>{'✕'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView style={{flex: 1}} contentContainerStyle={{padding: 16}}>
|
||||
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 12}}>
|
||||
Trag die OAuth2-Endpunkte des Anbieters ein. client_id + client_secret
|
||||
kommen anschliessend ins Edit-Formular. Die Callback-URL die du beim
|
||||
Anbieter eintragen musst, zeigt dir der OAuth-Block im Brain-System-Prompt.
|
||||
</Text>
|
||||
|
||||
<Text style={s.label}>Service-Name (z.B. dropbox, discord)</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="kurz, a-z 0-9 _ -"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={s.label}>auth_url</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={authUrl}
|
||||
onChangeText={setAuthUrl}
|
||||
placeholder="https://provider.com/oauth/authorize"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={s.label}>token_url</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={tokenUrl}
|
||||
onChangeText={setTokenUrl}
|
||||
placeholder="https://provider.com/oauth/token"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={s.label}>scopes (space-separated, optional)</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={scopes}
|
||||
onChangeText={setScopes}
|
||||
placeholder="read write user.email"
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<View style={{flexDirection: 'row', gap: 8, marginTop: 20}}>
|
||||
<TouchableOpacity style={[s.btn, {backgroundColor: '#1A1A2E', flex: 1}]} onPress={onClose}>
|
||||
<Text style={{color: '#8888AA', textAlign: 'center'}}>Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[s.btn, {backgroundColor: '#0096FF', flex: 1}]} onPress={create} disabled={creating}>
|
||||
<Text style={{color: '#fff', textAlign: 'center', fontWeight: '700'}}>
|
||||
{creating ? '...' : 'Anlegen'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Styles ─────────────────────────────────────────────────────────
|
||||
|
||||
const s = StyleSheet.create({
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: '#0D0D1A',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1E1E2E',
|
||||
},
|
||||
iconBtn: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
row: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
backgroundColor: '#0D0D1A',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1E1E2E',
|
||||
},
|
||||
err: {
|
||||
color: '#FF6B6B',
|
||||
padding: 12,
|
||||
fontSize: 12,
|
||||
},
|
||||
modal: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0D0D1A',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1E1E2E',
|
||||
},
|
||||
modalTitle: {
|
||||
color: '#E0E0F0',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
label: {
|
||||
color: '#8888AA',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginTop: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#1A1A2E',
|
||||
borderWidth: 1,
|
||||
borderColor: '#1E1E2E',
|
||||
borderRadius: 6,
|
||||
color: '#E0E0F0',
|
||||
padding: 10,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
metaBox: {
|
||||
backgroundColor: '#1A1A2E',
|
||||
borderRadius: 6,
|
||||
padding: 10,
|
||||
gap: 4,
|
||||
},
|
||||
meta: {
|
||||
color: '#8888AA',
|
||||
fontSize: 12,
|
||||
},
|
||||
btn: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
});
|
||||
|
||||
export default OAuthBrowser;
|
||||
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Skill-Browser — Liste aller Skills mit Toggle, Tap-zum-Details, Run,
|
||||
* Logs und Loeschen.
|
||||
*
|
||||
* Eingesetzt von SettingsScreen → Sektion "Skills".
|
||||
*
|
||||
* Brain-API ueber brainApi (RVS-Brain-Proxy). Code-Edits laufen NICHT
|
||||
* ueber diese UI — Skill-Code-Aenderungen sind ARIAs Domaene
|
||||
* (skill_update Brain-Tool). Hier nur Manifest-Felder + Run + Cleanup.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import brainApi, { Skill } from '../services/brainApi';
|
||||
|
||||
const COL_ACTIVE = '#34C759';
|
||||
const COL_INACTIVE = '#555570';
|
||||
const COL_ARIA = '#FFD60A';
|
||||
const COL_STEFAN = '#0096FF';
|
||||
|
||||
function relTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const t = new Date(iso).getTime();
|
||||
if (!t) return '—';
|
||||
const diffSec = Math.floor((Date.now() - t) / 1000);
|
||||
if (diffSec < 60) return `vor ${diffSec}s`;
|
||||
if (diffSec < 3600) return `vor ${Math.floor(diffSec / 60)}min`;
|
||||
if (diffSec < 86400) return `vor ${Math.floor(diffSec / 3600)}h`;
|
||||
return `vor ${Math.floor(diffSec / 86400)}d`;
|
||||
}
|
||||
|
||||
export const SkillBrowser: React.FC = () => {
|
||||
const [items, setItems] = useState<Skill[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
const [detail, setDetail] = useState<Skill | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true); setErr(null);
|
||||
brainApi.listSkills()
|
||||
.then(s => {
|
||||
s.sort((a, b) => {
|
||||
if (a.active !== b.active) return a.active ? -1 : 1;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
setItems(s);
|
||||
})
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const visible = items.filter(s => {
|
||||
if (filter === 'active') return s.active;
|
||||
if (filter === 'inactive') return !s.active;
|
||||
return true;
|
||||
});
|
||||
|
||||
const toggleActive = (s: Skill) => {
|
||||
brainApi.updateSkill(s.name, { active: !s.active })
|
||||
.then(() => load())
|
||||
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: Skill }) => {
|
||||
const isAria = (item.author || '').toLowerCase() === 'aria';
|
||||
const authorColor = isAria ? COL_ARIA : COL_STEFAN;
|
||||
const authorLabel = isAria ? '🤖 von ARIA' : '👤 von Stefan';
|
||||
return (
|
||||
<TouchableOpacity style={s.row} onPress={() => setDetail(item)}>
|
||||
<View style={{flex: 1, marginRight: 8}}>
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 4}}>
|
||||
<Text style={{color: authorColor, fontSize: 10, fontWeight: '700'}}>{authorLabel}</Text>
|
||||
<Text style={{color: '#E0E0F0', fontWeight: '600', flex: 1}} numberOfLines={1}>{item.name}</Text>
|
||||
</View>
|
||||
<Text style={{color: '#8888AA', fontSize: 12}} numberOfLines={2}>{item.description}</Text>
|
||||
{item.setup_error ? (
|
||||
<Text style={{color: '#FF6B6B', fontSize: 11, marginTop: 4}} numberOfLines={2}>
|
||||
⚠ Setup-Fehler: {item.setup_error}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{color: '#444460', fontSize: 10, marginTop: 4}}>
|
||||
{item.execution} · {item.use_count || 0}× ausgefuehrt · zuletzt: {relTime(item.last_used)}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={item.active}
|
||||
onValueChange={() => toggleActive(item)}
|
||||
trackColor={{ false: '#1E1E2E', true: COL_ACTIVE }}
|
||||
thumbColor="#E0E0F0"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{flex: 1}}>
|
||||
<View style={s.toolbar}>
|
||||
{(['all', 'active', 'inactive'] as const).map(f => (
|
||||
<TouchableOpacity
|
||||
key={f}
|
||||
style={[s.chip, filter === f && s.chipActive]}
|
||||
onPress={() => setFilter(f)}
|
||||
>
|
||||
<Text style={{color: filter === f ? '#0D0D1A' : '#8888AA', fontSize: 12, fontWeight: '600'}}>
|
||||
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktive' : 'Inaktive'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<View style={{flex: 1}} />
|
||||
<TouchableOpacity onPress={load} style={s.iconBtn}>
|
||||
<Text style={{fontSize: 16}}>{'↻'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||
|
||||
{loading && items.length === 0 ? (
|
||||
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={visible}
|
||||
keyExtractor={s => s.name}
|
||||
renderItem={renderItem}
|
||||
nestedScrollEnabled={true}
|
||||
ListEmptyComponent={
|
||||
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
|
||||
{items.length === 0
|
||||
? '(noch keine Skills — frag ARIA: "bau mir einen Skill der ...")'
|
||||
: '(keine Treffer für diesen Filter)'}
|
||||
</Text>
|
||||
}
|
||||
contentContainerStyle={{paddingBottom: 20}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detail ? (
|
||||
<SkillDetailModal
|
||||
skill={detail}
|
||||
onClose={() => setDetail(null)}
|
||||
onReload={() => { load(); brainApi.getSkill(detail.name).then(setDetail).catch(() => {}); }}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Detail-Modal mit Run + Logs + Delete ─────────────────────────────
|
||||
|
||||
interface DetailProps {
|
||||
skill: Skill;
|
||||
onClose: () => void;
|
||||
onReload: () => void;
|
||||
}
|
||||
|
||||
const SkillDetailModal: React.FC<DetailProps> = ({ skill, onClose, onReload }) => {
|
||||
const [argValues, setArgValues] = useState<Record<string, string>>({});
|
||||
const [running, setRunning] = useState(false);
|
||||
const [runResult, setRunResult] = useState<{
|
||||
ok: boolean; exit_code: number; stdout: string; stderr: string; duration_sec: number;
|
||||
} | null>(null);
|
||||
const [logs, setLogs] = useState<any[] | null>(null);
|
||||
const [loadingLogs, setLoadingLogs] = useState(false);
|
||||
|
||||
const args = Array.isArray(skill.args) ? skill.args : [];
|
||||
|
||||
const setArg = (name: string, value: string) =>
|
||||
setArgValues(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
const run = () => {
|
||||
setRunning(true); setRunResult(null);
|
||||
const argsObj: Record<string, any> = {};
|
||||
for (const a of args) {
|
||||
if (a?.name && argValues[a.name] !== undefined && argValues[a.name] !== '') {
|
||||
argsObj[a.name] = argValues[a.name];
|
||||
}
|
||||
}
|
||||
brainApi.runSkill(skill.name, argsObj)
|
||||
.then(r => setRunResult(r))
|
||||
.catch(e => setRunResult({
|
||||
ok: false, exit_code: -1, stdout: '', stderr: String(e?.message || e), duration_sec: 0,
|
||||
}))
|
||||
.finally(() => setRunning(false));
|
||||
};
|
||||
|
||||
const loadLogs = () => {
|
||||
setLoadingLogs(true);
|
||||
brainApi.getSkillLogs(skill.name, 20)
|
||||
.then(setLogs)
|
||||
.catch(e => Alert.alert('Logs-Fehler', String(e?.message || e)))
|
||||
.finally(() => setLoadingLogs(false));
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
Alert.alert(
|
||||
'Skill loeschen?',
|
||||
`"${skill.name}" wird komplett entfernt (venv, logs, manifest). Nicht rueckholbar.`,
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Loeschen',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
brainApi.deleteSkill(skill.name)
|
||||
.then(() => { onReload(); onClose(); })
|
||||
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
|
||||
<View style={s.modal}>
|
||||
<View style={s.modalHeader}>
|
||||
<Text style={s.modalTitle} numberOfLines={1}>{skill.name}</Text>
|
||||
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||||
<Text style={{color: '#8888AA', fontSize: 18}}>{'✕'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={{flex: 1}} contentContainerStyle={{padding: 16}}>
|
||||
<Text style={s.label}>Beschreibung</Text>
|
||||
<Text style={{color: '#E0E0F0', marginBottom: 12}}>{skill.description}</Text>
|
||||
|
||||
<View style={s.metaBox}>
|
||||
<Text style={s.meta}>execution: {skill.execution} · entry: {skill.entry}</Text>
|
||||
<Text style={s.meta}>author: {skill.author || '?'} · version: {skill.version || '?'}</Text>
|
||||
<Text style={s.meta}>{skill.use_count || 0}× ausgefuehrt · zuletzt: {relTime(skill.last_used)}</Text>
|
||||
{skill.setup_error ? (
|
||||
<Text style={[s.meta, {color: '#FF6B6B'}]}>setup_error: {skill.setup_error}</Text>
|
||||
) : null}
|
||||
{Array.isArray(skill.requires?.pip) && skill.requires!.pip!.length > 0 ? (
|
||||
<Text style={s.meta}>pip: {skill.requires!.pip!.join(', ')}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* Args-Inputs */}
|
||||
{args.length > 0 ? (
|
||||
<>
|
||||
<Text style={[s.label, {marginTop: 18}]}>Argumente</Text>
|
||||
{args.map((a: any) => (
|
||||
<View key={a.name} style={{marginBottom: 10}}>
|
||||
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 4}}>
|
||||
{a.name}{a.required ? ' *' : ''} {a.description ? `— ${a.description}` : ''}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={argValues[a.name] || ''}
|
||||
onChangeText={(v) => setArg(a.name, v)}
|
||||
placeholder={a.type || 'string'}
|
||||
placeholderTextColor="#444460"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<View style={{flexDirection: 'row', gap: 8, marginTop: 14}}>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: skill.active ? '#0096FF' : '#1E1E2E', flex: 1}]}
|
||||
onPress={run}
|
||||
disabled={!skill.active || running}
|
||||
>
|
||||
<Text style={{color: skill.active ? '#fff' : '#555570', fontWeight: '700', textAlign: 'center'}}>
|
||||
{running ? 'läuft...' : '▶ Ausführen'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, {backgroundColor: '#1A1A2E', flex: 1}]}
|
||||
onPress={loadLogs}
|
||||
>
|
||||
<Text style={{color: '#8888AA', textAlign: 'center'}}>📜 Logs</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{!skill.active ? (
|
||||
<Text style={{color: '#FFD60A', fontSize: 12, marginTop: 6, fontStyle: 'italic'}}>
|
||||
Skill ist deaktiviert — toggle in der Liste zum Aktivieren.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* Run-Result */}
|
||||
{runResult ? (
|
||||
<View style={[s.metaBox, {marginTop: 14, borderLeftWidth: 3, borderLeftColor: runResult.ok ? COL_ACTIVE : '#FF6B6B'}]}>
|
||||
<Text style={[s.meta, {color: runResult.ok ? COL_ACTIVE : '#FF6B6B', fontWeight: '700'}]}>
|
||||
{runResult.ok ? '✓ OK' : `✗ FEHLER (exit ${runResult.exit_code})`} · {runResult.duration_sec}s
|
||||
</Text>
|
||||
{runResult.stdout ? (
|
||||
<>
|
||||
<Text style={[s.meta, {marginTop: 6, color: '#8888AA', fontWeight: '600'}]}>stdout:</Text>
|
||||
<Text style={[s.meta, {fontFamily: 'monospace', color: '#C0C0D0'}]}>{runResult.stdout}</Text>
|
||||
</>
|
||||
) : null}
|
||||
{runResult.stderr ? (
|
||||
<>
|
||||
<Text style={[s.meta, {marginTop: 6, color: '#FF6B6B', fontWeight: '600'}]}>stderr:</Text>
|
||||
<Text style={[s.meta, {fontFamily: 'monospace', color: '#FF9999'}]}>{runResult.stderr}</Text>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Logs */}
|
||||
{loadingLogs ? (
|
||||
<ActivityIndicator color="#0096FF" style={{marginTop: 14}} />
|
||||
) : logs ? (
|
||||
<View style={{marginTop: 14}}>
|
||||
<Text style={[s.label, {marginTop: 0}]}>Letzte Runs (Top 20)</Text>
|
||||
{logs.length === 0 ? (
|
||||
<Text style={{color: '#555570', fontStyle: 'italic'}}>(keine Logs)</Text>
|
||||
) : logs.map((log, idx) => (
|
||||
<View key={idx} style={[s.metaBox, {marginTop: 6, borderLeftWidth: 2, borderLeftColor: log.ok ? COL_ACTIVE : '#FF6B6B'}]}>
|
||||
<Text style={[s.meta, {color: log.ok ? COL_ACTIVE : '#FF6B6B'}]}>
|
||||
{log.ok ? '✓' : '✗'} {log.ts ? new Date(log.ts).toLocaleString('de-DE') : '?'} · {log.duration_sec || 0}s
|
||||
</Text>
|
||||
{log.stdout ? (
|
||||
<Text style={[s.meta, {fontFamily: 'monospace', color: '#C0C0D0'}]} numberOfLines={3}>
|
||||
{String(log.stdout).slice(0, 300)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={{height: 30}} />
|
||||
</ScrollView>
|
||||
|
||||
<View style={s.modalFooter}>
|
||||
<TouchableOpacity style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: '#FF6B6B'}]} onPress={remove}>
|
||||
<Text style={{color: '#FF6B6B', fontWeight: '700'}}>🗑 Loeschen</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={{flex: 1}} />
|
||||
<TouchableOpacity style={[s.btn, {backgroundColor: '#1A1A2E'}]} onPress={onClose}>
|
||||
<Text style={{color: '#8888AA'}}>Schliessen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Styles ───────────────────────────────────────────────────────────
|
||||
|
||||
const s = StyleSheet.create({
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: '#0D0D1A',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1E1E2E',
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: '#FFD60A',
|
||||
},
|
||||
iconBtn: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
backgroundColor: '#0D0D1A',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1E1E2E',
|
||||
},
|
||||
err: {
|
||||
color: '#FF6B6B',
|
||||
padding: 12,
|
||||
fontSize: 12,
|
||||
},
|
||||
modal: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0D0D1A',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1E1E2E',
|
||||
},
|
||||
modalTitle: {
|
||||
color: '#E0E0F0',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
modalFooter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#1E1E2E',
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
color: '#8888AA',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginTop: 8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#1A1A2E',
|
||||
borderWidth: 1,
|
||||
borderColor: '#1E1E2E',
|
||||
borderRadius: 6,
|
||||
color: '#E0E0F0',
|
||||
padding: 10,
|
||||
fontSize: 14,
|
||||
},
|
||||
metaBox: {
|
||||
backgroundColor: '#1A1A2E',
|
||||
borderRadius: 6,
|
||||
padding: 10,
|
||||
marginTop: 6,
|
||||
gap: 4,
|
||||
},
|
||||
meta: {
|
||||
color: '#8888AA',
|
||||
fontSize: 12,
|
||||
},
|
||||
btn: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
});
|
||||
|
||||
export default SkillBrowser;
|
||||
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* Trigger-Browser — Liste aller Trigger (timer + watcher) mit Toggle,
|
||||
* Tap-zum-Bearbeiten und "+ Neu"-Knopf.
|
||||
*
|
||||
* Eingesetzt von SettingsScreen → Sektion "Trigger".
|
||||
*
|
||||
* Brain-API ueber brainApi (RVS-Brain-Proxy).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
Modal,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import brainApi, { Trigger } from '../services/brainApi';
|
||||
|
||||
const COL_ACTIVE = '#34C759';
|
||||
const COL_INACTIVE = '#555570';
|
||||
const COL_TIMER = '#0096FF';
|
||||
const COL_WATCHER = '#FFD60A';
|
||||
|
||||
function relTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const t = new Date(iso).getTime();
|
||||
if (!t) return '—';
|
||||
const diffSec = Math.floor((Date.now() - t) / 1000);
|
||||
if (diffSec < 60) return `vor ${diffSec}s`;
|
||||
if (diffSec < 3600) return `vor ${Math.floor(diffSec / 60)}min`;
|
||||
if (diffSec < 86400) return `vor ${Math.floor(diffSec / 3600)}h`;
|
||||
return `vor ${Math.floor(diffSec / 86400)}d`;
|
||||
}
|
||||
|
||||
export const TriggerBrowser: React.FC = () => {
|
||||
const [items, setItems] = useState<Trigger[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
const [editTrigger, setEditTrigger] = useState<Trigger | null>(null);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true); setErr(null);
|
||||
brainApi.listTriggers()
|
||||
.then(t => {
|
||||
// Sortierung: aktive zuerst, dann nach Name
|
||||
t.sort((a, b) => {
|
||||
if (a.active !== b.active) return a.active ? -1 : 1;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
setItems(t);
|
||||
})
|
||||
.catch(e => setErr(String(e?.message || e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const visible = items.filter(t => {
|
||||
if (filter === 'active') return t.active;
|
||||
if (filter === 'inactive') return !t.active;
|
||||
return true;
|
||||
});
|
||||
|
||||
const toggleActive = (t: Trigger) => {
|
||||
brainApi.updateTrigger(t.name, { active: !t.active })
|
||||
.then(() => load())
|
||||
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||||
};
|
||||
|
||||
const deleteTrigger = (t: Trigger) => {
|
||||
Alert.alert(
|
||||
'Trigger löschen?',
|
||||
`"${t.name}" — diese Aktion ist nicht rückgängig zu machen.`,
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
brainApi.deleteTrigger(t.name)
|
||||
.then(() => { setEditTrigger(null); load(); })
|
||||
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: Trigger }) => {
|
||||
const typeColor = item.type === 'timer' ? COL_TIMER : COL_WATCHER;
|
||||
const typeLabel = item.type === 'timer' ? '⏰ Timer' : '👁 Watcher';
|
||||
return (
|
||||
<TouchableOpacity style={s.row} onPress={() => setEditTrigger(item)}>
|
||||
<View style={{flex: 1, marginRight: 8}}>
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 4}}>
|
||||
<Text style={{color: typeColor, fontSize: 11, fontWeight: '700'}}>{typeLabel}</Text>
|
||||
<Text style={{color: '#E0E0F0', fontWeight: '600', flex: 1}} numberOfLines={1}>{item.name}</Text>
|
||||
</View>
|
||||
<Text style={{color: '#8888AA', fontSize: 12}} numberOfLines={2}>{item.message}</Text>
|
||||
{item.type === 'watcher' && item.condition ? (
|
||||
<Text style={{color: '#555570', fontSize: 11, marginTop: 4, fontFamily: 'monospace'}} numberOfLines={1}>
|
||||
{item.condition}
|
||||
</Text>
|
||||
) : null}
|
||||
{item.type === 'timer' && item.fires_at ? (
|
||||
<Text style={{color: '#555570', fontSize: 11, marginTop: 4}}>
|
||||
feuert: {new Date(item.fires_at).toLocaleString('de-DE')}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{color: '#444460', fontSize: 10, marginTop: 4}}>
|
||||
{item.fire_count || 0}× gefeuert · zuletzt: {relTime(item.last_fired_at)}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={item.active}
|
||||
onValueChange={() => toggleActive(item)}
|
||||
trackColor={{ false: '#1E1E2E', true: COL_ACTIVE }}
|
||||
thumbColor="#E0E0F0"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={{flex: 1}}>
|
||||
{/* Filter-Leiste + Reload + Neu */}
|
||||
<View style={s.toolbar}>
|
||||
{(['all', 'active', 'inactive'] as const).map(f => (
|
||||
<TouchableOpacity
|
||||
key={f}
|
||||
style={[s.chip, filter === f && s.chipActive]}
|
||||
onPress={() => setFilter(f)}
|
||||
>
|
||||
<Text style={{color: filter === f ? '#0D0D1A' : '#8888AA', fontSize: 12, fontWeight: '600'}}>
|
||||
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktive' : 'Inaktive'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<View style={{flex: 1}} />
|
||||
<TouchableOpacity onPress={load} style={s.iconBtn}>
|
||||
<Text style={{fontSize: 16}}>{'↻'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => setShowNew(true)} style={[s.iconBtn, {backgroundColor: '#0096FF'}]}>
|
||||
<Text style={{fontSize: 14, color: '#fff', fontWeight: '700'}}>+ Neu</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{err ? <Text style={s.err}>{err}</Text> : null}
|
||||
|
||||
{loading && items.length === 0 ? (
|
||||
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={visible}
|
||||
keyExtractor={t => t.name}
|
||||
renderItem={renderItem}
|
||||
nestedScrollEnabled={true}
|
||||
ListEmptyComponent={
|
||||
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
|
||||
{items.length === 0 ? '(keine Trigger angelegt)' : '(keine Treffer für diesen Filter)'}
|
||||
</Text>
|
||||
}
|
||||
contentContainerStyle={{paddingBottom: 20}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editTrigger ? (
|
||||
<TriggerEditModal
|
||||
trigger={editTrigger}
|
||||
onClose={() => setEditTrigger(null)}
|
||||
onSaved={() => { setEditTrigger(null); load(); }}
|
||||
onDelete={() => deleteTrigger(editTrigger)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showNew ? (
|
||||
<TriggerNewModal
|
||||
onClose={() => setShowNew(false)}
|
||||
onCreated={() => { setShowNew(false); load(); }}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Edit-Modal ─────────────────────────────────────────────────────────
|
||||
|
||||
interface EditProps {
|
||||
trigger: Trigger;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const TriggerEditModal: React.FC<EditProps> = ({ trigger, onClose, onSaved, onDelete }) => {
|
||||
const [message, setMessage] = useState(trigger.message || '');
|
||||
const [condition, setCondition] = useState(trigger.condition || '');
|
||||
const [firesAt, setFiresAt] = useState(trigger.fires_at || '');
|
||||
const [checkInterval, setCheckInterval] = useState(String(trigger.check_interval_sec || 300));
|
||||
const [throttle, setThrottle] = useState(String(trigger.throttle_sec || 3600));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const save = () => {
|
||||
setSaving(true);
|
||||
const patch: any = { message };
|
||||
if (trigger.type === 'watcher') {
|
||||
patch.condition = condition;
|
||||
patch.check_interval_sec = parseInt(checkInterval, 10) || 300;
|
||||
patch.throttle_sec = parseInt(throttle, 10) || 3600;
|
||||
} else if (trigger.type === 'timer') {
|
||||
patch.fires_at = firesAt;
|
||||
}
|
||||
brainApi.updateTrigger(trigger.name, patch)
|
||||
.then(onSaved)
|
||||
.catch(e => Alert.alert('Fehler beim Speichern', String(e?.message || e)))
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible animationType="slide" onRequestClose={onClose} transparent>
|
||||
<View style={s.modalBg}>
|
||||
<View style={s.modal}>
|
||||
<View style={s.modalHeader}>
|
||||
<Text style={{color: trigger.type === 'timer' ? COL_TIMER : COL_WATCHER, fontWeight: '700', fontSize: 16, flex: 1}}>
|
||||
{trigger.type === 'timer' ? '⏰' : '👁'} {trigger.name}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<Text style={{color: '#8888AA', fontSize: 24}}>×</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView style={{padding: 14}} nestedScrollEnabled>
|
||||
<Text style={s.label}>Nachricht</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={message}
|
||||
onChangeText={setMessage}
|
||||
multiline
|
||||
placeholder="Was soll ARIA sagen wenn der Trigger feuert?"
|
||||
placeholderTextColor="#555570"
|
||||
/>
|
||||
|
||||
{trigger.type === 'watcher' ? (
|
||||
<>
|
||||
<Text style={s.label}>Condition</Text>
|
||||
<TextInput
|
||||
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
|
||||
value={condition}
|
||||
onChangeText={setCondition}
|
||||
placeholder="z.B. near(53.0, 8.5, 300)"
|
||||
placeholderTextColor="#555570"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<View style={{flexDirection: 'row', gap: 8}}>
|
||||
<View style={{flex: 1}}>
|
||||
<Text style={s.label}>Check-Intervall (s)</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={checkInterval}
|
||||
onChangeText={setCheckInterval}
|
||||
keyboardType="number-pad"
|
||||
/>
|
||||
</View>
|
||||
<View style={{flex: 1}}>
|
||||
<Text style={s.label}>Throttle (s)</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={throttle}
|
||||
onChangeText={setThrottle}
|
||||
keyboardType="number-pad"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={s.label}>Feuert am (ISO, UTC)</Text>
|
||||
<TextInput
|
||||
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
|
||||
value={firesAt}
|
||||
onChangeText={setFiresAt}
|
||||
placeholder="2026-05-15T20:00:00+00:00"
|
||||
placeholderTextColor="#555570"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={s.metaBox}>
|
||||
<Text style={s.meta}>Status: {trigger.active ? '🟢 aktiv' : '⚪ inaktiv'}</Text>
|
||||
<Text style={s.meta}>Gefeuert: {trigger.fire_count || 0}×</Text>
|
||||
<Text style={s.meta}>Zuletzt gefeuert: {relTime(trigger.last_fired_at)}</Text>
|
||||
<Text style={s.meta}>Zuletzt geprüft: {relTime(trigger.last_checked_at)}</Text>
|
||||
{trigger.author ? <Text style={s.meta}>Angelegt von: {trigger.author}</Text> : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<View style={s.modalFooter}>
|
||||
<TouchableOpacity onPress={onDelete} style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: '#FF3B30'}]}>
|
||||
<Text style={{color: '#FF3B30', fontWeight: '700'}}>🗑 Löschen</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={{flex: 1}} />
|
||||
<TouchableOpacity onPress={save} disabled={saving} style={[s.btn, {backgroundColor: '#0096FF', opacity: saving ? 0.5 : 1}]}>
|
||||
<Text style={{color: '#fff', fontWeight: '700'}}>{saving ? 'Speichert...' : 'Speichern'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Neu-Modal ──────────────────────────────────────────────────────────
|
||||
|
||||
interface NewProps {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
const TriggerNewModal: React.FC<NewProps> = ({ onClose, onCreated }) => {
|
||||
const [ttype, setTtype] = useState<'timer' | 'watcher'>('watcher');
|
||||
const [name, setName] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [condition, setCondition] = useState('');
|
||||
const [firesAt, setFiresAt] = useState('');
|
||||
const [checkInterval, setCheckInterval] = useState('300');
|
||||
const [throttle, setThrottle] = useState('3600');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const create = () => {
|
||||
if (!name.trim() || !message.trim()) {
|
||||
Alert.alert('Name und Nachricht erforderlich');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const promise = ttype === 'timer'
|
||||
? brainApi.createTimer({
|
||||
name: name.trim(),
|
||||
fires_at: firesAt.trim(),
|
||||
message: message.trim(),
|
||||
})
|
||||
: brainApi.createWatcher({
|
||||
name: name.trim(),
|
||||
condition: condition.trim(),
|
||||
message: message.trim(),
|
||||
check_interval_sec: parseInt(checkInterval, 10) || 300,
|
||||
throttle_sec: parseInt(throttle, 10) || 3600,
|
||||
});
|
||||
promise
|
||||
.then(onCreated)
|
||||
.catch(e => Alert.alert('Fehler beim Anlegen', String(e?.message || e)))
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible animationType="slide" onRequestClose={onClose} transparent>
|
||||
<View style={s.modalBg}>
|
||||
<View style={s.modal}>
|
||||
<View style={s.modalHeader}>
|
||||
<Text style={{color: '#FFD60A', fontWeight: '700', fontSize: 16, flex: 1}}>+ Neuer Trigger</Text>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<Text style={{color: '#8888AA', fontSize: 24}}>×</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView style={{padding: 14}} nestedScrollEnabled>
|
||||
<Text style={s.label}>Typ</Text>
|
||||
<View style={{flexDirection: 'row', gap: 8, marginBottom: 12}}>
|
||||
{(['watcher', 'timer'] as const).map(t => (
|
||||
<TouchableOpacity
|
||||
key={t}
|
||||
onPress={() => setTtype(t)}
|
||||
style={[s.chip, ttype === t && s.chipActive, {flex: 1, paddingVertical: 10}]}
|
||||
>
|
||||
<Text style={{color: ttype === t ? '#0D0D1A' : '#8888AA', fontWeight: '700', textAlign: 'center'}}>
|
||||
{t === 'watcher' ? '👁 Watcher' : '⏰ Timer'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={s.label}>Name (kebab-case)</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="z.B. drk-kreyenbrueck-warnung"
|
||||
placeholderTextColor="#555570"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
|
||||
<Text style={s.label}>Nachricht</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={message}
|
||||
onChangeText={setMessage}
|
||||
multiline
|
||||
placeholder="Was soll ARIA sagen?"
|
||||
placeholderTextColor="#555570"
|
||||
/>
|
||||
|
||||
{ttype === 'watcher' ? (
|
||||
<>
|
||||
<Text style={s.label}>Condition</Text>
|
||||
<TextInput
|
||||
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
|
||||
value={condition}
|
||||
onChangeText={setCondition}
|
||||
placeholder="z.B. entered_near(53.0, 8.5, 300)"
|
||||
placeholderTextColor="#555570"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Text style={s.hint}>
|
||||
Funktionen: near() / entered_near() / left_near() · Variablen: disk_free_gb, hour_of_day, current_lat, current_lon, last_user_message_ago_sec
|
||||
</Text>
|
||||
<View style={{flexDirection: 'row', gap: 8}}>
|
||||
<View style={{flex: 1}}>
|
||||
<Text style={s.label}>Check-Intervall (s)</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={checkInterval}
|
||||
onChangeText={setCheckInterval}
|
||||
keyboardType="number-pad"
|
||||
/>
|
||||
</View>
|
||||
<View style={{flex: 1}}>
|
||||
<Text style={s.label}>Throttle (s)</Text>
|
||||
<TextInput
|
||||
style={s.input}
|
||||
value={throttle}
|
||||
onChangeText={setThrottle}
|
||||
keyboardType="number-pad"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={s.label}>Feuert am (ISO, UTC)</Text>
|
||||
<TextInput
|
||||
style={[s.input, {fontFamily: 'monospace', fontSize: 12}]}
|
||||
value={firesAt}
|
||||
onChangeText={setFiresAt}
|
||||
placeholder="2026-05-15T20:00:00+00:00"
|
||||
placeholderTextColor="#555570"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Text style={s.hint}>Beispiel oben: heute 20:00 UTC = 22:00 CEST</Text>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
<View style={s.modalFooter}>
|
||||
<View style={{flex: 1}} />
|
||||
<TouchableOpacity onPress={create} disabled={saving} style={[s.btn, {backgroundColor: '#0096FF', opacity: saving ? 0.5 : 1}]}>
|
||||
<Text style={{color: '#fff', fontWeight: '700'}}>{saving ? 'Legt an...' : 'Anlegen'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const s = StyleSheet.create({
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginBottom: 8,
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#1E1E2E',
|
||||
},
|
||||
chipActive: {
|
||||
backgroundColor: '#FFD60A',
|
||||
},
|
||||
iconBtn: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#1E1E2E',
|
||||
},
|
||||
err: {
|
||||
color: '#FF3B30',
|
||||
padding: 12,
|
||||
fontSize: 12,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
backgroundColor: '#1A1A2E',
|
||||
borderRadius: 8,
|
||||
marginBottom: 6,
|
||||
},
|
||||
modalBg: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
modal: {
|
||||
backgroundColor: '#0D0D1A',
|
||||
borderRadius: 12,
|
||||
width: '100%',
|
||||
maxWidth: 600,
|
||||
maxHeight: '90%',
|
||||
borderWidth: 1,
|
||||
borderColor: '#1E1E2E',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1E1E2E',
|
||||
},
|
||||
modalFooter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#1E1E2E',
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
color: '#8888AA',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginTop: 8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#1A1A2E',
|
||||
borderWidth: 1,
|
||||
borderColor: '#1E1E2E',
|
||||
borderRadius: 6,
|
||||
color: '#E0E0F0',
|
||||
padding: 10,
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
},
|
||||
hint: {
|
||||
color: '#555570',
|
||||
fontSize: 11,
|
||||
fontStyle: 'italic',
|
||||
marginTop: -4,
|
||||
marginBottom: 10,
|
||||
},
|
||||
metaBox: {
|
||||
backgroundColor: '#1A1A2E',
|
||||
borderRadius: 6,
|
||||
padding: 10,
|
||||
marginTop: 10,
|
||||
gap: 4,
|
||||
},
|
||||
meta: {
|
||||
color: '#8888AA',
|
||||
fontSize: 12,
|
||||
},
|
||||
btn: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
});
|
||||
|
||||
export default TriggerBrowser;
|
||||
@@ -44,7 +44,6 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
||||
const [meterDb, setMeterDb] = useState(-160);
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
const durationTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const isLongPress = useRef(false);
|
||||
|
||||
// Puls-Animation starten/stoppen
|
||||
useEffect(() => {
|
||||
@@ -93,66 +92,62 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
||||
}
|
||||
}, [isRecording]);
|
||||
|
||||
// VAD Silence Callback — Auto-Stop
|
||||
// VAD Silence Callback — Auto-Stop.
|
||||
// WICHTIG: NICHT auf isRecording prüfen (Closure ist stale) — stattdessen
|
||||
// audioService selber fragen. Empty deps → Listener wird EINMAL registriert.
|
||||
// audioService garantiert jetzt dass der Callback pro Aufnahme nur einmal
|
||||
// feuert (silenceFired-Latch).
|
||||
const onCompleteRef = useRef(onRecordingComplete);
|
||||
useEffect(() => { onCompleteRef.current = onRecordingComplete; }, [onRecordingComplete]);
|
||||
useEffect(() => {
|
||||
const unsubSilence = audioService.onSilenceDetected(async () => {
|
||||
if (!isRecording) return;
|
||||
setIsRecording(false);
|
||||
if (audioService.getRecordingState() !== 'recording') return;
|
||||
const result = await audioService.stopRecording();
|
||||
setIsRecording(false);
|
||||
if (result && result.durationMs > 500) {
|
||||
onRecordingComplete(result);
|
||||
onCompleteRef.current(result);
|
||||
}
|
||||
});
|
||||
return unsubSilence;
|
||||
}, [isRecording, onRecordingComplete]);
|
||||
}, []);
|
||||
|
||||
// Auto-Start fuer Wake Word (extern getriggert)
|
||||
const startAutoRecording = useCallback(async () => {
|
||||
if (disabled || isRecording) return;
|
||||
const started = await audioService.startRecording(true); // autoStop = true
|
||||
if (started) {
|
||||
isLongPress.current = false;
|
||||
setIsRecording(true);
|
||||
}
|
||||
}, [disabled, isRecording]);
|
||||
|
||||
// Push-to-Talk: Lang druecken
|
||||
const handlePressIn = async () => {
|
||||
if (disabled || isRecording) return;
|
||||
isLongPress.current = true;
|
||||
const started = await audioService.startRecording(false); // kein autoStop
|
||||
if (started) {
|
||||
setIsRecording(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePressOut = async () => {
|
||||
if (!isRecording || !isLongPress.current) return;
|
||||
isLongPress.current = false;
|
||||
setIsRecording(false);
|
||||
const result = await audioService.stopRecording();
|
||||
if (result && result.durationMs > 300) {
|
||||
onRecordingComplete(result);
|
||||
}
|
||||
};
|
||||
|
||||
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop
|
||||
// Tap-to-Talk: Einmal tippen startet mit Auto-Stop.
|
||||
// Guard gegen Doppel-Tap während asyncer Start/Stop.
|
||||
const tapBusy = useRef(false);
|
||||
const handleTap = async () => {
|
||||
if (disabled) return;
|
||||
if (isRecording) {
|
||||
// Aufnahme manuell stoppen
|
||||
setIsRecording(false);
|
||||
const result = await audioService.stopRecording();
|
||||
if (result && result.durationMs > 300) {
|
||||
onRecordingComplete(result);
|
||||
}
|
||||
} else {
|
||||
// Aufnahme mit Auto-Stop starten
|
||||
const started = await audioService.startRecording(true);
|
||||
if (started) {
|
||||
isLongPress.current = false;
|
||||
setIsRecording(true);
|
||||
if (disabled || tapBusy.current) return;
|
||||
tapBusy.current = true;
|
||||
try {
|
||||
// Fragen WIR den Service, nicht den React-State (Closure kann stale sein)
|
||||
const svcState = audioService.getRecordingState();
|
||||
if (svcState === 'recording') {
|
||||
// Aufnahme manuell stoppen
|
||||
const result = await audioService.stopRecording();
|
||||
setIsRecording(false);
|
||||
if (result && result.durationMs > 300) {
|
||||
onRecordingComplete(result);
|
||||
}
|
||||
} else if (svcState === 'idle') {
|
||||
// Aufnahme mit Auto-Stop starten
|
||||
const started = await audioService.startRecording(true);
|
||||
if (started) {
|
||||
setIsRecording(true);
|
||||
}
|
||||
}
|
||||
// svcState === 'processing': Stopp in progress — nichts tun, User
|
||||
// muss nochmal tippen wenn fertig. Aber wir blockieren mit tapBusy
|
||||
// kurz damit der User's UI-Feedback synchron bleibt.
|
||||
} finally {
|
||||
tapBusy.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -183,10 +178,6 @@ const VoiceButton: React.FC<VoiceButtonProps> = ({
|
||||
isRecording && styles.buttonOuterRecording,
|
||||
{ transform: [{ scale: pulseAnim }] },
|
||||
]}
|
||||
onStartShouldSetResponder={() => true}
|
||||
onResponderGrant={handlePressIn}
|
||||
onResponderRelease={handlePressOut}
|
||||
onResponderTerminate={handlePressOut}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* VoiceCloneModal — Eigene Stimme aufnehmen und an XTTS uploaden.
|
||||
*
|
||||
* Flow:
|
||||
* - Modal zeigt Vorlesetext (>30s Lesedauer) + Aufnahme-Button
|
||||
* - Bei Aufnahme: max 30s, Fortschrittsbalken, Countdown
|
||||
* - Bei Stop: Name abfragen, dann als voice_upload ueber RVS schicken
|
||||
* - XTTS-Bridge speichert /voices/<name>.wav, antwortet mit xtts_voice_saved
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
TextInput,
|
||||
} from 'react-native';
|
||||
import audioService from '../services/audio';
|
||||
import rvs from '../services/rvs';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SAMPLE_TEXT = `Das ist meine eigene Stimme fuer ARIA. Ich lese jetzt einen laengeren Absatz laut vor, damit das Voice-Cloning eine gute Grundlage hat. Guten Tag, ich heisse Stefan und baue gerade mit grosser Begeisterung an meinem persoenlichen KI-Assistenten. Wir automatisieren Infrastruktur, managen Sessions und spielen mit Sprachsynthese. Die letzten Jahre habe ich viel gelernt, vor allem dass Geduld genauso wichtig ist wie Neugier. Hoert sich das jetzt an wie ich selbst? Wenn alles klappt, spricht ARIA bald mit dieser Stimme.`;
|
||||
|
||||
const MAX_DURATION_MS = 30000;
|
||||
const TARGET_DURATION_MS = 15000;
|
||||
|
||||
const VoiceCloneModal: React.FC<Props> = ({ visible, onClose }) => {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [durationMs, setDurationMs] = useState(0);
|
||||
const [voiceName, setVoiceName] = useState('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [recordingPath, setRecordingPath] = useState('');
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
|
||||
// Zustand zuruecksetzen wenn Modal schliesst/oeffnet
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setRecording(false);
|
||||
setDurationMs(0);
|
||||
setVoiceName('');
|
||||
setProcessing(false);
|
||||
setRecordingPath('');
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// Cleanup bei Unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (recording) audioService.stopRecording().catch(() => {});
|
||||
};
|
||||
}, [recording]);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
// Frische Aufnahme
|
||||
setDurationMs(0);
|
||||
setRecordingPath('');
|
||||
const ok = await audioService.startRecording(false);
|
||||
if (!ok) {
|
||||
Alert.alert('Fehler', 'Aufnahme konnte nicht gestartet werden (Mikrofon-Berechtigung?)');
|
||||
return;
|
||||
}
|
||||
setRecording(true);
|
||||
startTimeRef.current = Date.now();
|
||||
timerRef.current = setInterval(async () => {
|
||||
const elapsed = Date.now() - startTimeRef.current;
|
||||
setDurationMs(elapsed);
|
||||
if (elapsed >= MAX_DURATION_MS) {
|
||||
await stopRecording();
|
||||
}
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
const stopRecording = useCallback(async () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (!recording) return;
|
||||
const result = await audioService.stopRecording();
|
||||
setRecording(false);
|
||||
if (!result) {
|
||||
Alert.alert('Keine Sprache erkannt', 'Versuch es bitte nochmal — sprich bis der Timer mindestens 10 Sekunden anzeigt.');
|
||||
setDurationMs(0);
|
||||
return;
|
||||
}
|
||||
// Temp-Datei wurde schon geloescht (stopRecording cleaned up).
|
||||
// Wir brauchen aber base64 aus result direkt fuers Upload.
|
||||
// result.base64 ist bereits da.
|
||||
setRecordingPath(result.base64);
|
||||
}, [recording]);
|
||||
|
||||
const uploadVoice = useCallback(async () => {
|
||||
const name = voiceName.trim();
|
||||
if (!name) {
|
||||
Alert.alert('Name fehlt', 'Bitte gib der Stimme einen Namen (nur Buchstaben, Zahlen, _ und -).');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
Alert.alert('Ungueltiger Name', 'Nur Buchstaben, Zahlen, _ und - erlaubt.');
|
||||
return;
|
||||
}
|
||||
if (!recordingPath) {
|
||||
Alert.alert('Keine Aufnahme', 'Bitte zuerst aufnehmen.');
|
||||
return;
|
||||
}
|
||||
setProcessing(true);
|
||||
try {
|
||||
// voice_upload erwartet samples als Array mit base64 (aus Diagnostic-Format kopiert)
|
||||
rvs.send('voice_upload' as any, {
|
||||
name,
|
||||
samples: [{ base64: recordingPath }],
|
||||
});
|
||||
Alert.alert('Hochgeladen', `Stimme "${name}" wird vom XTTS-Server verarbeitet. Nach ein paar Sekunden in der Liste verfuegbar.`);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
Alert.alert('Fehler', err.message);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
}, [voiceName, recordingPath, onClose]);
|
||||
|
||||
const progress = Math.min(durationMs / MAX_DURATION_MS, 1);
|
||||
const sec = Math.floor(durationMs / 1000);
|
||||
const enoughRecorded = durationMs >= TARGET_DURATION_MS;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Eigene Stimme aufnehmen</Text>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<Text style={styles.closeX}>{'\u2715'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content} contentContainerStyle={{padding: 16}}>
|
||||
<Text style={styles.hint}>
|
||||
Lies den Text laut und deutlich vor. Maximal 30 Sekunden. Je mehr du sprichst
|
||||
(ziel: bis zum Ende des Textes, ca. 20-30s), desto besser wird die geklonte
|
||||
Stimme.
|
||||
</Text>
|
||||
|
||||
<View style={styles.sampleTextBox}>
|
||||
<Text style={styles.sampleText}>{SAMPLE_TEXT}</Text>
|
||||
</View>
|
||||
|
||||
{/* Timer + Fortschritt */}
|
||||
<View style={{marginTop: 20, alignItems: 'center'}}>
|
||||
<Text style={[styles.timer, recording && styles.timerActive]}>
|
||||
{sec.toString().padStart(2, '0')} / 30 s
|
||||
</Text>
|
||||
<View style={styles.progressBar}>
|
||||
<View style={[styles.progressFill, {width: `${progress * 100}%`, backgroundColor: recording ? '#FF3B30' : '#0096FF'}]} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Aufnahme-Button */}
|
||||
{!recordingPath && (
|
||||
<TouchableOpacity
|
||||
style={[styles.recordBtn, recording && styles.recordBtnActive]}
|
||||
onPress={recording ? stopRecording : startRecording}
|
||||
>
|
||||
<Text style={styles.recordIcon}>{recording ? '\u25A0' : '\u25CF'}</Text>
|
||||
<Text style={styles.recordLabel}>{recording ? 'Stop' : 'Aufnahme starten'}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Nach Aufnahme: Name + Upload */}
|
||||
{recordingPath && (
|
||||
<View style={{marginTop: 20}}>
|
||||
<Text style={styles.hint}>
|
||||
Aufnahme ({sec}s) fertig. Vergib einen Namen und lade hoch.
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.nameInput}
|
||||
value={voiceName}
|
||||
onChangeText={setVoiceName}
|
||||
placeholder="z.B. stefan"
|
||||
placeholderTextColor="#555570"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<View style={{flexDirection: 'row', gap: 8, marginTop: 12}}>
|
||||
<TouchableOpacity
|
||||
style={[styles.secondaryBtn, {flex: 1}]}
|
||||
onPress={() => { setRecordingPath(''); setDurationMs(0); }}
|
||||
>
|
||||
<Text style={styles.secondaryBtnText}>Nochmal aufnehmen</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryBtn, {flex: 1}]}
|
||||
onPress={uploadVoice}
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? <ActivityIndicator color="#fff" />
|
||||
: <Text style={styles.primaryBtnText}>Hochladen</Text>
|
||||
}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{recording && !enoughRecorded && (
|
||||
<Text style={[styles.hint, {marginTop: 12, color: '#FFD60A', textAlign: 'center'}]}>
|
||||
Bitte weiter lesen — mindestens 15 Sekunden
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{recording && enoughRecorded && (
|
||||
<Text style={[styles.hint, {marginTop: 12, color: '#34C759', textAlign: 'center'}]}>
|
||||
Genug Audio fuer eine gute Clonung. Du kannst stoppen.
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0D0D1A',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 48,
|
||||
paddingBottom: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1E1E2E',
|
||||
},
|
||||
title: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
closeX: {
|
||||
color: '#8888AA',
|
||||
fontSize: 24,
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
hint: {
|
||||
color: '#8888AA',
|
||||
fontSize: 13,
|
||||
lineHeight: 20,
|
||||
},
|
||||
sampleTextBox: {
|
||||
marginTop: 12,
|
||||
padding: 14,
|
||||
backgroundColor: '#12122A',
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1E1E2E',
|
||||
},
|
||||
sampleText: {
|
||||
color: '#E0E0F0',
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
},
|
||||
timer: {
|
||||
color: '#666680',
|
||||
fontSize: 42,
|
||||
fontWeight: '700',
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
timerActive: {
|
||||
color: '#FF3B30',
|
||||
},
|
||||
progressBar: {
|
||||
marginTop: 8,
|
||||
width: '100%',
|
||||
height: 8,
|
||||
backgroundColor: '#1E1E2E',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
},
|
||||
recordBtn: {
|
||||
marginTop: 24,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
backgroundColor: '#1E1E2E',
|
||||
borderRadius: 12,
|
||||
padding: 18,
|
||||
borderWidth: 2,
|
||||
borderColor: '#34C759',
|
||||
},
|
||||
recordBtnActive: {
|
||||
borderColor: '#FF3B30',
|
||||
backgroundColor: 'rgba(255,59,48,0.15)',
|
||||
},
|
||||
recordIcon: {
|
||||
color: '#FF3B30',
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
},
|
||||
recordLabel: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
},
|
||||
nameInput: {
|
||||
marginTop: 10,
|
||||
backgroundColor: '#1E1E2E',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
color: '#FFFFFF',
|
||||
fontSize: 15,
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A2A3E',
|
||||
},
|
||||
primaryBtn: {
|
||||
backgroundColor: '#0096FF',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryBtnText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
secondaryBtn: {
|
||||
backgroundColor: '#1E1E2E',
|
||||
borderRadius: 10,
|
||||
padding: 14,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: '#2A2A3E',
|
||||
},
|
||||
secondaryBtnText: {
|
||||
color: '#8888AA',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default VoiceCloneModal;
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* ZoomableImage — Pinch-to-Zoom + Pan fuers Vollbild-Modal.
|
||||
*
|
||||
* Reine RN-Implementation, ohne react-native-gesture-handler.
|
||||
*
|
||||
* - 2 Finger: Pinch (Zoom 1x..5x) + simultaner Pan via Focal-Punkt
|
||||
* - 1 Finger: Pan wenn schon gezoomt (>1.02x)
|
||||
* - Doppel-Tap (<300ms zw. zwei Single-Taps): Toggle 1x ↔ 2.5x
|
||||
*
|
||||
* Implementierungs-Hinweise zur alten Version (warum's nicht ging):
|
||||
* - `gestureState.numberActiveTouches` ist nicht zuverlaessig direkt
|
||||
* nach onPanResponderGrant. Wir lesen Finger-Anzahl jetzt
|
||||
* ausschliesslich aus `e.nativeEvent.touches.length`.
|
||||
* - Beim Wechsel von 2 → 1 Fingern bleib die Pinch-Referenz haengen.
|
||||
* Jetzt: bei jedem Finger-Wechsel re-snapshotten wir die Geste.
|
||||
* - Animated.Image bekommt jetzt pointerEvents="none" damit der View
|
||||
* GARANTIERT die Touches abbekommt.
|
||||
* - useNativeDriver ist bewusst AUS — sonst koennen wir setValue()
|
||||
* nicht synchron mit dem Pan-Responder zusammen nutzen.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import {
|
||||
Animated,
|
||||
PanResponder,
|
||||
GestureResponderEvent,
|
||||
ImageStyle,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
interface Props {
|
||||
uri: string;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
style?: StyleProp<ImageStyle>;
|
||||
}
|
||||
|
||||
const MIN_SCALE = 1;
|
||||
const MAX_SCALE = 5;
|
||||
const DOUBLE_TAP_MS = 300;
|
||||
const DOUBLE_TAP_DIST = 30; // Bewegung max. damit ein Tap als Tap gilt
|
||||
const PAN_SLOP_AT_SCALE_1 = 4; // Mikro-Movement nicht als Pan werten
|
||||
|
||||
const ZoomableImage: React.FC<Props> = ({ uri, containerWidth, containerHeight, style }) => {
|
||||
// Animated-Werte fuer die Render-Transformation
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
const tx = useRef(new Animated.Value(0)).current;
|
||||
const ty = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// Logische Zustaende — wir lesen Animated.Value nicht zurueck (waere async)
|
||||
const view = useRef({ scale: 1, x: 0, y: 0 }).current;
|
||||
|
||||
// Geste-Snapshot: was war zu Beginn dieser Geste-Phase
|
||||
const gesture = useRef({
|
||||
fingers: 0, // aktuelle Finger-Anzahl
|
||||
startScale: 1,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
startDist: 0, // Pinch-Referenz-Distanz
|
||||
startFocalX: 0,
|
||||
startFocalY: 0,
|
||||
movedSinceTouch: 0, // fuer Tap-Erkennung
|
||||
touchStartedAt: 0,
|
||||
touchStartX: 0,
|
||||
touchStartY: 0,
|
||||
}).current;
|
||||
|
||||
// Doppel-Tap
|
||||
const lastTap = useRef({ at: 0, x: 0, y: 0 });
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||
|
||||
const applyClamped = (s: number, x: number, y: number) => {
|
||||
const ns = clamp(s, MIN_SCALE, MAX_SCALE);
|
||||
// Translation auf das verfuegbare Volumen begrenzen
|
||||
const maxX = Math.max(0, (containerWidth * ns - containerWidth) / 2);
|
||||
const maxY = Math.max(0, (containerHeight * ns - containerHeight) / 2);
|
||||
const nx = clamp(x, -maxX, maxX);
|
||||
const ny = clamp(y, -maxY, maxY);
|
||||
view.scale = ns;
|
||||
view.x = nx;
|
||||
view.y = ny;
|
||||
scale.setValue(ns);
|
||||
tx.setValue(nx);
|
||||
ty.setValue(ny);
|
||||
};
|
||||
|
||||
const distance = (touches: any[]) =>
|
||||
Math.hypot(touches[0].pageX - touches[1].pageX, touches[0].pageY - touches[1].pageY);
|
||||
|
||||
const focal = (touches: any[]) => ({
|
||||
x: (touches[0].pageX + touches[1].pageX) / 2,
|
||||
y: (touches[0].pageY + touches[1].pageY) / 2,
|
||||
});
|
||||
|
||||
// Snapshot vor jedem Phasenwechsel (1↔2 Finger) — verhindert Spruenge
|
||||
const snapshot = (touches: any[]) => {
|
||||
gesture.startScale = view.scale;
|
||||
gesture.startX = view.x;
|
||||
gesture.startY = view.y;
|
||||
if (touches.length >= 2) {
|
||||
gesture.startDist = distance(touches);
|
||||
const f = focal(touches);
|
||||
gesture.startFocalX = f.x;
|
||||
gesture.startFocalY = f.y;
|
||||
} else if (touches.length === 1) {
|
||||
gesture.startDist = 0;
|
||||
gesture.startFocalX = touches[0].pageX;
|
||||
gesture.startFocalY = touches[0].pageY;
|
||||
}
|
||||
};
|
||||
|
||||
const responder = useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onStartShouldSetPanResponderCapture: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponderCapture: () => true,
|
||||
|
||||
onPanResponderGrant: (e: GestureResponderEvent) => {
|
||||
const touches = e.nativeEvent.touches as any[];
|
||||
gesture.fingers = touches.length;
|
||||
gesture.movedSinceTouch = 0;
|
||||
gesture.touchStartedAt = Date.now();
|
||||
gesture.touchStartX = touches[0]?.pageX ?? 0;
|
||||
gesture.touchStartY = touches[0]?.pageY ?? 0;
|
||||
snapshot(touches);
|
||||
},
|
||||
|
||||
onPanResponderMove: (e: GestureResponderEvent, _gs) => {
|
||||
const touches = e.nativeEvent.touches as any[];
|
||||
|
||||
// Phasenwechsel? → Re-Snapshot, damit nicht gesprungen wird
|
||||
if (touches.length !== gesture.fingers) {
|
||||
gesture.fingers = touches.length;
|
||||
snapshot(touches);
|
||||
return;
|
||||
}
|
||||
|
||||
gesture.movedSinceTouch += 1;
|
||||
|
||||
if (touches.length >= 2) {
|
||||
// Pinch + Pan via Focal
|
||||
const d = distance(touches);
|
||||
if (gesture.startDist === 0) {
|
||||
// Sicherheitsnetz falls Snapshot gemissed wurde
|
||||
snapshot(touches);
|
||||
return;
|
||||
}
|
||||
const factor = d / gesture.startDist;
|
||||
const f = focal(touches);
|
||||
const newScale = clamp(gesture.startScale * factor, MIN_SCALE, MAX_SCALE);
|
||||
// Focal-basierter Pan: zoomt um den Mittelpunkt der zwei Finger
|
||||
const newX = gesture.startX + (f.x - gesture.startFocalX);
|
||||
const newY = gesture.startY + (f.y - gesture.startFocalY);
|
||||
applyClamped(newScale, newX, newY);
|
||||
} else if (touches.length === 1 && view.scale > 1.02) {
|
||||
const dx = touches[0].pageX - gesture.startFocalX;
|
||||
const dy = touches[0].pageY - gesture.startFocalY;
|
||||
if (Math.abs(dx) < PAN_SLOP_AT_SCALE_1 && Math.abs(dy) < PAN_SLOP_AT_SCALE_1) return;
|
||||
applyClamped(view.scale, gesture.startX + dx, gesture.startY + dy);
|
||||
}
|
||||
},
|
||||
|
||||
onPanResponderRelease: (e: GestureResponderEvent) => {
|
||||
const elapsed = Date.now() - gesture.touchStartedAt;
|
||||
const dx = (e.nativeEvent.changedTouches?.[0]?.pageX ?? gesture.touchStartX) - gesture.touchStartX;
|
||||
const dy = (e.nativeEvent.changedTouches?.[0]?.pageY ?? gesture.touchStartY) - gesture.touchStartY;
|
||||
const wasTap =
|
||||
elapsed < 280 &&
|
||||
Math.abs(dx) < DOUBLE_TAP_DIST &&
|
||||
Math.abs(dy) < DOUBLE_TAP_DIST;
|
||||
if (wasTap) {
|
||||
const now = Date.now();
|
||||
if (now - lastTap.current.at < DOUBLE_TAP_MS) {
|
||||
// Doppel-Tap → Zoom-Toggle
|
||||
if (view.scale > 1.1) {
|
||||
applyClamped(1, 0, 0);
|
||||
} else {
|
||||
applyClamped(2.5, 0, 0);
|
||||
}
|
||||
lastTap.current = { at: 0, x: 0, y: 0 };
|
||||
} else {
|
||||
lastTap.current = { at: now, x: gesture.touchStartX, y: gesture.touchStartY };
|
||||
}
|
||||
}
|
||||
gesture.fingers = 0;
|
||||
gesture.startDist = 0;
|
||||
},
|
||||
|
||||
onPanResponderTerminate: () => {
|
||||
gesture.fingers = 0;
|
||||
gesture.startDist = 0;
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={StyleSheet.absoluteFill}
|
||||
collapsable={false}
|
||||
{...responder.panHandlers}
|
||||
>
|
||||
<Animated.View pointerEvents="none" style={StyleSheet.absoluteFill}>
|
||||
<Animated.Image
|
||||
source={{ uri }}
|
||||
style={[
|
||||
style,
|
||||
{
|
||||
transform: [{ translateX: tx }, { translateY: ty }, { scale }],
|
||||
},
|
||||
]}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZoomableImage;
|
||||
+2721
-124
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1179
-43
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Background-Audio + Hintergrund-Persistenz: ARIAs TTS, Mic-Aufnahme,
|
||||
* Wake-Word-Lauschen UND der allgemeine Hintergrund-Modus laufen
|
||||
* weiter wenn die App minimiert ist. Wir starten dafuer einen Foreground-
|
||||
* Service mit foregroundServiceType=mediaPlayback|microphone, der eine
|
||||
* persistente Notification zeigt solange irgendein Slot aktiv ist.
|
||||
*
|
||||
* Mehrere Komponenten koennen den Service unabhaengig "halten":
|
||||
* - 'tts' : ARIA spricht
|
||||
* - 'rec' : Aufnahme laeuft
|
||||
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
|
||||
* - 'background' : Persistenter Hintergrund-Modus (Settings-Toggle).
|
||||
* Haelt JS-Engine + WebSocket auch ohne Audio am Leben
|
||||
* → Trigger-Replies, Reconnects, Push-Reaktionen.
|
||||
*
|
||||
* Solange mindestens ein Slot aktiv ist, laeuft der Service. Wenn alle
|
||||
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
|
||||
* den hoechstprioren Slot an (tts > rec > wake > background).
|
||||
*/
|
||||
|
||||
import { NativeModules } from 'react-native';
|
||||
|
||||
interface BackgroundAudioNative {
|
||||
start(reason: string): Promise<boolean>;
|
||||
stop(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
|
||||
|
||||
type Slot = 'tts' | 'rec' | 'wake' | 'background';
|
||||
|
||||
const slots = new Set<Slot>();
|
||||
|
||||
// Prioritaet fuer den Notification-Text — hoechste zuerst. 'background'
|
||||
// ist die fallback-Anzeige wenn nichts anderes laeuft.
|
||||
const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'background'];
|
||||
|
||||
function topReason(): string {
|
||||
for (const s of PRIORITY) {
|
||||
if (slots.has(s)) return s;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function applyState(): Promise<void> {
|
||||
if (!BackgroundAudio) return;
|
||||
if (slots.size === 0) {
|
||||
try { await BackgroundAudio.stop(); } catch {}
|
||||
console.log('[BackgroundAudio] Service gestoppt (keine Slots)');
|
||||
return;
|
||||
}
|
||||
const reason = topReason();
|
||||
try {
|
||||
await BackgroundAudio.start(reason);
|
||||
console.log('[BackgroundAudio] Service aktiv (slot=%s, slots=%s)',
|
||||
reason, [...slots].join('+'));
|
||||
} catch (err: any) {
|
||||
console.warn('[BackgroundAudio] start fehlgeschlagen:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireBackgroundAudio(slot: Slot): Promise<void> {
|
||||
if (slots.has(slot)) return;
|
||||
slots.add(slot);
|
||||
await applyState();
|
||||
}
|
||||
|
||||
export async function releaseBackgroundAudio(slot: Slot): Promise<void> {
|
||||
if (!slots.has(slot)) return;
|
||||
slots.delete(slot);
|
||||
await applyState();
|
||||
}
|
||||
|
||||
export function backgroundAudioActive(): boolean {
|
||||
return slots.size > 0;
|
||||
}
|
||||
|
||||
// --- Legacy API (nur tts-Slot) — fuer Aufruf-Sites die noch nichts vom Slot-
|
||||
// system wissen. Mappt auf den 'tts'-Slot. ---
|
||||
export const startBackgroundAudio = () => acquireBackgroundAudio('tts');
|
||||
export const stopBackgroundAudio = () => releaseBackgroundAudio('tts');
|
||||
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* Brain-API-Client fuer die App.
|
||||
*
|
||||
* Die App hat keinen direkten HTTP-Zugriff aufs Brain (nur via RVS). Wir
|
||||
* tunneln alle Memory-Operationen ueber den generischen brain_request /
|
||||
* brain_response RVS-Channel den die Bridge implementiert.
|
||||
*
|
||||
* Pattern: pro Call eine eindeutige requestId, Listener wartet auf passende
|
||||
* brain_response, Promise loest auf / wird abgelehnt bei status>=400.
|
||||
*/
|
||||
|
||||
import rvs from './rvs';
|
||||
|
||||
type AnyJson = any;
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (data: AnyJson) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
expectBinary?: boolean;
|
||||
}
|
||||
|
||||
const pending = new Map<string, PendingRequest>();
|
||||
let installed = false;
|
||||
|
||||
function _ensureListener() {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
rvs.onMessage((msg: any) => {
|
||||
if (!msg || msg.type !== 'brain_response') return;
|
||||
const p = msg.payload || {};
|
||||
const reqId: string = p.requestId || '';
|
||||
const handler = pending.get(reqId);
|
||||
if (!handler) return;
|
||||
pending.delete(reqId);
|
||||
clearTimeout(handler.timer);
|
||||
const status: number = Number(p.status || 0);
|
||||
if (status >= 200 && status < 300) {
|
||||
if (handler.expectBinary) {
|
||||
handler.resolve({ base64: p.base64 || '', contentType: p.contentType || '' });
|
||||
} else {
|
||||
handler.resolve(p.json !== undefined ? p.json : (p.text !== undefined ? p.text : null));
|
||||
}
|
||||
} else {
|
||||
const detail = (p.json && p.json.detail) || p.text || `HTTP ${status}`;
|
||||
handler.reject(new Error(`Brain ${status}: ${detail}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let _nextId = 0;
|
||||
function _newRequestId(): string {
|
||||
_nextId += 1;
|
||||
return `brain_${Date.now().toString(36)}_${_nextId}`;
|
||||
}
|
||||
|
||||
/** Mini-Query-String-Builder ohne URLSearchParams (Hermes-Polyfill kennt
|
||||
* kein URLSearchParams.set, crasht). Akzeptiert object mit string/number/
|
||||
* bool-Values; undefined/null/leere Strings werden ausgelassen. */
|
||||
function _qs(params: Record<string, unknown>): string {
|
||||
const parts: string[] = [];
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === '') continue;
|
||||
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
||||
}
|
||||
return parts.length ? `?${parts.join('&')}` : '';
|
||||
}
|
||||
|
||||
interface SendOpts {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
body?: AnyJson;
|
||||
bodyBase64?: string;
|
||||
contentType?: string;
|
||||
expectBinary?: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
function _send(path: string, opts: SendOpts = {}): Promise<AnyJson> {
|
||||
_ensureListener();
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = _newRequestId();
|
||||
const timer = setTimeout(() => {
|
||||
if (pending.delete(requestId)) {
|
||||
reject(new Error(`Brain-Timeout fuer ${path}`));
|
||||
}
|
||||
}, opts.timeoutMs || 30000);
|
||||
pending.set(requestId, { resolve, reject, timer, expectBinary: opts.expectBinary });
|
||||
rvs.send('brain_request' as any, {
|
||||
requestId,
|
||||
method: opts.method || 'GET',
|
||||
path,
|
||||
...(opts.body !== undefined ? { body: opts.body } : {}),
|
||||
...(opts.bodyBase64 ? { bodyBase64: opts.bodyBase64 } : {}),
|
||||
...(opts.contentType ? { contentType: opts.contentType } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Typen ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MemoryAttachment {
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface Memory {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
pinned: boolean;
|
||||
category: string;
|
||||
source: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
conversation_id?: string | null;
|
||||
score?: number | null;
|
||||
attachments?: MemoryAttachment[];
|
||||
}
|
||||
|
||||
/** OAuth-Service-Status wie aus Brain `/oauth/services` zurueckkommt. */
|
||||
export interface OAuthServiceStatus {
|
||||
service: string;
|
||||
configured: boolean;
|
||||
authenticated: boolean;
|
||||
expiresAt?: number | null;
|
||||
expiresInSec?: number | null;
|
||||
hasRefresh: boolean;
|
||||
scope?: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
/** OAuth-App-Config (client_id/scopes/URLs) — client_secret kommt NIE rausgegeben. */
|
||||
export interface OAuthAppConfig {
|
||||
client_id: string;
|
||||
has_client_secret: boolean;
|
||||
scopes?: string[] | null;
|
||||
auth_url?: string | null;
|
||||
token_url?: string | null;
|
||||
}
|
||||
|
||||
/** Skill-Manifest wie aus Brain `/skills/list` zurueckkommt. */
|
||||
export interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
execution: string; // local-venv | local-bin | bash
|
||||
entry: string; // run.py | run.sh
|
||||
args?: any[]; // [{name, type, required, description}]
|
||||
requires?: { pip?: string[]; binaries?: string[] };
|
||||
active: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
last_used?: string | null;
|
||||
use_count?: number;
|
||||
version?: string;
|
||||
author?: string; // "aria" | "stefan"
|
||||
setup_error?: string;
|
||||
}
|
||||
|
||||
/** Trigger-Manifest wie aus Brain `/triggers/list` zurueckkommt. */
|
||||
export interface Trigger {
|
||||
name: string;
|
||||
type: 'timer' | 'watcher' | string;
|
||||
active: boolean;
|
||||
author?: string;
|
||||
message: string;
|
||||
fires_at?: string; // ISO, nur timer
|
||||
condition?: string; // nur watcher
|
||||
check_interval_sec?: number; // nur watcher
|
||||
throttle_sec?: number; // nur watcher
|
||||
fire_count?: number;
|
||||
last_fired_at?: string | null;
|
||||
last_checked_at?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// ── Memory CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
export const brainApi = {
|
||||
/** Einzelne Memory holen (mit allen Feldern inkl. Anhaenge) */
|
||||
getMemory(id: string): Promise<Memory> {
|
||||
return _send(`/memory/get/${encodeURIComponent(id)}`);
|
||||
},
|
||||
|
||||
/** Liste aller Memories, optional nach Type gefiltert. */
|
||||
listMemories(opts: { type?: string; limit?: number } = {}): Promise<Memory[]> {
|
||||
const qs = _qs({ type: opts.type, limit: opts.limit || 500 });
|
||||
return _send(`/memory/list${qs}`);
|
||||
},
|
||||
|
||||
/** Volltext-Substring-Suche. */
|
||||
searchText(q: string, opts: { type?: string; includePinned?: boolean; k?: number } = {}): Promise<Memory[]> {
|
||||
const qs = _qs({
|
||||
q,
|
||||
type: opts.type,
|
||||
include_pinned: opts.includePinned !== false,
|
||||
k: opts.k || 50,
|
||||
});
|
||||
return _send(`/memory/search-text${qs}`);
|
||||
},
|
||||
|
||||
/** Semantische Suche (Embedder). */
|
||||
searchSemantic(q: string, opts: { type?: string; includePinned?: boolean; k?: number; threshold?: number } = {}): Promise<Memory[]> {
|
||||
const qs = _qs({
|
||||
q,
|
||||
type: opts.type,
|
||||
include_pinned: opts.includePinned !== false,
|
||||
k: opts.k || 10,
|
||||
score_threshold: opts.threshold ?? 0.30,
|
||||
});
|
||||
return _send(`/memory/search${qs}`);
|
||||
},
|
||||
|
||||
/** Memory anlegen. */
|
||||
saveMemory(body: {
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
pinned?: boolean;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
}): Promise<Memory> {
|
||||
return _send('/memory/save', {
|
||||
method: 'POST',
|
||||
body: { source: 'app', ...body },
|
||||
});
|
||||
},
|
||||
|
||||
/** Memory aktualisieren (Patch — nur uebergebene Felder werden geaendert). */
|
||||
updateMemory(id: string, body: Partial<Pick<Memory, 'title' | 'content' | 'pinned' | 'category' | 'tags'>>): Promise<Memory> {
|
||||
return _send(`/memory/update/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/** Memory loeschen. */
|
||||
deleteMemory(id: string): Promise<{ deleted: string }> {
|
||||
return _send(`/memory/delete/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
// ── Anhaenge ────────────────────────────────────────────────────────
|
||||
|
||||
/** Datei als Anhang an die Memory haengen (Base64-Upload). */
|
||||
uploadAttachment(memoryId: string, name: string, base64: string): Promise<Memory> {
|
||||
return _send(`/memory/${encodeURIComponent(memoryId)}/attachments`, {
|
||||
method: 'POST',
|
||||
body: { name, data_base64: base64 },
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Anhang loeschen. */
|
||||
deleteAttachment(memoryId: string, filename: string): Promise<Memory> {
|
||||
return _send(
|
||||
`/memory/${encodeURIComponent(memoryId)}/attachments/${encodeURIComponent(filename)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
},
|
||||
|
||||
/** Anhang-Bytes holen (fuer Vorschau / Download). Liefert Base64. */
|
||||
getAttachmentBytes(memoryId: string, filename: string): Promise<{ base64: string; contentType: string }> {
|
||||
return _send(
|
||||
`/memory/${encodeURIComponent(memoryId)}/attachments/${encodeURIComponent(filename)}`,
|
||||
{ expectBinary: true, timeoutMs: 60000 },
|
||||
);
|
||||
},
|
||||
|
||||
// ── Triggers ────────────────────────────────────────────────────────
|
||||
|
||||
/** Liste aller Trigger (aktive + inaktive).
|
||||
* Brain returnt {triggers: [...]} — wir unwrappen damit der Caller einfach
|
||||
* t.sort/filter/map nutzen kann. Ohne das Unwrap warf t.sort() eine
|
||||
* TypeError-Exception und der TriggerBrowser blieb leer. */
|
||||
listTriggers(): Promise<Trigger[]> {
|
||||
return _send('/triggers/list').then((r: any) => Array.isArray(r) ? r : (r?.triggers || []));
|
||||
},
|
||||
|
||||
/** Einzelnen Trigger holen (inkl. fire_count, last_fired_at, ...). */
|
||||
getTrigger(name: string): Promise<Trigger> {
|
||||
return _send(`/triggers/${encodeURIComponent(name)}`);
|
||||
},
|
||||
|
||||
/** Verfuegbare Condition-Variablen + Funktionen (fuer Watcher-Editor). */
|
||||
getTriggerConditions(): Promise<{ variables: any[]; functions: any[] }> {
|
||||
return _send('/triggers/conditions');
|
||||
},
|
||||
|
||||
/** Trigger-Logs (last N Feuerungen). */
|
||||
getTriggerLogs(name: string, limit: number = 50): Promise<any[]> {
|
||||
return _send(`/triggers/${encodeURIComponent(name)}/logs?limit=${limit}`);
|
||||
},
|
||||
|
||||
/** Timer anlegen. fires_at = ISO timestamp (UTC). */
|
||||
createTimer(body: { name: string; fires_at: string; message: string; author?: string }): Promise<Trigger> {
|
||||
return _send('/triggers/timer', {
|
||||
method: 'POST',
|
||||
body: { author: 'app', ...body },
|
||||
});
|
||||
},
|
||||
|
||||
/** Watcher anlegen. */
|
||||
createWatcher(body: {
|
||||
name: string;
|
||||
condition: string;
|
||||
message: string;
|
||||
check_interval_sec?: number;
|
||||
throttle_sec?: number;
|
||||
author?: string;
|
||||
}): Promise<Trigger> {
|
||||
return _send('/triggers/watcher', {
|
||||
method: 'POST',
|
||||
body: { author: 'app', ...body },
|
||||
});
|
||||
},
|
||||
|
||||
/** Trigger patchen (active/message/condition/throttle/interval/fires_at). */
|
||||
updateTrigger(name: string, body: Partial<{
|
||||
active: boolean;
|
||||
message: string;
|
||||
condition: string;
|
||||
throttle_sec: number;
|
||||
check_interval_sec: number;
|
||||
fires_at: string;
|
||||
}>): Promise<Trigger> {
|
||||
return _send(`/triggers/${encodeURIComponent(name)}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/** Trigger loeschen. */
|
||||
deleteTrigger(name: string): Promise<{ deleted: string }> {
|
||||
return _send(`/triggers/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
// ── Skills ────────────────────────────────────────────────────────
|
||||
|
||||
/** Liste aller Skills (aktive + inaktive). Brain returnt {skills: [...]}. */
|
||||
listSkills(): Promise<Skill[]> {
|
||||
return _send('/skills/list').then((r: any) => Array.isArray(r) ? r : (r?.skills || []));
|
||||
},
|
||||
|
||||
/** Einzelnen Skill holen (inkl. setup_error, last_used, use_count). */
|
||||
getSkill(name: string): Promise<Skill> {
|
||||
return _send(`/skills/${encodeURIComponent(name)}`);
|
||||
},
|
||||
|
||||
/** Skill ausfuehren (mit args als ENV ARG_XXX). Skill-Run kann lange dauern,
|
||||
* 5 min Default-Timeout. */
|
||||
runSkill(name: string, args: Record<string, any> = {}): Promise<{
|
||||
ok: boolean; exit_code: number; stdout: string; stderr: string;
|
||||
duration_sec: number; log_path?: string;
|
||||
}> {
|
||||
return _send('/skills/run', {
|
||||
method: 'POST',
|
||||
body: { name, args, timeout_sec: 300 },
|
||||
timeoutMs: 320000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Skill-Manifest aendern (description, active, args...). Code-Aenderungen
|
||||
* gehen ueber ARIAs eigene skill_update-Tool — die App-UI sollte sie
|
||||
* NICHT direkt anbieten (zu fehleranfaellig). */
|
||||
updateSkill(name: string, body: Partial<{
|
||||
description: string;
|
||||
active: boolean;
|
||||
args: any[];
|
||||
version: string;
|
||||
}>): Promise<Skill> {
|
||||
return _send(`/skills/${encodeURIComponent(name)}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Skill loeschen (samt venv + logs). */
|
||||
deleteSkill(name: string): Promise<{ deleted: string }> {
|
||||
return _send(`/skills/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Letzte Run-Logs eines Skills. */
|
||||
getSkillLogs(name: string, limit: number = 20): Promise<any[]> {
|
||||
return _send(`/skills/${encodeURIComponent(name)}/logs?limit=${limit}`);
|
||||
},
|
||||
|
||||
// ── OAuth ────────────────────────────────────────────────────────
|
||||
|
||||
/** Liste aller Services mit Auth-Status (configured/authenticated/expires). */
|
||||
listOAuthServices(): Promise<{ services: OAuthServiceStatus[] }> {
|
||||
return _send('/oauth/services');
|
||||
},
|
||||
|
||||
/** Persistierte Provider-Configs (URLs/scopes/client_id, KEIN client_secret). */
|
||||
getOAuthApps(): Promise<{ apps: Record<string, OAuthAppConfig>; defaults: string[] }> {
|
||||
return _send('/oauth/apps');
|
||||
},
|
||||
|
||||
/** Provider-Config setzen/aktualisieren. Leerer client_secret laesst
|
||||
* den bestehenden Wert stehen. */
|
||||
saveOAuthApp(body: {
|
||||
service: string;
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
scopes?: string[];
|
||||
auth_url?: string;
|
||||
token_url?: string;
|
||||
}): Promise<{ ok: boolean; service: string }> {
|
||||
return _send('/oauth/apps', {
|
||||
method: 'POST',
|
||||
body,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Service-Eintrag komplett entfernen (incl. Token). */
|
||||
deleteOAuthApp(service: string): Promise<{ ok: boolean }> {
|
||||
return _send(`/oauth/apps/${encodeURIComponent(service)}`, {
|
||||
method: 'DELETE',
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Authorize-URL bauen (Brain speichert state, gibt url + redirect_uri zurueck). */
|
||||
authorizeOAuth(service: string, scopes?: string[]): Promise<{
|
||||
url: string; state: string; redirect_uri: string; service: string;
|
||||
}> {
|
||||
return _send('/oauth/authorize', {
|
||||
method: 'POST',
|
||||
body: { service, scopes },
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
|
||||
/** Token loeschen (lokal — kein Provider-Revoke). */
|
||||
revokeOAuth(service: string): Promise<{ ok: boolean }> {
|
||||
return _send(`/oauth/${encodeURIComponent(service)}/revoke`, {
|
||||
method: 'POST',
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default brainApi;
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* GPS-Tracking-Service.
|
||||
*
|
||||
* Wenn aktiv: pushed alle paar Sekunden die aktuelle Position als
|
||||
* `location_update {lat, lon}` an den RVS-Server, damit Brain-Watcher
|
||||
* mit `near()`-Conditions etwas zum Vergleichen haben.
|
||||
*
|
||||
* Default: AUS. Wird entweder vom User manuell in Settings angeschaltet
|
||||
* oder von ARIA via location_tracking-RVS-Message (Brain-Tool
|
||||
* `request_location_tracking`).
|
||||
*
|
||||
* Energie-Schutz: distanceFilter 30m, interval 15s. Echte Fahrt-Updates
|
||||
* (Geschwindigkeit) kommen sauber durch, stationaer wird kaum gesendet.
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { PermissionsAndroid, Platform, ToastAndroid } from 'react-native';
|
||||
import Geolocation from '@react-native-community/geolocation';
|
||||
import rvs from './rvs';
|
||||
|
||||
type Listener = (active: boolean) => void;
|
||||
|
||||
class GpsTrackingService {
|
||||
private watchId: number | null = null;
|
||||
private active = false;
|
||||
private listeners: Set<Listener> = new Set();
|
||||
// Defensive: nicht zu schnell oeffentlich togglen
|
||||
private lastChangeAt = 0;
|
||||
// Letzte bekannte Position — wird vom Heartbeat-Timer alle 60s erneut
|
||||
// an die Bridge gesendet, sonst veraltet near() im Brain (NEAR_MAX_AGE_SEC
|
||||
// = 5 min) wenn der User stationaer ist und distanceFilter keine Updates
|
||||
// mehr triggert.
|
||||
private lastLat: number | null = null;
|
||||
private lastLon: number | null = null;
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
onChange(cb: Listener): () => void {
|
||||
this.listeners.add(cb);
|
||||
return () => { this.listeners.delete(cb); };
|
||||
}
|
||||
|
||||
private notify() {
|
||||
for (const cb of this.listeners) {
|
||||
try { cb(this.active); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Beim App-Start: gespeicherten Zustand wiederherstellen (Default off). */
|
||||
async restoreFromStorage(): Promise<void> {
|
||||
try {
|
||||
const v = await AsyncStorage.getItem('aria_gps_tracking');
|
||||
if (v === 'true') {
|
||||
console.log('[gps-track] Restore: war an, starte wieder');
|
||||
this.start('Beim Start wiederhergestellt');
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private async ensurePermission(): Promise<boolean> {
|
||||
if (Platform.OS !== 'android') return true;
|
||||
try {
|
||||
const granted = await PermissionsAndroid.request(
|
||||
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
|
||||
{
|
||||
title: 'GPS-Tracking',
|
||||
message: 'ARIA braucht laufende Standort-Updates damit GPS-Watcher (Blitzer-Warner, near()) funktionieren.',
|
||||
buttonPositive: 'Erlauben',
|
||||
buttonNegative: 'Abbrechen',
|
||||
},
|
||||
);
|
||||
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
||||
} catch (e) {
|
||||
console.warn('[gps-track] Permission-Fehler:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async start(reason: string = ''): Promise<boolean> {
|
||||
if (this.active) return true;
|
||||
const ok = await this.ensurePermission();
|
||||
if (!ok) {
|
||||
ToastAndroid.show('GPS-Tracking: Berechtigung abgelehnt', ToastAndroid.LONG);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
this.watchId = Geolocation.watchPosition(
|
||||
(pos) => {
|
||||
const lat = pos.coords.latitude;
|
||||
const lon = pos.coords.longitude;
|
||||
this.lastLat = lat;
|
||||
this.lastLon = lon;
|
||||
rvs.send('location_update' as any, { lat, lon });
|
||||
},
|
||||
(err) => {
|
||||
console.warn('[gps-track] watchPosition error:', err?.code, err?.message);
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
distanceFilter: 30, // erst senden wenn 30m gewandert
|
||||
interval: 15000, // (Android) gewuenschte Frequenz
|
||||
fastestInterval: 10000, // (Android) max Frequenz
|
||||
} as any,
|
||||
);
|
||||
// Heartbeat: alle 60s die letzte bekannte Position erneut senden.
|
||||
// Sonst bleibt der Brain-State stale wenn der User stationaer ist
|
||||
// (distanceFilter blockt watchPosition-Updates) → near()-Watcher
|
||||
// verwerfen die Position als veraltet (NEAR_MAX_AGE_SEC = 300s).
|
||||
// Kein neuer GPS-Wakeup, nur Re-Send der letzten Werte → akkufreundlich.
|
||||
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.lastLat != null && this.lastLon != null) {
|
||||
rvs.send('location_update' as any, { lat: this.lastLat, lon: this.lastLon });
|
||||
}
|
||||
}, 60_000);
|
||||
this.active = true;
|
||||
this.lastChangeAt = Date.now();
|
||||
this.notify();
|
||||
AsyncStorage.setItem('aria_gps_tracking', 'true').catch(() => {});
|
||||
ToastAndroid.show(
|
||||
reason ? `GPS-Tracking aktiv (${reason})` : 'GPS-Tracking aktiv',
|
||||
ToastAndroid.SHORT,
|
||||
);
|
||||
console.log('[gps-track] gestartet', reason ? `(${reason})` : '');
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
console.warn('[gps-track] start fehlgeschlagen:', e?.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
stop(reason: string = ''): void {
|
||||
if (!this.active) return;
|
||||
if (this.watchId !== null) {
|
||||
try { Geolocation.clearWatch(this.watchId); } catch {}
|
||||
this.watchId = null;
|
||||
}
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
this.active = false;
|
||||
this.lastChangeAt = Date.now();
|
||||
this.notify();
|
||||
AsyncStorage.setItem('aria_gps_tracking', 'false').catch(() => {});
|
||||
ToastAndroid.show(
|
||||
reason ? `GPS-Tracking aus (${reason})` : 'GPS-Tracking aus',
|
||||
ToastAndroid.SHORT,
|
||||
);
|
||||
console.log('[gps-track] gestoppt', reason ? `(${reason})` : '');
|
||||
}
|
||||
|
||||
async toggle(reason: string = ''): Promise<void> {
|
||||
if (this.active) this.stop(reason);
|
||||
else await this.start(reason);
|
||||
}
|
||||
}
|
||||
|
||||
export default new GpsTrackingService();
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Verbose-Logging-Toggle: console.log laesst sich global stummschalten.
|
||||
* console.warn/console.error bleiben immer an — Fehler will man immer sehen.
|
||||
*
|
||||
* Default: an (true). Toggle ueber Settings → Protokoll → Verbose Logging.
|
||||
* Beim Start wird der gespeicherte Wert geladen, vorher loggen wir normal.
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Platform } from 'react-native';
|
||||
import rvs from './rvs';
|
||||
|
||||
export const VERBOSE_LOGGING_KEY = 'aria_verbose_logging';
|
||||
|
||||
// Original-console.log retten, damit wir die Wrapper jederzeit wieder
|
||||
// "scharf" stellen koennen (sonst waere ein Toggle-an nach -aus tot).
|
||||
const originalLog = console.log.bind(console);
|
||||
const noop = () => {};
|
||||
|
||||
let _verbose = true;
|
||||
|
||||
function applyState(): void {
|
||||
console.log = _verbose ? originalLog : noop;
|
||||
}
|
||||
|
||||
/** Wert aus AsyncStorage laden und anwenden. Beim App-Start aufrufen. */
|
||||
export async function initLogger(): Promise<void> {
|
||||
try {
|
||||
const v = await AsyncStorage.getItem(VERBOSE_LOGGING_KEY);
|
||||
_verbose = v !== 'false'; // default: true
|
||||
} catch {}
|
||||
applyState();
|
||||
}
|
||||
|
||||
export function isVerboseLogging(): boolean {
|
||||
return _verbose;
|
||||
}
|
||||
|
||||
export function setVerboseLogging(verbose: boolean): void {
|
||||
_verbose = verbose;
|
||||
applyState();
|
||||
AsyncStorage.setItem(VERBOSE_LOGGING_KEY, String(verbose)).catch(() => {});
|
||||
}
|
||||
|
||||
// ─── App-Crash-Reporting via RVS ────────────────────────────────────
|
||||
//
|
||||
// Wenn die App crasht — egal ob React-Render-Fehler (ErrorBoundary) oder
|
||||
// ungefangener JS-Error (ErrorUtils-Handler) — schicken wir den Crash
|
||||
// als RVS-Message vom Typ "app_log" an die Bridge. Die schreibt in
|
||||
// /shared/logs/app.log, sodass wir/Diagnostic die Crashes mitlesen
|
||||
// koennen ohne ADB.
|
||||
|
||||
interface AppErrorEvent {
|
||||
scope: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
level?: 'error' | 'warn' | 'info';
|
||||
}
|
||||
|
||||
let _reportingInstalled = false;
|
||||
|
||||
/** Schickt einen App-Fehler via RVS an die Bridge. */
|
||||
export function reportAppError(ev: AppErrorEvent): void {
|
||||
try {
|
||||
rvs.send('app_log' as any, {
|
||||
ts: Date.now(),
|
||||
platform: Platform.OS,
|
||||
level: ev.level || 'error',
|
||||
scope: ev.scope,
|
||||
message: ev.message,
|
||||
stack: (ev.stack || '').slice(0, 8000),
|
||||
});
|
||||
} catch {
|
||||
// RVS noch nicht connected — Fehler geht im console weiter.
|
||||
}
|
||||
// Plus lokal: console.error, damit Stefan's adb (wenn doch mal verfuegbar)
|
||||
// den Crash sieht.
|
||||
console.error(`[app-error scope=${ev.scope}]`, ev.message, '\n', ev.stack || '');
|
||||
}
|
||||
|
||||
/** Installiert einen globalen JS-Error-Handler der ungefangene Errors via
|
||||
* RVS an die Bridge schickt. Beim App-Start aufrufen. */
|
||||
export function installGlobalCrashReporter(): void {
|
||||
if (_reportingInstalled) return;
|
||||
_reportingInstalled = true;
|
||||
try {
|
||||
const g: any = global as any;
|
||||
const prev = g.ErrorUtils?.getGlobalHandler?.();
|
||||
g.ErrorUtils?.setGlobalHandler?.((err: any, isFatal: boolean) => {
|
||||
reportAppError({
|
||||
scope: isFatal ? 'global-fatal' : 'global-nonfatal',
|
||||
message: (err && err.message) || String(err),
|
||||
stack: err && err.stack,
|
||||
});
|
||||
// Original-Handler weiterhin aufrufen damit React-Native das System-
|
||||
// Crash-Overlay zeigt (im Dev-Build) bzw. in Production sauber stirbt.
|
||||
if (typeof prev === 'function') {
|
||||
try { prev(err, isFatal); } catch {}
|
||||
}
|
||||
});
|
||||
// unhandled Promise-Rejections — manche RN-Versionen haben das nicht
|
||||
// automatisch im ErrorUtils.
|
||||
g.HermesInternal?.enablePromiseRejectionTracker?.({
|
||||
allRejections: true,
|
||||
onUnhandled: (id: number, err: any) => {
|
||||
reportAppError({
|
||||
scope: 'promise-unhandled',
|
||||
level: 'warn',
|
||||
message: (err && err.message) || String(err),
|
||||
stack: err && err.stack,
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// ErrorUtils nicht da → nix machen
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* PhoneCall-Service — pausiert ARIA bei Telefonaten:
|
||||
*
|
||||
* 1. Klassischer Mobilfunk-Anruf via TelephonyManager (PhoneCallModule.kt)
|
||||
* Status: idle / ringing / offhook
|
||||
*
|
||||
* 2. VoIP-Anrufe (WhatsApp, Signal, Discord, Telegram, Teams, ...) via
|
||||
* AudioFocus-Loss-Event (AudioFocusModule.kt). Diese Apps requestn
|
||||
* AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE wenn ein Anruf reinkommt — wir
|
||||
* bekommen ein "loss" Event und reagieren genauso wie auf RINGING.
|
||||
*
|
||||
* In beiden Faellen wird audioService.haltAllPlayback() + wakeWordService.
|
||||
* pauseForCall() gerufen. Bei call-end (idle / focus-gain) → resumeFromCall.
|
||||
*
|
||||
* Permission READ_PHONE_STATE ist nur fuer Pfad 1 noetig — Pfad 2 braucht
|
||||
* keine extra Berechtigung weil unser eigener AudioFocus-Listener feuert.
|
||||
*/
|
||||
|
||||
import {
|
||||
NativeEventEmitter,
|
||||
NativeModules,
|
||||
PermissionsAndroid,
|
||||
Platform,
|
||||
ToastAndroid,
|
||||
} from 'react-native';
|
||||
import audioService from './audio';
|
||||
import wakeWordService from './wakeword';
|
||||
|
||||
interface PhoneCallNative {
|
||||
start(): Promise<boolean>;
|
||||
stop(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const { PhoneCall } = NativeModules as { PhoneCall?: PhoneCallNative };
|
||||
|
||||
type PhoneState = 'idle' | 'ringing' | 'offhook';
|
||||
|
||||
class PhoneCallService {
|
||||
private started: boolean = false;
|
||||
private subscription: { remove: () => void } | null = null;
|
||||
private focusSubscription: { remove: () => void } | null = null;
|
||||
private lastState: PhoneState = 'idle';
|
||||
/** Damit Resume nach VoIP-Loss nicht doppelt feuert wenn auch
|
||||
* TelephonyManager-IDLE-Event kommt. */
|
||||
private interruptedByFocus: boolean = false;
|
||||
/** True wenn der TelephonyManager-Listener (Pfad 1) wirklich registriert
|
||||
* ist. False wenn READ_PHONE_STATE abgelehnt wurde oder Native nicht ging. */
|
||||
private telephonyAttached: boolean = false;
|
||||
|
||||
/** Status fuer Diagnose: laeuft die Anruf-Erkennung tatsaechlich? */
|
||||
status(): { focusAttached: boolean; telephonyAttached: boolean } {
|
||||
return {
|
||||
focusAttached: this.focusSubscription !== null,
|
||||
telephonyAttached: this.telephonyAttached,
|
||||
};
|
||||
}
|
||||
|
||||
/** Nach App-Resume: pruefen ob die Listener noch leben. Wenn der
|
||||
* TelephonyManager-Listener verloren ging (kann passieren wenn der
|
||||
* React-Bridge-Context recreated wurde), neu attachen. */
|
||||
async refresh(): Promise<void> {
|
||||
if (!this.started) return;
|
||||
if (this.telephonyAttached) return; // alles ok
|
||||
if (!PhoneCall) return;
|
||||
try {
|
||||
const ok = await PhoneCall.start();
|
||||
if (ok) {
|
||||
if (!this.subscription) {
|
||||
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
|
||||
this.subscription = emitter.addListener(
|
||||
'PhoneCallStateChanged',
|
||||
(e: { state: PhoneState }) => this._onStateChanged(e.state),
|
||||
);
|
||||
}
|
||||
this.telephonyAttached = true;
|
||||
console.log('[PhoneCall] refresh: TelephonyManager-Listener re-attached');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('[PhoneCall] refresh fehlgeschlagen:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<boolean> {
|
||||
if (this.started || Platform.OS !== 'android') return false;
|
||||
|
||||
// 1. AudioFocus-Listener IMMER registrieren — fangs VoIP-Calls (WhatsApp,
|
||||
// Signal, Discord etc.) abdecken, brauchen keine Permission.
|
||||
try {
|
||||
const focusEmitter = new NativeEventEmitter(NativeModules.AudioFocus as any);
|
||||
this.focusSubscription = focusEmitter.addListener(
|
||||
'AudioFocusChanged',
|
||||
(e: { type: 'loss' | 'loss_transient' | 'gain' }) => this._onFocusChanged(e.type),
|
||||
);
|
||||
console.log('[PhoneCall] AudioFocus-Listener aktiv (fuer VoIP-Calls)');
|
||||
} catch (err: any) {
|
||||
console.warn('[PhoneCall] AudioFocus-Subscription gescheitert', err?.message || err);
|
||||
}
|
||||
|
||||
// 2. TelephonyManager-Listener — fuer klassische Mobilfunk-Anrufe
|
||||
if (PhoneCall) {
|
||||
try {
|
||||
const granted = await PermissionsAndroid.request(
|
||||
PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE,
|
||||
{
|
||||
title: 'ARIA Cockpit — Anruf-Erkennung',
|
||||
message: 'Damit ARIA bei einem eingehenden Anruf nicht weiterredet, '
|
||||
+ 'darf die App den Anruf-Status sehen (Klingeln/Aktiv/Aufgelegt). '
|
||||
+ 'Es werden keine Anrufdaten gelesen oder gespeichert.',
|
||||
buttonPositive: 'Erlauben',
|
||||
buttonNegative: 'Spaeter',
|
||||
},
|
||||
);
|
||||
if (granted === PermissionsAndroid.RESULTS.GRANTED) {
|
||||
const ok = await PhoneCall.start();
|
||||
if (ok) {
|
||||
const emitter = new NativeEventEmitter(NativeModules.PhoneCall as any);
|
||||
this.subscription = emitter.addListener(
|
||||
'PhoneCallStateChanged',
|
||||
(e: { state: PhoneState }) => this._onStateChanged(e.state),
|
||||
);
|
||||
this.telephonyAttached = true;
|
||||
console.log('[PhoneCall] TelephonyManager-Listener aktiv');
|
||||
} else {
|
||||
console.warn('[PhoneCall] PhoneCall.start() lieferte false — Native-Listener nicht aktiv');
|
||||
}
|
||||
} else {
|
||||
console.warn('[PhoneCall] READ_PHONE_STATE abgelehnt — VoIP-Calls werden trotzdem ueber AudioFocus erkannt');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn('[PhoneCall] TelephonyManager-Setup gescheitert:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
this.started = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.started) return;
|
||||
try { this.subscription?.remove(); } catch {}
|
||||
try { this.focusSubscription?.remove(); } catch {}
|
||||
this.subscription = null;
|
||||
this.focusSubscription = null;
|
||||
if (PhoneCall) {
|
||||
try { await PhoneCall.stop(); } catch {}
|
||||
}
|
||||
this.started = false;
|
||||
this.lastState = 'idle';
|
||||
this.interruptedByFocus = false;
|
||||
this.telephonyAttached = false;
|
||||
}
|
||||
|
||||
private _onStateChanged(state: PhoneState): void {
|
||||
if (state === this.lastState) return;
|
||||
const prev = this.lastState;
|
||||
console.log('[PhoneCall] State: %s → %s', prev, state);
|
||||
this.lastState = state;
|
||||
if (state === 'ringing' || state === 'offhook') {
|
||||
this._haltForCall(state === 'ringing' ? 'Anruf — ARIA pausiert' : 'Im Gespraech — ARIA pausiert');
|
||||
} else if (state === 'idle' && prev !== 'idle') {
|
||||
// Wenn schon durch AudioFocus-Loss pausiert wurde, NICHT doppelt resumen.
|
||||
// Der Focus-Gain-Event triggert das Resume.
|
||||
if (!this.interruptedByFocus) {
|
||||
this._resumeAfterCall('Anruf beendet — ARIA wieder aktiv');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** AudioFocus-Loss = irgendeine andere App hat den Focus uebernommen.
|
||||
* Das passiert bei VoIP-Anrufen (was wir wollen) ABER auch bei normalen
|
||||
* Audio-Playern (anderer Player startet, Notification-Sound, sogar
|
||||
* unsere eigenen Sound-Calls beim Play-Button). Daher checken wir den
|
||||
* AudioMode — nur IN_CALL (2) oder IN_COMMUNICATION (3) zaehlt als Anruf. */
|
||||
private async _onFocusChanged(type: 'loss' | 'loss_transient' | 'gain'): Promise<void> {
|
||||
if (type === 'loss' || type === 'loss_transient') {
|
||||
// Schon durch klassischen TelephonyManager pausiert? Dann nichts doppeln.
|
||||
if (this.lastState === 'ringing' || this.lastState === 'offhook') return;
|
||||
// Mode pruefen — nur echte Anrufe behandeln.
|
||||
let mode = -1;
|
||||
try { mode = await (NativeModules.AudioFocus as any)?.getMode?.(); } catch {}
|
||||
if (mode !== 2 && mode !== 3) {
|
||||
// NORMAL-Mode → kein Anruf (Stefan hat z.B. Play-Button gedrueckt
|
||||
// oder Spotify hat sich neu reingedraengelt). Keine Toasts.
|
||||
console.log('[PhoneCall] FOCUS_LOSS ignoriert (AudioMode=%d, kein Call)', mode);
|
||||
return;
|
||||
}
|
||||
this.interruptedByFocus = true;
|
||||
this._haltForCall('Anruf erkannt (VoIP) — ARIA pausiert');
|
||||
// Pollen, weil GAIN nicht zuverlaessig kommt (wir releasen den Focus
|
||||
// selbst beim halt → kein automatischer GAIN). AudioMode != IN_COMMUNICATION
|
||||
// = Call vorbei.
|
||||
this._startVoipResumePoll();
|
||||
} else if (type === 'gain') {
|
||||
if (this.interruptedByFocus) {
|
||||
this.interruptedByFocus = false;
|
||||
this._stopVoipResumePoll();
|
||||
this._resumeAfterCall('Audio frei — ARIA wieder aktiv');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Polling-Fallback: alle 3s checken ob AudioMode wieder NORMAL ist. */
|
||||
private voipPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private _startVoipResumePoll(): void {
|
||||
if (this.voipPollTimer) return;
|
||||
this.voipPollTimer = setInterval(async () => {
|
||||
if (!this.interruptedByFocus) {
|
||||
this._stopVoipResumePoll();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mode = await (NativeModules.AudioFocus as any)?.getMode?.();
|
||||
// 0 = MODE_NORMAL — Call ist vorbei
|
||||
if (typeof mode === 'number' && mode === 0) {
|
||||
this.interruptedByFocus = false;
|
||||
this._stopVoipResumePoll();
|
||||
this._resumeAfterCall('Anruf beendet — ARIA wieder aktiv');
|
||||
}
|
||||
} catch {}
|
||||
}, 3000);
|
||||
}
|
||||
private _stopVoipResumePoll(): void {
|
||||
if (this.voipPollTimer) {
|
||||
clearInterval(this.voipPollTimer);
|
||||
this.voipPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _haltForCall(toast: string): void {
|
||||
// Position merken bevor wir den Stream killen — fuer Auto-Resume.
|
||||
audioService.captureInterruption();
|
||||
// pauseForCall (statt haltAllPlayback): pcmBuffer + messageId bleiben,
|
||||
// weitere Chunks werden weiter gesammelt damit isFinal die WAV schreibt.
|
||||
audioService.pauseForCall(toast);
|
||||
wakeWordService.pauseForCall().catch(() => {});
|
||||
ToastAndroid.show(toast, ToastAndroid.SHORT);
|
||||
}
|
||||
|
||||
private _resumeAfterCall(toast: string): void {
|
||||
// Anruf-Pause aufheben — neue Chunks duerfen wieder direkt abgespielt
|
||||
// werden (falls die Bridge mid-Anruf isFinal noch nicht geschickt hat).
|
||||
audioService.endCallPause();
|
||||
wakeWordService.resumeFromCall().catch(() => {});
|
||||
ToastAndroid.show(toast, ToastAndroid.SHORT);
|
||||
// 800ms warten bevor Auto-Resume — sonst kollidiert ARIA's neuer Focus-
|
||||
// Request mit Spotify's Auto-Resume nach Anruf-Ende. System haengt nach
|
||||
// dem Auflegen noch im IN_CALL-Mode-Uebergang, Spotify schaut auf Focus-
|
||||
// Gain und wuerde sofort wieder LOSS sehen → bleibt pausiert.
|
||||
// Mit Delay: Spotify resumed kurz, dann pausiert ARIA wieder ordnungs-
|
||||
// gemaess. Wenn ARIA nichts pending hat, bleibt Spotify einfach an.
|
||||
setTimeout(() => {
|
||||
audioService.resumeFromInterruption(30000).then(ok => {
|
||||
if (ok) {
|
||||
console.log('[PhoneCall] Auto-Resume von gemerkter Position gestartet');
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, 800);
|
||||
}
|
||||
}
|
||||
|
||||
const phoneCallService = new PhoneCallService();
|
||||
export default phoneCallService;
|
||||
@@ -12,7 +12,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
export type MessageType = 'chat' | 'audio' | 'file' | 'location' | 'mode' | 'log' | 'event';
|
||||
export type MessageType = 'chat' | 'audio' | 'file' | 'location' | 'mode' | 'log' | 'event' | 'update_available' | string;
|
||||
|
||||
export interface RVSMessage {
|
||||
type: MessageType;
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Auto-Update Service — prueft und installiert App-Updates via RVS
|
||||
*
|
||||
* Flow:
|
||||
* 1. App sendet "update_check" mit aktueller Version an RVS
|
||||
* 2. RVS vergleicht → sendet "update_available" mit Download-URL
|
||||
* 3. App zeigt Benachrichtigung → User bestaetigt → Download + Install
|
||||
*/
|
||||
|
||||
import { Alert, Linking, Platform, NativeModules } from 'react-native';
|
||||
import RNFS from 'react-native-fs';
|
||||
import rvs, { RVSMessage } from './rvs';
|
||||
|
||||
// Version aus package.json (wird beim Build eingebettet)
|
||||
const packageJson = require('../../package.json');
|
||||
const APP_VERSION = packageJson.version || '0.0.0.0';
|
||||
|
||||
type UpdateCallback = (info: UpdateInfo) => void;
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
class UpdateService {
|
||||
private listeners: UpdateCallback[] = [];
|
||||
private checking = false;
|
||||
private downloading = false;
|
||||
|
||||
constructor() {
|
||||
// Beim Start alte APK-Reste aus dem Cache wegraeumen — wenn diese App
|
||||
// laeuft, sind frueher heruntergeladene APKs entweder schon installiert
|
||||
// oder unvollstaendig gewesen. Spart sonst pro Update 20-30MB auf dem Handy.
|
||||
this.cleanupOldApks().catch(() => {});
|
||||
|
||||
// Auf update_available Nachrichten lauschen
|
||||
rvs.onMessage((msg: RVSMessage) => {
|
||||
if (msg.type === 'update_available' as any) {
|
||||
const info: UpdateInfo = {
|
||||
version: (msg.payload.version as string) || '',
|
||||
downloadUrl: (msg.payload.downloadUrl as string) || '',
|
||||
size: (msg.payload.size as number) || 0,
|
||||
};
|
||||
if (info.version && this.isNewer(info.version)) {
|
||||
console.log(`[Update] Neue Version verfuegbar: ${info.version} (aktuell: ${APP_VERSION})`);
|
||||
this.listeners.forEach(cb => cb(info));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Sucht ueberall wo .apk-Dateien herumliegen koennten. */
|
||||
private async _apkSearchDirs(): Promise<string[]> {
|
||||
const dirs = [RNFS.CachesDirectoryPath, RNFS.DocumentDirectoryPath];
|
||||
if ((RNFS as any).ExternalCachesDirectoryPath) {
|
||||
dirs.push((RNFS as any).ExternalCachesDirectoryPath);
|
||||
}
|
||||
if (RNFS.ExternalDirectoryPath) {
|
||||
dirs.push(RNFS.ExternalDirectoryPath);
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/** Raeumt alte heruntergeladene APK-Dateien aus den App-Verzeichnissen auf.
|
||||
* Public damit Settings den Button "Update-Cache leeren" benutzen kann. */
|
||||
async cleanupOldApks(keepCurrentName?: string): Promise<{ removed: number; freedMB: number }> {
|
||||
const dirs = await this._apkSearchDirs();
|
||||
let removed = 0;
|
||||
let freed = 0;
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
if (!(await RNFS.exists(dir))) continue;
|
||||
const files = await RNFS.readDir(dir);
|
||||
const apks = files.filter(f => /\.apk$/i.test(f.name));
|
||||
for (const f of apks) {
|
||||
if (keepCurrentName && f.name === keepCurrentName) continue;
|
||||
try {
|
||||
const size = parseInt(f.size as any, 10) || 0;
|
||||
await RNFS.unlink(f.path);
|
||||
removed += 1;
|
||||
freed += size;
|
||||
console.log(`[Update] APK geloescht: ${f.path} (${(size / 1024 / 1024).toFixed(1)}MB)`);
|
||||
} catch (err: any) {
|
||||
console.warn(`[Update] APK-Loeschen fehlgeschlagen: ${f.path} (${err?.message || err})`);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn(`[Update] Cleanup-Fehler in ${dir}: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
const freedMB = freed / 1024 / 1024;
|
||||
if (removed > 0) {
|
||||
console.log(`[Update] Cleanup fertig: ${removed} APK${removed === 1 ? '' : 's'} entfernt, ${freedMB.toFixed(1)}MB freigegeben`);
|
||||
}
|
||||
return { removed, freedMB };
|
||||
}
|
||||
|
||||
/** Aktuelle Groesse aller APK-Dateien in den App-Verzeichnissen (in MB). */
|
||||
async getApkCacheSize(): Promise<{ count: number; totalMB: number }> {
|
||||
const dirs = await this._apkSearchDirs();
|
||||
let count = 0;
|
||||
let total = 0;
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
if (!(await RNFS.exists(dir))) continue;
|
||||
const files = await RNFS.readDir(dir);
|
||||
for (const f of files) {
|
||||
if (!f.isFile() || !/\.apk$/i.test(f.name)) continue;
|
||||
count += 1;
|
||||
total += parseInt(f.size as any, 10) || 0;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return { count, totalMB: total / 1024 / 1024 };
|
||||
}
|
||||
|
||||
/** Bei App-Start Update pruefen */
|
||||
checkForUpdate(): void {
|
||||
if (this.checking) return;
|
||||
this.checking = true;
|
||||
|
||||
console.log(`[Update] Pruefe auf Updates (aktuell: ${APP_VERSION})`);
|
||||
rvs.send('update_check' as any, { version: APP_VERSION });
|
||||
|
||||
setTimeout(() => { this.checking = false; }, 10000);
|
||||
}
|
||||
|
||||
/** Callback registrieren */
|
||||
onUpdateAvailable(callback: UpdateCallback): () => void {
|
||||
this.listeners.push(callback);
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
/** Update-Dialog anzeigen */
|
||||
promptUpdate(info: UpdateInfo): void {
|
||||
const sizeMB = (info.size / 1024 / 1024).toFixed(1);
|
||||
Alert.alert(
|
||||
'ARIA Update verfuegbar',
|
||||
`Version ${info.version} (${sizeMB} MB)\n\nAktuell: ${APP_VERSION}\n\nJetzt herunterladen und installieren?`,
|
||||
[
|
||||
{ text: 'Spaeter', style: 'cancel' },
|
||||
{
|
||||
text: 'Installieren',
|
||||
onPress: () => this.downloadAndInstall(info),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** APK ueber WebSocket herunterladen und installieren */
|
||||
async downloadAndInstall(info: UpdateInfo): Promise<void> {
|
||||
if (this.downloading) return;
|
||||
this.downloading = true;
|
||||
|
||||
try {
|
||||
console.log(`[Update] Fordere APK v${info.version} an...`);
|
||||
Alert.alert('Download gestartet', `Version ${info.version} wird ueber RVS heruntergeladen...`);
|
||||
|
||||
// APK ueber WebSocket anfordern
|
||||
rvs.send('update_download' as any, {});
|
||||
|
||||
// Auf update_data warten (einmalig)
|
||||
const apkData = await new Promise<{base64: string, fileName: string}>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Download-Timeout (60s)')), 60000);
|
||||
const unsub = rvs.onMessage((msg: RVSMessage) => {
|
||||
if ((msg.type as string) === 'update_data') {
|
||||
clearTimeout(timeout);
|
||||
unsub();
|
||||
if (msg.payload.error) {
|
||||
reject(new Error(msg.payload.error as string));
|
||||
} else {
|
||||
resolve({
|
||||
base64: msg.payload.base64 as string,
|
||||
fileName: msg.payload.fileName as string || `ARIA-${info.version}.apk`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Vor dem Schreiben alte APKs im Cache wegraeumen — falls mehrere
|
||||
// Updates in einer Session gezogen werden
|
||||
await this.cleanupOldApks();
|
||||
|
||||
// Base64 als APK-Datei speichern
|
||||
const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`;
|
||||
await RNFS.writeFile(destPath, apkData.base64, 'base64');
|
||||
const fileSize = await RNFS.stat(destPath);
|
||||
console.log(`[Update] APK gespeichert: ${destPath} (${(parseInt(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`);
|
||||
|
||||
// APK installieren via natives ApkInstaller Module (FileProvider + Intent)
|
||||
if (Platform.OS === 'android') {
|
||||
try {
|
||||
const { ApkInstaller } = NativeModules;
|
||||
await ApkInstaller.install(destPath);
|
||||
} catch (installErr: any) {
|
||||
Alert.alert(
|
||||
'APK heruntergeladen',
|
||||
`Version ${info.version} gespeichert.\n\nBitte manuell installieren:\nDateimanager → ${apkData.fileName} antippen.\n\n(${installErr.message})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[Update] Fehler: ${err.message}`);
|
||||
Alert.alert('Update fehlgeschlagen', err.message);
|
||||
} finally {
|
||||
this.downloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Versionsvergleich */
|
||||
private isNewer(remote: string): boolean {
|
||||
const r = remote.split('.').map(Number);
|
||||
const l = APP_VERSION.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(r.length, l.length); i++) {
|
||||
const diff = (r[i] || 0) - (l[i] || 0);
|
||||
if (diff > 0) return true;
|
||||
if (diff < 0) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getCurrentVersion(): string {
|
||||
return APP_VERSION;
|
||||
}
|
||||
}
|
||||
|
||||
const updateService = new UpdateService();
|
||||
export default updateService;
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Spielt einen kurzen "Bereit"-Sound (Airplane Ding-Dong) wenn das Mikrofon
|
||||
* nach Wake-Word-Erkennung wirklich offen ist. Datei liegt in
|
||||
* android/app/src/main/res/raw/wake_ready_sound.mp3 — wird ueber Android's
|
||||
* Resource-System per react-native-sound abgespielt.
|
||||
*
|
||||
* Toggle: AsyncStorage-Key 'aria_wake_ready_sound_enabled' (default true).
|
||||
*/
|
||||
|
||||
import Sound from 'react-native-sound';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export const WAKE_READY_SOUND_STORAGE_KEY = 'aria_wake_ready_sound_enabled';
|
||||
|
||||
Sound.setCategory('Playback', false);
|
||||
|
||||
let cachedSound: Sound | null = null;
|
||||
let cachedFailed = false;
|
||||
|
||||
function getSound(): Promise<Sound | null> {
|
||||
if (cachedFailed) return Promise.resolve(null);
|
||||
if (cachedSound) return Promise.resolve(cachedSound);
|
||||
return new Promise(resolve => {
|
||||
const s = new Sound('wake_ready_sound', Sound.MAIN_BUNDLE, (err) => {
|
||||
if (err) {
|
||||
console.warn('[WakeReadySound] Konnte nicht geladen werden:', err);
|
||||
cachedFailed = true;
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
cachedSound = s;
|
||||
resolve(s);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** True wenn der User den "Bereit"-Sound aktiviert hat. Default: true. */
|
||||
export async function isWakeReadySoundEnabled(): Promise<boolean> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(WAKE_READY_SOUND_STORAGE_KEY);
|
||||
if (raw === null) return true; // Default an
|
||||
return raw === 'true';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setWakeReadySoundEnabled(enabled: boolean): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(WAKE_READY_SOUND_STORAGE_KEY, String(enabled));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Spielt den Bereit-Sound einmal ab — non-blocking. Wenn der User ihn
|
||||
* in den Settings deaktiviert hat oder die Datei nicht ladbar ist,
|
||||
* passiert einfach nichts. */
|
||||
export async function playWakeReadySound(): Promise<void> {
|
||||
if (!(await isWakeReadySoundEnabled())) return;
|
||||
const s = await getSound();
|
||||
if (!s) return;
|
||||
try {
|
||||
s.stop(() => {
|
||||
s.setCurrentTime(0);
|
||||
s.play((success) => {
|
||||
if (!success) console.warn('[WakeReadySound] Wiedergabe fehlgeschlagen');
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[WakeReadySound] play() Exception:', e);
|
||||
}
|
||||
}
|
||||
@@ -1,96 +1,405 @@
|
||||
/**
|
||||
* Wake Word Service — "ARIA" Erkennung
|
||||
* Gespraechsmodus / Wake Word Service
|
||||
*
|
||||
* Nutzt react-native-live-audio-stream fuer kontinuierliches Mikrofon-Monitoring.
|
||||
* Erkennt Sprache per Energie-Schwellwert und sendet kurze Audio-Clips
|
||||
* zur serverseitigen Wake-Word-Pruefung (openwakeword in der Bridge).
|
||||
* Wake-Word-Engine: openWakeWord (https://github.com/dscripka/openWakeWord),
|
||||
* komplett on-device via ONNX Runtime in Native-Kotlin (siehe
|
||||
* OpenWakeWordModule.kt + assets/openwakeword/). Kein API-Key, kein Cloud-
|
||||
* Roundtrip, kein Cent Lizenzgebuehren.
|
||||
*
|
||||
* Architektur:
|
||||
* App (Mikrofon) → Energie-Erkennung → Audio-Buffer
|
||||
* → RVS "wake_check" → Bridge → openwakeword → Bestaetigung
|
||||
* → App startet Aufnahme
|
||||
* Drei Zustaende:
|
||||
* off — Ohr aus, nichts laeuft
|
||||
* armed — Ohr aktiv, openWakeWord hoert passiv auf das Wake-Word.
|
||||
* Das Mikro ist von OpenWakeWord belegt; AudioRecorder ist aus.
|
||||
* conversing — Wake-Word getriggert (oder Ohr-Tap manuell):
|
||||
* aktive Konversation. OpenWakeWord pausiert (gibt Mikro frei),
|
||||
* AudioRecorder uebernimmt fuer die Aufnahme.
|
||||
* Nach jeder ARIA-Antwort oeffnet das Mikro fuer X Sekunden
|
||||
* (Conversation-Window). Stille im Fenster → zurueck zu armed.
|
||||
*
|
||||
* Aktuell (Phase 1): Einfacher Tap-to-Talk + Auto-Stop.
|
||||
* Spaeter (Phase 2): Porcupine on-device "ARIA" Keyword.
|
||||
* Faellt das Native-Modul aus (alte App-Version, ONNX-Init-Fehler), geht
|
||||
* 'start' direkt in 'conversing' (klassischer Direkt-Aufnahme-Modus).
|
||||
*/
|
||||
|
||||
import LiveAudioStream from 'react-native-live-audio-stream';
|
||||
import { NativeEventEmitter, NativeModules, ToastAndroid } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { acquireBackgroundAudio } from './backgroundAudio';
|
||||
|
||||
type WakeWordCallback = () => void;
|
||||
type StateCallback = (state: WakeWordState) => void;
|
||||
|
||||
export type WakeWordState = 'off' | 'listening' | 'detected';
|
||||
export type WakeWordState = 'off' | 'armed' | 'conversing';
|
||||
|
||||
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
|
||||
|
||||
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
|
||||
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
|
||||
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
|
||||
* werden — Diagnostic-Upload ist Phase 2. */
|
||||
export const WAKE_KEYWORDS = [
|
||||
'hey_jarvis',
|
||||
'computer',
|
||||
'alexa',
|
||||
'hey_mycroft',
|
||||
'hey_rhasspy',
|
||||
] as const;
|
||||
export type WakeKeyword = typeof WAKE_KEYWORDS[number];
|
||||
export const DEFAULT_KEYWORD: WakeKeyword = 'hey_jarvis';
|
||||
|
||||
/** Hilfs-Mapping fuer die Anzeige im UI. */
|
||||
export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
|
||||
hey_jarvis: 'Hey Jarvis',
|
||||
computer: 'Computer',
|
||||
alexa: 'Alexa',
|
||||
hey_mycroft: 'Hey Mycroft',
|
||||
hey_rhasspy: 'Hey Rhasspy',
|
||||
};
|
||||
|
||||
// Detection-Tuning — kann in Settings spaeter konfigurierbar werden.
|
||||
const DEFAULT_THRESHOLD = 0.5;
|
||||
const DEFAULT_PATIENCE = 2;
|
||||
const DEFAULT_DEBOUNCE_MS = 1500;
|
||||
|
||||
interface OpenWakeWordModule {
|
||||
init(modelName: string, threshold: number, patience: number, debounceMs: number): Promise<boolean>;
|
||||
start(): Promise<boolean>;
|
||||
stop(): Promise<boolean>;
|
||||
dispose(): Promise<boolean>;
|
||||
isAvailable(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const { OpenWakeWord } = NativeModules as { OpenWakeWord?: OpenWakeWordModule };
|
||||
|
||||
class WakeWordService {
|
||||
private state: WakeWordState = 'off';
|
||||
private wakeCallbacks: WakeWordCallback[] = [];
|
||||
private stateCallbacks: StateCallback[] = [];
|
||||
private isInitialized = false;
|
||||
/** Barge-In-Callbacks: feuern wenn Wake-Word WAEHREND ARIA spricht erkannt
|
||||
* wird. ChatScreen reagiert mit TTS-stop + neuer Aufnahme. */
|
||||
private bargeCallbacks: WakeWordCallback[] = [];
|
||||
/** True solange Wake-Word parallel zu TTS aktiv ist. */
|
||||
private bargeListening: boolean = false;
|
||||
/** Anruf-Pause: state wird gemerkt damit nach Auflegen wiederhergestellt wird. */
|
||||
private callPaused: boolean = false;
|
||||
private preCallState: WakeWordState = 'off';
|
||||
/** Cooldown nach App-Resume: kurze Phase in der Wake-Word-Detections
|
||||
* ignoriert werden. Beim Wechsel von Background nach Vordergrund gibt's
|
||||
* oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
|
||||
* der openWakeWord faelschlich triggern kann. */
|
||||
private cooldownUntilMs: number = 0;
|
||||
/** Zeitpunkt des letzten echten Wake-Word-Triggers — gebraucht damit
|
||||
* ChatScreen entscheiden kann ob ein 'conversing'-State bei App-Resume
|
||||
* ein false-positive war (Wake-Word im Hintergrund getriggert waehrend
|
||||
* Stefan gar nicht in der App war). */
|
||||
private lastTriggerAt: number = 0;
|
||||
|
||||
/** Wake Word Erkennung starten */
|
||||
async start(): Promise<boolean> {
|
||||
if (this.state === 'listening') return true;
|
||||
private keyword: WakeKeyword = DEFAULT_KEYWORD;
|
||||
private nativeReady: boolean = false;
|
||||
private initInProgress: Promise<boolean> | null = null;
|
||||
private eventSub: { remove: () => void } | null = null;
|
||||
|
||||
/** Beim App-Start aufrufen — laedt Settings, baut Native-Modul. */
|
||||
async loadFromStorage(): Promise<void> {
|
||||
try {
|
||||
if (!this.isInitialized) {
|
||||
LiveAudioStream.init({
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
bitsPerSample: 16,
|
||||
audioSource: 6, // VOICE_RECOGNITION
|
||||
bufferSize: 4096,
|
||||
});
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
// Audio-Stream starten und auf Energie pruefen
|
||||
LiveAudioStream.start();
|
||||
|
||||
LiveAudioStream.on('data', (base64Chunk: string) => {
|
||||
if (this.state !== 'listening') return;
|
||||
|
||||
// Base64 → Int16 Array → RMS berechnen
|
||||
const raw = this._base64ToInt16(base64Chunk);
|
||||
const rms = this._calculateRMS(raw);
|
||||
|
||||
// Schwellwert: wenn laut genug → Wake Word erkannt
|
||||
// Phase 1: Einfache Energie-Erkennung (jemand spricht)
|
||||
// Phase 2: Porcupine "ARIA" Keyword
|
||||
if (rms > 2000) {
|
||||
this.setState('detected');
|
||||
this.wakeCallbacks.forEach(cb => cb());
|
||||
// Nach Detection kurz pausieren, Aufnahme uebernimmt das Mikrofon
|
||||
this.stop();
|
||||
}
|
||||
});
|
||||
|
||||
this.setState('listening');
|
||||
console.log('[WakeWord] Listening gestartet');
|
||||
return true;
|
||||
const w = await AsyncStorage.getItem(WAKE_KEYWORD_STORAGE);
|
||||
const wt = (w || DEFAULT_KEYWORD).trim() as WakeKeyword;
|
||||
this.keyword = (WAKE_KEYWORDS as readonly string[]).includes(wt) ? wt : DEFAULT_KEYWORD;
|
||||
await this.initNative();
|
||||
} catch (err) {
|
||||
console.error('[WakeWord] Start fehlgeschlagen:', err);
|
||||
return false;
|
||||
console.warn('[WakeWord] loadFromStorage', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Wake Word Erkennung stoppen */
|
||||
stop(): void {
|
||||
if (this.state === 'off') return;
|
||||
try {
|
||||
LiveAudioStream.stop();
|
||||
} catch {}
|
||||
this.setState('off');
|
||||
console.log('[WakeWord] Gestoppt');
|
||||
/** Settings-Wechsel: anderes Wake-Word. Re-Init des Native-Moduls. */
|
||||
async configure(keyword: string): Promise<boolean> {
|
||||
const next: WakeKeyword = (WAKE_KEYWORDS as readonly string[]).includes(keyword)
|
||||
? (keyword as WakeKeyword)
|
||||
: DEFAULT_KEYWORD;
|
||||
this.keyword = next;
|
||||
await AsyncStorage.setItem(WAKE_KEYWORD_STORAGE, next);
|
||||
|
||||
// Laufende Instanz stoppen + neu initialisieren
|
||||
await this.disposeNative();
|
||||
const ok = await this.initNative();
|
||||
if (!ok) {
|
||||
ToastAndroid.show(
|
||||
`Wake-Word "${KEYWORD_LABELS[next]}" konnte nicht initialisiert werden — Logs pruefen`,
|
||||
ToastAndroid.LONG,
|
||||
);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
/** Nach Aufnahme erneut starten */
|
||||
async resume(): Promise<void> {
|
||||
// Kurze Pause damit Aufnahme das Mikrofon freigeben kann
|
||||
private async initNative(): Promise<boolean> {
|
||||
if (!OpenWakeWord) {
|
||||
console.warn('[WakeWord] OpenWakeWord Native-Modul nicht verfuegbar — Direkt-Aufnahme-Fallback aktiv');
|
||||
this.nativeReady = false;
|
||||
return false;
|
||||
}
|
||||
if (this.initInProgress) return this.initInProgress;
|
||||
this.initInProgress = (async () => {
|
||||
try {
|
||||
await OpenWakeWord.init(this.keyword, DEFAULT_THRESHOLD, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
|
||||
// Subscribe nur einmal
|
||||
if (!this.eventSub) {
|
||||
const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
|
||||
this.eventSub = emitter.addListener('WakeWordDetected', () => {
|
||||
console.log('[WakeWord] Native Detection-Event empfangen');
|
||||
this.onWakeDetected().catch(err =>
|
||||
console.warn('[WakeWord] onWakeDetected crashed:', err));
|
||||
});
|
||||
}
|
||||
this.nativeReady = true;
|
||||
console.log('[WakeWord] Init OK (model=%s)', this.keyword);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
console.warn('[WakeWord] Init fehlgeschlagen:', err?.message || err);
|
||||
this.nativeReady = false;
|
||||
return false;
|
||||
} finally {
|
||||
this.initInProgress = null;
|
||||
}
|
||||
})();
|
||||
return this.initInProgress;
|
||||
}
|
||||
|
||||
private async disposeNative(): Promise<void> {
|
||||
if (!OpenWakeWord) return;
|
||||
try { await OpenWakeWord.dispose(); } catch {}
|
||||
this.nativeReady = false;
|
||||
}
|
||||
|
||||
/** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */
|
||||
async start(): Promise<boolean> {
|
||||
if (this.state !== 'off') return true;
|
||||
// Foreground-Service VOR dem Mic-Zugriff hochziehen damit Background-
|
||||
// Lauschen funktioniert (Android braucht foregroundServiceType=microphone
|
||||
// aktiv zum Zeitpunkt des AudioRecord.startRecording).
|
||||
await acquireBackgroundAudio('wake');
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try {
|
||||
await OpenWakeWord.start();
|
||||
console.log('[WakeWord] armed — warte auf "%s"', this.keyword);
|
||||
ToastAndroid.show(`Lausche auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
|
||||
this.setState('armed');
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
console.warn('[WakeWord] start fehlgeschlagen — Fallback Direkt-Aufnahme:',
|
||||
err?.message || err);
|
||||
ToastAndroid.show(
|
||||
`Wake-Word-Start failed: ${err?.message || err}`,
|
||||
ToastAndroid.LONG,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn('[WakeWord] Native-Modul nicht bereit — Direkt-Aufnahme-Fallback');
|
||||
ToastAndroid.show(
|
||||
'Wake-Word nicht aktiv — direkte Aufnahme startet (Mikro hoert mit)',
|
||||
ToastAndroid.LONG,
|
||||
);
|
||||
}
|
||||
// Fallback: direkt in Konversation
|
||||
console.log('[WakeWord] Direkt-Aufnahme startet (kein Wake-Word)');
|
||||
this.setState('conversing');
|
||||
setTimeout(() => {
|
||||
if (this.state === 'off') {
|
||||
this.start();
|
||||
if (this.state === 'conversing') {
|
||||
this.wakeCallbacks.forEach(cb => cb());
|
||||
}
|
||||
}, 500);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Komplett ausschalten (Ohr abschalten) */
|
||||
async stop(): Promise<void> {
|
||||
console.log('[WakeWord] Ohr deaktiviert');
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try { await OpenWakeWord.stop(); } catch {}
|
||||
}
|
||||
this.bargeListening = false;
|
||||
this.setState('off');
|
||||
}
|
||||
|
||||
/** Cooldown setzen — alle Wake-Word-Detections in den naechsten ms ignorieren.
|
||||
* Wird beim App-Resume gerufen weil AppState-Wechsel Audio-Spikes erzeugen
|
||||
* die openWakeWord faelschlich als Trigger interpretiert. */
|
||||
setResumeCooldown(ms: number = 1500): void {
|
||||
this.cooldownUntilMs = Date.now() + ms;
|
||||
console.log('[WakeWord] Cooldown aktiv fuer %dms', ms);
|
||||
}
|
||||
|
||||
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
|
||||
private async onWakeDetected(): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (now < this.cooldownUntilMs) {
|
||||
const left = this.cooldownUntilMs - now;
|
||||
console.log('[WakeWord] Trigger ignoriert (Cooldown noch %dms aktiv — wahrscheinlich App-Resume-Spike)', left);
|
||||
return;
|
||||
}
|
||||
console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
|
||||
this.keyword, this.state, this.bargeListening);
|
||||
this.lastTriggerAt = now;
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try { await OpenWakeWord.stop(); } catch {}
|
||||
}
|
||||
this.bargeListening = false;
|
||||
// Wenn wir bereits in 'conversing' sind und der Trigger waehrend ARIAs TTS
|
||||
// kam (Barge-In via Wake-Word), feuern wir einen separaten Callback damit
|
||||
// ChatScreen das TTS abbrechen + neue Aufnahme starten kann. Sonst normal.
|
||||
if (this.state === 'conversing') {
|
||||
this.bargeCallbacks.forEach(cb => {
|
||||
try { cb(); } catch (e) { console.warn('[WakeWord] barge cb err:', e); }
|
||||
});
|
||||
// Kein erneutes setState — wir bleiben in 'conversing'.
|
||||
return;
|
||||
}
|
||||
this.setState('conversing');
|
||||
setTimeout(() => {
|
||||
if (this.state === 'conversing') {
|
||||
this.wakeCallbacks.forEach(cb => cb());
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
|
||||
* "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
|
||||
* Native-Modul verhindert dass ARIAs eigene Stimme triggert.
|
||||
* Voraussetzung: AudioRecorder muss frei sein (Recording aus). Wenn der
|
||||
* AudioRecorder gerade laeuft, hat der Vorrang — Wake-Word geht nicht. */
|
||||
async startBargeListening(): Promise<void> {
|
||||
if (!this.nativeReady || !OpenWakeWord) return;
|
||||
if (this.state !== 'conversing') return;
|
||||
if (this.bargeListening) return;
|
||||
try {
|
||||
await OpenWakeWord.start();
|
||||
this.bargeListening = true;
|
||||
console.log('[WakeWord] Barge-Listening aktiv (parallel zu TTS)');
|
||||
} catch (err) {
|
||||
console.warn('[WakeWord] Barge-Listening start fehlgeschlagen:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Barge-Listening wieder aus — z.B. wenn der AudioRecorder fuer die
|
||||
* naechste Aufnahme das Mikro braucht. */
|
||||
async stopBargeListening(): Promise<void> {
|
||||
if (!this.bargeListening) return;
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try { await OpenWakeWord.stop(); } catch {}
|
||||
}
|
||||
this.bargeListening = false;
|
||||
console.log('[WakeWord] Barge-Listening aus');
|
||||
}
|
||||
|
||||
/** Bei eingehendem Anruf: Wake-Word + Aufnahme stoppen, Pre-Call-State
|
||||
* merken. Telefonie-App belegt das Mikro waehrend des Anrufs, plus ARIA
|
||||
* soll nicht in laufende Telefonate reinhoeren. */
|
||||
async pauseForCall(): Promise<void> {
|
||||
if (this.callPaused) return;
|
||||
this.preCallState = this.state;
|
||||
if (this.state === 'off') {
|
||||
this.callPaused = true; // merken dass wir pausiert wurden
|
||||
return;
|
||||
}
|
||||
this.callPaused = true;
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try { await OpenWakeWord.stop(); } catch {}
|
||||
}
|
||||
this.bargeListening = false;
|
||||
console.log('[WakeWord] Anruf — Wake-Word pausiert (war: %s)', this.preCallState);
|
||||
}
|
||||
|
||||
/** Nach Auflegen: Pre-Call-State wiederherstellen. Aktive Konversation
|
||||
* geht zu armed zurueck (User soll nicht in einen halben Dialog springen). */
|
||||
async resumeFromCall(): Promise<void> {
|
||||
if (!this.callPaused) return;
|
||||
const restoreTo = this.preCallState;
|
||||
this.callPaused = false;
|
||||
this.preCallState = 'off';
|
||||
console.log('[WakeWord] Anruf zu Ende — restore state=%s', restoreTo);
|
||||
if (restoreTo === 'off') return;
|
||||
// Aktive Konversation war wahrscheinlich durch haltAllPlayback eh abgebrochen,
|
||||
// sicher zu armed degraden.
|
||||
if (restoreTo === 'conversing') this.setState('armed');
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try { await OpenWakeWord.start(); } catch (err) {
|
||||
console.warn('[WakeWord] Restore-Start fehlgeschlagen:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Konversation beenden — User hat im Window nichts gesagt.
|
||||
* Mit Wake-Word: zurueck zu 'armed' (Listener wieder an).
|
||||
* Ohne: zurueck zu 'off'.
|
||||
*/
|
||||
async endConversation(): Promise<void> {
|
||||
if (this.state !== 'conversing') return;
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try {
|
||||
await OpenWakeWord.start();
|
||||
console.log('[WakeWord] Konversation zu Ende — zurueck zu armed');
|
||||
ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
|
||||
this.setState('armed');
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn('[WakeWord] re-arm fehlgeschlagen:', err);
|
||||
}
|
||||
}
|
||||
console.log('[WakeWord] Konversation zu Ende — Ohr aus');
|
||||
ToastAndroid.show('Mikro aus', ToastAndroid.SHORT);
|
||||
this.setState('off');
|
||||
}
|
||||
|
||||
/** Wenn ein conversing-State auf einem Wake-Word-Trigger juenger als
|
||||
* maxAgeMs basiert: false-positive verwerfen, zurueck zu armed.
|
||||
* Wird vom ChatScreen aufgerufen wenn die App aus laengerem Hintergrund
|
||||
* zurueck kommt — dann ist ein „gerade getriggertes" Wake-Word sehr
|
||||
* wahrscheinlich ein TV-Spike, Husten, ARIAs eigene TTS-Aufnahme etc.
|
||||
* Returnt true wenn verworfen wurde. */
|
||||
async discardIfFreshlyTriggered(maxAgeMs: number = 10_000): Promise<boolean> {
|
||||
if (this.state !== 'conversing') return false;
|
||||
if (this.lastTriggerAt === 0) return false;
|
||||
const age = Date.now() - this.lastTriggerAt;
|
||||
if (age > maxAgeMs) return false;
|
||||
console.log('[WakeWord] Resume: verwerfe verdaechtiges conversing (age=%dms)', age);
|
||||
this.lastTriggerAt = 0;
|
||||
if (this.nativeReady && OpenWakeWord) {
|
||||
try {
|
||||
await OpenWakeWord.start();
|
||||
ToastAndroid.show('Hintergrund-Trigger verworfen — lausche wieder', ToastAndroid.SHORT);
|
||||
this.setState('armed');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[WakeWord] re-arm nach discard fehlgeschlagen:', err);
|
||||
}
|
||||
}
|
||||
this.setState('off');
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten */
|
||||
async resume(): Promise<void> {
|
||||
if (this.state !== 'conversing') return;
|
||||
// Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
if (this.state === 'conversing') {
|
||||
console.log('[WakeWord] TTS fertig — naechste Aufnahme im Conversation-Window');
|
||||
this.wakeCallbacks.forEach(cb => cb());
|
||||
}
|
||||
}
|
||||
|
||||
/** True solange das Ohr aktiv ist (armed ODER conversing). */
|
||||
isActive(): boolean {
|
||||
return this.state !== 'off';
|
||||
}
|
||||
|
||||
isConversing(): boolean {
|
||||
return this.state === 'conversing';
|
||||
}
|
||||
|
||||
hasWakeWord(): boolean {
|
||||
return this.nativeReady;
|
||||
}
|
||||
|
||||
getKeyword(): WakeKeyword {
|
||||
return this.keyword;
|
||||
}
|
||||
|
||||
// --- Callbacks ---
|
||||
@@ -102,6 +411,19 @@ class WakeWordService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Subscribe auf Barge-In-Events: Wake-Word erkannt waehrend ARIA noch
|
||||
* spricht. ChatScreen sollte dann TTS abbrechen + neue Aufnahme starten. */
|
||||
onBargeIn(callback: WakeWordCallback): () => void {
|
||||
this.bargeCallbacks.push(callback);
|
||||
return () => {
|
||||
this.bargeCallbacks = this.bargeCallbacks.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
isBargeListening(): boolean {
|
||||
return this.bargeListening;
|
||||
}
|
||||
|
||||
onStateChange(callback: StateCallback): () => void {
|
||||
this.stateCallbacks.push(callback);
|
||||
return () => {
|
||||
@@ -113,32 +435,12 @@ class WakeWordService {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
// --- Hilfsfunktionen ---
|
||||
|
||||
private setState(state: WakeWordState): void {
|
||||
if (this.state !== state) {
|
||||
this.state = state;
|
||||
this.stateCallbacks.forEach(cb => cb(state));
|
||||
}
|
||||
}
|
||||
|
||||
private _base64ToInt16(base64: string): Int16Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new Int16Array(bytes.buffer);
|
||||
}
|
||||
|
||||
private _calculateRMS(samples: Int16Array): number {
|
||||
if (samples.length === 0) return 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
sum += samples[i] * samples[i];
|
||||
}
|
||||
return Math.sqrt(sum / samples.length);
|
||||
}
|
||||
}
|
||||
|
||||
const wakeWordService = new WakeWordService();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# ARIA Brain — Agent + Memory Container
|
||||
#
|
||||
# FastAPI-Server mit Vector-DB-Memory (Qdrant).
|
||||
# Spricht via HTTP/WebSocket mit Bridge und Diagnostic.
|
||||
# LLM-Calls gehen ueber den Proxy (claude-max-api-proxy).
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
FROM python:3.12-slim
|
||||
|
||||
# System-Tools die Skills brauchen koennten (curl, jq, git, ssh-client,
|
||||
# Build-Basics fuer venv-Compiles). Bewusst sparsam — alles weitere
|
||||
# bringt der Skill selbst mit (siehe execution=local-bin).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
jq \
|
||||
git \
|
||||
openssh-client \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# CPU-only torch zuerst — sonst zieht sentence-transformers den Default
|
||||
# torch-Wheel der ~5 GB CUDA-Libs (nvidia-cudnn, nvidia-cublas, cuda-toolkit,
|
||||
# triton, ...) als Dependencies einsaugt. Brain laeuft komplett auf CPU
|
||||
# (MiniLM-Embeddings ~120 MB), wir brauchen das alles nicht.
|
||||
RUN pip install --no-cache-dir torch==2.5.1 \
|
||||
--index-url https://download.pytorch.org/whl/cpu
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Embedding-Model-Cache und Skills landen unter /data (Volume)
|
||||
ENV SENTENCE_TRANSFORMERS_HOME=/data/_models
|
||||
ENV ARIA_DATA_DIR=/data
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user