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