diff --git a/.gitignore b/.gitignore
index ee92525..529a458 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ captures/
.externalNativeBuild/
.cxx/
app/release/
+.artifacts
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1b42ddc..932ead1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,14 +5,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/ambient/launcher/BluetoothController.kt b/app/src/main/java/com/ambient/launcher/BluetoothController.kt
new file mode 100644
index 0000000..ab7f8c2
--- /dev/null
+++ b/app/src/main/java/com/ambient/launcher/BluetoothController.kt
@@ -0,0 +1,179 @@
+package com.ambient.launcher
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothProfile
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.media.AudioAttributes
+import android.media.AudioFocusRequest
+import android.media.AudioManager
+import android.media.session.MediaSession
+import android.media.session.PlaybackState
+import android.util.Log
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class BluetoothController(private val context: Context) {
+ private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
+ private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
+ private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+
+ private val _status = MutableStateFlow(BluetoothSnapshot())
+ val status: StateFlow = _status.asStateFlow()
+
+ private var a2dpSinkProxy: BluetoothProfile? = null
+ private var avrcpControllerProxy: BluetoothProfile? = null
+ private var mediaSession: MediaSession? = null
+
+ private val A2DP_SINK = 11
+ private val AVRCP_CONTROLLER = 12
+
+ private fun stateName(state: Int): String = when (state) {
+ BluetoothProfile.STATE_DISCONNECTED -> "DISCONNECTED"
+ BluetoothProfile.STATE_CONNECTING -> "CONNECTING"
+ BluetoothProfile.STATE_CONNECTED -> "CONNECTED"
+ BluetoothProfile.STATE_DISCONNECTING -> "DISCONNECTING"
+ else -> "UNKNOWN($state)"
+ }
+
+ // Track the device that is currently connected so we don't interfere with it
+ private var lastConnectedDevice: android.bluetooth.BluetoothDevice? = null
+
+ private val profileListener = object : BluetoothProfile.ServiceListener {
+ override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
+ when (profile) {
+ A2DP_SINK -> {
+ a2dpSinkProxy = proxy
+ Log.d("BluetoothController", "A2DP Sink proxy connected")
+ updateConnectionState()
+ }
+ }
+ }
+
+ override fun onServiceDisconnected(profile: Int) {
+ when (profile) {
+ A2DP_SINK -> {
+ a2dpSinkProxy = null
+ Log.d("BluetoothController", "A2DP Sink proxy disconnected")
+ }
+ }
+ updateConnectionState()
+ }
+ }
+
+ private val receiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ Log.d("BluetoothController", "Received broadcast: ${intent.action}")
+ when (intent.action) {
+ "android.bluetooth.a2dp-sink.profile.action.CONNECTION_STATE_CHANGED" -> {
+ val state = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, -1)
+ val prevState = intent.getIntExtra(BluetoothProfile.EXTRA_PREVIOUS_STATE, -1)
+ val device = intent.getParcelableExtra(
+ BluetoothDeviceExtra
+ )
+ Log.i("BluetoothController", "A2DP Sink ${stateName(prevState)} -> ${stateName(state)} device=$device")
+
+ when (state) {
+ BluetoothProfile.STATE_CONNECTED -> {
+ lastConnectedDevice = device
+ updateConnectionState()
+ }
+ BluetoothProfile.STATE_DISCONNECTED -> {
+ if (device == lastConnectedDevice) {
+ lastConnectedDevice = null
+ }
+ updateConnectionState()
+ }
+ else -> updateConnectionState()
+ }
+ }
+ BluetoothAdapter.ACTION_STATE_CHANGED -> {
+ updateBluetoothState()
+ }
+ }
+ }
+ }
+
+ init {
+ setupMediaSession()
+ if (bluetoothAdapter == null) {
+ _status.value = _status.value.copy(isSupported = false)
+ } else {
+ updateBluetoothState()
+ bluetoothAdapter.getProfileProxy(context, profileListener, A2DP_SINK)
+
+ val filter = IntentFilter().apply {
+ addAction("android.bluetooth.a2dp-sink.profile.action.CONNECTION_STATE_CHANGED")
+ addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
+ }
+ context.registerReceiver(receiver, filter)
+ }
+ }
+
+ private fun setupMediaSession() {
+ mediaSession = MediaSession(context, "AmbientLauncher").apply {
+ setCallback(object : MediaSession.Callback() {
+ // Diagnostic: Media controls disabled
+ })
+ val state = PlaybackState.Builder()
+ .setActions(0)
+ .setState(PlaybackState.STATE_STOPPED, PlaybackState.PLAYBACK_POSITION_UNKNOWN, 1.0f)
+ .build()
+ setPlaybackState(state)
+ isActive = false // Diagnostic: Do not activate by default
+ }
+ Log.d("BluetoothController", "MediaSession initialized (inactive)")
+ }
+
+ private fun updateBluetoothState() {
+ val enabled = bluetoothAdapter?.isEnabled == true
+ _status.value = _status.value.copy(isEnabled = enabled)
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun updateConnectionState() {
+ val sinkProxy = a2dpSinkProxy
+ val devices = sinkProxy?.connectedDevices ?: emptyList()
+ val device = devices.firstOrNull()
+
+ // Diagnostic build: No audio focus request or media session activation
+
+ _status.value = _status.value.copy(
+ connectedDeviceName = device?.name ?: device?.address
+ )
+ }
+
+ fun play() { /* Diagnostic stub */ }
+ fun pause() { /* Diagnostic stub */ }
+ fun next() { /* Diagnostic stub */ }
+ fun previous() { /* Diagnostic stub */ }
+
+ fun release() {
+ runCatching { context.unregisterReceiver(receiver) }
+ bluetoothAdapter?.closeProfileProxy(A2DP_SINK, a2dpSinkProxy)
+ mediaSession?.release()
+ mediaSession = null
+ Log.d("BluetoothController", "Released resources and media session")
+ }
+}
+
+/**
+ * Extra name for the BluetoothDevice parcelable in A2DP Sink broadcasts.
+ * On older Android versions the extra may use BluetoothDevice.EXTRA_DEVICE;
+ * this handles both.
+ */
+private val BluetoothDeviceExtra: String by lazy {
+ try {
+ // Use the standard BluetoothDevice.EXTRA_DEVICE constant
+ android.bluetooth.BluetoothDevice::class.java
+ .getField("EXTRA_DEVICE")
+ .get(null) as String
+ } catch (_: Exception) {
+ "android.bluetooth.device.extra.DEVICE"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/ambient/launcher/KeepAwakeService.kt b/app/src/main/java/com/ambient/launcher/KeepAwakeService.kt
new file mode 100644
index 0000000..2859e0e
--- /dev/null
+++ b/app/src/main/java/com/ambient/launcher/KeepAwakeService.kt
@@ -0,0 +1,134 @@
+package com.ambient.launcher
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.ServiceInfo
+import android.os.BatteryManager
+import android.os.Build
+import android.os.IBinder
+import android.os.PowerManager
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+
+class KeepAwakeService : Service() {
+ private var wakeLock: PowerManager.WakeLock? = null
+ private var mode: KeepAwakeMode = KeepAwakeMode.SYSTEM_DEFAULT
+ private var charging: Boolean = false
+
+ private val batteryReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ val status = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
+ charging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
+ status == BatteryManager.BATTERY_STATUS_FULL
+ updateWakeLock()
+ }
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannel()
+ val powerManager = getSystemService(POWER_SERVICE) as PowerManager
+ wakeLock = powerManager.newWakeLock(
+ PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP,
+ "AmbientLauncher:KeepAwakeGlobal"
+ )
+ val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
+ registerReceiver(batteryReceiver, filter)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val modeName = intent?.getStringExtra(EXTRA_MODE)
+ mode = KeepAwakeMode.entries.find { it.name == modeName } ?: KeepAwakeMode.SYSTEM_DEFAULT
+
+ if (mode == KeepAwakeMode.SYSTEM_DEFAULT || mode == KeepAwakeMode.WHILE_VISIBLE) {
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ promoteToForeground()
+ updateWakeLock()
+ return START_STICKY
+ }
+
+ private fun updateWakeLock() {
+ val shouldKeep = when (mode) {
+ KeepAwakeMode.ALWAYS -> true
+ KeepAwakeMode.WHILE_CHARGING -> charging
+ else -> false
+ }
+
+ if (shouldKeep) {
+ if (wakeLock?.isHeld == false) {
+ wakeLock?.acquire()
+ }
+ } else {
+ if (wakeLock?.isHeld == true) {
+ wakeLock?.release()
+ }
+ }
+ }
+
+ private fun promoteToForeground() {
+ val type = if (Build.VERSION.SDK_INT >= 34) {
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
+ } else 0
+
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ createNotification(),
+ type
+ )
+ }
+
+ private fun createNotification(): Notification {
+ val intent = Intent(this, MainActivity::class.java)
+ val pendingIntent = PendingIntent.getActivity(
+ this, 0, intent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
+ )
+
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.notification_keep_awake_title))
+ .setContentText(getString(R.string.notification_keep_awake_text))
+ .setSmallIcon(R.drawable.ic_launcher)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "Keep Awake Service",
+ NotificationManager.IMPORTANCE_LOW
+ )
+ val manager = getSystemService(NotificationManager::class.java)
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ override fun onDestroy() {
+ unregisterReceiver(batteryReceiver)
+ if (wakeLock?.isHeld == true) {
+ wakeLock?.release()
+ }
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ companion object {
+ const val EXTRA_MODE = "mode"
+ private const val CHANNEL_ID = "keep_awake_channel"
+ private const val NOTIFICATION_ID = 888
+ }
+}
diff --git a/app/src/main/java/com/ambient/launcher/LauncherViewModel.kt b/app/src/main/java/com/ambient/launcher/LauncherViewModel.kt
index 031ee1b..b817c94 100644
--- a/app/src/main/java/com/ambient/launcher/LauncherViewModel.kt
+++ b/app/src/main/java/com/ambient/launcher/LauncherViewModel.kt
@@ -4,6 +4,7 @@ import android.app.Application
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
+import android.location.Geocoder
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
@@ -17,26 +18,22 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-data class LauncherUiState(
- val settings: LauncherSettings = LauncherSettings(),
- val apps: List = emptyList(),
- val localImages: List = emptyList(),
- val weather: WeatherNow? = null,
- val weatherLoading: Boolean = false,
- val weatherError: String? = null,
- val wakeWord: WakeWordSnapshot = WakeWordSnapshot()
-)
-
class LauncherViewModel(application: Application) : AndroidViewModel(application) {
private val store = SettingsStore(application)
private val weatherRepository = WeatherRepository()
private val wakeWordController = WakeWordController(application)
+ // private val bluetoothController = BluetoothController(application)
private val _uiState = MutableStateFlow(LauncherUiState())
val uiState: StateFlow = _uiState.asStateFlow()
private var settingsLoaded = false
init {
loadApps()
+ /* viewModelScope.launch {
+ bluetoothController.status.collectLatest { status ->
+ _uiState.value = _uiState.value.copy(bluetooth = status)
+ }
+ } */
viewModelScope.launch {
wakeWordController.status.collectLatest { status ->
_uiState.value = _uiState.value.copy(wakeWord = status)
@@ -63,7 +60,10 @@ class LauncherViewModel(application: Application) : AndroidViewModel(application
_uiState.value = _uiState.value.copy(settings = settings)
wakeWordController.configure(
settings.wakeWord.enabled,
- settings.wakeWord.sensitivity
+ settings.wakeWord.sensitivity,
+ settings.wakeWord.autoCloseSeconds,
+ settings.wakeWord.toggleClose,
+ settings.language
)
if (
_uiState.value.wakeWord.modelState == ModelInstallState.READY &&
@@ -111,17 +111,36 @@ class LauncherViewModel(application: Application) : AndroidViewModel(application
fun refreshWeather() {
val settings = _uiState.value.settings
- val lat = settings.latitude.toDoubleOrNull()
- val lon = settings.longitude.toDoubleOrNull()
- if (lat == null || lon == null || lat !in -90.0..90.0 || lon !in -180.0..180.0) {
- _uiState.value = _uiState.value.copy(
- weatherLoading = false,
- weatherError = "Enter valid latitude and longitude."
- )
- return
- }
viewModelScope.launch {
_uiState.value = _uiState.value.copy(weatherLoading = true, weatherError = null)
+
+ val (lat, lon) = withContext(Dispatchers.IO) {
+ val latD = settings.latitude.toDoubleOrNull()
+ val lonD = settings.longitude.toDoubleOrNull()
+
+ if (latD != null && lonD != null && latD in -90.0..90.0 && lonD in -180.0..180.0) {
+ latD to lonD
+ } else if (settings.locationName.isNotBlank()) {
+ runCatching {
+ @Suppress("DEPRECATION")
+ Geocoder(getApplication()).getFromLocationName(settings.locationName, 1)
+ ?.firstOrNull()
+ ?.let { it.latitude to it.longitude }
+ }.getOrNull()
+ } else null
+ } ?: run {
+ _uiState.value = _uiState.value.copy(
+ weatherLoading = false,
+ weatherError = "Enter a city name or valid coordinates."
+ )
+ return@launch
+ }
+
+ // Update settings with found coordinates if they were missing/different
+ if (settings.latitude != lat.toString() || settings.longitude != lon.toString()) {
+ updateSettings { it.copy(latitude = lat.toString(), longitude = lon.toString()) }
+ }
+
runCatching { weatherRepository.load(lat, lon, settings.useFahrenheit) }
.onSuccess {
_uiState.value = _uiState.value.copy(
@@ -154,10 +173,31 @@ class LauncherViewModel(application: Application) : AndroidViewModel(application
}
}
+ fun setWakeWordAutoClose(seconds: Int) {
+ updateSettings { settings ->
+ settings.copy(wakeWord = settings.wakeWord.copy(autoCloseSeconds = seconds))
+ }
+ }
+
+ fun setWakeWordToggleClose(enabled: Boolean) {
+ updateSettings { settings ->
+ settings.copy(wakeWord = settings.wakeWord.copy(toggleClose = enabled))
+ }
+ }
+
+ fun setLanguage(language: AppLanguage) {
+ updateSettings { it.copy(language = language) }
+ }
+
fun pauseWakeWord() = wakeWordController.pause()
fun resumeWakeWord() = wakeWordController.resume()
fun testWakeWord() = wakeWordController.testDetection()
+ fun bluetoothPlay() { /* bluetoothController.play() */ }
+ fun bluetoothPause() { /* bluetoothController.pause() */ }
+ fun bluetoothNext() { /* bluetoothController.next() */ }
+ fun bluetoothPrevious() { /* bluetoothController.previous() */ }
+
fun launch(app: LauncherApp): Boolean {
val intent = Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
@@ -168,6 +208,11 @@ class LauncherViewModel(application: Application) : AndroidViewModel(application
}.isSuccess
}
+ override fun onCleared() {
+ // bluetoothController.release()
+ super.onCleared()
+ }
+
private fun loadApps() {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
diff --git a/app/src/main/java/com/ambient/launcher/MainActivity.kt b/app/src/main/java/com/ambient/launcher/MainActivity.kt
index 608935a..30e7754 100644
--- a/app/src/main/java/com/ambient/launcher/MainActivity.kt
+++ b/app/src/main/java/com/ambient/launcher/MainActivity.kt
@@ -1,6 +1,8 @@
package com.ambient.launcher
import android.Manifest
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothManager
import android.app.role.RoleManager
import android.content.BroadcastReceiver
import android.content.Context
@@ -15,6 +17,7 @@ import android.provider.Settings
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
@@ -53,16 +56,25 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Home
-import androidx.compose.material.icons.filled.Image as ImageIcon
+import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Mic
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.SkipNext
+import androidx.compose.material.icons.filled.SkipPrevious
+import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.filled.VolumeUp
+import androidx.compose.ui.res.stringResource
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
@@ -150,13 +162,17 @@ class MainActivity : ComponentActivity() {
}
setContent {
AmbientTheme {
- val state by viewModel.uiState.collectAsStateWithLifecycle()
- KeepAwakeEffect(state.settings.keepAwakeMode)
- AmbientLauncherApp(
- state = state,
- viewModel = viewModel,
- requestHomeRole = ::requestHomeRole
- )
+ Surface(color = MaterialTheme.colorScheme.background) {
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ CompositionLocalProvider(LocalAppLanguage provides state.settings.language) {
+ KeepAwakeEffect(state.settings.keepAwakeMode)
+ AmbientLauncherApp(
+ state = state,
+ viewModel = viewModel,
+ requestHomeRole = ::requestHomeRole
+ )
+ }
+ }
}
}
}
@@ -190,9 +206,15 @@ private fun AmbientTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = androidx.compose.material3.darkColorScheme(
primary = Color(0xFFB9D9FF),
+ onPrimary = Color(0xFF1B2D44),
secondary = Color(0xFFB8C8DA),
+ onSecondary = Color(0xFF233140),
surface = Color(0xFF11161D),
- surfaceVariant = Color(0xFF202832)
+ onSurface = Color(0xFFE1E2E8),
+ surfaceVariant = Color(0xFF202832),
+ onSurfaceVariant = Color(0xFFC1C7CE),
+ background = Color(0xFF000000),
+ onBackground = Color(0xFFE1E2E8)
),
content = content
)
@@ -203,23 +225,36 @@ private fun KeepAwakeEffect(mode: KeepAwakeMode) {
val context = LocalContext.current
val view = LocalView.current
var charging by remember { mutableStateOf(false) }
- DisposableEffect(context) {
+ DisposableEffect(context, mode) {
+ val serviceIntent = Intent(context, KeepAwakeService::class.java).apply {
+ putExtra(KeepAwakeService.EXTRA_MODE, mode.name)
+ }
+
+ if (mode == KeepAwakeMode.ALWAYS || mode == KeepAwakeMode.WHILE_CHARGING) {
+ ContextCompat.startForegroundService(context, serviceIntent)
+ } else {
+ context.stopService(serviceIntent)
+ }
+
fun update(intent: Intent?) {
val status = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
charging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
- status == BatteryManager.BATTERY_STATUS_FULL
+ status == BatteryManager.BATTERY_STATUS_FULL
}
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) = update(intent)
}
val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
update(context.registerReceiver(receiver, filter))
- onDispose { runCatching { context.unregisterReceiver(receiver) } }
+ onDispose {
+ runCatching { context.unregisterReceiver(receiver) }
+ }
}
LaunchedEffect(mode, charging, view) {
view.keepScreenOn = when (mode) {
KeepAwakeMode.WHILE_VISIBLE -> true
KeepAwakeMode.WHILE_CHARGING -> charging
+ KeepAwakeMode.ALWAYS -> true
KeepAwakeMode.SYSTEM_DEFAULT -> false
}
}
@@ -241,7 +276,11 @@ private fun AmbientLauncherApp(
onSettings = { screen = Screen.SETTINGS },
onRefreshWeather = viewModel::refreshWeather,
onTogglePinned = viewModel::togglePinned,
- onLaunchApp = viewModel::launch
+ onLaunchApp = viewModel::launch,
+ onBluetoothPlay = viewModel::bluetoothPlay,
+ onBluetoothPause = viewModel::bluetoothPause,
+ onBluetoothNext = viewModel::bluetoothNext,
+ onBluetoothPrevious = viewModel::bluetoothPrevious
)
Screen.SETTINGS -> SettingsScreen(
state = state,
@@ -252,6 +291,9 @@ private fun AmbientLauncherApp(
requestHomeRole = requestHomeRole,
setWakeWordEnabled = viewModel::setWakeWordEnabled,
setWakeWordSensitivity = viewModel::setWakeWordSensitivity,
+ setWakeWordAutoClose = viewModel::setWakeWordAutoClose,
+ setWakeWordToggleClose = viewModel::setWakeWordToggleClose,
+ setLanguage = viewModel::setLanguage,
pauseWakeWord = viewModel::pauseWakeWord,
resumeWakeWord = viewModel::resumeWakeWord,
testWakeWord = viewModel::testWakeWord
@@ -277,7 +319,11 @@ private fun HomeScreen(
onSettings: () -> Unit,
onRefreshWeather: () -> Unit,
onTogglePinned: (LauncherApp) -> Unit,
- onLaunchApp: (LauncherApp) -> Boolean
+ onLaunchApp: (LauncherApp) -> Boolean,
+ onBluetoothPlay: () -> Unit,
+ onBluetoothPause: () -> Unit,
+ onBluetoothNext: () -> Unit,
+ onBluetoothPrevious: () -> Unit
) {
var trayOpen by remember { mutableStateOf(false) }
var photoIndex by rememberSaveable { mutableIntStateOf(0) }
@@ -327,13 +373,13 @@ private fun HomeScreen(
targetState = if (showWeatherCard) "weather" else "photo-$photoIndex",
animationSpec = tween(1_400),
label = "slideshow"
- ) {
- if (showWeatherCard && state.weather != null) {
+ ) { currentTarget ->
+ if (currentTarget == "weather" && state.weather != null) {
FullWeatherSlide(state.weather, state.settings)
} else if (sources.isNotEmpty()) {
AsyncImage(
model = sources[photoIndex % sources.size],
- contentDescription = "Ambient photograph",
+ contentDescription = appStringResource(R.string.ambient_photograph),
placeholder = painterResource(R.drawable.ambient_fallback),
error = painterResource(R.drawable.ambient_fallback),
contentScale = ContentScale.Crop,
@@ -367,14 +413,14 @@ private fun HomeScreen(
modifier = Modifier.align(Alignment.TopStart).padding(24.dp),
colors = translucentIconColors()
) {
- Icon(Icons.Default.Apps, "Open applications")
+ Icon(Icons.Default.Apps, appStringResource(R.string.open_apps))
}
FilledIconButton(
onClick = onSettings,
modifier = Modifier.align(Alignment.TopEnd).padding(24.dp),
colors = translucentIconColors()
) {
- Icon(Icons.Default.Settings, "Open settings")
+ Icon(Icons.Default.Settings, appStringResource(R.string.open_settings))
}
if (!showWeatherCard) {
@@ -386,6 +432,17 @@ private fun HomeScreen(
onRefresh = onRefreshWeather,
modifier = clockAlignment(state.settings.clockCorner)
)
+
+ /* if (state.bluetooth.connectedDeviceName != null && !nightActive) {
+ MediaWidget(
+ state = state.bluetooth,
+ onPlay = onBluetoothPlay,
+ onPause = onBluetoothPause,
+ onNext = onBluetoothNext,
+ onPrevious = onBluetoothPrevious,
+ modifier = mediaAlignment(state.settings.clockCorner)
+ )
+ } */
}
}
@@ -416,6 +473,90 @@ private fun clockAlignment(corner: ClockCorner): Modifier = when (corner) {
ClockCorner.TOP_RIGHT -> Modifier.fillMaxSize().padding(28.dp, 92.dp).wrapContentSize(Alignment.TopEnd)
}
+private fun mediaAlignment(corner: ClockCorner): Modifier = when (corner) {
+ ClockCorner.BOTTOM_LEFT -> Modifier.fillMaxSize().padding(28.dp).wrapContentSize(Alignment.BottomEnd)
+ ClockCorner.BOTTOM_RIGHT -> Modifier.fillMaxSize().padding(28.dp).wrapContentSize(Alignment.BottomStart)
+ ClockCorner.TOP_LEFT -> Modifier.fillMaxSize().padding(28.dp, 92.dp).wrapContentSize(Alignment.TopEnd)
+ ClockCorner.TOP_RIGHT -> Modifier.fillMaxSize().padding(28.dp, 92.dp).wrapContentSize(Alignment.TopStart)
+}
+
+@Composable
+private fun MediaWidget(
+ state: BluetoothSnapshot,
+ onPlay: () -> Unit,
+ onPause: () -> Unit,
+ onNext: () -> Unit,
+ onPrevious: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ modifier = modifier
+ .width(280.dp)
+ .clip(RoundedCornerShape(24.dp)),
+ color = Color.Black.copy(alpha = .34f)
+ ) {
+ Column(Modifier.padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Default.Bluetooth,
+ null,
+ tint = Color(0xFFB9D9FF),
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(
+ state.connectedDeviceName ?: "",
+ fontSize = 12.sp,
+ color = Color.White.copy(alpha = 0.7f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ Spacer(Modifier.height(8.dp))
+ Text(
+ state.title ?: appStringResource(R.string.unknown_track),
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ state.artist ?: appStringResource(R.string.unknown_artist),
+ fontSize = 14.sp,
+ color = Color.White.copy(alpha = 0.8f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Spacer(Modifier.height(12.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onPrevious) {
+ Icon(Icons.Default.SkipPrevious, null, tint = Color.White)
+ }
+ Spacer(Modifier.width(8.dp))
+ FilledIconButton(
+ onClick = { if (state.isPlaying) onPause() else onPlay() },
+ colors = translucentIconColors()
+ ) {
+ Icon(
+ if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+ null,
+ tint = Color.White
+ )
+ }
+ Spacer(Modifier.width(8.dp))
+ IconButton(onClick = onNext) {
+ Icon(Icons.Default.SkipNext, null, tint = Color.White)
+ }
+ }
+ }
+ }
+}
+
@Composable
private fun ClockWeather(
now: LocalDateTime,
@@ -453,17 +594,19 @@ private fun ClockWeather(
if (weather != null) {
Text(
"${weatherGlyph(weather.weatherCode)} ${weather.temperature.roundToInt()}° " +
- "${weatherDescription(weather.weatherCode)}",
+ appStringResource(weatherDescription(weather.weatherCode)),
fontSize = 20.sp,
color = Color.White
)
Text(
- "${settings.locationName} H ${weather.high.roundToInt()}° L ${weather.low.roundToInt()}°",
+ "${settings.locationName} " +
+ appStringResource(R.string.temp_high, weather.high.roundToInt()) + " " +
+ appStringResource(R.string.temp_low, weather.low.roundToInt()),
fontSize = 14.sp,
color = Color.White.copy(alpha = .78f)
)
} else {
- TextButton(onClick = onRefresh) { Text("Weather unavailable · retry") }
+ TextButton(onClick = onRefresh) { Text(appStringResource(R.string.weather_unavailable) + " · " + appStringResource(R.string.retry)) }
}
}
}
@@ -489,31 +632,35 @@ private fun FullWeatherSlide(weather: WeatherNow, settings: LauncherSettings) {
Text("${weather.temperature.roundToInt()}°", fontSize = 88.sp, fontWeight = FontWeight.Light)
Spacer(Modifier.width(24.dp))
Column {
- Text(weatherDescription(weather.weatherCode), fontSize = 28.sp)
+ Text(appStringResource(weatherDescription(weather.weatherCode)), fontSize = 28.sp)
Text(
- "Feels ${weather.apparentTemperature.roundToInt()}° · " +
- "Humidity ${weather.humidity}% · Wind ${weather.windSpeed.roundToInt()}",
+ appStringResource(R.string.weather_feels, weather.apparentTemperature.roundToInt()) + " · " +
+ appStringResource(R.string.weather_humidity, weather.humidity) + " · " +
+ appStringResource(R.string.weather_wind, weather.windSpeed.roundToInt()),
color = Color.White.copy(alpha = .78f)
)
}
}
Spacer(Modifier.height(30.dp))
- Text("Next hours", fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
+ Text(appStringResource(R.string.next_hours), fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(vertical = 12.dp)
) {
items(weather.hourly) { hour ->
- Column(
- Modifier
- .clip(RoundedCornerShape(16.dp))
- .background(Color.White.copy(alpha = .11f))
- .padding(16.dp),
- horizontalAlignment = Alignment.CenterHorizontally
+ Surface(
+ shape = RoundedCornerShape(16.dp),
+ color = Color.White.copy(alpha = .11f),
+ contentColor = Color.White
) {
- Text(hour.time.takeLast(5))
- Text("${hour.temperature.roundToInt()}°", fontSize = 24.sp)
- Text("${hour.precipitationChance}% rain", fontSize = 12.sp)
+ Column(
+ Modifier.padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(hour.time.takeLast(5))
+ Text("${hour.temperature.roundToInt()}°", fontSize = 24.sp)
+ Text(appStringResource(R.string.rain_chance, hour.precipitationChance), fontSize = 12.sp)
+ }
}
}
}
@@ -559,16 +706,17 @@ private fun AppTray(
.fillMaxWidth(.78f)
.fillMaxHeight(.82f),
shape = RoundedCornerShape(28.dp),
- color = Color(0xF2181E25)
+ color = Color(0xF2181E25),
+ contentColor = Color.White
) {
Column(Modifier.padding(24.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
- if (showAll) "All applications" else "Quick launch",
+ if (showAll) appStringResource(R.string.all_applications) else appStringResource(R.string.quick_launch),
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f)
)
- IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, "Close") }
+ IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, appStringResource(R.string.close)) }
}
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
@@ -576,20 +724,20 @@ private fun AppTray(
onValueChange = { query = it },
singleLine = true,
leadingIcon = { Icon(Icons.Default.Search, null) },
- placeholder = { Text("Search apps") },
+ placeholder = { Text(appStringResource(R.string.search_apps)) },
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(12.dp))
OutlinedButton(onClick = { showAll = !showAll }) {
Icon(if (showAll) Icons.Default.Check else Icons.Default.Add, null)
Spacer(Modifier.width(6.dp))
- Text(if (showAll) "Done" else "Add app")
+ Text(if (showAll) appStringResource(R.string.done) else appStringResource(R.string.add_app))
}
}
Spacer(Modifier.height(18.dp))
if (visible.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Text(if (query.isBlank()) "No apps pinned yet." else "No matching apps.")
+ Text(if (query.isBlank()) appStringResource(R.string.no_apps_pinned) else appStringResource(R.string.no_matching_apps))
}
} else {
LazyVerticalGrid(
@@ -662,7 +810,23 @@ private fun AppTile(
}
}
-private enum class SettingsSection { DISPLAY, WEATHER, ROUTINES, VOICE, APPS, ACCESS }
+private enum class SettingsSection { DISPLAY, WEATHER, ROUTINES, VOICE, APPS, LANGUAGE, ACCESS }
+
+val LocalAppLanguage = staticCompositionLocalOf { AppLanguage.ENGLISH }
+
+@Composable
+private fun appStringResource(id: Int, vararg formatArgs: Any): String {
+ val language = LocalAppLanguage.current
+ val context = LocalContext.current
+
+ val targetLocale = when (language) {
+ AppLanguage.SPANISH, AppLanguage.SPANISH_ENGLISH -> Locale("es")
+ else -> Locale.ENGLISH
+ }
+
+ val conf = Configuration(context.resources.configuration).apply { setLocale(targetLocale) }
+ return context.createConfigurationContext(conf).getString(id, *formatArgs)
+}
@Composable
private fun SettingsScreen(
@@ -674,6 +838,9 @@ private fun SettingsScreen(
requestHomeRole: () -> Unit,
setWakeWordEnabled: (Boolean) -> Unit,
setWakeWordSensitivity: (Int) -> Unit,
+ setWakeWordAutoClose: (Int) -> Unit,
+ setWakeWordToggleClose: (Boolean) -> Unit,
+ setLanguage: (AppLanguage) -> Unit,
pauseWakeWord: () -> Unit,
resumeWakeWord: () -> Unit,
testWakeWord: () -> Unit
@@ -694,23 +861,36 @@ private fun SettingsScreen(
Row(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
NavigationRail(
header = {
- IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") }
+ IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, appStringResource(R.string.back)) }
}
) {
SettingsSection.entries.forEach { item ->
val icon = when (item) {
- SettingsSection.DISPLAY -> ImageIcon
+ SettingsSection.DISPLAY -> Icons.Default.Image
SettingsSection.WEATHER -> Icons.Default.Refresh
SettingsSection.ROUTINES -> Icons.Default.VolumeUp
SettingsSection.VOICE -> Icons.Default.Mic
SettingsSection.APPS -> Icons.Default.Apps
+ SettingsSection.LANGUAGE -> Icons.Default.Translate
SettingsSection.ACCESS -> Icons.Default.Info
}
NavigationRailItem(
selected = section == item,
onClick = { section = item },
icon = { Icon(icon, null) },
- label = { Text(item.name.lowercase().replaceFirstChar(Char::uppercase)) }
+ label = {
+ Text(
+ when (item) {
+ SettingsSection.DISPLAY -> appStringResource(R.string.display)
+ SettingsSection.WEATHER -> appStringResource(R.string.weather)
+ SettingsSection.ROUTINES -> appStringResource(R.string.routines)
+ SettingsSection.VOICE -> appStringResource(R.string.voice)
+ SettingsSection.APPS -> appStringResource(R.string.apps)
+ SettingsSection.LANGUAGE -> appStringResource(R.string.language)
+ SettingsSection.ACCESS -> appStringResource(R.string.access)
+ }
+ )
+ }
)
}
}
@@ -731,11 +911,14 @@ private fun SettingsScreen(
state = state,
setEnabled = setWakeWordEnabled,
setSensitivity = setWakeWordSensitivity,
+ setAutoClose = setWakeWordAutoClose,
+ setToggleClose = setWakeWordToggleClose,
pause = pauseWakeWord,
resume = resumeWakeWord,
test = testWakeWord
)
SettingsSection.APPS -> ApplicationsSettings(state, updateSettings)
+ SettingsSection.LANGUAGE -> LanguageSettings(state, setLanguage)
SettingsSection.ACCESS -> AccessSettings(requestHomeRole)
}
}
@@ -761,21 +944,22 @@ private fun DisplaySettings(
settings: LauncherSettings,
update: ((LauncherSettings) -> LauncherSettings) -> Unit,
onPickFolder: () -> Unit
-) = SettingsPage("Display") {
- SettingsCard("Keep screen awake", "Only applies while this launcher is visible.") {
+) = SettingsPage(appStringResource(R.string.display)) {
+ SettingsCard(appStringResource(R.string.keep_awake), appStringResource(R.string.keep_awake_detail)) {
ChoiceMenu(
current = settings.keepAwakeMode,
label = {
when (it) {
- KeepAwakeMode.WHILE_VISIBLE -> "While launcher is visible"
- KeepAwakeMode.WHILE_CHARGING -> "Only while charging"
- KeepAwakeMode.SYSTEM_DEFAULT -> "Follow Android timeout"
+ KeepAwakeMode.WHILE_VISIBLE -> appStringResource(R.string.keep_awake_visible)
+ KeepAwakeMode.WHILE_CHARGING -> appStringResource(R.string.keep_awake_charging)
+ KeepAwakeMode.ALWAYS -> appStringResource(R.string.keep_awake_always)
+ KeepAwakeMode.SYSTEM_DEFAULT -> appStringResource(R.string.keep_awake_default)
}
},
onSelect = { selected -> update { it.copy(keepAwakeMode = selected) } }
)
}
- SettingsCard("Photo interval", "${settings.intervalSeconds} seconds") {
+ SettingsCard(appStringResource(R.string.photo_interval), appStringResource(R.string.seconds, settings.intervalSeconds)) {
Slider(
value = settings.intervalSeconds.toFloat(),
onValueChange = { value ->
@@ -784,14 +968,14 @@ private fun DisplaySettings(
valueRange = 10f..900f
)
}
- SettingsCard("Image darkening", "${settings.darkenPercent}%") {
+ SettingsCard(appStringResource(R.string.image_darkening), "${settings.darkenPercent}%") {
Slider(
value = settings.darkenPercent.toFloat(),
onValueChange = { value -> update { it.copy(darkenPercent = value.roundToInt()) } },
valueRange = 0f..70f
)
}
- SettingsCard("Clock and weather corner", "The panel shifts subtly to reduce burn-in.") {
+ SettingsCard(appStringResource(R.string.clock_corner), appStringResource(R.string.clock_corner_detail)) {
ChoiceMenu(
current = settings.clockCorner,
label = { it.name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase) },
@@ -799,18 +983,18 @@ private fun DisplaySettings(
)
}
SettingsCard(
- "Photo source",
+ appStringResource(R.string.photo_source),
if (settings.localFolderUri.isBlank()) {
- "Curated online landscapes, cities, nature, and art."
+ appStringResource(R.string.photo_source_online)
} else {
- "Using the selected local folder."
+ appStringResource(R.string.photo_source_local)
}
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(onClick = onPickFolder) { Text("Choose local folder") }
+ Button(onClick = onPickFolder) { Text(appStringResource(R.string.choose_local_folder)) }
if (settings.localFolderUri.isNotBlank()) {
OutlinedButton(onClick = { update { it.copy(localFolderUri = "") } }) {
- Text("Use online collection")
+ Text(appStringResource(R.string.use_online_collection))
}
}
}
@@ -822,25 +1006,27 @@ private fun WeatherSettings(
state: LauncherUiState,
update: ((LauncherSettings) -> LauncherSettings) -> Unit,
refresh: () -> Unit
-) = SettingsPage("Weather") {
- SettingsCard("Show weather", "Compact conditions and periodic full forecast slides.") {
+) = SettingsPage(appStringResource(R.string.weather)) {
+ SettingsCard(appStringResource(R.string.show_weather), appStringResource(R.string.show_weather_detail)) {
Switch(
checked = state.settings.showWeather,
onCheckedChange = { checked -> update { it.copy(showWeather = checked) } }
)
}
- SettingsCard("Location", "Coordinates keep location permission unnecessary.") {
+ SettingsCard(appStringResource(R.string.location), appStringResource(R.string.location_detail)) {
OutlinedTextField(
value = state.settings.locationName,
onValueChange = { value -> update { it.copy(locationName = value) } },
- label = { Text("Location label") },
- singleLine = true
+ label = { Text(appStringResource(R.string.location_label)) },
+ placeholder = { Text(appStringResource(R.string.location_placeholder)) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = state.settings.latitude,
onValueChange = { value -> update { it.copy(latitude = value) } },
- label = { Text("Latitude") },
+ label = { Text(appStringResource(R.string.latitude)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
@@ -848,20 +1034,34 @@ private fun WeatherSettings(
OutlinedTextField(
value = state.settings.longitude,
onValueChange = { value -> update { it.copy(longitude = value) } },
- label = { Text("Longitude") },
+ label = { Text(appStringResource(R.string.longitude)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
}
- Button(onClick = refresh, enabled = !state.weatherLoading) {
- Icon(Icons.Default.Refresh, null)
- Spacer(Modifier.width(7.dp))
- Text(if (state.weatherLoading) "Refreshing…" else "Refresh weather")
+ Button(
+ onClick = refresh,
+ enabled = !state.weatherLoading,
+ modifier = Modifier.align(Alignment.End)
+ ) {
+ Icon(Icons.Default.Search, null)
+ Spacer(Modifier.width(8.dp))
+ Text(if (state.weatherLoading) appStringResource(R.string.searching) else appStringResource(R.string.search_refresh))
+ }
+ state.weatherError?.let {
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(top = 4.dp)
+ )
}
- state.weatherError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
- SettingsCard("Temperature unit", if (state.settings.useFahrenheit) "Fahrenheit" else "Celsius") {
+ SettingsCard(
+ appStringResource(R.string.temp_unit),
+ if (state.settings.useFahrenheit) appStringResource(R.string.fahrenheit) else appStringResource(R.string.celsius)
+ ) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("°C")
Switch(
@@ -875,8 +1075,8 @@ private fun WeatherSettings(
}
}
SettingsCard(
- "Full forecast frequency",
- "After every ${state.settings.weatherEveryPhotos} photos"
+ appStringResource(R.string.forecast_frequency),
+ appStringResource(R.string.after_every_photos, state.settings.weatherEveryPhotos)
) {
Slider(
value = state.settings.weatherEveryPhotos.toFloat(),
@@ -893,10 +1093,10 @@ private fun WeatherSettings(
private fun RoutineSettings(
settings: LauncherSettings,
update: ((LauncherSettings) -> LauncherSettings) -> Unit
-) = SettingsPage("Night routine") {
+) = SettingsPage(appStringResource(R.string.routines)) {
SettingsCard(
- "Scheduled ultra-dim",
- "Applies whenever the launcher is visible. Android is not awakened by this schedule."
+ appStringResource(R.string.ultra_dim),
+ appStringResource(R.string.ultra_dim_detail)
) {
Switch(
checked = settings.nightRoutineEnabled,
@@ -904,17 +1104,17 @@ private fun RoutineSettings(
)
}
SettingsCard(
- "Active hours",
+ appStringResource(R.string.active_hours),
"${formatHour(settings.nightStartHour)} – ${formatHour(settings.nightEndHour)}"
) {
- Text("Start")
+ Text(appStringResource(R.string.start))
Slider(
value = settings.nightStartHour.toFloat(),
onValueChange = { value -> update { it.copy(nightStartHour = value.roundToInt()) } },
valueRange = 0f..23f,
steps = 22
)
- Text("End")
+ Text(appStringResource(R.string.end))
Slider(
value = settings.nightEndHour.toFloat(),
onValueChange = { value -> update { it.copy(nightEndHour = value.roundToInt()) } },
@@ -922,13 +1122,13 @@ private fun RoutineSettings(
steps = 22
)
}
- SettingsCard("Ultra-dim strength", "${settings.nightDimPercent}%") {
+ SettingsCard(appStringResource(R.string.ultra_dim_strength), "${settings.nightDimPercent}%") {
Slider(
value = settings.nightDimPercent.toFloat(),
onValueChange = { value -> update { it.copy(nightDimPercent = value.roundToInt()) } },
valueRange = 50f..95f
)
- Text("Night mode also hides weather, disables image motion, and uses a dark-red clock.")
+ Text(appStringResource(R.string.night_mode_note))
}
}
@@ -937,10 +1137,12 @@ private fun VoiceSettings(
state: LauncherUiState,
setEnabled: (Boolean) -> Unit,
setSensitivity: (Int) -> Unit,
+ setAutoClose: (Int) -> Unit,
+ setToggleClose: (Boolean) -> Unit,
pause: () -> Unit,
resume: () -> Unit,
test: () -> Unit
-) = SettingsPage("Voice assistant") {
+) = SettingsPage(appStringResource(R.string.voice)) {
val context = LocalContext.current
val requiredPermissions = remember {
buildList {
@@ -958,9 +1160,8 @@ private fun VoiceSettings(
}
SettingsCard(
- "Listen for “Computer”",
- "Detection stays on-device. Continued listening in other apps requires a persistent " +
- "notification and Android’s microphone privacy indicator."
+ appStringResource(R.string.voice_listen),
+ appStringResource(R.string.voice_listen_detail)
) {
Switch(
checked = state.settings.wakeWord.enabled,
@@ -976,14 +1177,14 @@ private fun VoiceSettings(
)
Text(
when (state.wakeWord.state) {
- WakeWordState.DISABLED -> "Disabled"
- WakeWordState.DOWNLOADING -> "Downloading model"
- WakeWordState.INSTALLING -> "Installing model"
- WakeWordState.READY -> "Ready"
- WakeWordState.LISTENING -> "Listening"
- WakeWordState.PAUSED -> "Paused"
- WakeWordState.MICROPHONE_BUSY -> "Microphone busy; retrying"
- WakeWordState.ERROR -> "Error"
+ WakeWordState.DISABLED -> appStringResource(R.string.status_disabled)
+ WakeWordState.DOWNLOADING -> appStringResource(R.string.status_downloading)
+ WakeWordState.INSTALLING -> appStringResource(R.string.status_installing)
+ WakeWordState.READY -> appStringResource(R.string.status_ready)
+ WakeWordState.LISTENING -> appStringResource(R.string.status_listening)
+ WakeWordState.PAUSED -> appStringResource(R.string.status_paused)
+ WakeWordState.MICROPHONE_BUSY -> appStringResource(R.string.status_busy)
+ WakeWordState.ERROR -> appStringResource(R.string.status_error)
},
fontWeight = FontWeight.SemiBold
)
@@ -1002,8 +1203,8 @@ private fun VoiceSettings(
}
}
SettingsCard(
- "Sensitivity",
- "${state.settings.wakeWord.sensitivity}% · Higher values detect more easily but may false-trigger."
+ appStringResource(R.string.sensitivity),
+ appStringResource(R.string.sensitivity_detail)
) {
Slider(
value = state.settings.wakeWord.sensitivity.toFloat(),
@@ -1012,17 +1213,46 @@ private fun VoiceSettings(
)
}
SettingsCard(
- "Listening controls",
- "Testing releases this launcher’s microphone before opening the selected Android assistant."
+ appStringResource(R.string.auto_close),
+ if (state.settings.wakeWord.autoCloseSeconds > 0) {
+ appStringResource(R.string.auto_close_detail, state.settings.wakeWord.autoCloseSeconds)
+ } else appStringResource(R.string.auto_close_manual)
+ ) {
+ Slider(
+ value = state.settings.wakeWord.autoCloseSeconds.toFloat(),
+ onValueChange = { setAutoClose(it.roundToInt()) },
+ valueRange = 0f..120f,
+ steps = 23
+ )
+ }
+ SettingsCard(
+ appStringResource(R.string.toggle_wake),
+ appStringResource(R.string.toggle_wake_detail)
+ ) {
+ Switch(
+ checked = state.settings.wakeWord.toggleClose,
+ onCheckedChange = setToggleClose
+ )
+ if (state.settings.wakeWord.toggleClose) {
+ Text(
+ appStringResource(R.string.toggle_cooldown_note),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ SettingsCard(
+ appStringResource(R.string.listening_controls),
+ appStringResource(R.string.listening_controls_detail)
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (state.wakeWord.state == WakeWordState.PAUSED) {
- Button(onClick = resume) { Text("Resume") }
+ Button(onClick = resume) { Text(appStringResource(R.string.resume)) }
} else {
OutlinedButton(
onClick = pause,
enabled = state.settings.wakeWord.enabled
- ) { Text("Pause") }
+ ) { Text(appStringResource(R.string.pause)) }
}
Button(
onClick = test,
@@ -1031,7 +1261,7 @@ private fun VoiceSettings(
) {
Icon(Icons.Default.Mic, null)
Spacer(Modifier.width(7.dp))
- Text("Test assistant")
+ Text(appStringResource(R.string.test_assistant))
}
}
OutlinedButton(
@@ -1039,14 +1269,122 @@ private fun VoiceSettings(
runCatching { context.startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)) }
}
) {
- Text("Open Android voice settings")
+ Text(appStringResource(R.string.open_voice_settings))
}
}
SettingsCard(
- "Privacy",
- "Audio frames are processed locally and are never saved or transmitted."
+ appStringResource(R.string.privacy),
+ appStringResource(R.string.privacy_detail)
) {
- Text("Only the last detection time and service status are retained for diagnostics.")
+ Text(appStringResource(R.string.privacy_note))
+ }
+}
+
+@Composable
+private fun BluetoothSettings(state: BluetoothSnapshot) = SettingsPage(appStringResource(R.string.bluetooth)) {
+ val context = LocalContext.current
+ SettingsCard(
+ appStringResource(R.string.bluetooth_sink),
+ appStringResource(R.string.bluetooth_sink_detail)
+ ) {
+ if (!state.isSupported) {
+ Text(appStringResource(R.string.bluetooth_not_supported), color = MaterialTheme.colorScheme.error)
+ } else if (!state.isEnabled) {
+ Text(appStringResource(R.string.bluetooth_disabled), color = MaterialTheme.colorScheme.error)
+ Button(onClick = {
+ runCatching { context.startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) }
+ }) {
+ Text(appStringResource(R.string.open_bluetooth_settings))
+ }
+ } else {
+ Text(
+ if (state.connectedDeviceName != null) {
+ appStringResource(R.string.bluetooth_connected_to, state.connectedDeviceName)
+ } else {
+ appStringResource(R.string.bluetooth_ready)
+ },
+ fontWeight = FontWeight.Bold,
+ color = if (state.connectedDeviceName != null) Color(0xFF8ED49A) else MaterialTheme.colorScheme.primary
+ )
+
+ val bluetoothManager = context.getSystemService(BluetoothManager::class.java)
+ val bluetoothAdapter = bluetoothManager?.adapter
+ if (bluetoothAdapter != null) {
+ val hasPermission = if (Build.VERSION.SDK_INT >= 31) {
+ ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
+ } else true
+
+ val name = if (hasPermission) bluetoothAdapter.name else null
+ if (name != null) {
+ Text(
+ appStringResource(R.string.bluetooth_discoverable, name),
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+
+ OutlinedButton(onClick = {
+ runCatching { context.startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) }
+ }) {
+ Icon(Icons.Default.Bluetooth, null)
+ Spacer(Modifier.width(8.dp))
+ Text(appStringResource(R.string.open_bluetooth_settings))
+ }
+ }
+ }
+}
+
+@Composable
+private fun LanguageSettings(
+ state: LauncherUiState,
+ setLanguage: (AppLanguage) -> Unit
+) = SettingsPage(appStringResource(R.string.language)) {
+ val context = LocalContext.current
+ SettingsCard(
+ appStringResource(R.string.app_language),
+ appStringResource(R.string.language_settings_detail)
+ ) {
+ ChoiceMenu(
+ current = state.settings.language,
+ label = {
+ when (it) {
+ AppLanguage.ENGLISH -> appStringResource(R.string.lang_english)
+ AppLanguage.SPANISH -> appStringResource(R.string.lang_spanish)
+ AppLanguage.ENGLISH_SPANISH -> appStringResource(R.string.lang_english_spanish)
+ AppLanguage.SPANISH_ENGLISH -> appStringResource(R.string.lang_spanish_english)
+ }
+ },
+ onSelect = setLanguage
+ )
+ }
+
+ val systemLanguageMatch = remember(state.settings.language) {
+ val current = Locale.getDefault().language
+ when (state.settings.language) {
+ AppLanguage.SPANISH, AppLanguage.SPANISH_ENGLISH -> current == "es"
+ else -> current == "en"
+ }
+ }
+
+ SettingsCard(
+ appStringResource(R.string.system_language),
+ appStringResource(R.string.system_language_detail)
+ ) {
+ if (!systemLanguageMatch) {
+ Text(
+ text = appStringResource(R.string.system_language_warning),
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ Button(onClick = {
+ runCatching { context.startActivity(Intent(Settings.ACTION_LOCALE_SETTINGS)) }
+ }) {
+ Icon(Icons.Default.Translate, null)
+ Spacer(Modifier.width(7.dp))
+ Text(appStringResource(R.string.open_system_language))
+ }
}
}
@@ -1054,54 +1392,54 @@ private fun VoiceSettings(
private fun ApplicationsSettings(
state: LauncherUiState,
update: ((LauncherSettings) -> LauncherSettings) -> Unit
-) = SettingsPage("Applications") {
+) = SettingsPage(appStringResource(R.string.apps)) {
SettingsCard(
- "Quick launch",
- "${state.settings.pinnedComponents.size} app(s) pinned"
+ appStringResource(R.string.quick_launch),
+ appStringResource(R.string.apps_pinned, state.settings.pinnedComponents.size)
) {
- Text("Open the app tray from Home, then choose Add app to pin or remove applications.")
+ Text(appStringResource(R.string.apps_pinned_detail))
}
SettingsCard(
- "Privacy",
- "Recent-app tracking and usage access are disabled."
+ appStringResource(R.string.privacy),
+ appStringResource(R.string.apps_privacy_detail)
) {
- Text("This launcher only reads activities that advertise a launchable icon.")
+ Text(appStringResource(R.string.apps_privacy_note))
}
}
@Composable
-private fun AccessSettings(requestHomeRole: () -> Unit) = SettingsPage("System access") {
- SettingsCard("Default Home app", "Required for the hardware Home gesture to return here.") {
+private fun AccessSettings(requestHomeRole: () -> Unit) = SettingsPage(appStringResource(R.string.access)) {
+ SettingsCard(appStringResource(R.string.default_home), appStringResource(R.string.default_home_detail)) {
Button(onClick = requestHomeRole) {
Icon(Icons.Default.Home, null)
Spacer(Modifier.width(7.dp))
- Text("Make default Home app")
+ Text(appStringResource(R.string.make_default_home))
}
}
- PermissionStatus("Internet", true, "Used for weather and the built-in online photo collection.")
- PermissionStatus("Local photo folder", true, "Granted only to the folder you explicitly select.")
- PermissionStatus("Location", false, "Not requested. Weather uses coordinates entered in settings.")
+ PermissionStatus(appStringResource(R.string.permission_internet), true, appStringResource(R.string.permission_internet_detail))
+ PermissionStatus(appStringResource(R.string.permission_local_photo), true, appStringResource(R.string.permission_local_photo_detail))
+ PermissionStatus(appStringResource(R.string.permission_location), false, appStringResource(R.string.permission_location_detail))
PermissionStatus(
- "Microphone",
+ appStringResource(R.string.permission_microphone),
ContextCompat.checkSelfPermission(
LocalContext.current,
Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED,
- "Requested only when on-device wake-word listening is enabled."
+ appStringResource(R.string.permission_microphone_detail)
)
- PermissionStatus("Display over other apps", false, "Not requested. Ultra-dim only covers this launcher.")
- PermissionStatus("Usage access", false, "Not requested. Recent-app history is not collected.")
+ PermissionStatus(appStringResource(R.string.permission_overlay), false, appStringResource(R.string.permission_overlay_detail))
+ PermissionStatus(appStringResource(R.string.permission_usage), false, appStringResource(R.string.permission_usage_detail))
PermissionStatus(
- "Notifications",
+ appStringResource(R.string.permission_notifications),
Build.VERSION.SDK_INT < 33 || ContextCompat.checkSelfPermission(
LocalContext.current,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED,
- "Used for the visible listening service and assistant fallback action."
+ appStringResource(R.string.permission_notifications_detail)
)
SettingsCard(
- "Audio output",
- "A normal launcher cannot force other apps to use a specific device."
+ appStringResource(R.string.audio_output),
+ appStringResource(R.string.audio_output_detail)
) {
val context = LocalContext.current
OutlinedButton(
@@ -1112,7 +1450,7 @@ private fun AccessSettings(requestHomeRole: () -> Unit) = SettingsPage("System a
) {
Icon(Icons.Default.VolumeUp, null)
Spacer(Modifier.width(7.dp))
- Text("Open Android sound settings")
+ Text(appStringResource(R.string.open_sound_settings))
}
}
}
@@ -1121,7 +1459,7 @@ private fun AccessSettings(requestHomeRole: () -> Unit) = SettingsPage("System a
private fun PermissionStatus(title: String, enabled: Boolean, detail: String) {
SettingsCard(title, detail) {
Text(
- if (enabled) "Available" else "Not requested",
+ if (enabled) appStringResource(R.string.available) else appStringResource(R.string.not_requested),
color = if (enabled) Color(0xFF8ED49A) else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold
)
@@ -1156,7 +1494,7 @@ private fun SettingsCard(
@Composable
private inline fun > ChoiceMenu(
current: T,
- noinline label: (T) -> String,
+ noinline label: @Composable (T) -> String,
crossinline onSelect: (T) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
diff --git a/app/src/main/java/com/ambient/launcher/Models.kt b/app/src/main/java/com/ambient/launcher/Models.kt
index 266e77b..22c63b7 100644
--- a/app/src/main/java/com/ambient/launcher/Models.kt
+++ b/app/src/main/java/com/ambient/launcher/Models.kt
@@ -3,6 +3,26 @@ package com.ambient.launcher
import android.content.ComponentName
import android.graphics.drawable.Drawable
+data class LauncherUiState(
+ val settings: LauncherSettings = LauncherSettings(),
+ val apps: List = emptyList(),
+ val localImages: List = emptyList(),
+ val weather: WeatherNow? = null,
+ val weatherLoading: Boolean = false,
+ val weatherError: String? = null,
+ val wakeWord: WakeWordSnapshot = WakeWordSnapshot(),
+ val bluetooth: BluetoothSnapshot = BluetoothSnapshot()
+)
+
+data class BluetoothSnapshot(
+ val connectedDeviceName: String? = null,
+ val isPlaying: Boolean = false,
+ val artist: String? = null,
+ val title: String? = null,
+ val isSupported: Boolean = true,
+ val isEnabled: Boolean = false
+)
+
data class LauncherApp(
val label: String,
val component: ComponentName,
@@ -34,15 +54,18 @@ data class DailyWeather(
val weatherCode: Int
)
-enum class KeepAwakeMode { WHILE_VISIBLE, WHILE_CHARGING, SYSTEM_DEFAULT }
+enum class KeepAwakeMode { WHILE_VISIBLE, WHILE_CHARGING, ALWAYS, SYSTEM_DEFAULT }
enum class ClockCorner { BOTTOM_LEFT, BOTTOM_RIGHT, TOP_LEFT, TOP_RIGHT }
+enum class AppLanguage { ENGLISH, SPANISH, ENGLISH_SPANISH, SPANISH_ENGLISH }
enum class ModelInstallState { NOT_INSTALLED, DOWNLOADING, INSTALLING, READY, FAILED }
enum class WakeWordState { DISABLED, DOWNLOADING, INSTALLING, READY, LISTENING, PAUSED, MICROPHONE_BUSY, ERROR }
data class WakeWordSettings(
val enabled: Boolean = false,
val sensitivity: Int = 50,
- val installedModelVersion: String = ""
+ val installedModelVersion: String = "",
+ val autoCloseSeconds: Int = 60,
+ val toggleClose: Boolean = false
)
data class WakeWordSnapshot(
@@ -70,6 +93,7 @@ data class LauncherSettings(
val nightStartHour: Int = 23,
val nightEndHour: Int = 7,
val nightDimPercent: Int = 88,
+ val language: AppLanguage = AppLanguage.ENGLISH,
val wakeWord: WakeWordSettings = WakeWordSettings()
)
@@ -78,18 +102,18 @@ sealed interface Slide {
data object Weather : Slide
}
-fun weatherDescription(code: Int): String = when (code) {
- 0 -> "Clear"
- 1, 2 -> "Partly cloudy"
- 3 -> "Overcast"
- 45, 48 -> "Fog"
- 51, 53, 55, 56, 57 -> "Drizzle"
- 61, 63, 65, 66, 67 -> "Rain"
- 71, 73, 75, 77 -> "Snow"
- 80, 81, 82 -> "Rain showers"
- 85, 86 -> "Snow showers"
- 95, 96, 99 -> "Thunderstorm"
- else -> "Unknown"
+fun weatherDescription(code: Int): Int = when (code) {
+ 0 -> R.string.weather_clear
+ 1, 2 -> R.string.weather_partly_cloudy
+ 3 -> R.string.weather_overcast
+ 45, 48 -> R.string.weather_fog
+ 51, 53, 55, 56, 57 -> R.string.weather_drizzle
+ 61, 63, 65, 66, 67 -> R.string.weather_rain
+ 71, 73, 75, 77 -> R.string.weather_snow
+ 80, 81, 82 -> R.string.weather_rain_showers
+ 85, 86 -> R.string.weather_snow_showers
+ 95, 96, 99 -> R.string.weather_thunderstorm
+ else -> R.string.weather_unknown
}
fun weatherGlyph(code: Int): String = when (code) {
diff --git a/app/src/main/java/com/ambient/launcher/SettingsStore.kt b/app/src/main/java/com/ambient/launcher/SettingsStore.kt
index 95e3967..d7198db 100644
--- a/app/src/main/java/com/ambient/launcher/SettingsStore.kt
+++ b/app/src/main/java/com/ambient/launcher/SettingsStore.kt
@@ -29,9 +29,12 @@ class SettingsStore(private val context: Context) {
val nightStart = intPreferencesKey("night_start")
val nightEnd = intPreferencesKey("night_end")
val nightDim = intPreferencesKey("night_dim")
+ val language = stringPreferencesKey("language")
val wakeEnabled = booleanPreferencesKey("wake_enabled")
val wakeSensitivity = intPreferencesKey("wake_sensitivity")
val wakeModelVersion = stringPreferencesKey("wake_model_version")
+ val wakeAutoClose = intPreferencesKey("wake_auto_close")
+ val wakeToggleClose = booleanPreferencesKey("wake_toggle_close")
}
val settings: Flow = context.dataStore.data.map { p ->
@@ -56,10 +59,13 @@ class SettingsStore(private val context: Context) {
nightStartHour = p[Keys.nightStart] ?: 23,
nightEndHour = p[Keys.nightEnd] ?: 7,
nightDimPercent = p[Keys.nightDim] ?: 88,
+ language = enumOrDefault(p[Keys.language], AppLanguage.ENGLISH),
wakeWord = WakeWordSettings(
enabled = p[Keys.wakeEnabled] ?: false,
sensitivity = p[Keys.wakeSensitivity] ?: 50,
- installedModelVersion = p[Keys.wakeModelVersion] ?: ""
+ installedModelVersion = p[Keys.wakeModelVersion] ?: "",
+ autoCloseSeconds = p[Keys.wakeAutoClose] ?: 60,
+ toggleClose = p[Keys.wakeToggleClose] ?: false
)
)
}
@@ -82,9 +88,12 @@ class SettingsStore(private val context: Context) {
p[Keys.nightStart] = value.nightStartHour
p[Keys.nightEnd] = value.nightEndHour
p[Keys.nightDim] = value.nightDimPercent
+ p[Keys.language] = value.language.name
p[Keys.wakeEnabled] = value.wakeWord.enabled
p[Keys.wakeSensitivity] = value.wakeWord.sensitivity
p[Keys.wakeModelVersion] = value.wakeWord.installedModelVersion
+ p[Keys.wakeAutoClose] = value.wakeWord.autoCloseSeconds
+ p[Keys.wakeToggleClose] = value.wakeWord.toggleClose
}
}
diff --git a/app/src/main/java/com/ambient/launcher/voice/DefaultAssistantLauncher.kt b/app/src/main/java/com/ambient/launcher/voice/DefaultAssistantLauncher.kt
index cb1b019..eef0908 100644
--- a/app/src/main/java/com/ambient/launcher/voice/DefaultAssistantLauncher.kt
+++ b/app/src/main/java/com/ambient/launcher/voice/DefaultAssistantLauncher.kt
@@ -4,6 +4,7 @@ import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
+import android.provider.Settings
enum class AssistLaunchResult { LAUNCHED, NOT_CONFIGURED, BACKGROUND_BLOCKED }
@@ -32,6 +33,22 @@ class DefaultAssistantLauncher(private val context: Context) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
- private fun assistantIntent() = Intent(Intent.ACTION_ASSIST)
- .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ private fun assistantIntent(): Intent {
+ val alexaPackage = "com.amazon.dee.app"
+ val currentAssistant = Settings.Secure.getString(context.contentResolver, "assistant")
+
+ // If Alexa is the default assistant, try to pre-activate it using VOICE_COMMAND
+ if (currentAssistant?.contains(alexaPackage) == true) {
+ val voiceCommandIntent = Intent(Intent.ACTION_VOICE_COMMAND)
+ .setPackage(alexaPackage)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+
+ if (voiceCommandIntent.resolveActivity(context.packageManager) != null) {
+ return voiceCommandIntent
+ }
+ }
+
+ return Intent(Intent.ACTION_ASSIST)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ }
}
diff --git a/app/src/main/java/com/ambient/launcher/voice/WakeWordController.kt b/app/src/main/java/com/ambient/launcher/voice/WakeWordController.kt
index 7e92fdd..a2907f2 100644
--- a/app/src/main/java/com/ambient/launcher/voice/WakeWordController.kt
+++ b/app/src/main/java/com/ambient/launcher/voice/WakeWordController.kt
@@ -12,6 +12,7 @@ import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
+import com.ambient.launcher.AppLanguage
import com.ambient.launcher.ModelInstallState
import com.ambient.launcher.WakeWordSnapshot
import com.ambient.launcher.WakeWordState
@@ -46,6 +47,9 @@ class WakeWordController(private val context: Context) {
private var enabled = false
private var visible = false
private var sensitivity = 50
+ private var autoCloseSeconds = 0
+ private var toggleClose = false
+ private var language = AppLanguage.ENGLISH
init {
val lastDetection = context.getSharedPreferences("wake_runtime", Context.MODE_PRIVATE)
@@ -96,16 +100,28 @@ class WakeWordController(private val context: Context) {
}
}
- fun configure(isEnabled: Boolean, newSensitivity: Int) {
+ fun configure(
+ isEnabled: Boolean,
+ newSensitivity: Int,
+ newAutoClose: Int = 0,
+ newToggleClose: Boolean = false,
+ newLanguage: AppLanguage = AppLanguage.ENGLISH
+ ) {
enabled = isEnabled
sensitivity = newSensitivity.coerceIn(0, 100)
+ autoCloseSeconds = newAutoClose
+ toggleClose = newToggleClose
+ language = newLanguage
if (!enabled) disable()
else if (visible) enable()
}
fun setVisible(isVisible: Boolean) {
visible = isVisible
- if (visible && enabled) enable()
+ if (visible) {
+ if (enabled) enable()
+ sendAction(WakeWordService.ACTION_REPORT_VISIBLE)
+ }
}
fun enable() {
@@ -139,6 +155,9 @@ class WakeWordController(private val context: Context) {
val intent = Intent(context, WakeWordService::class.java)
.setAction(WakeWordService.ACTION_START)
.putExtra(WakeWordService.EXTRA_SENSITIVITY, sensitivity)
+ .putExtra(WakeWordService.EXTRA_AUTO_CLOSE, autoCloseSeconds)
+ .putExtra(WakeWordService.EXTRA_TOGGLE_CLOSE, toggleClose)
+ .putExtra(WakeWordService.EXTRA_LANGUAGE, language.name)
runCatching { ContextCompat.startForegroundService(context, intent) }
.onFailure {
WakeWordRuntime.update(
diff --git a/app/src/main/java/com/ambient/launcher/voice/WakeWordModel.kt b/app/src/main/java/com/ambient/launcher/voice/WakeWordModel.kt
index 9bf2001..df06f90 100644
--- a/app/src/main/java/com/ambient/launcher/voice/WakeWordModel.kt
+++ b/app/src/main/java/com/ambient/launcher/voice/WakeWordModel.kt
@@ -44,6 +44,38 @@ object WakeWordModel {
fun path(context: Context, name: String) = File(directory(context), name).absolutePath
private fun File.readTextOrNull(): String? = runCatching { readText() }.getOrNull()
+
+ fun createComputerKeyword(tokensFile: File, outputFile: File) {
+ val tokens = tokensFile.readLines().mapIndexedNotNull { index, line ->
+ val columns = line.trim().split(Regex("\\s+"))
+ val piece = columns.firstOrNull()?.takeIf { it.isNotBlank() } ?: return@mapIndexedNotNull null
+ piece to (columns.getOrNull(1)?.toIntOrNull() ?: index)
+ }
+ val target = "▁COMPUTER"
+ data class Candidate(val pieces: List>, val score: Int)
+ val best = arrayOfNulls(target.length + 1)
+ best[0] = Candidate(emptyList(), 0)
+ for (start in target.indices) {
+ val current = best[start] ?: continue
+ tokens.forEach { token ->
+ val piece = token.first
+ if (target.startsWith(piece, start)) {
+ val end = start + piece.length
+ val candidate = Candidate(current.pieces + token, current.score + token.second)
+ val previous = best[end]
+ if (
+ previous == null ||
+ candidate.pieces.size < previous.pieces.size ||
+ candidate.pieces.size == previous.pieces.size && candidate.score < previous.score
+ ) {
+ best[end] = candidate
+ }
+ }
+ }
+ }
+ val pieces = best[target.length]?.pieces ?: error("Model cannot tokenize COMPUTER")
+ outputFile.writeText(pieces.joinToString(" ") { it.first } + "\n")
+ }
}
class WakeWordModelWorker(
@@ -72,7 +104,10 @@ class WakeWordModelWorker(
staging.deleteRecursively()
staging.mkdirs()
extractSelected(archive, staging)
- createComputerKeyword(File(staging, "tokens.txt"), File(staging, "keywords.txt"))
+ WakeWordModel.createComputerKeyword(
+ File(staging, "tokens.txt"),
+ File(staging, "keywords.txt")
+ )
if (!WakeWordModel.requiredFiles.all { File(staging, it).isFile }) {
error("Model archive is incomplete")
}
@@ -151,38 +186,6 @@ class WakeWordModelWorker(
}
}
- private fun createComputerKeyword(tokensFile: File, output: File) {
- val tokens = tokensFile.readLines().mapIndexedNotNull { index, line ->
- val columns = line.trim().split(Regex("\\s+"))
- val piece = columns.firstOrNull()?.takeIf { it.isNotBlank() } ?: return@mapIndexedNotNull null
- piece to (columns.getOrNull(1)?.toIntOrNull() ?: index)
- }
- val target = "▁COMPUTER"
- data class Candidate(val pieces: List>, val score: Int)
- val best = arrayOfNulls(target.length + 1)
- best[0] = Candidate(emptyList(), 0)
- for (start in target.indices) {
- val current = best[start] ?: continue
- tokens.forEach { token ->
- val piece = token.first
- if (target.startsWith(piece, start)) {
- val end = start + piece.length
- val candidate = Candidate(current.pieces + token, current.score + token.second)
- val previous = best[end]
- if (
- previous == null ||
- candidate.pieces.size < previous.pieces.size ||
- candidate.pieces.size == previous.pieces.size && candidate.score < previous.score
- ) {
- best[end] = candidate
- }
- }
- }
- }
- val pieces = best[target.length]?.pieces ?: error("Model cannot tokenize COMPUTER")
- output.writeText(pieces.joinToString(" ") { it.first } + "\n")
- }
-
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered().use { input ->
diff --git a/app/src/main/java/com/ambient/launcher/voice/WakeWordService.kt b/app/src/main/java/com/ambient/launcher/voice/WakeWordService.kt
index 678338b..f2dfe31 100644
--- a/app/src/main/java/com/ambient/launcher/voice/WakeWordService.kt
+++ b/app/src/main/java/com/ambient/launcher/voice/WakeWordService.kt
@@ -19,6 +19,7 @@ import android.os.Looper
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
+import com.ambient.launcher.AppLanguage
import com.ambient.launcher.MainActivity
import com.ambient.launcher.R
import com.ambient.launcher.SettingsStore
@@ -46,7 +47,12 @@ class WakeWordService : Service() {
@Volatile private var listening = false
@Volatile private var paused = false
private var sensitivity = 50
+ private var autoCloseSeconds = 0
+ private var toggleClose = false
+ private var language = AppLanguage.ENGLISH
+ private var isAssistantActive = false
private var lastDetection = 0L
+ private val autoCloseRunnable = Runnable { returnToLauncher() }
override fun onCreate() {
super.onCreate()
@@ -61,6 +67,10 @@ class WakeWordService : Service() {
intent.getIntExtra(EXTRA_SENSITIVITY, 50).coerceIn(0, 100)
if (requestedSensitivity != sensitivity && listening) stopListening()
sensitivity = requestedSensitivity
+ autoCloseSeconds = intent.getIntExtra(EXTRA_AUTO_CLOSE, 0)
+ toggleClose = intent.getBooleanExtra(EXTRA_TOGGLE_CLOSE, false)
+ val langName = intent.getStringExtra(EXTRA_LANGUAGE)
+ language = AppLanguage.entries.find { it.name == langName } ?: AppLanguage.ENGLISH
paused = false
startListening()
}
@@ -77,6 +87,10 @@ class WakeWordService : Service() {
stopSelf()
}
}
+ ACTION_REPORT_VISIBLE -> {
+ isAssistantActive = false
+ mainHandler.removeCallbacks(autoCloseRunnable)
+ }
ACTION_TEST -> handleDetection(force = true)
else -> {
WakeWordRuntime.update(
@@ -214,6 +228,16 @@ class WakeWordService : Service() {
private fun handleDetection(force: Boolean) {
val now = System.currentTimeMillis()
if (!force && now - lastDetection < DETECTION_DEBOUNCE_MS) return
+
+ if (!force && toggleClose && isAssistantActive) {
+ lastDetection = now
+ returnToLauncher()
+ if (!paused) {
+ mainHandler.postDelayed({ startListening() }, toggleCooldown())
+ }
+ return
+ }
+
lastDetection = now
getSharedPreferences("wake_runtime", MODE_PRIVATE)
.edit()
@@ -227,10 +251,29 @@ class WakeWordService : Service() {
)
val launcher = DefaultAssistantLauncher(this)
val result = launcher.launch()
- if (result != AssistLaunchResult.LAUNCHED) showAssistantFallback(launcher)
- if (!paused) mainHandler.postDelayed({ startListening() }, ASSISTANT_COOLDOWN_MS)
+ if (result == AssistLaunchResult.LAUNCHED) {
+ isAssistantActive = true
+ if (autoCloseSeconds > 0) {
+ mainHandler.removeCallbacks(autoCloseRunnable)
+ mainHandler.postDelayed(autoCloseRunnable, autoCloseSeconds * 1000L)
+ }
+ } else {
+ showAssistantFallback(launcher)
+ }
+ if (!paused) mainHandler.postDelayed({ startListening() }, toggleCooldown())
}
+ private fun returnToLauncher() {
+ isAssistantActive = false
+ mainHandler.removeCallbacks(autoCloseRunnable)
+ val intent = Intent(Intent.ACTION_MAIN)
+ .addCategory(Intent.CATEGORY_HOME)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ runCatching { startActivity(intent) }
+ }
+
+ private fun toggleCooldown(): Long = if (toggleClose) 2_500L else ASSISTANT_COOLDOWN_MS
+
private fun pauseListening() {
paused = true
stopListening()
@@ -340,8 +383,12 @@ class WakeWordService : Service() {
const val ACTION_PAUSE = "com.ambient.launcher.voice.PAUSE"
const val ACTION_RESUME = "com.ambient.launcher.voice.RESUME"
const val ACTION_STOP = "com.ambient.launcher.voice.STOP"
+ const val ACTION_REPORT_VISIBLE = "com.ambient.launcher.voice.REPORT_VISIBLE"
const val ACTION_TEST = "com.ambient.launcher.voice.TEST"
const val EXTRA_SENSITIVITY = "sensitivity"
+ const val EXTRA_AUTO_CLOSE = "auto_close"
+ const val EXTRA_TOGGLE_CLOSE = "toggle_close"
+ const val EXTRA_LANGUAGE = "language"
private const val SAMPLE_RATE = 16_000
private const val DETECTION_DEBOUNCE_MS = 5_000L
private const val ASSISTANT_COOLDOWN_MS = 12_000L
diff --git a/app/src/main/res/drawable/ambient_fallback.xml b/app/src/main/res/drawable/ambient_fallback.xml
index 0e243a0..b0b07ca 100644
--- a/app/src/main/res/drawable/ambient_fallback.xml
+++ b/app/src/main/res/drawable/ambient_fallback.xml
@@ -1,8 +1,21 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
new file mode 100644
index 0000000..e2a4517
--- /dev/null
+++ b/app/src/main/res/values-es/strings.xml
@@ -0,0 +1,191 @@
+
+ Ambient Launcher
+
+
+ Atrás
+ Cerrar
+ Hecho
+ reintentar
+
+
+ Abrir aplicaciones
+ Abrir ajustes
+ Clima no disponible
+ Próximas horas
+ %1$d%% lluvia
+ Fotografía de ambiente
+
+
+ Todas las aplicaciones
+ Inicio rápido
+ Buscar aplicaciones
+ Añadir aplicación
+ No hay aplicaciones fijadas.
+ No hay aplicaciones coincidentes.
+
+
+ Pantalla
+ Clima
+ Rutina nocturna
+ Asistente de voz
+ Aplicaciones
+ Bluetooth
+ Acceso al sistema
+ Idioma
+
+
+ Mantener pantalla encendida
+ Mantiene la pantalla encendida globalmente o según la visibilidad.
+ Mientras el lanzador sea visible
+ Solo durante la carga (Global)
+ Siempre (Global)
+ Seguir tiempo de espera de Android
+ Intervalo de fotos
+ %1$d segundos
+ Oscurecimiento de imagen
+ Esquina de reloj y clima
+ El panel se desplaza sutilmente para reducir el desgaste.
+ Fuente de fotos
+ Paisajes, ciudades, naturaleza y arte seleccionados en línea.
+ Usando la carpeta local seleccionada.
+ Elegir carpeta local
+ Usar colección en línea
+
+
+ Mostrar clima
+ Condiciones compactas y diapositivas de pronóstico completo periódicas.
+ Ubicación
+ Introduzca el nombre de una ciudad o coordenadas. Las coordenadas se rellenan automáticamente al buscar por ciudad.
+ Ciudad, Estado/País
+ ej. Madrid, ES
+ Latitud
+ Longitud
+ Buscando…
+ Buscar y actualizar
+ Unidad de temperatura
+ Frecuencia de pronóstico completo
+ Después de cada %1$d fotos
+
+
+ Atenuación programada
+ Se aplica siempre que el lanzador es visible. Android no se despierta por este horario.
+ Horas activas
+ Inicio
+ Fin
+ Fuerza de atenuación
+ El modo nocturno también oculta el clima, desactiva el movimiento de imagen y usa un reloj rojo oscuro.
+
+
+ Escuchar “Computer”
+ La detección permanece en el dispositivo. Seguir escuchando en otras aplicaciones requiere una notificación persistente y el indicador de privacidad del micrófono de Android.
+ Desactivado
+ Descargando modelo
+ Instalando modelo
+ Listo
+ Escuchando
+ Pausado
+ Micrófono ocupado; reintentando
+ Error
+ Sensibilidad
+ Valores más altos detectan más fácilmente pero pueden causar activaciones falsas.
+ Cierre automático del asistente
+ Regresar automáticamente al lanzador después de %1$d segundos.
+ El asistente permanecerá abierto hasta que se cierre manualmente.
+ Alternar con palabra de activación
+ Si el asistente ya está abierto, decir “Computer” de nuevo lo cerrará y regresará aquí.
+ El tiempo de espera de escucha se reduce a 2.5s para alternar más rápido.
+ Controles de escucha
+ La prueba libera el micrófono de este lanzador antes de abrir el asistente de Android seleccionado.
+ Reanudar
+ Pausar
+ Probar asistente
+ Abrir ajustes de voz de Android
+ Privacidad
+ Los marcos de audio se procesan localmente y nunca se guardan ni transmiten.
+ Solo se conservan el último tiempo de detección y el estado del servicio para diagnóstico.
+
+
+ %1$d aplicación(es) fijada(s)
+ Abra la bandeja de aplicaciones desde Inicio, luego elija Añadir aplicación para fijar o quitar aplicaciones.
+ El seguimiento de aplicaciones recientes y el acceso de uso están desactivados.
+ Este lanzador solo lee las actividades que anuncian un icono ejecutable.
+
+
+ Aplicación de inicio predeterminada
+ Requerido para que el gesto de Inicio del hardware regrese aquí.
+ Establecer como aplicación de inicio
+ Internet
+ Se usa para el clima y la colección de fotos en línea integrada.
+ Carpeta de fotos local
+ Concedido solo a la carpeta que seleccione explíitamente.
+ Ubicación
+ No se solicita. El clima usa las coordenadas introducidas en los ajustes.
+ Micrófono
+ Se solicita solo cuando la escucha de palabra de activación en el dispositivo está habilitada.
+ Mostrar sobre otras aplicaciones
+ No se solicita. La atenuación ultra solo cubre este lanzador.
+ Acceso de uso
+ No se solicita. No se recoge el historial de aplicaciones recientes.
+ Notificaciones
+ Se usa para el servicio de escucha visible y la acción de respaldo del asistente.
+ Salida de audio
+ Un lanzador normal no puede forzar a otras aplicaciones a usar un dispositivo específico.
+ Abrir ajustes de sonido de Android
+ Disponible
+ No solicitado
+
+
+ Controles de idioma de la aplicación e idioma del sistema.
+ Idioma de la aplicación
+ Inglés
+ Español
+ Inglés-Español
+ Español-Inglés
+ Idioma del sistema
+ Cambiar los ajustes de idioma del sistema Android.
+ Abrir ajustes de idioma del sistema
+ El idioma del sistema no está actualizado
+
+
+ Receptor de Audio
+ Permite que otros dispositivos se conecten y reproduzcan música a través de esta tableta.
+ Estado de Bluetooth
+ Bluetooth no es compatible con este dispositivo.
+ Bluetooth está desactivado.
+ Listo para conectar.
+ Conectado a %s
+ Visible como “%s”
+ Abrir ajustes de Bluetooth
+ Reproduciendo ahora
+ Artista desconocido
+ Pista desconocida
+
+
+ Despejado
+ Parcialmente nublado
+ Nublado
+ Niebla
+ Llovizna
+ Lluvia
+ Nieve
+ Chubascos
+ Nevadas
+ Tormenta
+ Desconocido
+ Sensación %1$d°
+ Humedad %1$d%%
+ Viento %1$d
+ Máx %1$d°
+ Mín %1$d°
+ Fahrenheit
+ Celsius
+
+
+ Escuchando “Computer”
+ Toca para volver a Ambient Launcher
+ Escucha pausada
+ Palabra de activación detectada
+ Toca para abrir tu asistente predeterminado
+ Mantener pantalla encendida activo
+ Este servicio evita que la pantalla se apague.
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 04c2798..de7a571 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,191 @@
Ambient Launcher
+
+
+ Back
+ Close
+ Done
+ retry
+
+
+ Open applications
+ Open settings
+ Weather unavailable
+ Next hours
+ %1$d%% rain
+ Ambient photograph
+
+
+ All applications
+ Quick launch
+ Search apps
+ Add app
+ No apps pinned yet.
+ No matching apps.
+
+
+ Display
+ Weather
+ Night routine
+ Voice assistant
+ Applications
+ Bluetooth
+ System access
+ Language
+
+
+ Keep screen awake
+ Maintains the screen awake state globally or based on visibility.
+ While launcher is visible
+ Only while charging (Global)
+ Always (Global)
+ Follow Android timeout
+ Photo interval
+ %1$d seconds
+ Image darkening
+ Clock and weather corner
+ The panel shifts subtly to reduce burn-in.
+ Photo source
+ Curated online landscapes, cities, nature, and art.
+ Using the selected local folder.
+ Choose local folder
+ Use online collection
+
+
+ Show weather
+ Compact conditions and periodic full forecast slides.
+ Location
+ Enter a city name or coordinates. Coordinates are auto-filled when searching by city.
+ City, State/Country
+ e.g. London, UK
+ Latitude
+ Longitude
+ Searching…
+ Search & Refresh
+ Temperature unit
+ Full forecast frequency
+ After every %1$d photos
+
+
+ Scheduled ultra-dim
+ Applies whenever the launcher is visible. Android is not awakened by this schedule.
+ Active hours
+ Start
+ End
+ Ultra-dim strength
+ Night mode also hides weather, disables image motion, and uses a dark-red clock.
+
+
+ Listen for “Computer”
+ Detection stays on-device. Continued listening in other apps requires a persistent notification and Android’s microphone privacy indicator.
+ Disabled
+ Downloading model
+ Installing model
+ Ready
+ Listening
+ Paused
+ Microphone busy; retrying
+ Error
+ Sensitivity
+ Higher values detect more easily but may false-trigger.
+ Auto-close assistant
+ Automatically return to launcher after %1$d seconds.
+ Assistant will stay open until manually dismissed.
+ Toggle with wake word
+ If the assistant is already open, saying “Computer” again will close it and return here.
+ Listening cooldown is reduced to 2.5s for faster toggling.
+ Listening controls
+ Testing releases this launcher’s microphone before opening the selected Android assistant.
+ Resume
+ Pause
+ Test assistant
+ Open Android voice settings
+ Privacy
+ Audio frames are processed locally and are never saved or transmitted.
+ Only the last detection time and service status are retained for diagnostics.
+
+
+ %1$d app(s) pinned
+ Open the app tray from Home, then choose Add app to pin or remove applications.
+ Recent-app tracking and usage access are disabled.
+ This launcher only reads activities that advertise a launchable icon.
+
+
+ Default Home app
+ Required for the hardware Home gesture to return here.
+ Make default Home app
+ Internet
+ Used for weather and the built-in online photo collection.
+ Local photo folder
+ Granted only to the folder you explicitly select.
+ Location
+ Not requested. Weather uses coordinates entered in settings.
+ Microphone
+ Requested only when on-device wake-word listening is enabled.
+ Display over other apps
+ Not requested. Ultra-dim only covers this launcher.
+ Usage access
+ Not requested. Recent-app history is not collected.
+ Notifications
+ Used for the visible listening service and assistant fallback action.
+ Audio output
+ A normal launcher cannot force other apps to use a specific device.
+ Open Android sound settings
+ Available
+ Not requested
+
+
+ App language and system language controls.
+ App Language
+ English
+ Spanish
+ English-Spanish
+ Spanish-English
+ System Language
+ Change Android system language settings.
+ Open system language settings
+ System language is not up to date
+
+
+ Audio Receiver (Sink)
+ Allow other devices to connect and play music through this tablet.
+ Bluetooth Status
+ Bluetooth is not supported on this device.
+ Bluetooth is turned off.
+ Ready to connect.
+ Connected to %s
+ Discoverable as “%s”
+ Open Bluetooth settings
+ Now Playing
+ Unknown Artist
+ Unknown Track
+
+
+ Clear
+ Partly cloudy
+ Overcast
+ Fog
+ Drizzle
+ Rain
+ Snow
+ Rain showers
+ Snow showers
+ Thunderstorm
+ Unknown
+ Feels %1$d°
+ Humidity %1$d%%
+ Wind %1$d
+ H %1$d°
+ L %1$d°
+ Fahrenheit
+ Celsius
+
+
+ Listening for “Computer”
+ Tap to return to Ambient Launcher
+ Wake word paused
+ Wake word detected
+ Tap to open your default assistant
+ Keep screen awake active
+ This service keeps your screen from timing out.
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..61285a6
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..19a6bde
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100644
index 0000000..adff685
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..c4bdd3a
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,93 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/view.xml b/view.xml
new file mode 100644
index 0000000..2232cfb
--- /dev/null
+++ b/view.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/view_home.xml b/view_home.xml
new file mode 100644
index 0000000..2232cfb
--- /dev/null
+++ b/view_home.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/view_launcher.xml b/view_launcher.xml
new file mode 100644
index 0000000..1935ab2
--- /dev/null
+++ b/view_launcher.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/view_launcher_v2.xml b/view_launcher_v2.xml
new file mode 100644
index 0000000..037192b
--- /dev/null
+++ b/view_launcher_v2.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/view_launcher_v3.xml b/view_launcher_v3.xml
new file mode 100644
index 0000000..d4a8938
--- /dev/null
+++ b/view_launcher_v3.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/view_settings.xml b/view_settings.xml
new file mode 100644
index 0000000..d4a8938
--- /dev/null
+++ b/view_settings.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/view_settings_v2.xml b/view_settings_v2.xml
new file mode 100644
index 0000000..e4a787a
--- /dev/null
+++ b/view_settings_v2.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/view_voice.xml b/view_voice.xml
new file mode 100644
index 0000000..e4a787a
--- /dev/null
+++ b/view_voice.xml
@@ -0,0 +1 @@
+
\ No newline at end of file