diff --git a/app/.gitignore b/app/.gitignore
index 42afabfd2..56dadcc28 100644
--- a/app/.gitignore
+++ b/app/.gitignore
@@ -1 +1,3 @@
-/build
\ No newline at end of file
+/build
+/cache-test
+/cache-test2
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9bf2e70b1..8c0386fe0 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -47,8 +47,8 @@ android {
applicationId = "org.scottishtecharmy.soundscape"
minSdk = 30
targetSdk = 35
- versionCode = 172
- versionName = "0.3.10"
+ versionCode = 174
+ versionName = "0.3.12"
// Maintaining this list means that we can exclude translations that aren't complete yet
resourceConfigurations.addAll(listOf(
@@ -367,3 +367,11 @@ dependencies {
testImplementation(libs.json)
}
+
+dokka {
+ dokkaSourceSets.configureEach {
+ if (name == "main") {
+ suppress.set(true)
+ }
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4d81c65a5..38924d72c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -13,6 +13,8 @@
+
+
@@ -146,6 +148,9 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/MainActivity.kt b/app/src/main/java/org/scottishtecharmy/soundscape/MainActivity.kt
index a4750dfe6..f8fde362d 100644
--- a/app/src/main/java/org/scottishtecharmy/soundscape/MainActivity.kt
+++ b/app/src/main/java/org/scottishtecharmy/soundscape/MainActivity.kt
@@ -96,6 +96,10 @@ class MainActivity : AppCompatActivity() {
checkAndRequestLocationPermissions()
}
+ // Microphone permission for voice commands — best-effort; if denied, voice commands are silently skipped
+ private val micPermissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* no-op */ }
+
// we need location permission to be able to start the service
private val locationPermissionRequest = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
@@ -164,6 +168,12 @@ class MainActivity : AppCompatActivity() {
hintsEnabled = preferences.getBoolean(HINTS_KEY, HINTS_DEFAULT)
)
}
+
+ MEDIA_CONTROLS_MODE_KEY -> {
+ val mode = preferences.getString(MEDIA_CONTROLS_MODE_KEY, MEDIA_CONTROLS_MODE_DEFAULT)!!
+ Log.e(TAG, "mediaControlsMode $mode")
+ soundscapeServiceConnection.soundscapeService?.updateMediaControls(mode)
+ }
}
}
@@ -746,6 +756,11 @@ class MainActivity : AppCompatActivity() {
Log.e(TAG, "startSoundscapeService")
val serviceIntent = Intent(this, SoundscapeService::class.java)
startForegroundService(serviceIntent)
+ // Request microphone permission for voice commands (best-effort)
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
+ != android.content.pm.PackageManager.PERMISSION_GRANTED) {
+ micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
+ }
}
companion object {
@@ -796,6 +811,8 @@ class MainActivity : AppCompatActivity() {
const val GEOCODER_MODE_KEY = "GeocoderMode"
const val LAST_SPLASH_RELEASE_DEFAULT = ""
const val LAST_SPLASH_RELEASE_KEY = "LastNewRelease"
+ const val MEDIA_CONTROLS_MODE_DEFAULT = "Original"
+ const val MEDIA_CONTROLS_MODE_KEY = "MediaControlsMode"
const val FIRST_LAUNCH_KEY = "FirstLaunch"
const val AUDIO_TOUR_SHOWN_KEY = "AudioTourShown"
diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/SoundscapeServiceConnection.kt b/app/src/main/java/org/scottishtecharmy/soundscape/SoundscapeServiceConnection.kt
index 0326813fd..8d4042427 100644
--- a/app/src/main/java/org/scottishtecharmy/soundscape/SoundscapeServiceConnection.kt
+++ b/app/src/main/java/org/scottishtecharmy/soundscape/SoundscapeServiceConnection.kt
@@ -18,6 +18,7 @@ import org.scottishtecharmy.soundscape.services.BeaconState
import org.scottishtecharmy.soundscape.services.RoutePlayerState
import org.scottishtecharmy.soundscape.services.SoundscapeBinder
import org.scottishtecharmy.soundscape.services.SoundscapeService
+import org.scottishtecharmy.soundscape.services.mediacontrol.VoiceCommandState
import javax.inject.Inject
@ActivityRetainedScoped
@@ -48,13 +49,17 @@ class SoundscapeServiceConnection @Inject constructor() {
return soundscapeService?.gridStateFlow
}
+ fun getVoiceCommandStateFlow(): StateFlow? {
+ return soundscapeService?.voiceCommandStateFlow
+ }
+
fun setStreetPreviewMode(on : Boolean, location: LngLatAlt? = null) {
Log.d(TAG, "setStreetPreviewMode $on")
soundscapeService?.setStreetPreviewMode(on, location)
}
fun routeStart(routeId: Long) {
- soundscapeService?.routeStart(routeId)
+ soundscapeService?.routeStartById(routeId)
}
fun startBeacon(location: LngLatAlt, name: String) {
diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt
index 139855156..f8ba87552 100644
--- a/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt
+++ b/app/src/main/java/org/scottishtecharmy/soundscape/geoengine/GeoEngine.kt
@@ -438,7 +438,7 @@ class GeoEngine {
// So long as the AudioEngine is not already busy, run any auto callouts that we
// need. Auto Callouts use the direction of travel if there is one, otherwise
// falling back to use the phone direction.
- if((!soundscapeService.isAudioEngineBusy() || streetPreview.running) && !autoCalloutDisabled) {
+ if((!soundscapeService.isAudioEngineBusy() || streetPreview.running) && !autoCalloutDisabled && !soundscapeService.menuActive) {
val callout =
autoCallout.updateLocation(
getCurrentUserGeometry(UserGeometry.HeadingMode.CourseAuto),
diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/home/Home.kt b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/home/Home.kt
index 483910fd9..f7c3e0e0d 100644
--- a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/home/Home.kt
+++ b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/home/Home.kt
@@ -230,7 +230,8 @@ fun Home(
goToAppSettings = goToAppSettings,
fullscreenMap = fullscreenMap,
permissionsRequired = permissionsRequired,
- showMap = showMap
+ showMap = showMap,
+ voiceCommandListening = state.voiceCommandListening
)
}
}
diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/home/HomeContent.kt b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/home/HomeContent.kt
index 0b5ad0aa4..86be79cf5 100644
--- a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/home/HomeContent.kt
+++ b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/home/HomeContent.kt
@@ -1,6 +1,7 @@
package org.scottishtecharmy.soundscape.screens.home.home
import android.content.Context
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -8,6 +9,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -160,14 +162,16 @@ fun HomeContent(
goToAppSettings: (Context) -> Unit,
fullscreenMap: MutableState,
permissionsRequired: Boolean,
- showMap: Boolean) {
+ showMap: Boolean,
+ voiceCommandListening: Boolean = false) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var fetchingLocation by remember { mutableStateOf(false) }
+ Box(modifier = modifier.fillMaxSize()) {
Column(
verticalArrangement = Arrangement.spacedBy(spacing.small),
- modifier = modifier
+ modifier = Modifier.fillMaxSize()
) {
if (streetPreviewState.enabled != StreetPreviewEnabled.OFF) {
StreetPreview(streetPreviewState, streetPreviewFunctions)
@@ -390,6 +394,25 @@ fun HomeContent(
}
}
}
+ if (voiceCommandListening) {
+ Box(
+ contentAlignment = Alignment.BottomCenter,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Text(
+ text = stringResource(R.string.voice_cmd_listening),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f))
+ .padding(vertical = spacing.small)
+ .semantics { liveRegion = LiveRegionMode.Assertive },
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ } // end Box
}
@Preview
diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/settings/Settings.kt b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/settings/Settings.kt
index 3893c4b38..78adb1fd8 100644
--- a/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/settings/Settings.kt
+++ b/app/src/main/java/org/scottishtecharmy/soundscape/screens/home/settings/Settings.kt
@@ -258,6 +258,17 @@ fun Settings(
"Offline"
)
+ val mediaControlsDescriptions = listOf(
+ stringResource(R.string.settings_media_controls_original),
+ stringResource(R.string.settings_media_controls_voice_command),
+ stringResource(R.string.settings_media_controls_audio_menu),
+ )
+ val mediaControlsValues = listOf(
+ "Original",
+ "VoiceControl",
+ "AudioMenu",
+ )
+
if (showConfirmationDialog.value) {
AlertDialog(
onDismissRequest = { showConfirmationDialog.value = false },
@@ -683,6 +694,42 @@ fun Settings(
}
}
+ // Media control section
+ item(key = "header_media_control") {
+ ExpandableSectionHeader(
+ title = stringResource(R.string.menu_media_controls),
+ expanded = expandedSection.value == "media_controls",
+ onToggle = { expandedSection.value = if (expandedSection.value == "media_controls") null else "media_controls" },
+ textColor = textColor
+ )
+ }
+ if (expandedSection.value == "media_controls") {
+ listPreference(
+ key = MainActivity.MEDIA_CONTROLS_MODE_KEY,
+ defaultValue = MainActivity.MEDIA_CONTROLS_MODE_DEFAULT,
+ values = mediaControlsValues,
+ modifier = expandedSectionModifier,
+ title = {
+ SettingDetails(
+ R.string.settings_section_media_controls,
+ R.string.settings_section_media_controls_description,
+ textColor
+ )
+ },
+ item = { value, currentValue, onClick ->
+ ListPreferenceItem(mediaControlsDescriptions[mediaControlsValues.indexOf(value)], value, currentValue, onClick, mediaControlsValues.indexOf(value), mediaControlsValues.size)
+ },
+ summary = {
+ Text(
+ text = mediaControlsDescriptions[mediaControlsValues.indexOf(it)],
+ color = textColor,
+ style = MaterialTheme.typography.bodyLarge
+ )
+ },
+ )
+ }
+
+
// Debug Section
item(key = "header_debug") {
ExpandableSectionHeader(
@@ -800,6 +847,11 @@ fun SettingsPreviewLanguage() {
}
@Preview
@Composable
+fun SettingsPreviewMediaControls() {
+ SettingsPreview("media_controls")
+}
+@Preview
+@Composable
fun SettingsPreviewDebug() {
SettingsPreview("debug")
}
diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/screens/onboarding/navigating/NavigatingScreen.kt b/app/src/main/java/org/scottishtecharmy/soundscape/screens/onboarding/navigating/NavigatingScreen.kt
index b51c1f611..d1cdbe445 100644
--- a/app/src/main/java/org/scottishtecharmy/soundscape/screens/onboarding/navigating/NavigatingScreen.kt
+++ b/app/src/main/java/org/scottishtecharmy/soundscape/screens/onboarding/navigating/NavigatingScreen.kt
@@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.LocationOn
+import androidx.compose.material.icons.rounded.Mic
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -51,12 +52,14 @@ fun NavigatingScreen(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_BACKGROUND_LOCATION,
- Manifest.permission.POST_NOTIFICATIONS
+ Manifest.permission.POST_NOTIFICATIONS,
+ Manifest.permission.RECORD_AUDIO
)
} else {
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
- Manifest.permission.ACCESS_BACKGROUND_LOCATION
+ Manifest.permission.ACCESS_BACKGROUND_LOCATION,
+ Manifest.permission.RECORD_AUDIO
)
}
val multiplePermissionResultLauncher = rememberLauncherForActivityResult(
@@ -135,7 +138,7 @@ fun Navigating(
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
- Spacer(modifier = Modifier.width(spacing.medium))
+ Spacer(modifier = Modifier.width(spacing.extraSmall))
Column(
modifier = Modifier.semantics(mergeDescendants = true) {},
) {
@@ -182,7 +185,33 @@ fun Navigating(
)
}
}
-
+ }
+ Row(
+ modifier = Modifier
+ .mediumPadding()
+ .fillMaxWidth(),
+ )
+ {
+ Icon(
+ Icons.Rounded.Mic,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.width(spacing.extraSmall))
+ Column(
+ modifier = Modifier.semantics(mergeDescendants = true) {},
+ ) {
+ Text(
+ text = stringResource(R.string.first_launch_permissions_record_audio),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = stringResource(R.string.first_launch_permissions_required_for_voice_control),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
}
}
diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeDummyMediaPlayer.kt b/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeDummyMediaPlayer.kt
deleted file mode 100644
index 61067709b..000000000
--- a/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeDummyMediaPlayer.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-package org.scottishtecharmy.soundscape.services
-
-import android.os.Looper
-import androidx.annotation.OptIn
-import androidx.media3.common.DeviceInfo
-import androidx.media3.common.Player
-import androidx.media3.common.SimpleBasePlayer
-import androidx.media3.common.util.UnstableApi
-
-@OptIn(UnstableApi::class)
-class SoundscapeDummyMediaPlayer : SimpleBasePlayer(Looper.getMainLooper()) {
-
- override fun getState(): State {
-
- val commands : Player.Commands = Player.Commands.EMPTY
- commands.buildUpon().addAll(
- COMMAND_PLAY_PAUSE,
- COMMAND_STOP,
- COMMAND_SEEK_TO_NEXT,
- COMMAND_SEEK_TO_PREVIOUS,
- COMMAND_SEEK_FORWARD,
- COMMAND_SEEK_BACK,
- COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
- COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
-
- return State.Builder()
- // Set which playback commands the player can handle
- .setAvailableCommands(commands)
- // Configure additional playback properties
- .setPlayWhenReady(true, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
- .setCurrentMediaItemIndex(0)
- .setContentPositionMs(0)
- .setDeviceInfo(DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_LOCAL).build())
- .build()
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeService.kt b/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeService.kt
index a29266974..a5b04cc1e 100644
--- a/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeService.kt
+++ b/app/src/main/java/org/scottishtecharmy/soundscape/services/SoundscapeService.kt
@@ -1,9 +1,11 @@
package org.scottishtecharmy.soundscape.services
+import android.Manifest
import android.content.Context
import android.content.res.Configuration
import android.annotation.SuppressLint
import android.app.ForegroundServiceStartNotAllowedException
+import android.content.pm.PackageManager
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -23,9 +25,11 @@ import android.widget.Toast
import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
+import androidx.core.content.ContextCompat
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
+import androidx.preference.PreferenceManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -35,14 +39,22 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.scottishtecharmy.soundscape.MainActivity
+import org.scottishtecharmy.soundscape.MainActivity.Companion.MEDIA_CONTROLS_MODE_DEFAULT
+import org.scottishtecharmy.soundscape.MainActivity.Companion.MEDIA_CONTROLS_MODE_KEY
import org.scottishtecharmy.soundscape.R
import org.scottishtecharmy.soundscape.audio.AudioType
import org.scottishtecharmy.soundscape.audio.NativeAudioEngine
+import org.scottishtecharmy.soundscape.audio.NativeAudioEngine.Companion.EARCON_CALLOUTS_ON
+import org.scottishtecharmy.soundscape.audio.NativeAudioEngine.Companion.EARCON_MODE_ENTER
+import org.scottishtecharmy.soundscape.audio.NativeAudioEngine.Companion.EARCON_MODE_EXIT
import org.scottishtecharmy.soundscape.database.local.MarkersAndRoutesDatabase
+import org.scottishtecharmy.soundscape.database.local.model.MarkerEntity
+import org.scottishtecharmy.soundscape.database.local.model.RouteEntity
import org.scottishtecharmy.soundscape.geoengine.GeoEngine
import org.scottishtecharmy.soundscape.geoengine.GridState
import org.scottishtecharmy.soundscape.geoengine.StreetPreviewChoice
@@ -60,6 +72,15 @@ import org.scottishtecharmy.soundscape.locationprovider.DirectionProvider
import org.scottishtecharmy.soundscape.locationprovider.LocationProvider
import org.scottishtecharmy.soundscape.locationprovider.StaticLocationProvider
import org.scottishtecharmy.soundscape.screens.home.data.LocationDescription
+import org.scottishtecharmy.soundscape.services.mediacontrol.AudioMenu
+import org.scottishtecharmy.soundscape.services.mediacontrol.AudioMenuMediaControls
+import org.scottishtecharmy.soundscape.services.mediacontrol.MediaControlTarget
+import org.scottishtecharmy.soundscape.services.mediacontrol.OriginalMediaControls
+import org.scottishtecharmy.soundscape.services.mediacontrol.SoundscapeDummyMediaPlayer
+import org.scottishtecharmy.soundscape.services.mediacontrol.SoundscapeMediaSessionCallback
+import org.scottishtecharmy.soundscape.services.mediacontrol.VoiceCommandManager
+import org.scottishtecharmy.soundscape.services.mediacontrol.VoiceCommandMediaControls
+import org.scottishtecharmy.soundscape.services.mediacontrol.VoiceCommandState
import org.scottishtecharmy.soundscape.utils.Analytics
import org.scottishtecharmy.soundscape.utils.getCurrentLocale
import kotlin.time.Duration
@@ -93,6 +114,12 @@ class SoundscapeService : MediaSessionService() {
var audioEngine = NativeAudioEngine(this)
private var audioBeacon: Long = 0
+ // Audio menu (navigated via media buttons when no route is active)
+ var audioMenu : AudioMenu? = null
+
+ /** True while the user is actively navigating the audio menu. Suppresses auto callouts. */
+ var menuActive: Boolean = false
+
// Audio focus
private lateinit var audioManager: AudioManager
private var audioFocusRequest: AudioFocusRequest? = null
@@ -148,9 +175,16 @@ class SoundscapeService : MediaSessionService() {
private val _gridStateFlow = MutableStateFlow(null)
var gridStateFlow: StateFlow = _gridStateFlow
+ // Voice command manager — only initialized when RECORD_AUDIO permission is granted
+ private var voiceCommandManager: VoiceCommandManager? = null
+ val voiceCommandStateFlow: StateFlow
+ get() = voiceCommandManager?.state ?: MutableStateFlow(VoiceCommandState.Idle)
+
// Media control button code
private var mediaSession: MediaSession? = null
- private val mediaPlayer = SoundscapeDummyMediaPlayer()
+
+ private var mediaControlsTarget : MediaControlTarget = OriginalMediaControls(this)
+ private val mediaPlayer = SoundscapeDummyMediaPlayer { mediaControlsTarget }
var running: Boolean = false
var started: Boolean = false
@@ -196,10 +230,6 @@ class SoundscapeService : MediaSessionService() {
locationProvider.start(this)
directionProvider.start(audioEngine, locationProvider)
- val configLocale = getCurrentLocale()
- val configuration = Configuration(applicationContext.resources.configuration)
- configuration.setLocale(configLocale)
- localizedContext = applicationContext.createConfigurationContext(configuration)
geoEngine.start(application, locationProvider, directionProvider, this, localizedContext)
}
@@ -234,6 +264,7 @@ class SoundscapeService : MediaSessionService() {
val configuration = Configuration(applicationContext.resources.configuration)
configuration.setLocale(configLocale)
localizedContext = applicationContext.createConfigurationContext(configuration)
+ voiceCommandManager?.updateContext(localizedContext)
geoEngine.start(application, locationProvider, directionProvider, this, localizedContext)
started = true
}
@@ -259,7 +290,7 @@ class SoundscapeService : MediaSessionService() {
audioEngine.initialize(applicationContext)
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
-
+ audioMenu = AudioMenu(this, application)
routePlayer = RoutePlayer(this, applicationContext)
if(hasPlayServices(this)) {
@@ -273,16 +304,60 @@ class SoundscapeService : MediaSessionService() {
// create new RealmDB or open existing
startRealms(applicationContext)
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
+ == PackageManager.PERMISSION_GRANTED) {
+ voiceCommandManager = VoiceCommandManager(
+ service = this,
+ onError = { }
+ )
+ }
+
+ // Update the media controls mode
+ val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
+ val mode = sharedPreferences.getString(MEDIA_CONTROLS_MODE_KEY, MEDIA_CONTROLS_MODE_DEFAULT)!!
+ updateMediaControls(mode)
+
+ // Keep biasing strings up to date whenever markers or routes change
+ val dao = MarkersAndRoutesDatabase.getMarkersInstance(applicationContext).routeDao()
+ coroutineScope.launch {
+ dao.getAllMarkersFlow().collect { markers ->
+ voiceCommandManager?.updateMarkers(markers)
+ }
+ }
+ coroutineScope.launch {
+ dao.getAllRoutesFlow().collect { routes ->
+ voiceCommandManager?.updateRoutes(routes)
+ }
+ }
+
mediaSession = MediaSession.Builder(this, mediaPlayer)
- .setCallback(SoundscapeMediaSessionCallback(this))
+ .setId("org.scottishtecharmy.soundscape")
+ .setCallback(SoundscapeMediaSessionCallback { mediaControlsTarget })
.build()
}
}
+ fun updateMediaControls(target: String) {
+ val hasRecordAudio = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
+ PackageManager.PERMISSION_GRANTED
+ mediaControlsTarget = when (target) {
+ "VoiceControl" if hasRecordAudio -> {
+ voiceCommandManager?.initialize()
+ VoiceCommandMediaControls(this)
+ }
+ "VoiceControl" -> AudioMenuMediaControls(audioMenu)
+ "AudioMenu" -> AudioMenuMediaControls(audioMenu)
+ "Original" -> OriginalMediaControls(this)
+ else -> OriginalMediaControls(this)
+ }
+ }
+
override fun onTaskRemoved(rootIntent: Intent?) {
Log.d(TAG, "onTaskRemoved for service - ignoring, as we want to keep running")
}
override fun onDestroy() {
+ suppressionJob?.cancel()
+
// If _mediaSession is not null, run the following block
mediaSession?.run {
// Release the player
@@ -295,6 +370,7 @@ class SoundscapeService : MediaSessionService() {
super.onDestroy()
Log.d(TAG, "onDestroy")
+ audioMenu?.destroy()
audioEngine.destroyBeacon(audioBeacon)
audioBeacon = 0
audioEngine.destroy()
@@ -313,6 +389,8 @@ class SoundscapeService : MediaSessionService() {
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
+ voiceCommandManager?.destroy()
+
// Clear service reference in binder so that it can be garbage collected
binder?.reset()
}
@@ -505,6 +583,36 @@ class SoundscapeService : MediaSessionService() {
}
}
+ fun triggerVoiceCommand() {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
+ != PackageManager.PERMISSION_GRANTED) return
+
+ if (!requestAudioFocus()) {
+ Log.w(TAG, "speakText: Could not get audio focus. Aborting callouts.")
+ return
+ }
+
+ // Stop callbacks whilst we handle voice commands
+ callbackHoldOff()
+
+ val ctx = if (::localizedContext.isInitialized) localizedContext else this
+
+ // Play earcon as feedback to indicate that we're starting to listen.
+ speak2dText("", true, EARCON_CALLOUTS_ON)
+
+ // Wait for the TTS to finish before opening the mic, otherwise we lose audio focus before
+ // the icon completes.
+ coroutineScope.launch {
+ val deadline = System.currentTimeMillis() + 1000L
+ while (isAudioEngineBusy() && System.currentTimeMillis() < deadline) {
+ delay(20)
+ }
+ withContext(Dispatchers.Main) {
+ voiceCommandManager?.startListening()
+ }
+ }
+ }
+
suspend fun searchResult(searchString: String): List? {
return geoEngine.searchResult(searchString)
}
@@ -516,7 +624,7 @@ class SoundscapeService : MediaSessionService() {
fun startBeacon(location: LngLatAlt, name: String) {
routePlayer.startBeacon(location, name)
}
- fun routeStart(routeId: Long) {
+ fun routeStartById(routeId: Long) {
routePlayer.startRoute(routeId)
}
fun routeStop() {
@@ -542,6 +650,46 @@ class SoundscapeService : MediaSessionService() {
}
return false
}
+ fun routeListRoutes() {
+ coroutineScope.launch {
+ val ctx = if (::localizedContext.isInitialized) localizedContext else this@SoundscapeService
+ val routes = MarkersAndRoutesDatabase.getMarkersInstance(applicationContext).routeDao().getAllRoutes()
+ if (routes.isEmpty())
+ speak2dText(ctx.getString(R.string.voice_cmd_no_routes))
+ else {
+ val names = routes.joinToString(". ") { it.name }
+ speak2dText(ctx.getString(R.string.voice_cmd_routes_list) + names)
+ }
+ }
+ }
+
+ fun routeStart(route: RouteEntity) {
+ val ctx = if (::localizedContext.isInitialized) localizedContext else this@SoundscapeService
+ speak2dText(ctx.getString(R.string.voice_cmd_starting_route).format(route.name))
+ routeStartById(route.routeId)
+ }
+
+ fun routeListMarkers() {
+ coroutineScope.launch {
+ val ctx = if (::localizedContext.isInitialized) localizedContext else this@SoundscapeService
+ val markers = MarkersAndRoutesDatabase.getMarkersInstance(applicationContext).routeDao().getAllMarkers()
+ if (markers.isEmpty()) {
+ speak2dText(ctx.getString(R.string.voice_cmd_no_markers))
+ } else {
+ val names = markers.joinToString(". ") { it.name }
+ speak2dText(ctx.getString(R.string.voice_cmd_markers_list) + names)
+ }
+ }
+ }
+
+ fun markerStart(marker: MarkerEntity) {
+ val ctx = if (::localizedContext.isInitialized) localizedContext else this@SoundscapeService
+ speak2dText(ctx.getString(R.string.voice_cmd_starting_beacon_at_marker).format(marker.name))
+
+ val location = LngLatAlt(marker.longitude, marker.latitude)
+ startBeacon(location, marker.name)
+ }
+
/**
* isAudioEngineBusy returns true if there is more than one entry in the
* audio engine queue. The queue consists of earcons and text-to-speech.
@@ -559,24 +707,37 @@ class SoundscapeService : MediaSessionService() {
heading: Double = Double.NaN) {
if (!requestAudioFocus()) {
- Log.w(TAG, "speakText: Could not get audio focus. Aborting callouts.")
+ Log.w(TAG, "speakText: Could not get audio focus.")
return
}
Log.d(TAG, "speakText $text")
audioEngine.createTextToSpeech(text, type, latitude, longitude, heading)
}
+ fun speak2dText(text: String, clearQueue: Boolean = false, earcon: String? = null) {
+ if (!requestAudioFocus()) {
+ Log.w(TAG, "speak2dText: Could not get audio focus.")
+ return
+ }
+ if(clearQueue)
+ audioEngine.clearTextToSpeechQueue()
+ if(earcon != null) {
+ audioEngine.createEarcon(earcon, AudioType.STANDARD)
+ }
+ if(text.isNotEmpty())
+ audioEngine.createTextToSpeech(text, AudioType.STANDARD)
+ }
fun speakCallout(callout: TrackedCallout?, addModeEarcon: Boolean) {
if(callout == null) return
if (!requestAudioFocus()) {
- Log.w(TAG, "SpeakCallout: Could not get audio focus. Aborting callouts.")
+ Log.w(TAG, "SpeakCallout: Could not get audio focus.")
return
}
- if(addModeEarcon) audioEngine.createEarcon(NativeAudioEngine.EARCON_MODE_ENTER, AudioType.STANDARD)
+ if(addModeEarcon) audioEngine.createEarcon(EARCON_MODE_ENTER, AudioType.STANDARD)
for(result in callout.positionedStrings) {
if(result.location == null) {
var type = result.type
@@ -604,7 +765,7 @@ class SoundscapeService : MediaSessionService() {
)
}
}
- if(addModeEarcon) audioEngine.createEarcon(NativeAudioEngine.EARCON_MODE_EXIT, AudioType.STANDARD)
+ if(addModeEarcon) audioEngine.createEarcon(EARCON_MODE_EXIT, AudioType.STANDARD)
callout.calloutHistory?.add(callout)
callout.locationFilter?.update(callout.userGeometry)
@@ -701,12 +862,30 @@ class SoundscapeService : MediaSessionService() {
audioFocusGained = false
}
+ /**
+ * Called on every menu interaction. Marks the menu as active (suppressing auto callouts)
+ * and resets the 10-second countdown after which auto callouts are re-enabled.
+ */
+ /** Cancels pending re-enable of auto callouts and restarts the 10-second countdown. */
+ private var suppressionJob: Job? = null
+ private val scope = CoroutineScope(Dispatchers.Default)
+ fun callbackHoldOff() {
+ menuActive = true
+ suppressionJob?.cancel()
+ suppressionJob = scope.launch {
+ delay(CALLOUT_SUPPRESS_TIMEOUT_MS)
+ menuActive = false
+ }
+ }
+
companion object {
private const val TAG = "SoundscapeService"
// Secondary "service" every n seconds
private val TICKER_PERIOD_SECONDS = 3600.seconds
+ private const val CALLOUT_SUPPRESS_TIMEOUT_MS = 8_000L
+
private const val CHANNEL_ID = "SoundscapeService_channel_01"
private const val NOTIFICATION_CHANNEL_NAME = "Soundscape_SoundscapeService"
private const val NOTIFICATION_ID = 100000
diff --git a/app/src/main/java/org/scottishtecharmy/soundscape/services/mediacontrol/AudioMenu.kt b/app/src/main/java/org/scottishtecharmy/soundscape/services/mediacontrol/AudioMenu.kt
new file mode 100644
index 000000000..6db49c0e5
--- /dev/null
+++ b/app/src/main/java/org/scottishtecharmy/soundscape/services/mediacontrol/AudioMenu.kt
@@ -0,0 +1,266 @@
+package org.scottishtecharmy.soundscape.services.mediacontrol
+
+import android.content.Context
+import android.content.res.Configuration
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.scottishtecharmy.soundscape.R
+import org.scottishtecharmy.soundscape.audio.AudioType
+import org.scottishtecharmy.soundscape.audio.NativeAudioEngine.Companion.EARCON_MODE_ENTER
+import org.scottishtecharmy.soundscape.audio.NativeAudioEngine.Companion.EARCON_MODE_EXIT
+import org.scottishtecharmy.soundscape.database.local.MarkersAndRoutesDatabase
+import org.scottishtecharmy.soundscape.geojsonparser.geojson.LngLatAlt
+import org.scottishtecharmy.soundscape.services.SoundscapeService
+import org.scottishtecharmy.soundscape.utils.getCurrentLocale
+
+/**
+ * AudioMenu provides a hierarchical, navigable audio menu controlled by media buttons.
+ *
+ * - NEXT : advance to next item at the current level (wraps at end)
+ * - PREVIOUS: go back one item (wraps at end)
+ * - SELECT : enter a sub-menu, or execute a leaf action
+ *
+ * There is no inactivity timeout — the current position is remembered until changed.
+ */
+class AudioMenu(
+ private val service: SoundscapeService,
+ private val application: Context) {
+
+ // ── Menu item types ───────────────────────────────────────────────────────
+
+ sealed class MenuItem {
+ abstract val label: String
+
+ /** A leaf node that executes an action when selected. */
+ data class Action(
+ override val label: String,
+ val action: () -> Unit
+ ) : MenuItem()
+
+ /** A sub-menu with a fixed list of children. */
+ data class Submenu(
+ override val label: String,
+ val children: List