Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
Expand Down
54 changes: 54 additions & 0 deletions app/src/main/java/com/sameerasw/essentials/domain/diy/Trigger.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>
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<String>
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<String>
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<String>
get() = listOf(android.Manifest.permission.ACCESS_FINE_LOCATION)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1047,7 +1047,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()
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,6 +65,8 @@ object AutomationManager {
val powerAutomations = mutableListOf<Automation>()
val displayAutomations = mutableListOf<Automation>()
val timeAutomations = mutableListOf<Automation>()
val bluetoothAutomations = mutableListOf<Automation>()
val wifiAutomations = mutableListOf<Automation>()

enabledAutomations.forEach { automation ->
when (automation.type) {
Expand All @@ -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 -> {}
}
}
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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<Automation> = 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
}
}
}
}

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)
}
appCtx.registerReceiver(receiver, filter)
}

override fun stop(context: Context) {
val appCtx = appContext ?: context.applicationContext
try {
appCtx.unregisterReceiver(receiver)
} catch (e: Exception) {
// Ignore if not registered
}
appContext = null
}

override fun updateAutomations(automations: List<Automation>) {
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)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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 = "<unknown ssid>"
}

override val id: String = ID
private var automations: List<Automation> = emptyList()
private val scope = CoroutineScope(Dispatchers.IO)
private var connectivityManager: ConnectivityManager? = null
private var appContext: Context? = null
private val activeNetworkSsids = java.util.concurrent.ConcurrentHashMap<Network, String>()

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 (activeNetworkSsids[network] == ssid) return
Log.d(ID, "Wi-Fi connected: $ssid")
activeNetworkSsids[network] = ssid
handleTrigger { it is Trigger.WifiConnected && it.ssid == ssid }
}

override fun onLost(network: Network) {
val ssid = activeNetworkSsids.remove(network) ?: return
Log.d(ID, "Wi-Fi disconnected: $ssid")
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)
}
}

// 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)
} catch (e: Exception) {
// Ignore if not registered
}
connectivityManager = null
appContext = null
activeNetworkSsids.clear()
}

override fun updateAutomations(automations: List<Automation>) {
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)
}
}
}
}
}
Loading