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