Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9115699db | |||
| f2bfd4bbc6 | |||
| b182ef5ed5 | |||
| 9818dc1867 | |||
| 543ad3c46d | |||
| 408d20a087 |
@@ -79,8 +79,8 @@ android {
|
|||||||
applicationId "com.ariacockpit"
|
applicationId "com.ariacockpit"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 10604
|
versionCode 10606
|
||||||
versionName "0.1.6.4"
|
versionName "0.1.6.6"
|
||||||
// Fallback fuer Libraries mit Product Flavors
|
// Fallback fuer Libraries mit Product Flavors
|
||||||
missingDimensionStrategy 'react-native-camera', 'general'
|
missingDimensionStrategy 'react-native-camera', 'general'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,26 @@
|
|||||||
<!-- Optional: GPS-Position der Frage anhaengen (nur wenn User in Settings aktiviert) -->
|
<!-- 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_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<!-- Background-Location ist OPT-IN (Settings → GPS auch im Hintergrund).
|
||||||
|
Muss vom User explizit in Android-Einstellungen auf "Immer erlauben"
|
||||||
|
gesetzt werden — kann nicht ueber den normalen Permission-Dialog
|
||||||
|
angefordert werden (Android 10+). Default: aus. -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
<!-- Foreground-Service damit TTS auch bei minimierter App weiterlaeuft.
|
<!-- Foreground-Service damit TTS auch bei minimierter App weiterlaeuft.
|
||||||
FOREGROUND_SERVICE_MICROPHONE ist Pflicht ab Android 14 wenn der
|
FOREGROUND_SERVICE_MICROPHONE ist Pflicht ab Android 14 wenn der
|
||||||
Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word,
|
Service waehrend des Backgrounds aufs Mikro zugreift (Wake-Word,
|
||||||
Aufnahme im Gespraechsmodus). -->
|
Aufnahme im Gespraechsmodus). LOCATION wird nur aktiv wenn der
|
||||||
|
User Background-GPS in Settings einschaltet. -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<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_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- WAKE_LOCK damit Wake-Word + JS-Bridge auch bei aus-Display und Doze
|
||||||
|
arbeiten: ohne Lock pausiert Android die CPU, Native-AudioRecord
|
||||||
|
laeuft weiter aber JS-Bridge frisst die DeviceEvents nicht mehr ->
|
||||||
|
Wake-Word wird erkannt aber callbacks feuern erst beim App-Resume. -->
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
@@ -52,6 +64,6 @@
|
|||||||
<service
|
<service
|
||||||
android:name=".AriaPlaybackService"
|
android:name=".AriaPlaybackService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="mediaPlayback|microphone" />
|
android:foregroundServiceType="mediaPlayback|microphone|location" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
@@ -32,15 +34,26 @@ class AriaPlaybackService : Service() {
|
|||||||
|
|
||||||
private var currentReason: String = ""
|
private var currentReason: String = ""
|
||||||
|
|
||||||
|
// PARTIAL_WAKE_LOCK haelt die CPU wach solange der Foreground-Service
|
||||||
|
// aktiv ist. Damit bleibt die JS-Bridge im Doze ansprechbar und die
|
||||||
|
// gesamte Sprach-Pipeline (Wake → Aufnahme → POST → ARIA → TTS → wieder
|
||||||
|
// Wake) laeuft durchgehend im Hintergrund. Ein einziger Lock fuer den
|
||||||
|
// ganzen Foreground-Cycle, nicht pro Sub-Modul.
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
ensureNotificationChannel()
|
ensureNotificationChannel()
|
||||||
|
acquireWakeLock()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
val reason = intent?.getStringExtra(EXTRA_REASON) ?: ""
|
val reason = intent?.getStringExtra(EXTRA_REASON) ?: ""
|
||||||
currentReason = reason
|
currentReason = reason
|
||||||
Log.i(TAG, "Foreground-Service start/update (reason=$reason)")
|
Log.i(TAG, "Foreground-Service start/update (reason=$reason)")
|
||||||
|
// Falls der Lock zwischendurch released wurde (z.B. nach onCreate-
|
||||||
|
// race oder OS-quirk), hier sicherheits-halber erneut anfordern.
|
||||||
|
acquireWakeLock()
|
||||||
try {
|
try {
|
||||||
startForeground(NOTIFICATION_ID, buildNotification(reason))
|
startForeground(NOTIFICATION_ID, buildNotification(reason))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -53,10 +66,36 @@ class AriaPlaybackService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
releaseWakeLock()
|
||||||
Log.i(TAG, "Foreground-Service gestoppt")
|
Log.i(TAG, "Foreground-Service gestoppt")
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun acquireWakeLock() {
|
||||||
|
if (wakeLock?.isHeld == true) return
|
||||||
|
try {
|
||||||
|
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
|
"AriaCockpit:Pipeline").apply {
|
||||||
|
setReferenceCounted(false)
|
||||||
|
acquire(8 * 60 * 60 * 1000L) // 8h Sicherheits-Cap
|
||||||
|
}
|
||||||
|
Log.i(TAG, "WakeLock acquired (CPU bleibt wach im Hintergrund)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseWakeLock() {
|
||||||
|
try {
|
||||||
|
wakeLock?.takeIf { it.isHeld }?.release()
|
||||||
|
if (wakeLock != null) Log.i(TAG, "WakeLock released")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "WakeLock release fehlgeschlagen: ${e.message}")
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
private fun ensureNotificationChannel() {
|
private fun ensureNotificationChannel() {
|
||||||
|
|||||||
@@ -131,6 +131,58 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
|
|||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sanfter Spotify-Resume-Nudge: kurz USAGE_MEDIA mit TRANSIENT
|
||||||
|
* requesten und sofort abandonen. Spotify bekommt das als
|
||||||
|
* Focus-Frei-Signal und resumed automatisch — aber weil TRANSIENT
|
||||||
|
* (nicht GAIN permanent), interpretiert Spotify das NICHT als
|
||||||
|
* "user stopped" was Auto-Resume verhindert haette.
|
||||||
|
*
|
||||||
|
* Hintergrund: ARIA spricht TTS via USAGE_ASSISTANT GAIN_TRANSIENT,
|
||||||
|
* Spotify pausiert. ARIA released. Spotify SOLLTE nach
|
||||||
|
* TRANSIENT-Loss + Abandon automatisch resumen, tut es aber bei
|
||||||
|
* manchen Versionen / Geraeten nicht zuverlaessig. Dieser Nudge
|
||||||
|
* triggert den Focus-Stack-Refresh ohne den Spotify-Auto-Stop-Bug
|
||||||
|
* der alten kickReleaseMedia mit GAIN permanent.
|
||||||
|
*/
|
||||||
|
@ReactMethod
|
||||||
|
fun nudgeMediaResume(promise: Promise) {
|
||||||
|
val am = audioManager()
|
||||||
|
if (am == null) {
|
||||||
|
promise.resolve(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 nudgeListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
|
||||||
|
val nudgeReq = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
||||||
|
.setAudioAttributes(attrs)
|
||||||
|
.setOnAudioFocusChangeListener(nudgeListener)
|
||||||
|
.build()
|
||||||
|
am.requestAudioFocus(nudgeReq)
|
||||||
|
Thread.sleep(100)
|
||||||
|
am.abandonAudioFocusRequest(nudgeReq)
|
||||||
|
} else {
|
||||||
|
val nudgeListener = AudioManager.OnAudioFocusChangeListener { /* ignorieren */ }
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
am.requestAudioFocus(nudgeListener, AudioManager.STREAM_MUSIC,
|
||||||
|
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
||||||
|
Thread.sleep(100)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
am.abandonAudioFocus(nudgeListener)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "nudgeMediaResume: USAGE_MEDIA TRANSIENT request+abandon (Spotify-Resume-Trigger)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "nudgeMediaResume failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
promise.resolve(true)
|
||||||
|
}
|
||||||
|
|
||||||
/** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
|
/** Den USAGE_MEDIA-Focus-Stack im System aufmischen, damit Spotify/YouTube
|
||||||
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
|
* resumen wenn ein anderer Player (z.B. react-native-sound) seinen Focus
|
||||||
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
|
* nicht ordnungsgemaess released hat. Strategie: kurz selbst USAGE_MEDIA
|
||||||
@@ -140,6 +192,10 @@ class AudioFocusModule(reactContext: ReactApplicationContext) : ReactContextBase
|
|||||||
*
|
*
|
||||||
* Workaround fuer das react-native-sound-Bug: Sound.stop()/release()
|
* Workaround fuer das react-native-sound-Bug: Sound.stop()/release()
|
||||||
* laesst den AudioFocusRequest haengen.
|
* laesst den AudioFocusRequest haengen.
|
||||||
|
*
|
||||||
|
* ⚠️ ACHTUNG: nutzt AUDIOFOCUS_GAIN (permanent), Spotify kann das als
|
||||||
|
* "user-action stopp" interpretieren und Auto-Resume verhindern.
|
||||||
|
* Fuer Spotify-Resume nach TTS lieber nudgeMediaResume() nehmen (sanfter).
|
||||||
*/
|
*/
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun kickReleaseMedia(promise: Promise) {
|
fun kickReleaseMedia(promise: Promise) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ai.onnxruntime.OnnxTensor
|
|||||||
import ai.onnxruntime.OrtEnvironment
|
import ai.onnxruntime.OrtEnvironment
|
||||||
import ai.onnxruntime.OrtSession
|
import ai.onnxruntime.OrtSession
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.media.AudioFormat
|
import android.media.AudioFormat
|
||||||
import android.media.AudioRecord
|
import android.media.AudioRecord
|
||||||
@@ -11,6 +12,7 @@ import android.media.MediaRecorder
|
|||||||
import android.media.audiofx.AcousticEchoCanceler
|
import android.media.audiofx.AcousticEchoCanceler
|
||||||
import android.media.audiofx.AutomaticGainControl
|
import android.media.audiofx.AutomaticGainControl
|
||||||
import android.media.audiofx.NoiseSuppressor
|
import android.media.audiofx.NoiseSuppressor
|
||||||
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.facebook.react.bridge.Promise
|
import com.facebook.react.bridge.Promise
|
||||||
@@ -80,6 +82,13 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
private var ns: NoiseSuppressor? = null
|
private var ns: NoiseSuppressor? = null
|
||||||
private var agc: AutomaticGainControl? = null
|
private var agc: AutomaticGainControl? = null
|
||||||
|
|
||||||
|
// PARTIAL_WAKE_LOCK damit die CPU bei aus-Display nicht in Doze geht und
|
||||||
|
// die JS-Bridge die WakeWordDetected-Events live verarbeitet (sonst
|
||||||
|
// queuen sich die Events nur und werden erst beim App-Foreground
|
||||||
|
// delivered — Stefan-Beobachtung: "Spotify pausiert, aber Gong/Aufnahme
|
||||||
|
// kommen erst wenn ich die App nach vorne hole").
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
|
||||||
// Inferenz-State
|
// Inferenz-State
|
||||||
private val melBuffer: ArrayList<FloatArray> = ArrayList(256) // Liste von 32-dim Frames
|
private val melBuffer: ArrayList<FloatArray> = ArrayList(256) // Liste von 32-dim Frames
|
||||||
private var melProcessedIdx: Int = 0
|
private var melProcessedIdx: Int = 0
|
||||||
@@ -198,6 +207,21 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
running.set(true)
|
running.set(true)
|
||||||
record.startRecording()
|
record.startRecording()
|
||||||
|
|
||||||
|
// PARTIAL_WAKE_LOCK greifen damit die CPU nicht in Doze geht und
|
||||||
|
// die JS-Bridge die emit("WakeWordDetected")-Events live verarbeitet.
|
||||||
|
// 8h Cap als Sicherheit gegen forgotten-release.
|
||||||
|
try {
|
||||||
|
val pm = reactApplicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
|
"AriaCockpit:WakeWordRecord").apply {
|
||||||
|
setReferenceCounted(false)
|
||||||
|
acquire(8 * 60 * 60 * 1000L)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "WakeLock acquired")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "WakeLock acquire fehlgeschlagen: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
|
captureThread = Thread({ captureLoop() }, "OpenWakeWordCapture").apply {
|
||||||
isDaemon = true
|
isDaemon = true
|
||||||
start()
|
start()
|
||||||
@@ -232,6 +256,7 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
try { audioRecord?.release() } catch (_: Exception) {}
|
try { audioRecord?.release() } catch (_: Exception) {}
|
||||||
audioRecord = null
|
audioRecord = null
|
||||||
releaseAudioEffects()
|
releaseAudioEffects()
|
||||||
|
releaseWakeLock()
|
||||||
Log.i(TAG, "Lauschen gestoppt")
|
Log.i(TAG, "Lauschen gestoppt")
|
||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
}
|
}
|
||||||
@@ -245,10 +270,21 @@ class OpenWakeWordModule(reactContext: ReactApplicationContext) : ReactContextBa
|
|||||||
try { audioRecord?.release() } catch (_: Exception) {}
|
try { audioRecord?.release() } catch (_: Exception) {}
|
||||||
audioRecord = null
|
audioRecord = null
|
||||||
releaseAudioEffects()
|
releaseAudioEffects()
|
||||||
|
releaseWakeLock()
|
||||||
disposeSessions()
|
disposeSessions()
|
||||||
promise.resolve(true)
|
promise.resolve(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun releaseWakeLock() {
|
||||||
|
try {
|
||||||
|
wakeLock?.takeIf { it.isHeld }?.release()
|
||||||
|
if (wakeLock != null) Log.i(TAG, "WakeLock released")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "WakeLock release fehlgeschlagen: ${e.message}")
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
}
|
||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun isAvailable(promise: Promise) {
|
fun isAvailable(promise: Promise) {
|
||||||
// Wake-Word ist immer verfuegbar (kein API-Key, alles on-device)
|
// Wake-Word ist immer verfuegbar (kein API-Key, alles on-device)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aria-cockpit",
|
"name": "aria-cockpit",
|
||||||
"version": "0.1.6.4",
|
"version": "0.1.6.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"android": "react-native run-android",
|
"android": "react-native run-android",
|
||||||
|
|||||||
@@ -52,7 +52,11 @@ import {
|
|||||||
TTS_SPEED_STORAGE_KEY,
|
TTS_SPEED_STORAGE_KEY,
|
||||||
} from '../services/audio';
|
} from '../services/audio';
|
||||||
import audioService from '../services/audio';
|
import audioService from '../services/audio';
|
||||||
import gpsTrackingService from '../services/gpsTracking';
|
import gpsTrackingService, {
|
||||||
|
isBackgroundGpsEnabled,
|
||||||
|
setBackgroundGpsEnabled,
|
||||||
|
ensureBackgroundLocationPermission,
|
||||||
|
} from '../services/gpsTracking';
|
||||||
import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio';
|
import { acquireBackgroundAudio, releaseBackgroundAudio } from '../services/backgroundAudio';
|
||||||
import MemoryBrowser from '../components/MemoryBrowser';
|
import MemoryBrowser from '../components/MemoryBrowser';
|
||||||
import TriggerBrowser from '../components/TriggerBrowser';
|
import TriggerBrowser from '../components/TriggerBrowser';
|
||||||
@@ -134,6 +138,7 @@ const SettingsScreen: React.FC = () => {
|
|||||||
const [currentMode, setCurrentMode] = useState('normal');
|
const [currentMode, setCurrentMode] = useState('normal');
|
||||||
const [gpsEnabled, setGpsEnabled] = useState(false);
|
const [gpsEnabled, setGpsEnabled] = useState(false);
|
||||||
const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive());
|
const [gpsTracking, setGpsTracking] = useState(gpsTrackingService.isActive());
|
||||||
|
const [bgGpsEnabled, setBgGpsEnabled] = useState(false);
|
||||||
const [backgroundMode, setBackgroundMode] = useState(true); // Default an
|
const [backgroundMode, setBackgroundMode] = useState(true); // Default an
|
||||||
const [showSystemHints, setShowSystemHints] = useState(false); // Default aus
|
const [showSystemHints, setShowSystemHints] = useState(false); // Default aus
|
||||||
const [scannerVisible, setScannerVisible] = useState(false);
|
const [scannerVisible, setScannerVisible] = useState(false);
|
||||||
@@ -216,6 +221,8 @@ const SettingsScreen: React.FC = () => {
|
|||||||
const offGps = gpsTrackingService.onChange(setGpsTracking);
|
const offGps = gpsTrackingService.onChange(setGpsTracking);
|
||||||
// Persistierten Status wiederherstellen (war Tracking beim letzten Mal an?)
|
// Persistierten Status wiederherstellen (war Tracking beim letzten Mal an?)
|
||||||
gpsTrackingService.restoreFromStorage().catch(() => {});
|
gpsTrackingService.restoreFromStorage().catch(() => {});
|
||||||
|
// Background-GPS-Toggle initial laden
|
||||||
|
isBackgroundGpsEnabled().then(setBgGpsEnabled).catch(() => {});
|
||||||
AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => {
|
AsyncStorage.getItem(TTS_PREROLL_STORAGE_KEY).then(saved => {
|
||||||
if (saved != null) {
|
if (saved != null) {
|
||||||
const n = parseFloat(saved);
|
const n = parseFloat(saved);
|
||||||
@@ -1117,6 +1124,52 @@ const SettingsScreen: React.FC = () => {
|
|||||||
thumbColor={gpsTracking ? '#FFFFFF' : '#666680'}
|
thumbColor={gpsTracking ? '#FFFFFF' : '#666680'}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Background-GPS opt-in — Default AUS. Braucht ACCESS_BACKGROUND_LOCATION
|
||||||
|
(User muss in Android-Settings 'Immer erlauben' aktivieren). */}
|
||||||
|
<View style={[styles.toggleRow, {marginTop: 12, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 12}]}>
|
||||||
|
<View style={styles.toggleInfo}>
|
||||||
|
<Text style={styles.toggleLabel}>GPS auch im Hintergrund</Text>
|
||||||
|
<Text style={styles.toggleHint}>
|
||||||
|
Damit ARIA auch unterwegs deine aktuelle Position kennt wenn die
|
||||||
|
App im Hintergrund ist (Auto, Handy-Tasche). Standard: aus.
|
||||||
|
{'\n\n'}
|
||||||
|
Android verlangt fuer Background-GPS, dass du in den
|
||||||
|
System-Einstellungen unter Standort "Immer erlauben" auswaehlst.
|
||||||
|
Beim Aktivieren wird Android-Settings geoeffnet falls noetig.
|
||||||
|
{'\n\n'}
|
||||||
|
Akku-Verbrauch: ~3-5% mehr pro Tag durch dauerhaftes Polling.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={bgGpsEnabled}
|
||||||
|
onValueChange={async (v) => {
|
||||||
|
if (v) {
|
||||||
|
const ok = await ensureBackgroundLocationPermission();
|
||||||
|
if (!ok) {
|
||||||
|
// User muss in Android-Settings auf "Immer erlauben" — Toggle
|
||||||
|
// bleibt aus bis er zurueckkommt und nochmal tippt.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setBackgroundGpsEnabled(true);
|
||||||
|
setBgGpsEnabled(true);
|
||||||
|
// Wenn Tracking bereits laeuft: neu starten damit der
|
||||||
|
// Foreground-Service jetzt mit location-Slot kommt
|
||||||
|
if (gpsTrackingService.isActive()) {
|
||||||
|
gpsTrackingService.stop('bg-toggle');
|
||||||
|
gpsTrackingService.start('bg-aktiviert').catch(() => {});
|
||||||
|
}
|
||||||
|
ToastAndroid.show('Background-GPS aktiviert', ToastAndroid.SHORT);
|
||||||
|
} else {
|
||||||
|
await setBackgroundGpsEnabled(false);
|
||||||
|
setBgGpsEnabled(false);
|
||||||
|
ToastAndroid.show('Background-GPS aus — nur noch Foreground', ToastAndroid.SHORT);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
trackColor={{ false: '#2A2A3E', true: '#FF3B30' }}
|
||||||
|
thumbColor={bgGpsEnabled ? '#FFFFFF' : '#666680'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* === Bubble-Anzeige === */}
|
{/* === Bubble-Anzeige === */}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const { AudioFocus, PcmStreamPlayer } = NativeModules as {
|
|||||||
AudioFocus?: {
|
AudioFocus?: {
|
||||||
requestDuck: () => Promise<boolean>;
|
requestDuck: () => Promise<boolean>;
|
||||||
requestExclusive: () => Promise<boolean>;
|
requestExclusive: () => Promise<boolean>;
|
||||||
|
nudgeMediaResume: () => Promise<boolean>;
|
||||||
release: () => Promise<boolean>;
|
release: () => Promise<boolean>;
|
||||||
kickReleaseMedia: () => Promise<boolean>;
|
kickReleaseMedia: () => Promise<boolean>;
|
||||||
getMode?: () => Promise<number>;
|
getMode?: () => Promise<number>;
|
||||||
@@ -332,6 +333,13 @@ class AudioService {
|
|||||||
}
|
}
|
||||||
console.log('[Audio] AudioFocus jetzt released');
|
console.log('[Audio] AudioFocus jetzt released');
|
||||||
AudioFocus?.release().catch(() => {});
|
AudioFocus?.release().catch(() => {});
|
||||||
|
// Spotify-Resume-Trigger: nach Abandon den USAGE_MEDIA-Focus-Stack
|
||||||
|
// mit kurzem TRANSIENT-Nudge aufmischen. Spotify resumed sonst bei
|
||||||
|
// manchen Versionen / Geraeten nicht zuverlaessig nach Auto-Loss.
|
||||||
|
// 50ms Delay damit das Abandon erst durch ist.
|
||||||
|
setTimeout(() => {
|
||||||
|
AudioFocus?.nudgeMediaResume().catch(() => {});
|
||||||
|
}, 50);
|
||||||
}, this.FOCUS_RELEASE_DELAY_MS);
|
}, this.FOCUS_RELEASE_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,14 @@
|
|||||||
* - 'tts' : ARIA spricht
|
* - 'tts' : ARIA spricht
|
||||||
* - 'rec' : Aufnahme laeuft
|
* - 'rec' : Aufnahme laeuft
|
||||||
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
|
* - 'wake' : Wake-Word lauscht passiv (Ohr aktiv)
|
||||||
|
* - 'location' : Background-GPS-Tracking (opt-in in Settings)
|
||||||
* - 'background' : Persistenter Hintergrund-Modus (Settings-Toggle).
|
* - 'background' : Persistenter Hintergrund-Modus (Settings-Toggle).
|
||||||
* Haelt JS-Engine + WebSocket auch ohne Audio am Leben
|
* Haelt JS-Engine + WebSocket auch ohne Audio am Leben
|
||||||
* → Trigger-Replies, Reconnects, Push-Reaktionen.
|
* → Trigger-Replies, Reconnects, Push-Reaktionen.
|
||||||
*
|
*
|
||||||
* Solange mindestens ein Slot aktiv ist, laeuft der Service. Wenn alle
|
* Solange mindestens ein Slot aktiv ist, laeuft der Service. Wenn alle
|
||||||
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
|
* Slots leer sind, wird er gestoppt. Der Notification-Text passt sich an
|
||||||
* den hoechstprioren Slot an (tts > rec > wake > background).
|
* den hoechstprioren Slot an (tts > rec > wake > location > background).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NativeModules } from 'react-native';
|
import { NativeModules } from 'react-native';
|
||||||
@@ -27,13 +28,13 @@ interface BackgroundAudioNative {
|
|||||||
|
|
||||||
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
|
const { BackgroundAudio } = NativeModules as { BackgroundAudio?: BackgroundAudioNative };
|
||||||
|
|
||||||
type Slot = 'tts' | 'rec' | 'wake' | 'background';
|
type Slot = 'tts' | 'rec' | 'wake' | 'location' | 'background';
|
||||||
|
|
||||||
const slots = new Set<Slot>();
|
const slots = new Set<Slot>();
|
||||||
|
|
||||||
// Prioritaet fuer den Notification-Text — hoechste zuerst. 'background'
|
// Prioritaet fuer den Notification-Text — hoechste zuerst. 'background'
|
||||||
// ist die fallback-Anzeige wenn nichts anderes laeuft.
|
// ist die fallback-Anzeige wenn nichts anderes laeuft.
|
||||||
const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'background'];
|
const PRIORITY: Slot[] = ['tts', 'rec', 'wake', 'location', 'background'];
|
||||||
|
|
||||||
function topReason(): string {
|
function topReason(): string {
|
||||||
for (const s of PRIORITY) {
|
for (const s of PRIORITY) {
|
||||||
|
|||||||
@@ -14,9 +14,62 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { PermissionsAndroid, Platform, ToastAndroid } from 'react-native';
|
import { Linking, PermissionsAndroid, Platform, ToastAndroid } from 'react-native';
|
||||||
import Geolocation from '@react-native-community/geolocation';
|
import Geolocation from '@react-native-community/geolocation';
|
||||||
import rvs from './rvs';
|
import rvs from './rvs';
|
||||||
|
import { acquireBackgroundAudio, releaseBackgroundAudio } from './backgroundAudio';
|
||||||
|
|
||||||
|
// Opt-in Background-GPS — Settings-Toggle "GPS auch im Hintergrund".
|
||||||
|
// Default AUS. Wenn AN: ACCESS_BACKGROUND_LOCATION-Permission noetig
|
||||||
|
// (kann nicht ueber Standard-Dialog angefordert werden, User muss in
|
||||||
|
// Android-Settings auf "Immer erlauben" gehen) + ForegroundService mit
|
||||||
|
// foregroundServiceType=location wird hochgezogen.
|
||||||
|
export const BG_GPS_STORAGE_KEY = 'aria_gps_background_enabled';
|
||||||
|
|
||||||
|
export async function isBackgroundGpsEnabled(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const v = await AsyncStorage.getItem(BG_GPS_STORAGE_KEY);
|
||||||
|
return v === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setBackgroundGpsEnabled(enabled: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(BG_GPS_STORAGE_KEY, String(enabled));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prueft ob ACCESS_BACKGROUND_LOCATION gewaehrt ist und oeffnet sonst die
|
||||||
|
* Android-App-Settings damit der User "Immer erlauben" auswaehlen kann.
|
||||||
|
* Returns true wenn permission ok, false wenn User Settings oeffnen muss. */
|
||||||
|
export async function ensureBackgroundLocationPermission(): Promise<boolean> {
|
||||||
|
if (Platform.OS !== 'android') return true;
|
||||||
|
try {
|
||||||
|
const granted = await PermissionsAndroid.check(
|
||||||
|
'android.permission.ACCESS_BACKGROUND_LOCATION' as any,
|
||||||
|
);
|
||||||
|
if (granted) return true;
|
||||||
|
// Erst FINE_LOCATION anfordern falls noch nicht da
|
||||||
|
const fine = await PermissionsAndroid.request(
|
||||||
|
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
|
||||||
|
);
|
||||||
|
if (fine !== PermissionsAndroid.RESULTS.GRANTED) return false;
|
||||||
|
// Ab Android 10+ kann BACKGROUND_LOCATION NICHT ueber den normalen
|
||||||
|
// PermissionsAndroid.request abgefragt werden — User muss in Settings
|
||||||
|
// auf "Immer erlauben" wechseln. Wir oeffnen die App-Settings-Seite.
|
||||||
|
ToastAndroid.show(
|
||||||
|
'Bitte in Android-Einstellungen unter Standort "Immer erlauben" auswaehlen',
|
||||||
|
ToastAndroid.LONG,
|
||||||
|
);
|
||||||
|
Linking.openSettings();
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[gps-track] BG-Permission-Check fehlgeschlagen:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Listener = (active: boolean) => void;
|
type Listener = (active: boolean) => void;
|
||||||
|
|
||||||
@@ -86,6 +139,14 @@ class GpsTrackingService {
|
|||||||
ToastAndroid.show('GPS-Tracking: Berechtigung abgelehnt', ToastAndroid.LONG);
|
ToastAndroid.show('GPS-Tracking: Berechtigung abgelehnt', ToastAndroid.LONG);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Background-GPS opt-in: wenn aktiv, ForegroundService mit type=location
|
||||||
|
// hochziehen. Brauche ACCESS_BACKGROUND_LOCATION (User muss in Android-
|
||||||
|
// Settings 'Immer erlauben' aktivieren). Wenn die fehlt, watchPosition
|
||||||
|
// liefert im Hintergrund keine Updates (nur Heartbeat sendet alte Werte).
|
||||||
|
const bgEnabled = await isBackgroundGpsEnabled();
|
||||||
|
if (bgEnabled) {
|
||||||
|
try { await acquireBackgroundAudio('location'); } catch {}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
this.watchId = Geolocation.watchPosition(
|
this.watchId = Geolocation.watchPosition(
|
||||||
(pos) => {
|
(pos) => {
|
||||||
@@ -142,6 +203,8 @@ class GpsTrackingService {
|
|||||||
clearInterval(this.heartbeatTimer);
|
clearInterval(this.heartbeatTimer);
|
||||||
this.heartbeatTimer = null;
|
this.heartbeatTimer = null;
|
||||||
}
|
}
|
||||||
|
// Location-Foreground-Service-Slot freigeben (falls vorher acquired)
|
||||||
|
try { releaseBackgroundAudio('location'); } catch {}
|
||||||
this.active = false;
|
this.active = false;
|
||||||
this.lastChangeAt = Date.now();
|
this.lastChangeAt = Date.now();
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|||||||
Reference in New Issue
Block a user