diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ddbb7ef..51ef118 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -16,7 +16,7 @@ android {
minSdk = 24
targetSdk = 36
versionCode = 1
- versionName = "1.0"
+ versionName = "1.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -123,7 +123,7 @@ dependencies {
implementation(libs.litert.support.api)
// Gson for JSON parsing
- implementation("com.google.code.gson:gson:2.10.1")
+ implementation("com.google.code.gson:gson:2.13.2")
implementation(libs.okhttp)
// ML Kit Text Recognition (OCR)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fffc797..6125d5e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -249,6 +249,15 @@
+
+
+
+
+
> = _devices.asStateFlow()
+ override fun getSelectedDevices(): Flow> =
+ _devices.map { list -> list.filter { it.isSelected } }
+
override suspend fun getDeviceById(id: String): Device? {
return _devices.value.find { it.id == id }
}
@@ -20,11 +24,19 @@ class InMemoryDeviceRepository : DeviceRepository {
_devices.update { currentList ->
val existingIndex = currentList.indexOfFirst { it.id == device.id }
if (existingIndex >= 0) {
+ val existing = currentList[existingIndex]
val mutableList = currentList.toMutableList()
- mutableList[existingIndex] = device
+ // Preserve isSelected from existing device unless explicitly changed
+ mutableList[existingIndex] = device.copy(isSelected = existing.isSelected)
mutableList
} else {
- currentList + device
+ // Auto-select if this is the first and only device
+ val updatedDevice = if (currentList.isEmpty()) {
+ device.copy(isSelected = true)
+ } else {
+ device
+ }
+ currentList + updatedDevice
}
}
}
@@ -34,4 +46,16 @@ class InMemoryDeviceRepository : DeviceRepository {
currentList.filterNot { it.id == id }
}
}
+
+ override suspend fun toggleDeviceSelection(id: String) {
+ _devices.update { currentList ->
+ currentList.map { device ->
+ if (device.id == id) {
+ device.copy(isSelected = !device.isSelected)
+ } else {
+ device
+ }
+ }
+ }
+ }
}
diff --git a/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/domain/Device.kt b/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/domain/Device.kt
index 6aef265..896aee3 100644
--- a/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/domain/Device.kt
+++ b/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/domain/Device.kt
@@ -23,5 +23,6 @@ data class Device(
val port: Int,
val status: DeviceStatus = DeviceStatus.UNKNOWN,
val lastSeen: Long = System.currentTimeMillis(),
- val capabilities: List = emptyList()
+ val capabilities: List = emptyList(),
+ val isSelected: Boolean = false
)
diff --git a/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/domain/DeviceRepository.kt b/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/domain/DeviceRepository.kt
index d61370f..28335b3 100644
--- a/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/domain/DeviceRepository.kt
+++ b/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/domain/DeviceRepository.kt
@@ -4,7 +4,9 @@ import kotlinx.coroutines.flow.Flow
interface DeviceRepository {
fun getAllDevices(): Flow>
+ fun getSelectedDevices(): Flow>
suspend fun getDeviceById(id: String): Device?
suspend fun addOrUpdateDevice(device: Device)
suspend fun removeDevice(id: String)
+ suspend fun toggleDeviceSelection(id: String)
}
diff --git a/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/engine/HardwareButtonMapper.kt b/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/engine/HardwareButtonMapper.kt
index 96ba3cf..41ea19e 100644
--- a/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/engine/HardwareButtonMapper.kt
+++ b/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/engine/HardwareButtonMapper.kt
@@ -1,16 +1,25 @@
package com.autonion.automationcompanion.features.cross_device_automation.engine
import android.content.Context
+import android.database.ContentObserver
+import android.media.AudioManager
+import android.media.VolumeProvider
+import android.media.session.MediaSession
+import android.media.session.PlaybackState
+import android.os.Handler
+import android.os.Looper
import android.os.PowerManager
+import android.provider.Settings
import android.view.KeyEvent
import android.util.Log
import com.autonion.automationcompanion.features.cross_device_automation.CrossDeviceAutomationManager
-import com.autonion.automationcompanion.features.cross_device_automation.domain.AutomationPrompt
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.UUID
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicLong
enum class GestureType { SINGLE_TAP, DOUBLE_TAP, LONG_PRESS }
@@ -18,23 +27,40 @@ sealed class DesktopAction {
data class SendKey(val keyName: String) : DesktopAction()
}
+/**
+ * Three-layer volume key interception:
+ *
+ * 1. **onKeyEvent** (AccessibilityService) — Screen ON only. Full gesture detection.
+ * Consumes keys (returns true) so system volume doesn't change.
+ *
+ * 2. **VolumeProvider** (MediaSession) — Works on stock Android screen-off.
+ * OEM ROMs often bypass this entirely.
+ *
+ * 3. **ContentObserver** (Settings.System) — THE primary screen-off mechanism.
+ * When screen is off, onKeyEvent does NOT consume keys, so the system volume
+ * changes. ContentObserver detects the change and fires the action.
+ * Volume is held at midpoint so both directions always register.
+ *
+ * Silent AudioTrack in ForegroundService keeps the audio subsystem alive,
+ * preventing deep Doze from suspending ContentObserver delivery.
+ */
object HardwareButtonMapper {
private const val TAG = "HardwareButtonMapper"
private const val DOUBLE_TAP_TIMEOUT = 300L
private const val LONG_PRESS_TIMEOUT = 500L
+ private const val DEDUP_WINDOW_MS = 800L
private var activeMappings = mutableMapOf, DesktopAction>()
private var applicationContext: Context? = null
-
+
private val _isActive = MutableStateFlow(false)
val isActive: StateFlow = _isActive.asStateFlow()
val currentMappings: Map, DesktopAction> get() = activeMappings.toMap()
- private var wakeLock: PowerManager.WakeLock? = null
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
- // State for gesture detection
+ // Gesture detection state (onKeyEvent path — screen ON only)
private var lastKeyDownTime = 0L
private var lastKeyUpTime = 0L
private var currentKeyCode = 0
@@ -42,12 +68,28 @@ object HardwareButtonMapper {
private var singleTapJob: Job? = null
private var wasLongPressHandled = false
+ private var mediaSession: MediaSession? = null
+ private var powerManager: PowerManager? = null
+
+ // Deduplication: timestamp of the last action executed by ANY path
+ private val lastActionTime = AtomicLong(0L)
+
+ // ContentObserver for screen-off volume detection
+ private var volumeObserver: ContentObserver? = null
+ private var audioManager: AudioManager? = null
+ private var anchorVolume: Int = -1
+ private var originalVolume: Int = -1
+ private val isRestoringVolume = AtomicBoolean(false)
+
fun activate(context: Context, mappings: Map, DesktopAction>) {
applicationContext = context.applicationContext
+ powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
activeMappings.clear()
activeMappings.putAll(mappings)
_isActive.value = true
- acquireWakeLock()
+ HardwareRemoteForegroundService.start(context)
+ setupMediaSession(context)
+ setupVolumeObserver(context)
saveMappings(context, mappings)
Log.d(TAG, "Activated with mappings: $mappings")
}
@@ -55,107 +97,181 @@ object HardwareButtonMapper {
fun deactivate() {
activeMappings.clear()
_isActive.value = false
- releaseWakeLock()
+ applicationContext?.let { HardwareRemoteForegroundService.stop(it) }
+ teardownVolumeObserver()
+ teardownMediaSession()
cancelAllJobs()
+ powerManager = null
Log.d(TAG, "Deactivated")
}
- fun saveMappings(context: Context, mappings: Map, DesktopAction>) {
- val prefs = context.getSharedPreferences("HardwareRemotePrefs", Context.MODE_PRIVATE)
- val editor = prefs.edit()
- editor.clear()
- for ((key, action) in mappings) {
- val keyCode = key.first
- val gesture = key.second.name
- if (action is DesktopAction.SendKey) {
- editor.putString("${keyCode}|${gesture}", action.keyName)
+ // ── ContentObserver: primary screen-off mechanism ────────────────────
+
+ private fun setupVolumeObserver(context: Context) {
+ val ctx = context.applicationContext
+ audioManager = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ val am = audioManager ?: return
+
+ // Save original volume to restore on deactivate
+ originalVolume = am.getStreamVolume(AudioManager.STREAM_MUSIC)
+ val maxVol = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
+ anchorVolume = maxVol / 2
+ // Set to midpoint so both up/down always register a change
+ am.setStreamVolume(AudioManager.STREAM_MUSIC, anchorVolume, 0)
+ Log.d(TAG, "VolumeObserver: anchor=$anchorVolume, original=$originalVolume, max=$maxVol")
+
+ val handler = Handler(Looper.getMainLooper())
+ volumeObserver = object : ContentObserver(handler) {
+ override fun onChange(selfChange: Boolean) {
+ if (!_isActive.value) return
+ if (isRestoringVolume.get()) return
+
+ val am2 = audioManager ?: return
+ val currentVol = am2.getStreamVolume(AudioManager.STREAM_MUSIC)
+ if (currentVol == anchorVolume) return
+
+ // Deduplicate with onKeyEvent (screen-on path)
+ val elapsed = System.currentTimeMillis() - lastActionTime.get()
+ if (elapsed < DEDUP_WINDOW_MS) {
+ // onKeyEvent already handled this — just restore anchor
+ restoreAnchorVolume(am2)
+ return
+ }
+
+ val keyCode = if (currentVol > anchorVolume) {
+ KeyEvent.KEYCODE_VOLUME_UP
+ } else {
+ KeyEvent.KEYCODE_VOLUME_DOWN
+ }
+
+ Log.d(TAG, "VolumeObserver: volume $anchorVolume→$currentVol, keyCode=$keyCode")
+ restoreAnchorVolume(am2)
+ executeAction(keyCode, GestureType.SINGLE_TAP)
}
}
- editor.apply()
- Log.d(TAG, "Saved ${mappings.size} mappings to prefs")
+
+ ctx.contentResolver.registerContentObserver(
+ Settings.System.CONTENT_URI, true, volumeObserver!!
+ )
+ Log.d(TAG, "VolumeObserver registered")
}
- fun loadMappings(context: Context): Map, DesktopAction> {
- val prefs = context.getSharedPreferences("HardwareRemotePrefs", Context.MODE_PRIVATE)
- val loadedMappings = mutableMapOf, DesktopAction>()
- for (key in prefs.all.keys) {
- val parts = key.split("|", limit = 2)
- if (parts.size == 2) {
- val keyCode = parts[0].toIntOrNull()
- val gesture = try { GestureType.valueOf(parts[1]) } catch (e: Exception) { null }
- val actionString = prefs.getString(key, null)
- if (keyCode != null && gesture != null && actionString != null) {
- loadedMappings[Pair(keyCode, gesture)] = DesktopAction.SendKey(actionString)
- }
- }
+ private fun restoreAnchorVolume(am: AudioManager) {
+ isRestoringVolume.set(true)
+ try {
+ am.setStreamVolume(AudioManager.STREAM_MUSIC, anchorVolume, 0)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to restore anchor volume", e)
+ } finally {
+ Handler(Looper.getMainLooper()).postDelayed({
+ isRestoringVolume.set(false)
+ }, 250)
}
- Log.d(TAG, "Loaded ${loadedMappings.size} mappings from prefs")
- return loadedMappings
}
- private fun acquireWakeLock() {
- if (wakeLock == null) {
- applicationContext?.let { ctx ->
- val powerManager = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
- wakeLock = powerManager.newWakeLock(
- PowerManager.PARTIAL_WAKE_LOCK,
- "AutomationCompanion:HardwareRemoteWakeLock"
- )
- }
+ private fun teardownVolumeObserver() {
+ volumeObserver?.let { observer ->
+ applicationContext?.contentResolver?.unregisterContentObserver(observer)
}
- if (wakeLock?.isHeld == false) {
- wakeLock?.acquire() // no timeout, we hold it until deactivated
- Log.d(TAG, "WakeLock acquired")
+ volumeObserver = null
+ if (originalVolume >= 0) {
+ audioManager?.setStreamVolume(AudioManager.STREAM_MUSIC, originalVolume, 0)
+ Log.d(TAG, "Restored original volume: $originalVolume")
}
+ audioManager = null
}
- private fun releaseWakeLock() {
- if (wakeLock?.isHeld == true) {
- wakeLock?.release()
- Log.d(TAG, "WakeLock released")
+ // ── MediaSession VolumeProvider ─────────────────────────────────────
+
+ private fun setupMediaSession(context: Context) {
+ if (mediaSession == null) {
+ mediaSession = MediaSession(context, "HardwareRemoteSession").apply {
+ val state = PlaybackState.Builder()
+ .setActions(PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PLAY_PAUSE)
+ .setState(PlaybackState.STATE_PLAYING, PlaybackState.PLAYBACK_POSITION_UNKNOWN, 1.0f)
+ .build()
+ setPlaybackState(state)
+
+ setCallback(object : MediaSession.Callback() {
+ override fun onMediaButtonEvent(mediaButtonIntent: android.content.Intent): Boolean {
+ Log.d(TAG, "MediaSession: onMediaButtonEvent")
+ return super.onMediaButtonEvent(mediaButtonIntent)
+ }
+ })
+
+ val volumeProvider = object : VolumeProvider(
+ VOLUME_CONTROL_RELATIVE, 15, 7
+ ) {
+ override fun onAdjustVolume(direction: Int) {
+ if (!_isActive.value) return
+ val elapsed = System.currentTimeMillis() - lastActionTime.get()
+ if (elapsed < DEDUP_WINDOW_MS) return
+ val keyCode = when {
+ direction > 0 -> KeyEvent.KEYCODE_VOLUME_UP
+ direction < 0 -> KeyEvent.KEYCODE_VOLUME_DOWN
+ else -> return
+ }
+ Log.d(TAG, "VolumeProvider: direction=$direction")
+ executeAction(keyCode, GestureType.SINGLE_TAP)
+ }
+ }
+ setPlaybackToRemote(volumeProvider)
+ isActive = true
+ }
+ Log.d(TAG, "MediaSession active with VolumeProvider")
}
}
+ private fun teardownMediaSession() {
+ mediaSession?.isActive = false
+ mediaSession?.release()
+ mediaSession = null
+ }
+
+ // ── AccessibilityService onKeyEvent: screen-ON path ─────────────────
+
+ /**
+ * Called by AccessibilityService. When screen is ON, we consume the key
+ * (return true) and do full gesture detection. When screen is OFF, we
+ * let the key pass through (return false) so the system volume changes
+ * and ContentObserver can detect it — this is the only reliable screen-off
+ * mechanism on OEM ROMs.
+ */
fun onKeyEvent(event: KeyEvent): Boolean {
if (!_isActive.value) return false
-
val keyCode = event.keyCode
if (keyCode != KeyEvent.KEYCODE_VOLUME_UP && keyCode != KeyEvent.KEYCODE_VOLUME_DOWN) {
- return false // We only handle volume keys
+ return false
}
+ // If screen is OFF → don't consume, let ContentObserver handle it
+ val isScreenOn = powerManager?.isInteractive ?: true
+ if (!isScreenOn) {
+ Log.d(TAG, "onKeyEvent: screen OFF, passing through to ContentObserver")
+ return false
+ }
+
+ // Screen is ON — full gesture detection
+ lastActionTime.set(System.currentTimeMillis())
when (event.action) {
- KeyEvent.ACTION_DOWN -> {
- if (event.repeatCount == 0) {
- handleActionDown(keyCode)
- }
- }
- KeyEvent.ACTION_UP -> {
- handleActionUp(keyCode)
- }
+ KeyEvent.ACTION_DOWN -> { if (event.repeatCount == 0) handleActionDown(keyCode) }
+ KeyEvent.ACTION_UP -> handleActionUp(keyCode)
}
- return true // Always consume volume keys when active
+ return true // consume key when screen is ON
}
private fun handleActionDown(keyCode: Int) {
val now = System.currentTimeMillis()
-
- // Check if this is the start of a double tap
if (keyCode == currentKeyCode && (now - lastKeyUpTime) < DOUBLE_TAP_TIMEOUT && singleTapJob?.isActive == true) {
- // It's a double tap!
singleTapJob?.cancel()
- cancelAllJobs() // Clean up
+ cancelAllJobs()
executeAction(keyCode, GestureType.DOUBLE_TAP)
return
}
-
- // Fresh press
currentKeyCode = keyCode
lastKeyDownTime = now
wasLongPressHandled = false
cancelAllJobs()
-
- // Start long press timer
longPressJob = scope.launch {
delay(LONG_PRESS_TIMEOUT)
wasLongPressHandled = true
@@ -165,12 +281,9 @@ object HardwareButtonMapper {
private fun handleActionUp(keyCode: Int) {
if (keyCode != currentKeyCode) return
-
lastKeyUpTime = System.currentTimeMillis()
longPressJob?.cancel()
-
if (!wasLongPressHandled) {
- // It was a short press. Start the single tap job, waiting to see if a double tap comes
singleTapJob = scope.launch {
delay(DOUBLE_TAP_TIMEOUT)
executeAction(keyCode, GestureType.SINGLE_TAP)
@@ -178,14 +291,15 @@ object HardwareButtonMapper {
}
}
+ // ── Shared action execution ─────────────────────────────────────────
+
private fun executeAction(keyCode: Int, gesture: GestureType) {
+ lastActionTime.set(System.currentTimeMillis())
Log.d(TAG, "Gesture detected: KeyCode=$keyCode, Gesture=$gesture")
val action = activeMappings[Pair(keyCode, gesture)]
if (action != null) {
when (action) {
- is DesktopAction.SendKey -> {
- broadcastKeyToDesktop(action.keyName)
- }
+ is DesktopAction.SendKey -> broadcastKeyToDesktop(action.keyName)
}
}
}
@@ -203,6 +317,41 @@ object HardwareButtonMapper {
}
}
+ // ── Persistence ─────────────────────────────────────────────────────
+
+ fun saveMappings(context: Context, mappings: Map, DesktopAction>) {
+ val prefs = context.getSharedPreferences("HardwareRemotePrefs", Context.MODE_PRIVATE)
+ val editor = prefs.edit()
+ editor.clear()
+ for ((key, action) in mappings) {
+ val keyCode = key.first
+ val gesture = key.second.name
+ if (action is DesktopAction.SendKey) {
+ editor.putString("${keyCode}|${gesture}", action.keyName)
+ }
+ }
+ editor.apply()
+ Log.d(TAG, "Saved ${mappings.size} mappings to prefs")
+ }
+
+ fun loadMappings(context: Context): Map, DesktopAction> {
+ val prefs = context.getSharedPreferences("HardwareRemotePrefs", Context.MODE_PRIVATE)
+ val loadedMappings = mutableMapOf, DesktopAction>()
+ for (key in prefs.all.keys) {
+ val parts = key.split("|", limit = 2)
+ if (parts.size == 2) {
+ val keyCode = parts[0].toIntOrNull()
+ val gesture = try { GestureType.valueOf(parts[1]) } catch (e: Exception) { null }
+ val actionString = prefs.getString(key, null)
+ if (keyCode != null && gesture != null && actionString != null) {
+ loadedMappings[Pair(keyCode, gesture)] = DesktopAction.SendKey(actionString)
+ }
+ }
+ }
+ Log.d(TAG, "Loaded ${loadedMappings.size} mappings from prefs")
+ return loadedMappings
+ }
+
private fun cancelAllJobs() {
longPressJob?.cancel()
singleTapJob?.cancel()
diff --git a/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/engine/HardwareRemoteForegroundService.kt b/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/engine/HardwareRemoteForegroundService.kt
new file mode 100644
index 0000000..24490b5
--- /dev/null
+++ b/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/engine/HardwareRemoteForegroundService.kt
@@ -0,0 +1,267 @@
+package com.autonion.automationcompanion.features.cross_device_automation.engine
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.media.AudioAttributes
+import android.media.AudioFormat
+import android.media.AudioManager
+import android.media.AudioTrack
+import android.net.wifi.WifiManager
+import android.os.Build
+import android.os.IBinder
+import android.os.PowerManager
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import com.autonion.automationcompanion.MainActivity
+import com.autonion.automationcompanion.R
+
+/**
+ * Foreground service that keeps the Hardware Remote alive during screen-off / Doze mode.
+ *
+ * Three mechanisms keep the process alive on aggressive OEM ROMs:
+ *
+ * 1. **Foreground notification** — Exempts from battery optimisation.
+ * 2. **WakeLock + WifiLock** — Prevents CPU & WiFi radio suspension.
+ * 3. **Silent AudioTrack** — Keeps the audio subsystem active, which prevents
+ * the OS from suspending audio/volume key dispatch and ContentObserver
+ * delivery during deep Doze. This is the same technique music players
+ * (Spotify, YouTube Music) use to maintain background playback.
+ */
+class HardwareRemoteForegroundService : Service() {
+
+ companion object {
+ private const val TAG = "HardwareRemoteService"
+ private const val CHANNEL_ID = "hardware_remote_channel"
+ private const val NOTIFICATION_ID = 2001
+
+ fun start(context: Context) {
+ val intent = Intent(context, HardwareRemoteForegroundService::class.java)
+ ContextCompat.startForegroundService(context, intent)
+ }
+
+ fun stop(context: Context) {
+ val intent = Intent(context, HardwareRemoteForegroundService::class.java)
+ context.stopService(intent)
+ }
+ }
+
+ private var wakeLock: PowerManager.WakeLock? = null
+ private var wifiLock: WifiManager.WifiLock? = null
+ private var silentAudioTrack: AudioTrack? = null
+ private var silentAudioThread: Thread? = null
+ @Volatile private var isPlayingSilence = false
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ // Handle stop action from notification
+ if (intent?.action == "ACTION_STOP") {
+ Log.d(TAG, "Stop action received from notification")
+ HardwareButtonMapper.deactivate() // This will call stop(context) on us
+ return START_NOT_STICKY
+ }
+
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ startForeground(
+ NOTIFICATION_ID,
+ buildNotification(),
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
+ )
+ } else {
+ // Pre-Android 14: don't specify a type — specialUse doesn't exist
+ // and passing a mismatched type crashes the app.
+ startForeground(NOTIFICATION_ID, buildNotification())
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start foreground service: ${e.message}")
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ acquireLocks()
+ startSilentAudio()
+ Log.d(TAG, "Foreground service started — WiFi, CPU locks, and silent audio active")
+ return START_STICKY
+ }
+
+ // ── Silent audio playback ───────────────────────────────────────────
+
+ /**
+ * Plays an inaudible audio stream to keep the audio subsystem alive.
+ *
+ * When the screen is off and the device enters deep Doze, the OS suspends
+ * audio-related callbacks (ContentObserver, VolumeProvider) and even the
+ * AccessibilityService key event dispatch pipeline. An active AudioTrack
+ * prevents this suspension by making the OS treat this process as an
+ * active media playback session.
+ *
+ * The buffer is all zeros (silence), so no sound is produced.
+ */
+ private fun startSilentAudio() {
+ if (isPlayingSilence) return
+
+ isPlayingSilence = true
+ silentAudioThread = Thread({
+ try {
+ val sampleRate = 8000
+ val bufferSize = AudioTrack.getMinBufferSize(
+ sampleRate,
+ AudioFormat.CHANNEL_OUT_MONO,
+ AudioFormat.ENCODING_PCM_16BIT
+ )
+
+ val audioTrack = AudioTrack.Builder()
+ .setAudioAttributes(
+ AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
+ .build()
+ )
+ .setAudioFormat(
+ AudioFormat.Builder()
+ .setSampleRate(sampleRate)
+ .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
+ .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
+ .build()
+ )
+ .setBufferSizeInBytes(bufferSize)
+ .setTransferMode(AudioTrack.MODE_STREAM)
+ .build()
+
+ silentAudioTrack = audioTrack
+ audioTrack.play()
+ Log.d(TAG, "Silent audio started (keeps audio subsystem alive)")
+
+ val silence = ByteArray(bufferSize)
+ while (isPlayingSilence) {
+ audioTrack.write(silence, 0, silence.size)
+ // Small sleep to avoid burning CPU
+ Thread.sleep(500)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Silent audio error: ${e.message}")
+ }
+ }, "SilentAudioThread").apply {
+ isDaemon = true
+ start()
+ }
+ }
+
+ private fun stopSilentAudio() {
+ isPlayingSilence = false
+ try {
+ silentAudioTrack?.stop()
+ silentAudioTrack?.release()
+ } catch (e: Exception) {
+ Log.w(TAG, "Error stopping silent audio: ${e.message}")
+ }
+ silentAudioTrack = null
+ silentAudioThread = null
+ Log.d(TAG, "Silent audio stopped")
+ }
+
+ // ── Lock management ─────────────────────────────────────────────────
+
+ private fun acquireLocks() {
+ if (wakeLock == null) {
+ val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
+ wakeLock = powerManager.newWakeLock(
+ PowerManager.PARTIAL_WAKE_LOCK,
+ "AutomationCompanion:HardwareRemoteFgWakeLock"
+ )
+ }
+ if (wakeLock?.isHeld == false) {
+ wakeLock?.acquire()
+ Log.d(TAG, "WakeLock acquired")
+ }
+
+ if (wifiLock == null) {
+ val wifiManager = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
+ @Suppress("DEPRECATION")
+ wifiLock = wifiManager.createWifiLock(
+ WifiManager.WIFI_MODE_FULL_HIGH_PERF,
+ "AutomationCompanion:HardwareRemoteFgWifiLock"
+ )
+ }
+ if (wifiLock?.isHeld == false) {
+ wifiLock?.acquire()
+ Log.d(TAG, "WifiLock acquired")
+ }
+ }
+
+ private fun releaseLocks() {
+ if (wakeLock?.isHeld == true) {
+ wakeLock?.release()
+ Log.d(TAG, "WakeLock released")
+ }
+ if (wifiLock?.isHeld == true) {
+ wifiLock?.release()
+ Log.d(TAG, "WifiLock released")
+ }
+ }
+
+ // ── Notification ────────────────────────────────────────────────────
+
+ private fun buildNotification(): Notification {
+ val openIntent = Intent(this, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ }
+ val openPi = PendingIntent.getActivity(
+ this, 0, openIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val stopIntent = Intent(this, HardwareRemoteForegroundService::class.java).apply {
+ action = "ACTION_STOP"
+ }
+ val stopPi = PendingIntent.getService(
+ this, 1, stopIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("Hardware Remote Active")
+ .setContentText("Volume keys are controlling your desktop")
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentIntent(openPi)
+ .addAction(R.drawable.ic_close, "Stop", stopPi)
+ .setOngoing(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .build()
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "Hardware Remote",
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = "Keeps the hardware remote connected while screen is off"
+ }
+ val nm = getSystemService(NotificationManager::class.java)
+ nm.createNotificationChannel(channel)
+ }
+ }
+
+ override fun onDestroy() {
+ stopSilentAudio()
+ releaseLocks()
+ Log.d(TAG, "Foreground service destroyed")
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+}
diff --git a/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/networking/NetworkingManager.kt b/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/networking/NetworkingManager.kt
index b5b4aeb..dc1de10 100644
--- a/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/networking/NetworkingManager.kt
+++ b/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/networking/NetworkingManager.kt
@@ -14,10 +14,12 @@ import com.autonion.automationcompanion.features.cross_device_automation.event_p
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -44,11 +46,12 @@ class NetworkingManager(
private val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS)
- .pingInterval(30, TimeUnit.SECONDS) // Keep-alive for background stability
+ .pingInterval(15, TimeUnit.SECONDS) // Aggressive keep-alive to survive Doze
.build()
private val activeConnections = ConcurrentHashMap()
private val connectedEndpoints = ConcurrentHashMap.newKeySet() // Tracks IP:port to prevent duplicate connections
+ private val reconnectingDevices = ConcurrentHashMap.newKeySet() // Prevents duplicate reconnect coroutines
private val gson = Gson()
private val scope = CoroutineScope(Dispatchers.IO)
@@ -65,12 +68,27 @@ class NetworkingManager(
if (collectionJob?.isActive == true) return
collectionJob = scope.launch {
- deviceRepository.getAllDevices().collectLatest { devices ->
- devices.forEach { device ->
- val endpoint = "${device.ipAddress}:${device.port}"
- if (!activeConnections.containsKey(device.id) && !connectedEndpoints.contains(endpoint)) {
- connectToDevice(device)
+ deviceRepository.getSelectedDevices().collectLatest { selectedDevices ->
+ // Disconnect from devices that are no longer selected
+ val selectedIds = selectedDevices.map { it.id }.toSet()
+ val toDisconnect = activeConnections.keys.filter { it !in selectedIds }
+ for (deviceId in toDisconnect) {
+ val ws = activeConnections.remove(deviceId)
+ ws?.close(1000, "Device deselected")
+ // Clean up the endpoint tracking
+ scope.launch {
+ val device = deviceRepository.getDeviceById(deviceId)
+ if (device != null) {
+ connectedEndpoints.remove("${device.ipAddress}:${device.port}")
+ }
}
+ listener?.onDeviceDisconnected(deviceId)
+ Log.d(TAG, "Disconnected deselected device: $deviceId")
+ }
+
+ // Connect to newly selected devices
+ selectedDevices.forEach { device ->
+ connectToDevice(device)
}
}
}
@@ -78,9 +96,20 @@ class NetworkingManager(
- private fun connectToDevice(device: Device) {
+ private fun connectToDevice(device: Device): Boolean {
+ val endpoint = "${device.ipAddress}:${device.port}"
+ // Synchronized check to prevent duplicate WebSocket creation
+ synchronized(this) {
+ if (activeConnections.containsKey(device.id) || connectedEndpoints.contains(endpoint)) {
+ Log.d(TAG, "Skipping duplicate connection to ${device.name} ($endpoint)")
+ return false
+ }
+ // Mark endpoint as connecting to block other attempts
+ connectedEndpoints.add(endpoint)
+ }
+
val request = Request.Builder()
- .url("ws://${device.ipAddress}:${device.port}/automation") // Assuming path
+ .url("ws://${endpoint}/automation")
.build()
Log.d(TAG, "Connecting to ${device.name} at ${device.ipAddress}")
@@ -100,8 +129,14 @@ class NetworkingManager(
"Connected to ${device.name} at ws://${device.ipAddress}:${device.port}",
TAG
)
- activeConnections[device.id] = webSocket
- connectedEndpoints.add("${device.ipAddress}:${device.port}")
+ // Close any stale duplicate if one snuck through
+ val oldWs = activeConnections.put(device.id, webSocket)
+ if (oldWs != null && oldWs !== webSocket) {
+ Log.d(TAG, "Closing stale duplicate connection for ${device.name}")
+ oldWs.close(1000, "Replaced by new connection")
+ }
+ connectedEndpoints.add(endpoint)
+ reconnectingDevices.remove(device.id)
this@NetworkingManager.listener?.onDeviceConnected(device)
}
@@ -207,6 +242,7 @@ class NetworkingManager(
activeConnections.remove(device.id)
connectedEndpoints.remove("${device.ipAddress}:${device.port}")
this@NetworkingManager.listener?.onDeviceDisconnected(device.id)
+ // Don't reconnect on graceful close (user-initiated or deselect)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
@@ -220,10 +256,13 @@ class NetworkingManager(
activeConnections.remove(device.id)
connectedEndpoints.remove("${device.ipAddress}:${device.port}")
this@NetworkingManager.listener?.onDeviceDisconnected(device.id)
+ // Auto-reconnect if this device is still selected
+ scheduleReconnect(device)
}
}
client.newWebSocket(request, listener)
+ return true
}
fun sendCommand(deviceId: String, command: Any) {
@@ -262,5 +301,64 @@ class NetworkingManager(
client.dispatcher.cancelAll()
}
+ private fun scheduleReconnect(device: Device) {
+ // Prevent multiple concurrent reconnect loops for the same device
+ if (!reconnectingDevices.add(device.id)) {
+ Log.d(TAG, "Reconnect already in progress for ${device.name}, skipping")
+ return
+ }
+
+ scope.launch {
+ var attempt = 0
+ val maxDelay = 30_000L // 30 seconds max
+ try {
+ while (collectionJob?.isActive == true) {
+ // Check if device is still selected before reconnecting
+ val selectedDevices = deviceRepository.getSelectedDevices().first()
+ val stillSelected = selectedDevices.any { it.id == device.id }
+ if (!stillSelected) {
+ Log.d(TAG, "Device ${device.name} no longer selected, stopping reconnect")
+ return@launch
+ }
+ // Already reconnected by another path
+ if (activeConnections.containsKey(device.id)) {
+ Log.d(TAG, "Device ${device.name} already reconnected")
+ return@launch
+ }
+
+ val delayMs = minOf((2000L * (1 shl attempt)), maxDelay)
+ Log.d(TAG, "Reconnecting to ${device.name} in ${delayMs}ms (attempt ${attempt + 1})")
+ delay(delayMs)
+
+ // Re-check after delay
+ val stillSelectedAfterDelay = deviceRepository.getSelectedDevices().first().any { it.id == device.id }
+ if (!stillSelectedAfterDelay || activeConnections.containsKey(device.id)) {
+ return@launch
+ }
+
+ // Fetch latest device info (IP may have changed)
+ val latestDevice = deviceRepository.getDeviceById(device.id) ?: return@launch
+ Log.d(TAG, "Attempting reconnect to ${latestDevice.name} at ${latestDevice.ipAddress}:${latestDevice.port}")
+ val connected = connectToDevice(latestDevice)
+
+ if (!connected) {
+ // connectToDevice was blocked (already connecting), just wait
+ delay(3000)
+ } else {
+ // Wait a bit to see if connection succeeds
+ delay(3000)
+ }
+ if (activeConnections.containsKey(device.id)) {
+ Log.d(TAG, "Reconnect to ${device.name} succeeded")
+ return@launch
+ }
+ attempt++
+ }
+ } finally {
+ reconnectingDevices.remove(device.id)
+ }
+ }
+ }
+
}
diff --git a/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/presentation/DeviceManagementScreen.kt b/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/presentation/DeviceManagementScreen.kt
index 9f98366..7ab4795 100644
--- a/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/presentation/DeviceManagementScreen.kt
+++ b/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/presentation/DeviceManagementScreen.kt
@@ -12,9 +12,11 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Computer
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.PhoneAndroid
+import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material.icons.filled.Tv
@@ -270,7 +272,7 @@ fun DeviceManagementScreen() {
// ─── Device Cards ───────────────────────────
itemsIndexed(devices) { index, device ->
- StaggeredDeviceItem(device = device, index = index)
+ StaggeredDeviceItem(device = device, index = index, onToggleSelection = { viewModel.toggleDeviceSelection(device.id) })
}
}
}
@@ -509,7 +511,7 @@ private fun SetupStep(number: String, text: String) {
// ═══════════════════════════════════════════════════════════════
@Composable
-private fun StaggeredDeviceItem(device: Device, index: Int) {
+private fun StaggeredDeviceItem(device: Device, index: Int, onToggleSelection: () -> Unit) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(index * 100L)
@@ -520,21 +522,23 @@ private fun StaggeredDeviceItem(device: Device, index: Int) {
visible = visible,
enter = fadeIn(tween(300)) + slideInVertically(tween(300)) { it / 2 }
) {
- DeviceGlassCard(device)
+ DeviceGlassCard(device, onToggleSelection = onToggleSelection)
}
}
@Composable
-private fun DeviceGlassCard(device: Device) {
+private fun DeviceGlassCard(device: Device, onToggleSelection: () -> Unit) {
val statusColor = when (device.status) {
DeviceStatus.ONLINE -> OnlineGreen
DeviceStatus.OFFLINE -> OfflineRed
DeviceStatus.UNKNOWN -> UnknownGray
}
- val statusLabel = when (device.status) {
- DeviceStatus.ONLINE -> "Online"
- DeviceStatus.OFFLINE -> "Offline"
- DeviceStatus.UNKNOWN -> "Unknown"
+ val statusLabel = when {
+ device.isSelected && device.status == DeviceStatus.ONLINE -> "Connected"
+ device.isSelected -> "Selected"
+ device.status == DeviceStatus.ONLINE -> "Available"
+ device.status == DeviceStatus.OFFLINE -> "Offline"
+ else -> "Unknown"
}
val iconBgColor = when (device.role) {
@@ -553,7 +557,11 @@ private fun DeviceGlassCard(device: Device) {
listOf(CardGlass, CardGlass.copy(alpha = 0.35f))
)
)
- .background(CardBorder, RoundedCornerShape(16.dp))
+ .background(
+ if (device.isSelected) AccentPurple.copy(alpha = 0.08f) else CardBorder,
+ RoundedCornerShape(16.dp)
+ )
+ .clickable { onToggleSelection() }
) {
Row(
modifier = Modifier
@@ -625,11 +633,21 @@ private fun DeviceGlassCard(device: Device) {
Spacer(Modifier.width(6.dp))
Text(
statusLabel,
- color = statusColor.copy(alpha = 0.8f),
+ color = (if (device.isSelected) AccentPurple else statusColor).copy(alpha = 0.8f),
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
+
+ Spacer(Modifier.width(8.dp))
+
+ // Selection toggle icon
+ Icon(
+ imageVector = if (device.isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
+ contentDescription = if (device.isSelected) "Connected" else "Tap to connect",
+ tint = if (device.isSelected) AccentPurple else Color.White.copy(alpha = 0.3f),
+ modifier = Modifier.size(24.dp)
+ )
}
}
}
diff --git a/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/presentation/DeviceManagementViewModel.kt b/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/presentation/DeviceManagementViewModel.kt
index afe2d72..81f822d 100644
--- a/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/presentation/DeviceManagementViewModel.kt
+++ b/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/presentation/DeviceManagementViewModel.kt
@@ -49,4 +49,10 @@ class DeviceManagementViewModel(
}
}
}
+
+ fun toggleDeviceSelection(deviceId: String) {
+ viewModelScope.launch {
+ manager.deviceRepository.toggleDeviceSelection(deviceId)
+ }
+ }
}
diff --git a/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/presentation/HardwareRemoteSheet.kt b/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/presentation/HardwareRemoteSheet.kt
index a0972e9..abbe877 100644
--- a/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/presentation/HardwareRemoteSheet.kt
+++ b/app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/presentation/HardwareRemoteSheet.kt
@@ -15,6 +15,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -43,6 +45,7 @@ fun HardwareRemoteSheet(
}
val availableKeys = listOf("Enter", "Space", "Up Arrow", "Down Arrow", "Left Arrow", "Right Arrow", "Escape", "Backspace")
+ val scrollState = rememberScrollState()
ModalBottomSheet(
onDismissRequest = onDismissRequest,
@@ -53,7 +56,8 @@ fun HardwareRemoteSheet(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
- .padding(bottom = 32.dp),
+ .padding(bottom = 32.dp)
+ .verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
diff --git a/app/src/main/java/com/autonion/automationcompanion/features/system_context_automation/battery/engine/BatteryMonitoringService.kt b/app/src/main/java/com/autonion/automationcompanion/features/system_context_automation/battery/engine/BatteryMonitoringService.kt
index 3ceed3b..be22763 100644
--- a/app/src/main/java/com/autonion/automationcompanion/features/system_context_automation/battery/engine/BatteryMonitoringService.kt
+++ b/app/src/main/java/com/autonion/automationcompanion/features/system_context_automation/battery/engine/BatteryMonitoringService.kt
@@ -48,11 +48,21 @@ class BatteryMonitoringService : Service() {
}
private fun startForegroundService() {
+ val stopIntent = Intent(this, BatteryMonitoringService::class.java).apply {
+ action = ACTION_STOP
+ }
+ val stopPendingIntent = PendingIntent.getService(
+ this, 0, stopIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Battery Automation")
.setContentText("Monitoring battery level")
.setSmallIcon(R.drawable.ic_notification) // Use your own icon
.setPriority(NotificationCompat.PRIORITY_LOW)
+ .addAction(R.drawable.ic_stop, "Stop", stopPendingIntent)
+ .setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification)
@@ -71,6 +81,14 @@ class BatteryMonitoringService : Service() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_STOP -> {
+ Log.i(TAG, "Stop action received — stopping battery monitoring")
+ stopForeground(true)
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ }
return START_STICKY // Service will restart if killed
}
@@ -92,6 +110,8 @@ class BatteryMonitoringService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
companion object {
+ private const val ACTION_STOP = "com.autonion.automationcompanion.ACTION_STOP_BATTERY_MONITORING"
+
fun startService(context: Context) {
val intent = Intent(context, BatteryMonitoringService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
diff --git a/app/src/main/java/com/autonion/automationcompanion/features/system_context_automation/location/engine/location_receiver/StopTrackingReceiver.kt b/app/src/main/java/com/autonion/automationcompanion/features/system_context_automation/location/engine/location_receiver/StopTrackingReceiver.kt
index 49e3a1c..e04340e 100644
--- a/app/src/main/java/com/autonion/automationcompanion/features/system_context_automation/location/engine/location_receiver/StopTrackingReceiver.kt
+++ b/app/src/main/java/com/autonion/automationcompanion/features/system_context_automation/location/engine/location_receiver/StopTrackingReceiver.kt
@@ -17,6 +17,7 @@ class StopTrackingReceiver : BroadcastReceiver() {
private const val TAG = "StopTrackingReceiver"
const val ACTION_STOP_TRACKING = "com.autonion.automationcompanion.ACTION_STOP_TRACKING"
const val TRACKING_NOTIFICATION_ID = 1
+ const val BATTERY_NOTIFICATION_ID = 1001
/**
* Helper to create the PendingIntent used in the notification action.
@@ -36,13 +37,8 @@ class StopTrackingReceiver : BroadcastReceiver() {
}
override fun onReceive(context: Context, intent: Intent?) {
- val action = intent?.action
- if (action != ACTION_STOP_TRACKING) {
- Log.w(TAG, "Received unknown action: $action")
- return
- }
+ Log.i(TAG, "Stop action received — stopping tracking (action=${intent?.action})")
- Log.i(TAG, "Stop action received — stopping tracking")
// Stop the foreground tracking service (stops location updates / geofences)
try {
TrackingForegroundService.stop(context)
@@ -50,27 +46,35 @@ class StopTrackingReceiver : BroadcastReceiver() {
Log.e(TAG, "Error stopping TrackingForegroundService", e)
}
- // Cancel the persistent notification (in case service was killed or notification persists)
+ // Cancel all known persistent notifications (tracking + battery)
try {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- nm.cancel(TRACKING_NOTIFICATION_ID)
+ nm.cancel(TRACKING_NOTIFICATION_ID) // Location tracking notification (ID = 1)
+ nm.cancel(BATTERY_NOTIFICATION_ID) // Battery monitoring notification (ID = 1001)
} catch (e: Exception) {
Log.w(TAG, "Failed to cancel notification: ${e.message}")
}
- // Unregister any geofences / listeners asynchronously (implement your own logic)
+ // Also stop BatteryMonitoringService if running
+ try {
+ val batteryIntent = Intent(context,
+ com.autonion.automationcompanion.features.system_context_automation.battery.engine.BatteryMonitoringService::class.java)
+ context.stopService(batteryIntent)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to stop BatteryMonitoringService: ${e.message}")
+ }
+
+ // Unregister any geofences / listeners asynchronously
CoroutineScope(Dispatchers.IO).launch {
try {
- LocationHelper.unregisterAllGeofences(context) // <-- implement this helper
+ LocationHelper.unregisterAllGeofences(context)
Log.i(TAG, "Geofences unregistered")
} catch (e: Exception) {
Log.w(TAG, "Failed to unregister geofences: ${e.message}")
}
}
- // Optional: give an immediate UX cue
+ // Give an immediate UX cue
Toast.makeText(context, "Tracking stopped", Toast.LENGTH_SHORT).show()
-
- // Optional: add audit/log entry in your local DB here
}
}
diff --git a/app/src/main/java/com/autonion/automationcompanion/features/system_context_automation/location/engine/location_receiver/TrackingForegroundService.kt b/app/src/main/java/com/autonion/automationcompanion/features/system_context_automation/location/engine/location_receiver/TrackingForegroundService.kt
index 99b1278..07bc7b8 100644
--- a/app/src/main/java/com/autonion/automationcompanion/features/system_context_automation/location/engine/location_receiver/TrackingForegroundService.kt
+++ b/app/src/main/java/com/autonion/automationcompanion/features/system_context_automation/location/engine/location_receiver/TrackingForegroundService.kt
@@ -182,8 +182,21 @@ class TrackingForegroundService : Service() {
// TODO: start FusedLocationProvider or Geofence registration here
}
+ // Track whether this service is being intentionally stopped to prevent
+ // START_STICKY from restarting and re-creating the notification.
+ private var isStopping = false
+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ // If the system restarted us (null intent) after we intentionally stopped,
+ // or if there's simply no action, just die quietly.
+ if (intent == null || intent.action == null) {
+ Log.i("TrackingService", "Null intent/action — stopping orphan restart")
+ stopForeground(true)
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val hasFine = ContextCompat.checkSelfPermission(
this,
@@ -221,9 +234,9 @@ class TrackingForegroundService : Service() {
return START_NOT_STICKY
}
- val action = intent?.action
- val slotId = intent?.getLongExtra("slotId", -1L) ?: -1L
- Log.i("TrackingService", "onStartCommand action=${intent?.action} slotId=$slotId")
+ val action = intent.action
+ val slotId = intent.getLongExtra("slotId", -1L)
+ Log.i("TrackingService", "onStartCommand action=$action slotId=$slotId")
when (action) {
@@ -238,12 +251,11 @@ class TrackingForegroundService : Service() {
}
ACTION_PERFORM_VOLUME -> {
- val ring = intent?.getIntExtra("ring", -1) ?: -1
- val media = intent?.getIntExtra("media", -1) ?: -1
- val alarm = intent?.getIntExtra("alarm", 8) ?: 8
+ val ring = intent.getIntExtra("ring", -1)
+ val media = intent.getIntExtra("media", -1)
+ val alarm = intent.getIntExtra("alarm", 8)
val ringerModeOrdinal =
- intent?.getIntExtra("ringerMode", RingerMode.NORMAL.ordinal)
- ?: RingerMode.NORMAL.ordinal
+ intent.getIntExtra("ringerMode", RingerMode.NORMAL.ordinal)
val ringerMode =
RingerMode.values().getOrElse(ringerModeOrdinal) { RingerMode.NORMAL }
if (ring != -1 && media != -1) {
@@ -265,8 +277,10 @@ class TrackingForegroundService : Service() {
if (slotId != -1L) {
unregisterGeofenceForSlot(slotId)
}
+ isStopping = true
stopForeground(true)
stopSelf()
+ return START_NOT_STICKY
}
}
@@ -504,8 +518,13 @@ class TrackingForegroundService : Service() {
private fun buildNotification(): Notification {
- val stopIntent = Intent(this, StopTrackingReceiver::class.java)
- val pi = PendingIntent.getBroadcast(this, 0, stopIntent, PendingIntent.FLAG_MUTABLE)
+ val stopIntent = Intent(this, StopTrackingReceiver::class.java).apply {
+ action = StopTrackingReceiver.ACTION_STOP_TRACKING
+ }
+ val pi = PendingIntent.getBroadcast(
+ this, 0, stopIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Tracking active")
.setContentText("Monitoring location for your slot")
diff --git a/app/src/main/java/com/autonion/automationcompanion/ui/HomeScreen.kt b/app/src/main/java/com/autonion/automationcompanion/ui/HomeScreen.kt
index 692ad5f..231820b 100644
--- a/app/src/main/java/com/autonion/automationcompanion/ui/HomeScreen.kt
+++ b/app/src/main/java/com/autonion/automationcompanion/ui/HomeScreen.kt
@@ -2,6 +2,7 @@
package com.autonion.automationcompanion.ui
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
@@ -11,9 +12,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.automirrored.filled.ViewQuilt
@@ -210,6 +214,44 @@ fun HomeScreen(onOpen: (String) -> Unit) {
}
Spacer(modifier = Modifier.height(24.dp))
}
+
+ // Footer: Version & GitHub
+ item {
+ val uriHandler = LocalUriHandler.current
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "v1.0.1",
+ style = MaterialTheme.typography.bodySmall.copy(
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
+ fontSize = 12.sp
+ )
+ )
+ Text(
+ text = " · ",
+ style = MaterialTheme.typography.bodySmall.copy(
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
+ fontSize = 12.sp
+ )
+ )
+ Text(
+ text = "github.com/Autonion",
+ style = MaterialTheme.typography.bodySmall.copy(
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
+ fontSize = 12.sp,
+ textDecoration = TextDecoration.Underline
+ ),
+ modifier = Modifier.clickable {
+ uriHandler.openUri("https://github.com/Autonion")
+ }
+ )
+ }
+ }
}
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 12b3d32..6e59f8b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
[versions]
-agp = "9.1.1"
+agp = "9.2.0"
biometric = "1.1.0"
composeBom = "2026.02.00"
kotlin = "2.3.10"
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 49d6c7e..1f214f1 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
#Mon Dec 01 12:56:20 IST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME