Files
Ambient-Launcher/app/src/main/java/com/ambient/launcher/MainActivity.kt
T
jahruz67 3e1c2e714a feat: add offline wake-word detection with sherpa-onnx
- Integrate sherpa-onnx for local "Computer" wake-word detection
- Add foreground service (microphone type) for continued listening across apps
- Request RECORD_AUDIO, POST_NOTIFICATIONS, and foreground service permissions
- Download and verify pinned sherpa-onnx model (17 MB) on first enable
- Update LauncherViewModel with wake-word state and controller
- Add WorkManager and commons-compress dependencies for model extraction
- Update README with wake-word feature details and limitations
2026-07-25 14:48:05 -07:00

1196 lines
46 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.ambient.launcher
import android.Manifest
import android.app.role.RoleManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.os.BatteryManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
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.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.Info
import androidx.compose.material.icons.filled.Mic
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.VolumeUp
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import kotlinx.coroutines.delay
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.format.TextStyle
import java.util.Locale
import kotlin.math.roundToInt
class MainActivity : ComponentActivity() {
private val viewModel: LauncherViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false)
WindowInsetsControllerCompat(window, window.decorView).apply {
hide(WindowInsetsCompat.Type.systemBars())
systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
setContent {
AmbientTheme {
val state by viewModel.uiState.collectAsStateWithLifecycle()
KeepAwakeEffect(state.settings.keepAwakeMode)
AmbientLauncherApp(
state = state,
viewModel = viewModel,
requestHomeRole = ::requestHomeRole
)
}
}
}
override fun onStart() {
super.onStart()
viewModel.setLauncherVisible(true)
}
override fun onStop() {
viewModel.setLauncherVisible(false)
super.onStop()
}
private fun requestHomeRole() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val roleManager = getSystemService(RoleManager::class.java)
if (roleManager.isRoleAvailable(RoleManager.ROLE_HOME) &&
!roleManager.isRoleHeld(RoleManager.ROLE_HOME)
) {
startActivity(roleManager.createRequestRoleIntent(RoleManager.ROLE_HOME))
}
} else {
startActivity(Intent(Settings.ACTION_HOME_SETTINGS))
}
}
}
@Composable
private fun AmbientTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = androidx.compose.material3.darkColorScheme(
primary = Color(0xFFB9D9FF),
secondary = Color(0xFFB8C8DA),
surface = Color(0xFF11161D),
surfaceVariant = Color(0xFF202832)
),
content = content
)
}
@Composable
private fun KeepAwakeEffect(mode: KeepAwakeMode) {
val context = LocalContext.current
val view = LocalView.current
var charging by remember { mutableStateOf(false) }
DisposableEffect(context) {
fun update(intent: Intent?) {
val status = intent?.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
charging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
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) } }
}
LaunchedEffect(mode, charging, view) {
view.keepScreenOn = when (mode) {
KeepAwakeMode.WHILE_VISIBLE -> true
KeepAwakeMode.WHILE_CHARGING -> charging
KeepAwakeMode.SYSTEM_DEFAULT -> false
}
}
}
private enum class Screen { HOME, SETTINGS }
@Composable
private fun AmbientLauncherApp(
state: LauncherUiState,
viewModel: LauncherViewModel,
requestHomeRole: () -> Unit
) {
var screen by rememberSaveable { mutableStateOf(Screen.HOME) }
AnimatedContent(targetState = screen, label = "screen") { destination ->
when (destination) {
Screen.HOME -> HomeScreen(
state,
onSettings = { screen = Screen.SETTINGS },
onRefreshWeather = viewModel::refreshWeather,
onTogglePinned = viewModel::togglePinned,
onLaunchApp = viewModel::launch
)
Screen.SETTINGS -> SettingsScreen(
state = state,
onBack = { screen = Screen.HOME },
updateSettings = viewModel::updateSettings,
setLocalFolder = viewModel::setLocalFolder,
refreshWeather = viewModel::refreshWeather,
requestHomeRole = requestHomeRole,
setWakeWordEnabled = viewModel::setWakeWordEnabled,
setWakeWordSensitivity = viewModel::setWakeWordSensitivity,
pauseWakeWord = viewModel::pauseWakeWord,
resumeWakeWord = viewModel::resumeWakeWord,
testWakeWord = viewModel::testWakeWord
)
}
}
}
private val onlinePhotos = listOf(
"https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?auto=format&fit=crop&w=2400&q=88",
"https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=2400&q=88",
"https://images.unsplash.com/photo-1449824913935-59a10b8d2000?auto=format&fit=crop&w=2400&q=88",
"https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=2400&q=88",
"https://images.unsplash.com/photo-1472214103451-9374bd1c798e?auto=format&fit=crop&w=2400&q=88",
"https://images.unsplash.com/photo-1511818966892-d7d671e672a2?auto=format&fit=crop&w=2400&q=88",
"https://images.unsplash.com/photo-1501854140801-50d01698950b?auto=format&fit=crop&w=2400&q=88",
"https://images.unsplash.com/photo-1470252649378-9c29740c9fa8?auto=format&fit=crop&w=2400&q=88"
)
@Composable
private fun HomeScreen(
state: LauncherUiState,
onSettings: () -> Unit,
onRefreshWeather: () -> Unit,
onTogglePinned: (LauncherApp) -> Unit,
onLaunchApp: (LauncherApp) -> Boolean
) {
var trayOpen by remember { mutableStateOf(false) }
var photoIndex by rememberSaveable { mutableIntStateOf(0) }
var photoCount by rememberSaveable { mutableIntStateOf(0) }
var showWeatherCard by rememberSaveable { mutableStateOf(false) }
val sources = remember(state.localImages, state.settings.localFolderUri) {
if (state.settings.localFolderUri.isNotBlank() && state.localImages.isNotEmpty()) {
state.localImages
} else {
onlinePhotos
}
}
LaunchedEffect(state.settings.intervalSeconds, sources.size, state.settings.weatherEveryPhotos) {
while (true) {
delay(state.settings.intervalSeconds.coerceAtLeast(10) * 1_000L)
if (showWeatherCard) {
showWeatherCard = false
photoIndex = (photoIndex + 1) % sources.size.coerceAtLeast(1)
} else {
photoCount++
if (
state.settings.showWeather &&
state.weather != null &&
photoCount % state.settings.weatherEveryPhotos.coerceAtLeast(1) == 0
) {
showWeatherCard = true
} else {
photoIndex = (photoIndex + 1) % sources.size.coerceAtLeast(1)
}
}
}
}
var now by remember { mutableStateOf(LocalDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
now = LocalDateTime.now()
delay(30_000)
}
}
val nightActive = isNightRoutineActive(state.settings, now.toLocalTime())
val overlayPercent = if (nightActive) {
maxOf(state.settings.darkenPercent, state.settings.nightDimPercent)
} else state.settings.darkenPercent
Box(Modifier.fillMaxSize().background(Color.Black)) {
Crossfade(
targetState = if (showWeatherCard) "weather" else "photo-$photoIndex",
animationSpec = tween(1_400),
label = "slideshow"
) {
if (showWeatherCard && state.weather != null) {
FullWeatherSlide(state.weather, state.settings)
} else if (sources.isNotEmpty()) {
AsyncImage(
model = sources[photoIndex % sources.size],
contentDescription = "Ambient photograph",
placeholder = painterResource(R.drawable.ambient_fallback),
error = painterResource(R.drawable.ambient_fallback),
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = if (nightActive) 1f else 1.025f
scaleY = if (nightActive) 1f else 1.025f
}
)
}
}
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = overlayPercent.coerceIn(0, 95) / 100f))
)
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
listOf(Color.Black.copy(alpha = .28f), Color.Transparent, Color.Black.copy(alpha = .34f))
)
)
)
FilledIconButton(
onClick = { trayOpen = true },
modifier = Modifier.align(Alignment.TopStart).padding(24.dp),
colors = translucentIconColors()
) {
Icon(Icons.Default.Apps, "Open applications")
}
FilledIconButton(
onClick = onSettings,
modifier = Modifier.align(Alignment.TopEnd).padding(24.dp),
colors = translucentIconColors()
) {
Icon(Icons.Default.Settings, "Open settings")
}
if (!showWeatherCard) {
ClockWeather(
now = now,
weather = state.weather,
settings = state.settings,
nightActive = nightActive,
onRefresh = onRefreshWeather,
modifier = clockAlignment(state.settings.clockCorner)
)
}
}
if (trayOpen) {
AppTray(
apps = state.apps,
pinned = state.settings.pinnedComponents,
onDismiss = { trayOpen = false },
onTogglePinned = onTogglePinned,
onLaunch = { app ->
if (onLaunchApp(app)) trayOpen = false
}
)
}
}
@Composable
private fun translucentIconColors() =
androidx.compose.material3.IconButtonDefaults.filledIconButtonColors(
containerColor = Color.Black.copy(alpha = .42f),
contentColor = Color.White
)
private fun clockAlignment(corner: ClockCorner): Modifier = when (corner) {
ClockCorner.BOTTOM_LEFT -> Modifier.fillMaxSize().padding(28.dp).wrapContentSize(Alignment.BottomStart)
ClockCorner.BOTTOM_RIGHT -> Modifier.fillMaxSize().padding(28.dp).wrapContentSize(Alignment.BottomEnd)
ClockCorner.TOP_LEFT -> Modifier.fillMaxSize().padding(28.dp, 92.dp).wrapContentSize(Alignment.TopStart)
ClockCorner.TOP_RIGHT -> Modifier.fillMaxSize().padding(28.dp, 92.dp).wrapContentSize(Alignment.TopEnd)
}
@Composable
private fun ClockWeather(
now: LocalDateTime,
weather: WeatherNow?,
settings: LauncherSettings,
nightActive: Boolean,
onRefresh: () -> Unit,
modifier: Modifier = Modifier
) {
val shift = ((now.minute / 10) % 4)
val x = if (shift % 2 == 0) 0.dp else 5.dp
val y = if (shift < 2) 0.dp else 4.dp
Column(
modifier = modifier
.offset(x, y)
.clip(RoundedCornerShape(24.dp))
.background(Color.Black.copy(alpha = .34f))
.padding(horizontal = 22.dp, vertical = 15.dp),
horizontalAlignment = Alignment.End
) {
Text(
now.format(DateTimeFormatter.ofPattern("h:mm")),
fontSize = 56.sp,
lineHeight = 56.sp,
fontWeight = FontWeight.Light,
color = if (nightActive) Color(0xFF9B3030) else Color.White
)
Text(
now.format(DateTimeFormatter.ofPattern("EEEE, MMMM d")),
fontSize = 16.sp,
color = if (nightActive) Color(0xFF7E3535) else Color.White.copy(alpha = .86f)
)
if (settings.showWeather && !nightActive) {
Spacer(Modifier.height(7.dp))
if (weather != null) {
Text(
"${weatherGlyph(weather.weatherCode)} ${weather.temperature.roundToInt()}° " +
"${weatherDescription(weather.weatherCode)}",
fontSize = 20.sp,
color = Color.White
)
Text(
"${settings.locationName} H ${weather.high.roundToInt()}° L ${weather.low.roundToInt()}°",
fontSize = 14.sp,
color = Color.White.copy(alpha = .78f)
)
} else {
TextButton(onClick = onRefresh) { Text("Weather unavailable · retry") }
}
}
}
}
@Composable
private fun FullWeatherSlide(weather: WeatherNow, settings: LauncherSettings) {
Box(
Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(Color(0xFF10243E), Color(0xFF315A75), Color(0xFF754E58))
)
)
.padding(horizontal = 64.dp, vertical = 44.dp)
) {
Column(Modifier.fillMaxSize()) {
Text(settings.locationName, fontSize = 24.sp, color = Color.White.copy(alpha = .82f))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(weatherGlyph(weather.weatherCode), fontSize = 72.sp)
Spacer(Modifier.width(18.dp))
Text("${weather.temperature.roundToInt()}°", fontSize = 88.sp, fontWeight = FontWeight.Light)
Spacer(Modifier.width(24.dp))
Column {
Text(weatherDescription(weather.weatherCode), fontSize = 28.sp)
Text(
"Feels ${weather.apparentTemperature.roundToInt()}° · " +
"Humidity ${weather.humidity}% · Wind ${weather.windSpeed.roundToInt()}",
color = Color.White.copy(alpha = .78f)
)
}
}
Spacer(Modifier.height(30.dp))
Text("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
) {
Text(hour.time.takeLast(5))
Text("${hour.temperature.roundToInt()}°", fontSize = 24.sp)
Text("${hour.precipitationChance}% rain", fontSize = 12.sp)
}
}
}
Spacer(Modifier.height(20.dp))
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
weather.daily.forEach { day ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
LocalDate.parse(day.date).dayOfWeek
.getDisplayName(TextStyle.SHORT, Locale.getDefault())
)
Text(weatherGlyph(day.weatherCode), fontSize = 28.sp)
Text("${day.high.roundToInt()}° / ${day.low.roundToInt()}°")
}
}
}
}
}
}
@Composable
private fun AppTray(
apps: List<LauncherApp>,
pinned: Set<String>,
onDismiss: () -> Unit,
onTogglePinned: (LauncherApp) -> Unit,
onLaunch: (LauncherApp) -> Unit
) {
var query by rememberSaveable { mutableStateOf("") }
var showAll by rememberSaveable { mutableStateOf(false) }
val pinnedApps = apps.filter { it.component.flattenToString() in pinned }
val visible = (if (showAll || pinnedApps.isEmpty()) apps else pinnedApps)
.filter { it.label.contains(query, ignoreCase = true) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
Modifier
.fillMaxWidth(.78f)
.fillMaxHeight(.82f),
shape = RoundedCornerShape(28.dp),
color = Color(0xF2181E25)
) {
Column(Modifier.padding(24.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
if (showAll) "All applications" else "Quick launch",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, "Close") }
}
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
singleLine = true,
leadingIcon = { Icon(Icons.Default.Search, null) },
placeholder = { Text("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")
}
}
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.")
}
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(96.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
items(visible, key = { it.component.flattenToString() }) { app ->
AppTile(
app = app,
isPinned = app.component.flattenToString() in pinned,
editMode = showAll,
onClick = {
if (showAll) onTogglePinned(app) else onLaunch(app)
}
)
}
}
}
}
}
}
}
@Composable
private fun AppTile(
app: LauncherApp,
isPinned: Boolean,
editMode: Boolean,
onClick: () -> Unit
) {
Column(
Modifier
.clip(RoundedCornerShape(18.dp))
.clickable(onClick = onClick)
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box {
Image(
bitmap = remember(app.component) { app.icon.toBitmap(96, 96).asImageBitmap() },
contentDescription = app.label,
modifier = Modifier.size(58.dp)
)
if (editMode) {
Box(
Modifier
.align(Alignment.TopEnd)
.size(20.dp)
.clip(CircleShape)
.background(if (isPinned) Color(0xFF81C784) else Color(0xFF45505D)),
contentAlignment = Alignment.Center
) {
Icon(
if (isPinned) Icons.Default.Check else Icons.Default.Add,
null,
modifier = Modifier.size(14.dp)
)
}
}
}
Spacer(Modifier.height(6.dp))
Text(
app.label,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
fontSize = 12.sp
)
}
}
private enum class SettingsSection { DISPLAY, WEATHER, ROUTINES, VOICE, APPS, ACCESS }
@Composable
private fun SettingsScreen(
state: LauncherUiState,
onBack: () -> Unit,
updateSettings: ((LauncherSettings) -> LauncherSettings) -> Unit,
setLocalFolder: (String) -> Unit,
refreshWeather: () -> Unit,
requestHomeRole: () -> Unit,
setWakeWordEnabled: (Boolean) -> Unit,
setWakeWordSensitivity: (Int) -> Unit,
pauseWakeWord: () -> Unit,
resumeWakeWord: () -> Unit,
testWakeWord: () -> Unit
) {
var section by rememberSaveable { mutableStateOf(SettingsSection.DISPLAY) }
val context = LocalContext.current
val folderPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
setLocalFolder(uri.toString())
}
}
Row(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
NavigationRail(
header = {
IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") }
}
) {
SettingsSection.entries.forEach { item ->
val icon = when (item) {
SettingsSection.DISPLAY -> ImageIcon
SettingsSection.WEATHER -> Icons.Default.Refresh
SettingsSection.ROUTINES -> Icons.Default.VolumeUp
SettingsSection.VOICE -> Icons.Default.Mic
SettingsSection.APPS -> Icons.Default.Apps
SettingsSection.ACCESS -> Icons.Default.Info
}
NavigationRailItem(
selected = section == item,
onClick = { section = item },
icon = { Icon(icon, null) },
label = { Text(item.name.lowercase().replaceFirstChar(Char::uppercase)) }
)
}
}
Box(Modifier.fillMaxSize()) {
when (section) {
SettingsSection.DISPLAY -> DisplaySettings(
state.settings,
updateSettings,
onPickFolder = { folderPicker.launch(null) }
)
SettingsSection.WEATHER -> WeatherSettings(
state,
updateSettings,
refreshWeather
)
SettingsSection.ROUTINES -> RoutineSettings(state.settings, updateSettings)
SettingsSection.VOICE -> VoiceSettings(
state = state,
setEnabled = setWakeWordEnabled,
setSensitivity = setWakeWordSensitivity,
pause = pauseWakeWord,
resume = resumeWakeWord,
test = testWakeWord
)
SettingsSection.APPS -> ApplicationsSettings(state, updateSettings)
SettingsSection.ACCESS -> AccessSettings(requestHomeRole)
}
}
}
}
@Composable
private fun SettingsPage(title: String, content: @Composable ColumnScope.() -> Unit) {
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 36.dp),
contentPadding = PaddingValues(top = 28.dp, bottom = 40.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item { Text(title, style = MaterialTheme.typography.headlineMedium) }
item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp), content = content)
}
}
}
@Composable
private fun DisplaySettings(
settings: LauncherSettings,
update: ((LauncherSettings) -> LauncherSettings) -> Unit,
onPickFolder: () -> Unit
) = SettingsPage("Display") {
SettingsCard("Keep screen awake", "Only applies while this launcher is visible.") {
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"
}
},
onSelect = { selected -> update { it.copy(keepAwakeMode = selected) } }
)
}
SettingsCard("Photo interval", "${settings.intervalSeconds} seconds") {
Slider(
value = settings.intervalSeconds.toFloat(),
onValueChange = { value ->
update { it.copy(intervalSeconds = (value / 10).roundToInt() * 10) }
},
valueRange = 10f..900f
)
}
SettingsCard("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.") {
ChoiceMenu(
current = settings.clockCorner,
label = { it.name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase) },
onSelect = { selected -> update { it.copy(clockCorner = selected) } }
)
}
SettingsCard(
"Photo source",
if (settings.localFolderUri.isBlank()) {
"Curated online landscapes, cities, nature, and art."
} else {
"Using the selected local folder."
}
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onPickFolder) { Text("Choose local folder") }
if (settings.localFolderUri.isNotBlank()) {
OutlinedButton(onClick = { update { it.copy(localFolderUri = "") } }) {
Text("Use online collection")
}
}
}
}
}
@Composable
private fun WeatherSettings(
state: LauncherUiState,
update: ((LauncherSettings) -> LauncherSettings) -> Unit,
refresh: () -> Unit
) = SettingsPage("Weather") {
SettingsCard("Show weather", "Compact conditions and periodic full forecast slides.") {
Switch(
checked = state.settings.showWeather,
onCheckedChange = { checked -> update { it.copy(showWeather = checked) } }
)
}
SettingsCard("Location", "Coordinates keep location permission unnecessary.") {
OutlinedTextField(
value = state.settings.locationName,
onValueChange = { value -> update { it.copy(locationName = value) } },
label = { Text("Location label") },
singleLine = true
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
OutlinedTextField(
value = state.settings.latitude,
onValueChange = { value -> update { it.copy(latitude = value) } },
label = { Text("Latitude") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = state.settings.longitude,
onValueChange = { value -> update { it.copy(longitude = value) } },
label = { Text("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")
}
state.weatherError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
SettingsCard("Temperature unit", if (state.settings.useFahrenheit) "Fahrenheit" else "Celsius") {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("°C")
Switch(
checked = state.settings.useFahrenheit,
onCheckedChange = { checked ->
update { it.copy(useFahrenheit = checked) }
refresh()
}
)
Text("°F")
}
}
SettingsCard(
"Full forecast frequency",
"After every ${state.settings.weatherEveryPhotos} photos"
) {
Slider(
value = state.settings.weatherEveryPhotos.toFloat(),
onValueChange = { value ->
update { it.copy(weatherEveryPhotos = value.roundToInt()) }
},
valueRange = 1f..20f,
steps = 18
)
}
}
@Composable
private fun RoutineSettings(
settings: LauncherSettings,
update: ((LauncherSettings) -> LauncherSettings) -> Unit
) = SettingsPage("Night routine") {
SettingsCard(
"Scheduled ultra-dim",
"Applies whenever the launcher is visible. Android is not awakened by this schedule."
) {
Switch(
checked = settings.nightRoutineEnabled,
onCheckedChange = { value -> update { it.copy(nightRoutineEnabled = value) } }
)
}
SettingsCard(
"Active hours",
"${formatHour(settings.nightStartHour)} ${formatHour(settings.nightEndHour)}"
) {
Text("Start")
Slider(
value = settings.nightStartHour.toFloat(),
onValueChange = { value -> update { it.copy(nightStartHour = value.roundToInt()) } },
valueRange = 0f..23f,
steps = 22
)
Text("End")
Slider(
value = settings.nightEndHour.toFloat(),
onValueChange = { value -> update { it.copy(nightEndHour = value.roundToInt()) } },
valueRange = 0f..23f,
steps = 22
)
}
SettingsCard("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.")
}
}
@Composable
private fun VoiceSettings(
state: LauncherUiState,
setEnabled: (Boolean) -> Unit,
setSensitivity: (Int) -> Unit,
pause: () -> Unit,
resume: () -> Unit,
test: () -> Unit
) = SettingsPage("Voice assistant") {
val context = LocalContext.current
val requiredPermissions = remember {
buildList {
add(Manifest.permission.RECORD_AUDIO)
if (Build.VERSION.SDK_INT >= 33) add(Manifest.permission.POST_NOTIFICATIONS)
}.toTypedArray()
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { results ->
if (requiredPermissions.all { results[it] == true }) setEnabled(true)
}
fun permissionsGranted(): Boolean = requiredPermissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
SettingsCard(
"Listen for “Computer”",
"Detection stays on-device. Continued listening in other apps requires a persistent " +
"notification and Androids microphone privacy indicator."
) {
Switch(
checked = state.settings.wakeWord.enabled,
onCheckedChange = { enabled ->
if (!enabled) {
setEnabled(false)
} else if (permissionsGranted()) {
setEnabled(true)
} else {
permissionLauncher.launch(requiredPermissions)
}
}
)
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"
},
fontWeight = FontWeight.SemiBold
)
if (state.wakeWord.message.isNotBlank()) {
Text(state.wakeWord.message, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (
state.wakeWord.modelState == ModelInstallState.DOWNLOADING ||
state.wakeWord.modelState == ModelInstallState.INSTALLING
) {
LinearProgressIndicator(
progress = { state.wakeWord.installProgress / 100f },
modifier = Modifier.fillMaxWidth()
)
Text("${state.wakeWord.installProgress}%")
}
}
SettingsCard(
"Sensitivity",
"${state.settings.wakeWord.sensitivity}% · Higher values detect more easily but may false-trigger."
) {
Slider(
value = state.settings.wakeWord.sensitivity.toFloat(),
onValueChange = { setSensitivity(it.roundToInt()) },
valueRange = 0f..100f
)
}
SettingsCard(
"Listening controls",
"Testing releases this launchers microphone before opening the selected Android assistant."
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (state.wakeWord.state == WakeWordState.PAUSED) {
Button(onClick = resume) { Text("Resume") }
} else {
OutlinedButton(
onClick = pause,
enabled = state.settings.wakeWord.enabled
) { Text("Pause") }
}
Button(
onClick = test,
enabled = state.settings.wakeWord.enabled &&
state.wakeWord.modelState == ModelInstallState.READY
) {
Icon(Icons.Default.Mic, null)
Spacer(Modifier.width(7.dp))
Text("Test assistant")
}
}
OutlinedButton(
onClick = {
runCatching { context.startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS)) }
}
) {
Text("Open Android voice settings")
}
}
SettingsCard(
"Privacy",
"Audio frames are processed locally and are never saved or transmitted."
) {
Text("Only the last detection time and service status are retained for diagnostics.")
}
}
@Composable
private fun ApplicationsSettings(
state: LauncherUiState,
update: ((LauncherSettings) -> LauncherSettings) -> Unit
) = SettingsPage("Applications") {
SettingsCard(
"Quick launch",
"${state.settings.pinnedComponents.size} app(s) pinned"
) {
Text("Open the app tray from Home, then choose Add app to pin or remove applications.")
}
SettingsCard(
"Privacy",
"Recent-app tracking and usage access are disabled."
) {
Text("This launcher only reads activities that advertise a launchable icon.")
}
}
@Composable
private fun AccessSettings(requestHomeRole: () -> Unit) = SettingsPage("System access") {
SettingsCard("Default Home app", "Required for the hardware Home gesture to return here.") {
Button(onClick = requestHomeRole) {
Icon(Icons.Default.Home, null)
Spacer(Modifier.width(7.dp))
Text("Make default Home app")
}
}
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(
"Microphone",
ContextCompat.checkSelfPermission(
LocalContext.current,
Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED,
"Requested only when on-device wake-word listening is enabled."
)
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(
"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."
)
SettingsCard(
"Audio output",
"A normal launcher cannot force other apps to use a specific device."
) {
val context = LocalContext.current
OutlinedButton(
onClick = {
val intent = Intent(Settings.ACTION_SOUND_SETTINGS)
runCatching { context.startActivity(intent) }
}
) {
Icon(Icons.Default.VolumeUp, null)
Spacer(Modifier.width(7.dp))
Text("Open Android sound settings")
}
}
}
@Composable
private fun PermissionStatus(title: String, enabled: Boolean, detail: String) {
SettingsCard(title, detail) {
Text(
if (enabled) "Available" else "Not requested",
color = if (enabled) Color(0xFF8ED49A) else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold
)
}
}
@Composable
private fun SettingsCard(
title: String,
subtitle: String,
content: @Composable ColumnScope.() -> Unit
) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.fillMaxWidth()
) {
Column(
Modifier.fillMaxWidth().padding(18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(
subtitle,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
content()
}
}
}
@Composable
private inline fun <reified T : Enum<T>> ChoiceMenu(
current: T,
noinline label: (T) -> String,
crossinline onSelect: (T) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Box {
OutlinedButton(onClick = { expanded = true }) { Text(label(current)) }
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
enumValues<T>().forEach { value ->
DropdownMenuItem(
text = { Text(label(value)) },
onClick = {
onSelect(value)
expanded = false
},
trailingIcon = {
if (value == current) Icon(Icons.Default.Check, null)
}
)
}
}
}
}
private fun isNightRoutineActive(settings: LauncherSettings, time: LocalTime): Boolean {
if (!settings.nightRoutineEnabled) return false
val hour = time.hour
return if (settings.nightStartHour == settings.nightEndHour) {
true
} else if (settings.nightStartHour < settings.nightEndHour) {
hour in settings.nightStartHour until settings.nightEndHour
} else {
hour >= settings.nightStartHour || hour < settings.nightEndHour
}
}
private fun formatHour(hour: Int): String =
LocalTime.of(hour.coerceIn(0, 23), 0).format(DateTimeFormatter.ofPattern("h a"))