diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1399842eb..43acf0d19 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -21,6 +21,7 @@
+
diff --git a/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt b/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt
index f138ff9c6..25536e810 100644
--- a/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt
+++ b/app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt
@@ -53,4 +53,58 @@ sealed interface Trigger {
override val icon: Int get() = R.drawable.rounded_nest_clock_farsight_analog_24
override val isConfigurable: Boolean get() = true
}
+
+ @Keep
+ data class BluetoothConnected(
+ @SerializedName("deviceAddress") val deviceAddress: String = "",
+ @SerializedName("deviceName") val deviceName: String = ""
+ ) : Trigger {
+ override val title: Int get() = R.string.diy_trigger_bluetooth_connected
+ override val icon: Int get() = R.drawable.rounded_bluetooth_24
+ override val isConfigurable: Boolean get() = true
+ override val permissions: List
+ get() = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+ listOf(android.Manifest.permission.BLUETOOTH_CONNECT)
+ } else {
+ emptyList()
+ }
+ }
+
+ @Keep
+ data class BluetoothDisconnected(
+ @SerializedName("deviceAddress") val deviceAddress: String = "",
+ @SerializedName("deviceName") val deviceName: String = ""
+ ) : Trigger {
+ override val title: Int get() = R.string.diy_trigger_bluetooth_disconnected
+ override val icon: Int get() = R.drawable.rounded_bluetooth_24
+ override val isConfigurable: Boolean get() = true
+ override val permissions: List
+ get() = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
+ listOf(android.Manifest.permission.BLUETOOTH_CONNECT)
+ } else {
+ emptyList()
+ }
+ }
+
+ @Keep
+ data class WifiConnected(
+ @SerializedName("ssid") val ssid: String = ""
+ ) : Trigger {
+ override val title: Int get() = R.string.diy_trigger_wifi_connected
+ override val icon: Int get() = R.drawable.rounded_android_wifi_4_bar_plus_24
+ override val isConfigurable: Boolean get() = true
+ override val permissions: List
+ get() = listOf(android.Manifest.permission.ACCESS_FINE_LOCATION)
+ }
+
+ @Keep
+ data class WifiDisconnected(
+ @SerializedName("ssid") val ssid: String = ""
+ ) : Trigger {
+ override val title: Int get() = R.string.diy_trigger_wifi_disconnected
+ override val icon: Int get() = R.drawable.rounded_android_wifi_3_bar_24
+ override val isConfigurable: Boolean get() = true
+ override val permissions: List
+ get() = listOf(android.Manifest.permission.ACCESS_FINE_LOCATION)
+ }
}
diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt
index 92d54dd00..f252dceb9 100644
--- a/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt
+++ b/app/src/main/java/com/sameerasw/essentials/services/automation/AutomationManager.kt
@@ -6,9 +6,11 @@ import com.sameerasw.essentials.domain.diy.Automation
import com.sameerasw.essentials.domain.diy.DIYRepository
import com.sameerasw.essentials.domain.diy.Trigger
import com.sameerasw.essentials.services.automation.modules.AutomationModule
+import com.sameerasw.essentials.services.automation.modules.BluetoothModule
import com.sameerasw.essentials.services.automation.modules.DisplayModule
import com.sameerasw.essentials.services.automation.modules.PowerModule
import com.sameerasw.essentials.services.automation.modules.TimeModule
+import com.sameerasw.essentials.services.automation.modules.WifiModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -63,6 +65,8 @@ object AutomationManager {
val powerAutomations = mutableListOf()
val displayAutomations = mutableListOf()
val timeAutomations = mutableListOf()
+ val bluetoothAutomations = mutableListOf()
+ val wifiAutomations = mutableListOf()
enabledAutomations.forEach { automation ->
when (automation.type) {
@@ -83,6 +87,16 @@ object AutomationManager {
timeAutomations.add(automation)
}
+ is Trigger.BluetoothConnected, is Trigger.BluetoothDisconnected -> {
+ requiredModuleIds.add(BluetoothModule.ID)
+ bluetoothAutomations.add(automation)
+ }
+
+ is Trigger.WifiConnected, is Trigger.WifiDisconnected -> {
+ requiredModuleIds.add(WifiModule.ID)
+ wifiAutomations.add(automation)
+ }
+
else -> {}
}
}
@@ -158,6 +172,26 @@ object AutomationManager {
} else {
activeModules.remove(TimeModule.ID)?.stop(context)
}
+
+ // Bluetooth Module
+ if (requiredModuleIds.contains(BluetoothModule.ID)) {
+ val module = activeModules.getOrPut(BluetoothModule.ID) {
+ BluetoothModule().also { it.start(context) }
+ }
+ module.updateAutomations(bluetoothAutomations)
+ } else {
+ activeModules.remove(BluetoothModule.ID)?.stop(context)
+ }
+
+ // Wifi Module
+ if (requiredModuleIds.contains(WifiModule.ID)) {
+ val module = activeModules.getOrPut(WifiModule.ID) {
+ WifiModule().also { it.start(context) }
+ }
+ module.updateAutomations(wifiAutomations)
+ } else {
+ activeModules.remove(WifiModule.ID)?.stop(context)
+ }
}
private fun startService(context: Context) {
diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/modules/BluetoothModule.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/BluetoothModule.kt
new file mode 100644
index 000000000..44ed8a287
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/BluetoothModule.kt
@@ -0,0 +1,79 @@
+package com.sameerasw.essentials.services.automation.modules
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothDevice
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import com.sameerasw.essentials.domain.diy.Automation
+import com.sameerasw.essentials.domain.diy.Trigger
+import com.sameerasw.essentials.services.automation.executors.CombinedActionExecutor
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class BluetoothModule : AutomationModule {
+ companion object {
+ const val ID = "bluetooth_module"
+ }
+
+ override val id: String = ID
+ private var automations: List = emptyList()
+ private val scope = CoroutineScope(Dispatchers.IO)
+
+ @SuppressLint("MissingPermission")
+ private val receiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val device = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
+ } ?: return
+
+ val address = device.address ?: return
+
+ when (intent.action) {
+ BluetoothDevice.ACTION_ACL_CONNECTED -> handleTrigger(context, address) {
+ it is Trigger.BluetoothConnected && it.deviceAddress == address
+ }
+
+ BluetoothDevice.ACTION_ACL_DISCONNECTED -> handleTrigger(context, address) {
+ it is Trigger.BluetoothDisconnected && it.deviceAddress == address
+ }
+ }
+ }
+ }
+
+ override fun start(context: Context) {
+ val filter = IntentFilter().apply {
+ addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
+ addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
+ }
+ context.registerReceiver(receiver, filter)
+ }
+
+ override fun stop(context: Context) {
+ try {
+ context.unregisterReceiver(receiver)
+ } catch (e: Exception) {
+ // Ignore if not registered
+ }
+ }
+
+ override fun updateAutomations(automations: List) {
+ this.automations = automations
+ }
+
+ private fun handleTrigger(context: Context, address: String, matches: (Trigger) -> Boolean) {
+ scope.launch {
+ automations.filter { it.type == Automation.Type.TRIGGER && it.trigger?.let(matches) == true }
+ .forEach { automation ->
+ automation.actions.forEach { action ->
+ CombinedActionExecutor.execute(context, action)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/services/automation/modules/WifiModule.kt b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/WifiModule.kt
new file mode 100644
index 000000000..a643052b0
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/services/automation/modules/WifiModule.kt
@@ -0,0 +1,114 @@
+package com.sameerasw.essentials.services.automation.modules
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.net.wifi.WifiManager
+import android.util.Log
+import com.sameerasw.essentials.domain.diy.Automation
+import com.sameerasw.essentials.domain.diy.Trigger
+import com.sameerasw.essentials.services.automation.executors.CombinedActionExecutor
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class WifiModule : AutomationModule {
+ companion object {
+ const val ID = "wifi_module"
+ private const val UNKNOWN_SSID = ""
+ }
+
+ override val id: String = ID
+ private var automations: List = emptyList()
+ private val scope = CoroutineScope(Dispatchers.IO)
+ private var connectivityManager: ConnectivityManager? = null
+ private var appContext: Context? = null
+ private var currentSsid: String? = null
+
+ // NetworkCapabilities.getTransportInfo() only exists from API 31 onward, and querying it
+ // on older OS versions throws NoSuchMethodError inside the (system-swallowed) callback.
+ // WifiManager.connectionInfo works on every API level and respects the same location
+ // permission gating, so it's used unconditionally instead of branching by SDK level.
+ @Suppress("DEPRECATION")
+ private fun currentWifiSsid(): String? {
+ val wifiManager =
+ appContext?.getSystemService(Context.WIFI_SERVICE) as? WifiManager ?: return null
+ val ssid = try {
+ wifiManager.connectionInfo?.ssid
+ } catch (e: Exception) {
+ Log.w(ID, "Failed to read current SSID", e)
+ null
+ }
+ if (ssid.isNullOrEmpty() || ssid == UNKNOWN_SSID) return null
+ return ssid.removeSurrounding("\"")
+ }
+
+ private val networkCallback = object : ConnectivityManager.NetworkCallback() {
+ override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
+ if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) return
+ val ssid = currentWifiSsid()
+ if (ssid == null) {
+ Log.d(ID, "Wi-Fi capabilities changed but SSID unavailable (check location permission/services)")
+ return
+ }
+ if (ssid == currentSsid) return
+ Log.d(ID, "Wi-Fi connected: $ssid")
+ currentSsid = ssid
+ handleTrigger { it is Trigger.WifiConnected && it.ssid == ssid }
+ }
+
+ override fun onLost(network: Network) {
+ val ssid = currentSsid ?: return
+ Log.d(ID, "Wi-Fi disconnected: $ssid")
+ currentSsid = null
+ handleTrigger { it is Trigger.WifiDisconnected && it.ssid == ssid }
+ }
+ }
+
+ override fun start(context: Context) {
+ appContext = context.applicationContext
+ val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ connectivityManager = manager
+ val request = NetworkRequest.Builder()
+ .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+ // Default builder requires validated internet access; many Wi-Fi networks
+ // (captive portals, offline IoT/local networks) never satisfy that, so the
+ // callback would otherwise never fire for them.
+ .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .build()
+ try {
+ manager.registerNetworkCallback(request, networkCallback)
+ } catch (e: Exception) {
+ Log.e(ID, "Failed to register Wi-Fi network callback", e)
+ }
+ }
+
+ override fun stop(context: Context) {
+ try {
+ connectivityManager?.unregisterNetworkCallback(networkCallback)
+ } catch (e: Exception) {
+ // Ignore if not registered
+ }
+ connectivityManager = null
+ appContext = null
+ currentSsid = null
+ }
+
+ override fun updateAutomations(automations: List) {
+ this.automations = automations
+ }
+
+ private fun handleTrigger(matches: (Trigger) -> Boolean) {
+ val context = appContext ?: return
+ scope.launch {
+ automations.filter { it.type == Automation.Type.TRIGGER && it.trigger?.let(matches) == true }
+ .forEach { automation ->
+ automation.actions.forEach { action ->
+ CombinedActionExecutor.execute(context, action)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt b/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt
index 264b570de..cdc25d8d4 100644
--- a/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt
+++ b/app/src/main/java/com/sameerasw/essentials/ui/activities/AutomationEditorActivity.kt
@@ -70,9 +70,11 @@ import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer
import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenu
import com.sameerasw.essentials.ui.components.menus.SegmentedDropdownMenuItem
import com.sameerasw.essentials.ui.components.pickers.SegmentedPicker
+import com.sameerasw.essentials.ui.components.sheets.BluetoothDeviceSelectionSheet
import com.sameerasw.essentials.ui.components.sheets.DimWallpaperSettingsSheet
import com.sameerasw.essentials.ui.components.sheets.ScreenOffSettingsSheet
import com.sameerasw.essentials.ui.components.sheets.SoundModeSettingsSheet
+import com.sameerasw.essentials.ui.components.sheets.WifiNetworkSelectionSheet
import com.sameerasw.essentials.ui.theme.EssentialsTheme
import com.sameerasw.essentials.utils.AppUtil
import com.sameerasw.essentials.utils.HapticUtil
@@ -228,10 +230,20 @@ class AutomationEditorActivity : ComponentActivity() {
var showDeviceEffectsSettings by remember { mutableStateOf(false) }
var showSoundModeSettings by remember { mutableStateOf(false) }
var showTimeSettings by remember { mutableStateOf(false) }
+ var showBluetoothSettings by remember { mutableStateOf(false) }
+ var showWifiSettings by remember { mutableStateOf(false) }
var configAction by remember { mutableStateOf(null) } // Generic config action
+ val isTriggerConfigured = when (val trigger = selectedTrigger) {
+ is Trigger.BluetoothConnected -> trigger.deviceAddress.isNotBlank()
+ is Trigger.BluetoothDisconnected -> trigger.deviceAddress.isNotBlank()
+ is Trigger.WifiConnected -> trigger.ssid.isNotBlank()
+ is Trigger.WifiDisconnected -> trigger.ssid.isNotBlank()
+ else -> true
+ }
+
val isValid = when (automationType) {
- Automation.Type.TRIGGER -> selectedTrigger != null && selectedAction != null
+ Automation.Type.TRIGGER -> selectedTrigger != null && selectedAction != null && isTriggerConfigured
Automation.Type.ACTION_SHORTCUT, Automation.Type.PIXEL_SEARCHBAR -> selectedAction != null
Automation.Type.STATE -> selectedState != null && (selectedInAction != null || selectedOutAction != null)
Automation.Type.APP -> selectedApps.isNotEmpty() && (selectedInAction != null || selectedOutAction != null)
@@ -504,6 +516,26 @@ class AutomationEditorActivity : ComponentActivity() {
?: 0,
days = (selectedTrigger as? Trigger.Schedule)?.days
?: emptySet()
+ ),
+ Trigger.BluetoothConnected(
+ deviceAddress = (selectedTrigger as? Trigger.BluetoothConnected)?.deviceAddress
+ ?: "",
+ deviceName = (selectedTrigger as? Trigger.BluetoothConnected)?.deviceName
+ ?: ""
+ ),
+ Trigger.BluetoothDisconnected(
+ deviceAddress = (selectedTrigger as? Trigger.BluetoothDisconnected)?.deviceAddress
+ ?: "",
+ deviceName = (selectedTrigger as? Trigger.BluetoothDisconnected)?.deviceName
+ ?: ""
+ ),
+ Trigger.WifiConnected(
+ ssid = (selectedTrigger as? Trigger.WifiConnected)?.ssid
+ ?: ""
+ ),
+ Trigger.WifiDisconnected(
+ ssid = (selectedTrigger as? Trigger.WifiDisconnected)?.ssid
+ ?: ""
)
)
triggers.forEach { trigger ->
@@ -514,8 +546,19 @@ class AutomationEditorActivity : ComponentActivity() {
isConfigurable = trigger.isConfigurable,
onClick = { selectedTrigger = trigger },
onSettingsClick = {
- if (trigger is Trigger.Schedule) {
- showTimeSettings = true
+ when (trigger) {
+ is Trigger.Schedule -> showTimeSettings =
+ true
+
+ is Trigger.BluetoothConnected,
+ is Trigger.BluetoothDisconnected -> showBluetoothSettings =
+ true
+
+ is Trigger.WifiConnected,
+ is Trigger.WifiDisconnected -> showWifiSettings =
+ true
+
+ else -> {}
}
}
)
@@ -720,6 +763,47 @@ class AutomationEditorActivity : ComponentActivity() {
)
}
+ if (showBluetoothSettings) {
+ BluetoothDeviceSelectionSheet(
+ onDismiss = { showBluetoothSettings = false },
+ onSave = { address, name ->
+ selectedTrigger = when (selectedTrigger) {
+ is Trigger.BluetoothConnected -> Trigger.BluetoothConnected(
+ deviceAddress = address,
+ deviceName = name
+ )
+
+ is Trigger.BluetoothDisconnected -> Trigger.BluetoothDisconnected(
+ deviceAddress = address,
+ deviceName = name
+ )
+
+ else -> selectedTrigger
+ }
+ showBluetoothSettings = false
+ }
+ )
+ }
+
+ if (showWifiSettings) {
+ WifiNetworkSelectionSheet(
+ initialSsid = when (val trigger = selectedTrigger) {
+ is Trigger.WifiConnected -> trigger.ssid
+ is Trigger.WifiDisconnected -> trigger.ssid
+ else -> null
+ },
+ onDismiss = { showWifiSettings = false },
+ onSave = { ssid ->
+ selectedTrigger = when (selectedTrigger) {
+ is Trigger.WifiConnected -> Trigger.WifiConnected(ssid = ssid)
+ is Trigger.WifiDisconnected -> Trigger.WifiDisconnected(ssid = ssid)
+ else -> selectedTrigger
+ }
+ showWifiSettings = false
+ }
+ )
+ }
+
if (showDimSettings && configAction is Action.DimWallpaper) {
DimWallpaperSettingsSheet(
initialAction = configAction as Action.DimWallpaper,
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/BluetoothDeviceSelectionSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/BluetoothDeviceSelectionSheet.kt
new file mode 100644
index 000000000..208445f86
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/BluetoothDeviceSelectionSheet.kt
@@ -0,0 +1,133 @@
+package com.sameerasw.essentials.ui.components.sheets
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import com.sameerasw.essentials.R
+import com.sameerasw.essentials.utils.BluetoothPairedDevicesUtil
+import com.sameerasw.essentials.utils.HapticUtil
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BluetoothDeviceSelectionSheet(
+ onDismiss: () -> Unit,
+ onSave: (address: String, name: String) -> Unit
+) {
+ val context = LocalContext.current
+ val view = LocalView.current
+
+ var hasPermission by remember {
+ mutableStateOf(
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.BLUETOOTH_CONNECT
+ ) == PackageManager.PERMISSION_GRANTED
+ )
+ }
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission()
+ ) { granted -> hasPermission = granted }
+
+ LaunchedEffect(Unit) {
+ if (!hasPermission) {
+ permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
+ }
+ }
+
+ val devices = remember(hasPermission) {
+ if (hasPermission) BluetoothPairedDevicesUtil.getPairedDevices(context) else emptyList()
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ dragHandle = null
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(24.dp)
+ .fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.diy_select_bluetooth_device_title),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ if (!hasPermission) {
+ Text(
+ text = stringResource(R.string.diy_bluetooth_permission_required),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ } else if (devices.isEmpty()) {
+ Text(
+ text = stringResource(R.string.diy_no_paired_bluetooth_devices),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ } else {
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ items(devices) { device ->
+ ListItem(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ HapticUtil.performUIHaptic(view)
+ onSave(device.address, device.name)
+ },
+ headlineContent = { Text(device.name) },
+ supportingContent = { Text(device.address) },
+ leadingContent = {
+ Icon(
+ painter = painterResource(R.drawable.rounded_bluetooth_24),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.surfaceBright
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/WifiNetworkSelectionSheet.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/WifiNetworkSelectionSheet.kt
new file mode 100644
index 000000000..a5142757b
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/ui/components/sheets/WifiNetworkSelectionSheet.kt
@@ -0,0 +1,228 @@
+package com.sameerasw.essentials.ui.components.sheets
+
+import android.Manifest
+import android.content.pm.PackageManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import com.sameerasw.essentials.R
+import com.sameerasw.essentials.utils.HapticUtil
+import com.sameerasw.essentials.utils.WifiUtil
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun WifiNetworkSelectionSheet(
+ initialSsid: String? = null,
+ onDismiss: () -> Unit,
+ onSave: (ssid: String) -> Unit
+) {
+ val context = LocalContext.current
+ val view = LocalView.current
+
+ var ssid by remember { mutableStateOf(initialSsid ?: "") }
+ var isLoadingSavedNetworks by remember { mutableStateOf(true) }
+ var savedNetworks by remember { mutableStateOf>(emptyList()) }
+
+ LaunchedEffect(Unit) {
+ val networks = withContext(Dispatchers.IO) {
+ if (WifiUtil.canReadSavedNetworks(context)) {
+ WifiUtil.getSavedNetworkSsids(context)
+ } else {
+ emptyList()
+ }
+ }
+ savedNetworks = networks
+ isLoadingSavedNetworks = false
+ }
+
+ val locationPermissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission()
+ ) { granted ->
+ if (granted) {
+ WifiUtil.getCurrentSsid(context)?.let { ssid = it }
+ }
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ dragHandle = null
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(24.dp)
+ .fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.diy_select_wifi_network_title),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ when {
+ isLoadingSavedNetworks -> {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+
+ savedNetworks.isNotEmpty() -> {
+ Text(
+ text = stringResource(R.string.diy_saved_networks_via_shizuku),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ items(savedNetworks) { network ->
+ ListItem(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ HapticUtil.performUIHaptic(view)
+ onSave(network)
+ },
+ headlineContent = { Text(network) },
+ leadingContent = {
+ Icon(
+ painter = painterResource(R.drawable.rounded_android_wifi_4_bar_plus_24),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = MaterialTheme.colorScheme.surfaceBright
+ )
+ )
+ }
+ }
+ }
+
+ else -> {
+ OutlinedTextField(
+ value = ssid,
+ onValueChange = { ssid = it },
+ label = { Text(stringResource(R.string.diy_wifi_ssid_label)) },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+
+ OutlinedButton(
+ onClick = {
+ HapticUtil.performUIHaptic(view)
+ val hasPermission = ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.ACCESS_FINE_LOCATION
+ ) == PackageManager.PERMISSION_GRANTED
+ if (hasPermission) {
+ WifiUtil.getCurrentSsid(context)?.let { ssid = it }
+ } else {
+ locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.rounded_android_wifi_4_bar_plus_24),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(stringResource(R.string.diy_use_current_network))
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Button(
+ onClick = {
+ HapticUtil.performVirtualKeyHaptic(view)
+ onDismiss()
+ },
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.surfaceBright,
+ contentColor = MaterialTheme.colorScheme.onSurface
+ )
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_close_24),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(stringResource(R.string.action_cancel))
+ }
+
+ Button(
+ onClick = {
+ HapticUtil.performVirtualKeyHaptic(view)
+ if (ssid.isNotBlank()) {
+ onSave(ssid.trim())
+ }
+ },
+ modifier = Modifier.weight(1f),
+ enabled = ssid.isNotBlank()
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_check_24),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(stringResource(R.string.action_save))
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/utils/BluetoothPairedDevicesUtil.kt b/app/src/main/java/com/sameerasw/essentials/utils/BluetoothPairedDevicesUtil.kt
new file mode 100644
index 000000000..24ed00c52
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/utils/BluetoothPairedDevicesUtil.kt
@@ -0,0 +1,35 @@
+package com.sameerasw.essentials.utils
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothManager
+import android.content.Context
+import androidx.annotation.Keep
+
+object BluetoothPairedDevicesUtil {
+
+ @Keep
+ data class PairedDevice(
+ val name: String,
+ val address: String
+ )
+
+ @SuppressLint("MissingPermission")
+ fun getPairedDevices(context: Context): List {
+ val bluetoothManager =
+ context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
+ val adapter = bluetoothManager?.adapter ?: return emptyList()
+
+ val devices = try {
+ adapter.bondedDevices
+ } catch (e: SecurityException) {
+ return emptyList()
+ }
+
+ return devices.map { device ->
+ PairedDevice(
+ name = device.alias ?: device.name ?: device.address,
+ address = device.address
+ )
+ }.sortedBy { it.name.lowercase() }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/essentials/utils/WifiUtil.kt b/app/src/main/java/com/sameerasw/essentials/utils/WifiUtil.kt
new file mode 100644
index 000000000..79c98602c
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/essentials/utils/WifiUtil.kt
@@ -0,0 +1,38 @@
+package com.sameerasw.essentials.utils
+
+import android.content.Context
+import android.net.wifi.WifiManager
+
+object WifiUtil {
+ private const val UNKNOWN_SSID = ""
+ private val SSID_REGEX = Regex("\"([^\"]*)\"")
+
+ @Suppress("DEPRECATION")
+ fun getCurrentSsid(context: Context): String? {
+ val wifiManager =
+ context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
+ ?: return null
+ val ssid = wifiManager.connectionInfo?.ssid ?: return null
+ if (ssid.isEmpty() || ssid == UNKNOWN_SSID) return null
+ return ssid.removeSurrounding("\"")
+ }
+
+ /**
+ * Shizuku/root grant shell-level privileges, which can run `cmd wifi list-networks`
+ * (normally restricted to system apps) to read the device's saved Wi-Fi networks.
+ */
+ fun canReadSavedNetworks(context: Context): Boolean {
+ return ShellUtils.isAvailable(context) && ShellUtils.hasPermission(context)
+ }
+
+ fun getSavedNetworkSsids(context: Context): List {
+ val output = ShellUtils.runCommandWithOutput(context, "cmd wifi list-networks")
+ ?: return emptyList()
+ return output.lineSequence()
+ .drop(1) // header row: "Network Id SSID Security type"
+ .mapNotNull { line -> SSID_REGEX.find(line)?.groupValues?.get(1) }
+ .filter { it.isNotBlank() }
+ .distinct()
+ .toList()
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 57389c37f..959dc4ed6 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -877,6 +877,17 @@
Charger Connected
Charger Disconnected
Schedule
+ Bluetooth Device Connected
+ Bluetooth Device Disconnected
+ Wi-Fi Network Connected
+ Wi-Fi Network Disconnected
+ Select Bluetooth Device
+ Bluetooth permission is required to list paired devices.
+ No paired Bluetooth devices found. Pair a device first in system settings.
+ Select Wi-Fi Network
+ Network name (SSID)
+ Use Current Network
+ Saved networks (via Shizuku)
Time Period
Select Time
Select Time Range