From ab06dc6e459b5114866bb919a12491d721bfc59e Mon Sep 17 00:00:00 2001 From: Mudit200408 Date: Fri, 10 Jul 2026 15:06:27 +0530 Subject: [PATCH 1/4] feat: implement pocket mode exclusions and active app checks - Improve the app detection and also filter system noise --- .../domain/registry/FeatureRegistry.kt | 2 +- .../services/AppDetectionService.kt | 21 +++++ .../services/handlers/AppFlowHandler.kt | 63 ++++++++++++- .../tiles/ScreenOffAccessibilityService.kt | 88 +++++++++++++++---- .../configs/PocketModeSettingsUI.kt | 2 +- .../essentials/viewmodels/MainViewModel.kt | 5 +- 6 files changed, 161 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index 35c2e0bea..991a0af67 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -962,7 +962,7 @@ object FeatureRegistry { override fun isEnabled(viewModel: MainViewModel) = viewModel.isPocketModeEnabled.value override fun onToggle(viewModel: MainViewModel, context: Context, enabled: Boolean) = - viewModel.setPocketModeEnabled(enabled) + viewModel.setPocketModeEnabled(enabled, context) override fun isDeviceSupported(context: Context) = !DeviceUtils.isGoogleDevice() }, diff --git a/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt b/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt index 0447434dc..c9f83ef05 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/AppDetectionService.kt @@ -4,6 +4,7 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service +import android.app.usage.UsageEvents import android.app.usage.UsageStats import android.app.usage.UsageStatsManager import android.content.BroadcastReceiver @@ -101,6 +102,26 @@ class AppDetectionService : Service() { private fun getForegroundPackage(): String? { val usageStatsManager = getSystemService(USAGE_STATS_SERVICE) as UsageStatsManager val time = System.currentTimeMillis() + + // 1. Try to find the last resumed activity using queryEvents (real-time & accurate) + try { + val events = usageStatsManager.queryEvents(time - 1000 * 15, time) + val event = UsageEvents.Event() + var lastResumedPackage: String? = null + while (events.hasNextEvent()) { + events.getNextEvent(event) + if (event.eventType == UsageEvents.Event.ACTIVITY_RESUMED) { + lastResumedPackage = event.packageName + } + } + if (lastResumedPackage != null) { + return lastResumedPackage + } + } catch (e: Exception) { + android.util.Log.e("AppDetectionService", "Failed to query usage events", e) + } + + // 2. Fallback to queryUsageStats if no events found in the window val stats = usageStatsManager.queryUsageStats( UsageStatsManager.INTERVAL_DAILY, time - 1000 * 10, diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt index 1a6b75f94..cab12289f 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/AppFlowHandler.kt @@ -9,8 +9,10 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager +import android.media.AudioManager import android.os.Handler import android.os.Looper +import android.view.inputmethod.InputMethodManager import android.provider.Settings import android.util.Log import androidx.core.app.NotificationCompat @@ -105,14 +107,67 @@ class AppFlowHandler( private val ignoredSystemPackages = listOf( "android", "com.android.systemui", - "com.google.android.inputmethod.latin" + "com.google.android.inputmethod.latin", + "com.google.android.gms" ) + private fun isIgnoredPackage(packageName: String): Boolean { + if (ignoredSystemPackages.contains(packageName)) return true + + val lowerPkg = packageName.lowercase() + if (lowerPkg.contains("systemui") || + lowerPkg.contains("keyguard") || + lowerPkg.contains("volume") || + lowerPkg.contains("soundassistant") || + lowerPkg.contains("dialer") || + lowerPkg.contains("telecom") || + lowerPkg.contains("phone") || + lowerPkg.contains("incallui") || + lowerPkg.contains("packageinstaller") || + lowerPkg.contains("permissioncontroller") + ) { + return true + } + + // Check active call state via AudioManager mode + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (audioManager != null) { + val mode = audioManager.mode + if (mode == AudioManager.MODE_IN_CALL || + mode == AudioManager.MODE_IN_COMMUNICATION || + mode == AudioManager.MODE_RINGTONE + ) { + return true + } + } + + return try { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + val ims = imm?.enabledInputMethodList + ims?.any { it.packageName == packageName } == true + } catch (_: Exception) { + false + } + } + fun onPackageChanged(packageName: String, isFromUsageStats: Boolean = false) { val prefs = context.getSharedPreferences("essentials_prefs", Context.MODE_PRIVATE) val useUsageAccess = prefs.getBoolean("use_usage_access", false) + Log.d("AppFlowHandler", "onPackageChanged: packageName=$packageName, isFromUsageStats=$isFromUsageStats, useUsageAccess=$useUsageAccess, currentPackage=$currentPackage") + + if (isIgnoredPackage(packageName)) { + Log.d("AppFlowHandler", "onPackageChanged: Ignoring system/IME/volume/call package $packageName") + return + } + val oldPackage = currentPackage + + val serviceInstance = com.sameerasw.essentials.services.tiles.ScreenOffAccessibilityService.instance + if (serviceInstance != null && serviceInstance.isAppBypassedForPocketMode(packageName)) { + serviceInstance.dismissPocketMode() + } + if (isFromUsageStats == useUsageAccess) { currentPackage = packageName if (oldPackage != null && oldPackage != packageName) { @@ -215,7 +270,7 @@ class AppFlowHandler( pendingNLRunnable?.let { handler.removeCallbacks(it) } - if (ignoredSystemPackages.contains(packageName)) { + if (isIgnoredPackage(packageName)) { Log.d("NightLight", "Ignoring system package $packageName") return } @@ -287,6 +342,10 @@ class AppFlowHandler( } private fun checkAppAutomations(packageName: String) { + if (isIgnoredPackage(packageName)) { + Log.d("AppFlowHandler", "checkAppAutomations: Ignoring system/IME package $packageName") + return + } scope.launch { val automations = DIYRepository.automations.value val appAutomations = diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index 26d8eaf1d..a558cbfc1 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -198,8 +198,10 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene statusBarIconHandler.updateAll() } else if (key == "pocket_mode_enabled" || key == "pocket_mode_use_light_sensor") { updatePocketModeSensors() + com.sameerasw.essentials.utils.ServiceUtils.startRequiredServices(this) } else if (key == "pocket_mode_excluded_apps") { updatePocketModeExcludedAppsSet() + com.sameerasw.essentials.utils.ServiceUtils.startRequiredServices(this) } } @@ -249,7 +251,9 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene Intent.ACTION_USER_PRESENT -> { val prefs = getSharedPreferences("essentials_prefs", MODE_PRIVATE) - if (prefs.getBoolean("pocket_mode_lock_screen_only", false)) { + val lockScreenOnly = prefs.getBoolean("pocket_mode_lock_screen_only", false) + val currentApp = appFlowHandler.currentPackage + if (lockScreenOnly || isAppBypassedForPocketMode(currentApp)) { pocketModeHandler.onScreenOff() // cancel pending timer + remove overlay } } @@ -389,6 +393,66 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene override fun onInterrupt() {} + fun isAppBypassedForPocketMode(packageName: String?): Boolean { + val prefs = getSharedPreferences("essentials_prefs", MODE_PRIVATE) + val lockScreenOnly = prefs.getBoolean("pocket_mode_lock_screen_only", false) + if (lockScreenOnly && !keyguardManager.isKeyguardLocked) { + return true + } + + val checkedPackage = if (packageName != null && ( + pocketModeExcludedAppsSet.contains(packageName) || + isGameOrVideoApp(packageName) || + hasActiveMediaSession(packageName) + )) { + packageName + } else { + val realPkg = getActivePackageName() + if (realPkg != null && ( + pocketModeExcludedAppsSet.contains(realPkg) || + isGameOrVideoApp(realPkg) || + hasActiveMediaSession(realPkg) + )) { + realPkg + } else { + packageName + } + } + + val isExcluded = !keyguardManager.isKeyguardLocked && checkedPackage != null && ( + pocketModeExcludedAppsSet.contains(checkedPackage) || + isGameOrVideoApp(checkedPackage) || + hasActiveMediaSession(checkedPackage) + ) + return isExcluded + } + + private fun getActivePackageName(): String? { + val prefs = getSharedPreferences("essentials_prefs", MODE_PRIVATE) + val useUsageAccess = prefs.getBoolean("use_usage_access", false) + if (useUsageAccess) { + try { + val usm = getSystemService(USAGE_STATS_SERVICE) as? android.app.usage.UsageStatsManager + val time = System.currentTimeMillis() + val events = usm?.queryEvents(time - 1000 * 15, time) + val event = android.app.usage.UsageEvents.Event() + var lastResumed: String? = null + while (events?.hasNextEvent() == true) { + events.getNextEvent(event) + if (event.eventType == android.app.usage.UsageEvents.Event.ACTIVITY_RESUMED) { + lastResumed = event.packageName + } + } + if (lastResumed != null) return lastResumed + } catch (_: Exception) {} + } + return rootInActiveWindow?.packageName?.toString() + } + + fun dismissPocketMode() { + pocketModeHandler.onScreenOff() + } + private fun updatePocketModeSensors() { val prefs = getSharedPreferences("essentials_prefs", MODE_PRIVATE) val pocketModeEnabled = prefs.getBoolean("pocket_mode_enabled", false) @@ -434,15 +498,12 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene val pocketModeEnabled = prefs.getBoolean("pocket_mode_enabled", false) val useLightSensor = prefs.getBoolean("pocket_mode_use_light_sensor", false) val triggerDelayMs = (prefs.getFloat("pocket_mode_trigger_delay", 3f) * 1000).toLong() - val lockScreenOnly = prefs.getBoolean("pocket_mode_lock_screen_only", false) if (pocketModeEnabled && !pocketModeHandler.isBypassed) { val currentApp = appFlowHandler.currentPackage - val shouldBypass = (currentApp != null && ( - pocketModeExcludedAppsSet.contains(currentApp) || - isGameOrVideoApp(currentApp) || - hasActiveMediaSession(currentApp) - )) || (lockScreenOnly && !keyguardManager.isKeyguardLocked) - if (!shouldBypass) { + val shouldBypass = isAppBypassedForPocketMode(currentApp) + if (shouldBypass) { + pocketModeHandler.onScreenOff() + } else { pocketModeHandler.onProximityChanged( isBlocked = flashlightHandler.isProximityBlocked, isLightDark = lightSensorLux <= 3f, @@ -470,15 +531,12 @@ class ScreenOffAccessibilityService : AccessibilityService(), SensorEventListene val pocketModeEnabled = prefs.getBoolean("pocket_mode_enabled", false) val useLightSensor = prefs.getBoolean("pocket_mode_use_light_sensor", false) val triggerDelayMs = (prefs.getFloat("pocket_mode_trigger_delay", 3f) * 1000).toLong() - val lockScreenOnly = prefs.getBoolean("pocket_mode_lock_screen_only", false) if (pocketModeEnabled && !pocketModeHandler.isBypassed) { val currentApp = appFlowHandler.currentPackage - val shouldBypass = (currentApp != null && ( - pocketModeExcludedAppsSet.contains(currentApp) || - isGameOrVideoApp(currentApp) || - hasActiveMediaSession(currentApp) - )) || (lockScreenOnly && !keyguardManager.isKeyguardLocked) - if (!shouldBypass) { + val shouldBypass = isAppBypassedForPocketMode(currentApp) + if (shouldBypass) { + pocketModeHandler.onScreenOff() + } else { pocketModeHandler.onProximityChanged( isBlocked = isBlocked, isLightDark = lightSensorLux <= 3f, diff --git a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/PocketModeSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/PocketModeSettingsUI.kt index 0e82ae603..eb2754d20 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/PocketModeSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/composables/configs/PocketModeSettingsUI.kt @@ -62,7 +62,7 @@ fun PocketModeSettingsUI( description = stringResource(R.string.feat_pocket_mode_desc), isChecked = viewModel.isPocketModeEnabled.value, onCheckedChange = { isChecked -> - viewModel.setPocketModeEnabled(isChecked) + viewModel.setPocketModeEnabled(isChecked, context) }, enabled = true, iconRes = R.drawable.ic_pocket_mode, diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index f47245904..e554bbfc1 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -4035,9 +4035,10 @@ class MainViewModel : ViewModel() { isAodForceTurnOffEnabled.value = enabled } - fun setPocketModeEnabled(enabled: Boolean) { + fun setPocketModeEnabled(enabled: Boolean, context: Context) { settingsRepository.putBoolean(SettingsRepository.KEY_POCKET_MODE_ENABLED, enabled) isPocketModeEnabled.value = enabled + updateAppDetectionService(context) } fun setPocketModeUseLightSensor(enabled: Boolean) { @@ -4085,6 +4086,7 @@ class MainViewModel : ViewModel() { fun savePocketModeExcludedApps(context: Context, apps: List) { settingsRepository.savePocketModeExcludedApps(apps) + updateAppDetectionService(context) } fun updatePocketModeExcludedAppEnabled( @@ -4093,6 +4095,7 @@ class MainViewModel : ViewModel() { enabled: Boolean ) { settingsRepository.updatePocketModeExcludedAppSelection(packageName, enabled) + updateAppDetectionService(context) } override fun onCleared() { From 96c7f7f7d8841362ce07ef48c4c6abfd93c64e6c Mon Sep 17 00:00:00 2001 From: "Thomas Borgogno (personal)" Date: Thu, 30 Jul 2026 11:04:07 +0200 Subject: [PATCH 2/4] feat: add Bluetooth and Wi-Fi automation triggers --- app/src/main/AndroidManifest.xml | 1 + .../essentials/domain/diy/Trigger.kt | 54 +++++ .../services/automation/AutomationManager.kt | 34 +++ .../automation/modules/BluetoothModule.kt | 79 ++++++ .../services/automation/modules/WifiModule.kt | 114 +++++++++ .../ui/activities/AutomationEditorActivity.kt | 90 ++++++- .../sheets/BluetoothDeviceSelectionSheet.kt | 133 ++++++++++ .../sheets/WifiNetworkSelectionSheet.kt | 228 ++++++++++++++++++ .../utils/BluetoothPairedDevicesUtil.kt | 35 +++ .../sameerasw/essentials/utils/WifiUtil.kt | 38 +++ app/src/main/res/values/strings.xml | 11 + 11 files changed, 814 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/services/automation/modules/BluetoothModule.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/services/automation/modules/WifiModule.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/components/sheets/BluetoothDeviceSelectionSheet.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/components/sheets/WifiNetworkSelectionSheet.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/utils/BluetoothPairedDevicesUtil.kt create mode 100644 app/src/main/java/com/sameerasw/essentials/utils/WifiUtil.kt 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 From 046bfae451b72820fae0f95c7008e60e27c70376 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 2 Aug 2026 13:17:57 +0530 Subject: [PATCH 3/4] refactor: support multi-network monitoring in WifiModule and fix receiver registration context in BluetoothModule --- .../automation/modules/BluetoothModule.kt | 10 +++- .../services/automation/modules/WifiModule.kt | 47 +++++++++---------- 2 files changed, 31 insertions(+), 26 deletions(-) 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 index 44ed8a287..7436a3779 100644 --- 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 @@ -46,20 +46,26 @@ class BluetoothModule : AutomationModule { } } + private var appContext: Context? = null + override fun start(context: Context) { + val appCtx = context.applicationContext + appContext = appCtx val filter = IntentFilter().apply { addAction(BluetoothDevice.ACTION_ACL_CONNECTED) addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED) } - context.registerReceiver(receiver, filter) + appCtx.registerReceiver(receiver, filter) } override fun stop(context: Context) { + val appCtx = appContext ?: context.applicationContext try { - context.unregisterReceiver(receiver) + appCtx.unregisterReceiver(receiver) } catch (e: Exception) { // Ignore if not registered } + appContext = null } override fun updateAutomations(automations: List) { 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 index a643052b0..e846e2113 100644 --- 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 @@ -25,25 +25,7 @@ class WifiModule : AutomationModule { 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 activeNetworkSsids = java.util.concurrent.ConcurrentHashMap() private val networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { @@ -53,16 +35,15 @@ class WifiModule : AutomationModule { Log.d(ID, "Wi-Fi capabilities changed but SSID unavailable (check location permission/services)") return } - if (ssid == currentSsid) return + if (activeNetworkSsids[network] == ssid) return Log.d(ID, "Wi-Fi connected: $ssid") - currentSsid = ssid + activeNetworkSsids[network] = ssid handleTrigger { it is Trigger.WifiConnected && it.ssid == ssid } } override fun onLost(network: Network) { - val ssid = currentSsid ?: return + val ssid = activeNetworkSsids.remove(network) ?: return Log.d(ID, "Wi-Fi disconnected: $ssid") - currentSsid = null handleTrigger { it is Trigger.WifiDisconnected && it.ssid == ssid } } } @@ -85,6 +66,24 @@ class WifiModule : AutomationModule { } } + // 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("\"") + } + override fun stop(context: Context) { try { connectivityManager?.unregisterNetworkCallback(networkCallback) @@ -93,7 +92,7 @@ class WifiModule : AutomationModule { } connectivityManager = null appContext = null - currentSsid = null + activeNetworkSsids.clear() } override fun updateAutomations(automations: List) { From 9714aa3a1f9fc18d3ae7b522c0b337d82fe91b89 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sun, 2 Aug 2026 13:29:45 +0530 Subject: [PATCH 4/4] refactor: introduce SelectionCardItem and migrate Bluetooth and Wifi selection sheets to use RoundedCardContainer --- .../ui/components/cards/SelectionCardItem.kt | 55 +++++++++++++++++++ .../sheets/BluetoothDeviceSelectionSheet.kt | 44 +++++---------- .../sheets/WifiNetworkSelectionSheet.kt | 35 +++++------- 3 files changed, 81 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/essentials/ui/components/cards/SelectionCardItem.kt diff --git a/app/src/main/java/com/sameerasw/essentials/ui/components/cards/SelectionCardItem.kt b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/SelectionCardItem.kt new file mode 100644 index 000000000..30b988188 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/ui/components/cards/SelectionCardItem.kt @@ -0,0 +1,55 @@ +package com.sameerasw.essentials.ui.components.cards + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.utils.HapticUtil + +@Composable +fun SelectionCardItem( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + description: String? = null, + iconRes: Int? = null +) { + val view = LocalView.current + + ListItem( + modifier = modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.extraSmall) + .clickable { + HapticUtil.performUIHaptic(view) + onClick() + }, + headlineContent = { Text(title) }, + supportingContent = if (description != null) { + { Text(description) } + } else null, + leadingContent = if (iconRes != null && iconRes != 0) { + { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } else null, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ) + ) +} 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 index 208445f86..90bc6dcd7 100644 --- 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 @@ -5,7 +5,6 @@ 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 @@ -13,9 +12,6 @@ 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 @@ -27,15 +23,14 @@ 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.ui.components.cards.SelectionCardItem +import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer import com.sameerasw.essentials.utils.BluetoothPairedDevicesUtil -import com.sameerasw.essentials.utils.HapticUtil @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -44,7 +39,6 @@ fun BluetoothDeviceSelectionSheet( onSave: (address: String, name: String) -> Unit ) { val context = LocalContext.current - val view = LocalView.current var hasPermission by remember { mutableStateOf( @@ -101,30 +95,18 @@ fun BluetoothDeviceSelectionSheet( 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 + RoundedCardContainer { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + items(devices) { device -> + SelectionCardItem( + title = device.name, + description = device.address, + iconRes = R.drawable.rounded_bluetooth_24, + onClick = { onSave(device.address, device.name) } ) - ) + } } } } 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 index a5142757b..230922a56 100644 --- 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 @@ -35,6 +35,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource @@ -43,6 +44,8 @@ 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.ui.components.cards.SelectionCardItem +import com.sameerasw.essentials.ui.components.containers.RoundedCardContainer import com.sameerasw.essentials.utils.HapticUtil import com.sameerasw.essentials.utils.WifiUtil import kotlinx.coroutines.Dispatchers @@ -118,29 +121,17 @@ fun WifiNetworkSelectionSheet( 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 + RoundedCardContainer { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + items(savedNetworks) { network -> + SelectionCardItem( + title = network, + iconRes = R.drawable.rounded_android_wifi_4_bar_plus_24, + onClick = { onSave(network) } ) - ) + } } } }