Add new XML layout files for view settings, view settings v2, and voice interface

- Created view_settings.xml to define the layout for the settings view.
- Added view_settings_v2.xml for an updated version of the settings layout.
- Introduced view_voice.xml to establish the layout for the voice interface.
This commit is contained in:
jahruz67
2026-07-25 17:44:27 -07:00
parent 3e1c2e714a
commit 874a243e80
27 changed files with 1793 additions and 210 deletions
+19
View File
@@ -5,14 +5,23 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<package android:name="com.amazon.dee.app" />
</queries>
<application
@@ -45,5 +54,15 @@
android:exported="false"
android:foregroundServiceType="microphone"
android:stopWithTask="false" />
<service
android:name=".KeepAwakeService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:stopWithTask="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintains the screen awake state based on user settings across all applications." />
</service>
</application>
</manifest>
@@ -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<BluetoothSnapshot> = _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<android.bluetooth.BluetoothDevice>(
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"
}
}
@@ -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
}
}
@@ -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<LauncherApp> = emptyList(),
val localImages: List<Any> = 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<LauncherUiState> = _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) {
File diff suppressed because it is too large Load Diff
@@ -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<LauncherApp> = emptyList(),
val localImages: List<Any> = 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) {
@@ -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<LauncherSettings> = 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
}
}
@@ -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)
}
}
@@ -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(
@@ -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<Pair<String, Int>>, val score: Int)
val best = arrayOfNulls<Candidate>(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<Pair<String, Int>>, val score: Int)
val best = arrayOfNulls<Candidate>(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 ->
@@ -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
+21 -8
View File
@@ -1,8 +1,21 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="315"
android:endColor="#16243A"
android:startColor="#594755"
android:type="linear" />
</shape>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M0,0h24v24h-24z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="0"
android:startY="0"
android:endX="24"
android:endY="24"
android:type="linear">
<item android:offset="0" android:color="#594755" />
<item android:offset="1" android:color="#16243A" />
</gradient>
</aapt:attr>
</path>
</vector>
+191
View File
@@ -0,0 +1,191 @@
<resources>
<string name="app_name">Ambient Launcher</string>
<!-- General -->
<string name="back">Atrás</string>
<string name="close">Cerrar</string>
<string name="done">Hecho</string>
<string name="retry">reintentar</string>
<!-- Home Screen -->
<string name="open_apps">Abrir aplicaciones</string>
<string name="open_settings">Abrir ajustes</string>
<string name="weather_unavailable">Clima no disponible</string>
<string name="next_hours">Próximas horas</string>
<string name="rain_chance">%1$d%% lluvia</string>
<string name="ambient_photograph">Fotografía de ambiente</string>
<!-- App Tray -->
<string name="all_applications">Todas las aplicaciones</string>
<string name="quick_launch">Inicio rápido</string>
<string name="search_apps">Buscar aplicaciones</string>
<string name="add_app">Añadir aplicación</string>
<string name="no_apps_pinned">No hay aplicaciones fijadas.</string>
<string name="no_matching_apps">No hay aplicaciones coincidentes.</string>
<!-- Settings Sections -->
<string name="display">Pantalla</string>
<string name="weather">Clima</string>
<string name="routines">Rutina nocturna</string>
<string name="voice">Asistente de voz</string>
<string name="apps">Aplicaciones</string>
<string name="bluetooth">Bluetooth</string>
<string name="access">Acceso al sistema</string>
<string name="language">Idioma</string>
<!-- Display Settings -->
<string name="keep_awake">Mantener pantalla encendida</string>
<string name="keep_awake_detail">Mantiene la pantalla encendida globalmente o según la visibilidad.</string>
<string name="keep_awake_visible">Mientras el lanzador sea visible</string>
<string name="keep_awake_charging">Solo durante la carga (Global)</string>
<string name="keep_awake_always">Siempre (Global)</string>
<string name="keep_awake_default">Seguir tiempo de espera de Android</string>
<string name="photo_interval">Intervalo de fotos</string>
<string name="seconds">%1$d segundos</string>
<string name="image_darkening">Oscurecimiento de imagen</string>
<string name="clock_corner">Esquina de reloj y clima</string>
<string name="clock_corner_detail">El panel se desplaza sutilmente para reducir el desgaste.</string>
<string name="photo_source">Fuente de fotos</string>
<string name="photo_source_online">Paisajes, ciudades, naturaleza y arte seleccionados en línea.</string>
<string name="photo_source_local">Usando la carpeta local seleccionada.</string>
<string name="choose_local_folder">Elegir carpeta local</string>
<string name="use_online_collection">Usar colección en línea</string>
<!-- Weather Settings -->
<string name="show_weather">Mostrar clima</string>
<string name="show_weather_detail">Condiciones compactas y diapositivas de pronóstico completo periódicas.</string>
<string name="location">Ubicación</string>
<string name="location_detail">Introduzca el nombre de una ciudad o coordenadas. Las coordenadas se rellenan automáticamente al buscar por ciudad.</string>
<string name="location_label">Ciudad, Estado/País</string>
<string name="location_placeholder">ej. Madrid, ES</string>
<string name="latitude">Latitud</string>
<string name="longitude">Longitud</string>
<string name="searching">Buscando…</string>
<string name="search_refresh">Buscar y actualizar</string>
<string name="temp_unit">Unidad de temperatura</string>
<string name="forecast_frequency">Frecuencia de pronóstico completo</string>
<string name="after_every_photos">Después de cada %1$d fotos</string>
<!-- Routine Settings -->
<string name="ultra_dim">Atenuación programada</string>
<string name="ultra_dim_detail">Se aplica siempre que el lanzador es visible. Android no se despierta por este horario.</string>
<string name="active_hours">Horas activas</string>
<string name="start">Inicio</string>
<string name="end">Fin</string>
<string name="ultra_dim_strength">Fuerza de atenuación</string>
<string name="night_mode_note">El modo nocturno también oculta el clima, desactiva el movimiento de imagen y usa un reloj rojo oscuro.</string>
<!-- Voice Settings -->
<string name="voice_listen">Escuchar “Computer”</string>
<string name="voice_listen_detail">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.</string>
<string name="status_disabled">Desactivado</string>
<string name="status_downloading">Descargando modelo</string>
<string name="status_installing">Instalando modelo</string>
<string name="status_ready">Listo</string>
<string name="status_listening">Escuchando</string>
<string name="status_paused">Pausado</string>
<string name="status_busy">Micrófono ocupado; reintentando</string>
<string name="status_error">Error</string>
<string name="sensitivity">Sensibilidad</string>
<string name="sensitivity_detail">Valores más altos detectan más fácilmente pero pueden causar activaciones falsas.</string>
<string name="auto_close">Cierre automático del asistente</string>
<string name="auto_close_detail">Regresar automáticamente al lanzador después de %1$d segundos.</string>
<string name="auto_close_manual">El asistente permanecerá abierto hasta que se cierre manualmente.</string>
<string name="toggle_wake">Alternar con palabra de activación</string>
<string name="toggle_wake_detail">Si el asistente ya está abierto, decir “Computer” de nuevo lo cerrará y regresará aquí.</string>
<string name="toggle_cooldown_note">El tiempo de espera de escucha se reduce a 2.5s para alternar más rápido.</string>
<string name="listening_controls">Controles de escucha</string>
<string name="listening_controls_detail">La prueba libera el micrófono de este lanzador antes de abrir el asistente de Android seleccionado.</string>
<string name="resume">Reanudar</string>
<string name="pause">Pausar</string>
<string name="test_assistant">Probar asistente</string>
<string name="open_voice_settings">Abrir ajustes de voz de Android</string>
<string name="privacy">Privacidad</string>
<string name="privacy_detail">Los marcos de audio se procesan localmente y nunca se guardan ni transmiten.</string>
<string name="privacy_note">Solo se conservan el último tiempo de detección y el estado del servicio para diagnóstico.</string>
<!-- Applications Settings -->
<string name="apps_pinned">%1$d aplicación(es) fijada(s)</string>
<string name="apps_pinned_detail">Abra la bandeja de aplicaciones desde Inicio, luego elija Añadir aplicación para fijar o quitar aplicaciones.</string>
<string name="apps_privacy_detail">El seguimiento de aplicaciones recientes y el acceso de uso están desactivados.</string>
<string name="apps_privacy_note">Este lanzador solo lee las actividades que anuncian un icono ejecutable.</string>
<!-- Access Settings -->
<string name="default_home">Aplicación de inicio predeterminada</string>
<string name="default_home_detail">Requerido para que el gesto de Inicio del hardware regrese aquí.</string>
<string name="make_default_home">Establecer como aplicación de inicio</string>
<string name="permission_internet">Internet</string>
<string name="permission_internet_detail">Se usa para el clima y la colección de fotos en línea integrada.</string>
<string name="permission_local_photo">Carpeta de fotos local</string>
<string name="permission_local_photo_detail">Concedido solo a la carpeta que seleccione explíitamente.</string>
<string name="permission_location">Ubicación</string>
<string name="permission_location_detail">No se solicita. El clima usa las coordenadas introducidas en los ajustes.</string>
<string name="permission_microphone">Micrófono</string>
<string name="permission_microphone_detail">Se solicita solo cuando la escucha de palabra de activación en el dispositivo está habilitada.</string>
<string name="permission_overlay">Mostrar sobre otras aplicaciones</string>
<string name="permission_overlay_detail">No se solicita. La atenuación ultra solo cubre este lanzador.</string>
<string name="permission_usage">Acceso de uso</string>
<string name="permission_usage_detail">No se solicita. No se recoge el historial de aplicaciones recientes.</string>
<string name="permission_notifications">Notificaciones</string>
<string name="permission_notifications_detail">Se usa para el servicio de escucha visible y la acción de respaldo del asistente.</string>
<string name="audio_output">Salida de audio</string>
<string name="audio_output_detail">Un lanzador normal no puede forzar a otras aplicaciones a usar un dispositivo específico.</string>
<string name="open_sound_settings">Abrir ajustes de sonido de Android</string>
<string name="available">Disponible</string>
<string name="not_requested">No solicitado</string>
<!-- Language Settings -->
<string name="language_settings_detail">Controles de idioma de la aplicación e idioma del sistema.</string>
<string name="app_language">Idioma de la aplicación</string>
<string name="lang_english">Inglés</string>
<string name="lang_spanish">Español</string>
<string name="lang_english_spanish">Inglés-Español</string>
<string name="lang_spanish_english">Español-Inglés</string>
<string name="system_language">Idioma del sistema</string>
<string name="system_language_detail">Cambiar los ajustes de idioma del sistema Android.</string>
<string name="open_system_language">Abrir ajustes de idioma del sistema</string>
<string name="system_language_warning">El idioma del sistema no está actualizado</string>
<!-- Bluetooth Settings -->
<string name="bluetooth_sink">Receptor de Audio</string>
<string name="bluetooth_sink_detail">Permite que otros dispositivos se conecten y reproduzcan música a través de esta tableta.</string>
<string name="bluetooth_status">Estado de Bluetooth</string>
<string name="bluetooth_not_supported">Bluetooth no es compatible con este dispositivo.</string>
<string name="bluetooth_disabled">Bluetooth está desactivado.</string>
<string name="bluetooth_ready">Listo para conectar.</string>
<string name="bluetooth_connected_to">Conectado a %s</string>
<string name="bluetooth_discoverable">Visible como “%s”</string>
<string name="open_bluetooth_settings">Abrir ajustes de Bluetooth</string>
<string name="now_playing">Reproduciendo ahora</string>
<string name="unknown_artist">Artista desconocido</string>
<string name="unknown_track">Pista desconocida</string>
<!-- Weather Descriptions -->
<string name="weather_clear">Despejado</string>
<string name="weather_partly_cloudy">Parcialmente nublado</string>
<string name="weather_overcast">Nublado</string>
<string name="weather_fog">Niebla</string>
<string name="weather_drizzle">Llovizna</string>
<string name="weather_rain">Lluvia</string>
<string name="weather_snow">Nieve</string>
<string name="weather_rain_showers">Chubascos</string>
<string name="weather_snow_showers">Nevadas</string>
<string name="weather_thunderstorm">Tormenta</string>
<string name="weather_unknown">Desconocido</string>
<string name="weather_feels">Sensación %1$d°</string>
<string name="weather_humidity">Humedad %1$d%%</string>
<string name="weather_wind">Viento %1$d</string>
<string name="temp_high">Máx %1$d°</string>
<string name="temp_low">Mín %1$d°</string>
<string name="fahrenheit">Fahrenheit</string>
<string name="celsius">Celsius</string>
<!-- Service and Notifications -->
<string name="notification_listening_title">Escuchando “Computer”</string>
<string name="notification_listening_text">Toca para volver a Ambient Launcher</string>
<string name="notification_paused_title">Escucha pausada</string>
<string name="notification_detected_title">Palabra de activación detectada</string>
<string name="notification_fallback_text">Toca para abrir tu asistente predeterminado</string>
<string name="notification_keep_awake_title">Mantener pantalla encendida activo</string>
<string name="notification_keep_awake_text">Este servicio evita que la pantalla se apague.</string>
</resources>
+188
View File
@@ -1,3 +1,191 @@
<resources>
<string name="app_name">Ambient Launcher</string>
<!-- General -->
<string name="back">Back</string>
<string name="close">Close</string>
<string name="done">Done</string>
<string name="retry">retry</string>
<!-- Home Screen -->
<string name="open_apps">Open applications</string>
<string name="open_settings">Open settings</string>
<string name="weather_unavailable">Weather unavailable</string>
<string name="next_hours">Next hours</string>
<string name="rain_chance">%1$d%% rain</string>
<string name="ambient_photograph">Ambient photograph</string>
<!-- App Tray -->
<string name="all_applications">All applications</string>
<string name="quick_launch">Quick launch</string>
<string name="search_apps">Search apps</string>
<string name="add_app">Add app</string>
<string name="no_apps_pinned">No apps pinned yet.</string>
<string name="no_matching_apps">No matching apps.</string>
<!-- Settings Sections -->
<string name="display">Display</string>
<string name="weather">Weather</string>
<string name="routines">Night routine</string>
<string name="voice">Voice assistant</string>
<string name="apps">Applications</string>
<string name="bluetooth">Bluetooth</string>
<string name="access">System access</string>
<string name="language">Language</string>
<!-- Display Settings -->
<string name="keep_awake">Keep screen awake</string>
<string name="keep_awake_detail">Maintains the screen awake state globally or based on visibility.</string>
<string name="keep_awake_visible">While launcher is visible</string>
<string name="keep_awake_charging">Only while charging (Global)</string>
<string name="keep_awake_always">Always (Global)</string>
<string name="keep_awake_default">Follow Android timeout</string>
<string name="photo_interval">Photo interval</string>
<string name="seconds">%1$d seconds</string>
<string name="image_darkening">Image darkening</string>
<string name="clock_corner">Clock and weather corner</string>
<string name="clock_corner_detail">The panel shifts subtly to reduce burn-in.</string>
<string name="photo_source">Photo source</string>
<string name="photo_source_online">Curated online landscapes, cities, nature, and art.</string>
<string name="photo_source_local">Using the selected local folder.</string>
<string name="choose_local_folder">Choose local folder</string>
<string name="use_online_collection">Use online collection</string>
<!-- Weather Settings -->
<string name="show_weather">Show weather</string>
<string name="show_weather_detail">Compact conditions and periodic full forecast slides.</string>
<string name="location">Location</string>
<string name="location_detail">Enter a city name or coordinates. Coordinates are auto-filled when searching by city.</string>
<string name="location_label">City, State/Country</string>
<string name="location_placeholder">e.g. London, UK</string>
<string name="latitude">Latitude</string>
<string name="longitude">Longitude</string>
<string name="searching">Searching…</string>
<string name="search_refresh">Search &amp; Refresh</string>
<string name="temp_unit">Temperature unit</string>
<string name="forecast_frequency">Full forecast frequency</string>
<string name="after_every_photos">After every %1$d photos</string>
<!-- Routine Settings -->
<string name="ultra_dim">Scheduled ultra-dim</string>
<string name="ultra_dim_detail">Applies whenever the launcher is visible. Android is not awakened by this schedule.</string>
<string name="active_hours">Active hours</string>
<string name="start">Start</string>
<string name="end">End</string>
<string name="ultra_dim_strength">Ultra-dim strength</string>
<string name="night_mode_note">Night mode also hides weather, disables image motion, and uses a dark-red clock.</string>
<!-- Voice Settings -->
<string name="voice_listen">Listen for “Computer”</string>
<string name="voice_listen_detail">Detection stays on-device. Continued listening in other apps requires a persistent notification and Androids microphone privacy indicator.</string>
<string name="status_disabled">Disabled</string>
<string name="status_downloading">Downloading model</string>
<string name="status_installing">Installing model</string>
<string name="status_ready">Ready</string>
<string name="status_listening">Listening</string>
<string name="status_paused">Paused</string>
<string name="status_busy">Microphone busy; retrying</string>
<string name="status_error">Error</string>
<string name="sensitivity">Sensitivity</string>
<string name="sensitivity_detail">Higher values detect more easily but may false-trigger.</string>
<string name="auto_close">Auto-close assistant</string>
<string name="auto_close_detail">Automatically return to launcher after %1$d seconds.</string>
<string name="auto_close_manual">Assistant will stay open until manually dismissed.</string>
<string name="toggle_wake">Toggle with wake word</string>
<string name="toggle_wake_detail">If the assistant is already open, saying “Computer” again will close it and return here.</string>
<string name="toggle_cooldown_note">Listening cooldown is reduced to 2.5s for faster toggling.</string>
<string name="listening_controls">Listening controls</string>
<string name="listening_controls_detail">Testing releases this launchers microphone before opening the selected Android assistant.</string>
<string name="resume">Resume</string>
<string name="pause">Pause</string>
<string name="test_assistant">Test assistant</string>
<string name="open_voice_settings">Open Android voice settings</string>
<string name="privacy">Privacy</string>
<string name="privacy_detail">Audio frames are processed locally and are never saved or transmitted.</string>
<string name="privacy_note">Only the last detection time and service status are retained for diagnostics.</string>
<!-- Applications Settings -->
<string name="apps_pinned">%1$d app(s) pinned</string>
<string name="apps_pinned_detail">Open the app tray from Home, then choose Add app to pin or remove applications.</string>
<string name="apps_privacy_detail">Recent-app tracking and usage access are disabled.</string>
<string name="apps_privacy_note">This launcher only reads activities that advertise a launchable icon.</string>
<!-- Access Settings -->
<string name="default_home">Default Home app</string>
<string name="default_home_detail">Required for the hardware Home gesture to return here.</string>
<string name="make_default_home">Make default Home app</string>
<string name="permission_internet">Internet</string>
<string name="permission_internet_detail">Used for weather and the built-in online photo collection.</string>
<string name="permission_local_photo">Local photo folder</string>
<string name="permission_local_photo_detail">Granted only to the folder you explicitly select.</string>
<string name="permission_location">Location</string>
<string name="permission_location_detail">Not requested. Weather uses coordinates entered in settings.</string>
<string name="permission_microphone">Microphone</string>
<string name="permission_microphone_detail">Requested only when on-device wake-word listening is enabled.</string>
<string name="permission_overlay">Display over other apps</string>
<string name="permission_overlay_detail">Not requested. Ultra-dim only covers this launcher.</string>
<string name="permission_usage">Usage access</string>
<string name="permission_usage_detail">Not requested. Recent-app history is not collected.</string>
<string name="permission_notifications">Notifications</string>
<string name="permission_notifications_detail">Used for the visible listening service and assistant fallback action.</string>
<string name="audio_output">Audio output</string>
<string name="audio_output_detail">A normal launcher cannot force other apps to use a specific device.</string>
<string name="open_sound_settings">Open Android sound settings</string>
<string name="available">Available</string>
<string name="not_requested">Not requested</string>
<!-- Language Settings -->
<string name="language_settings_detail">App language and system language controls.</string>
<string name="app_language">App Language</string>
<string name="lang_english">English</string>
<string name="lang_spanish">Spanish</string>
<string name="lang_english_spanish">English-Spanish</string>
<string name="lang_spanish_english">Spanish-English</string>
<string name="system_language">System Language</string>
<string name="system_language_detail">Change Android system language settings.</string>
<string name="open_system_language">Open system language settings</string>
<string name="system_language_warning">System language is not up to date</string>
<!-- Bluetooth Settings -->
<string name="bluetooth_sink">Audio Receiver (Sink)</string>
<string name="bluetooth_sink_detail">Allow other devices to connect and play music through this tablet.</string>
<string name="bluetooth_status">Bluetooth Status</string>
<string name="bluetooth_not_supported">Bluetooth is not supported on this device.</string>
<string name="bluetooth_disabled">Bluetooth is turned off.</string>
<string name="bluetooth_ready">Ready to connect.</string>
<string name="bluetooth_connected_to">Connected to %s</string>
<string name="bluetooth_discoverable">Discoverable as “%s”</string>
<string name="open_bluetooth_settings">Open Bluetooth settings</string>
<string name="now_playing">Now Playing</string>
<string name="unknown_artist">Unknown Artist</string>
<string name="unknown_track">Unknown Track</string>
<!-- Weather Descriptions -->
<string name="weather_clear">Clear</string>
<string name="weather_partly_cloudy">Partly cloudy</string>
<string name="weather_overcast">Overcast</string>
<string name="weather_fog">Fog</string>
<string name="weather_drizzle">Drizzle</string>
<string name="weather_rain">Rain</string>
<string name="weather_snow">Snow</string>
<string name="weather_rain_showers">Rain showers</string>
<string name="weather_snow_showers">Snow showers</string>
<string name="weather_thunderstorm">Thunderstorm</string>
<string name="weather_unknown">Unknown</string>
<string name="weather_feels">Feels %1$d°</string>
<string name="weather_humidity">Humidity %1$d%%</string>
<string name="weather_wind">Wind %1$d</string>
<string name="temp_high">H %1$d°</string>
<string name="temp_low">L %1$d°</string>
<string name="fahrenheit">Fahrenheit</string>
<string name="celsius">Celsius</string>
<!-- Service and Notifications -->
<string name="notification_listening_title">Listening for “Computer”</string>
<string name="notification_listening_text">Tap to return to Ambient Launcher</string>
<string name="notification_paused_title">Wake word paused</string>
<string name="notification_detected_title">Wake word detected</string>
<string name="notification_fallback_text">Tap to open your default assistant</string>
<string name="notification_keep_awake_title">Keep screen awake active</string>
<string name="notification_keep_awake_text">This service keeps your screen from timing out.</string>
</resources>