From e88c26d8a2d1b5019adbded3b76620c6574c2a4e Mon Sep 17 00:00:00 2001 From: Guru Preetam Bodapati Date: Thu, 23 Apr 2026 10:17:03 +0530 Subject: [PATCH 1/5] Fixed Hardware Remote bugs and added Device Selection. --- app/src/main/AndroidManifest.xml | 9 + .../automation_debugger/DebuggerViewModel.kt | 10 +- .../CrossDeviceAutomationManager.kt | 2 +- .../data/InMemoryDeviceRepository.kt | 28 +- .../cross_device_automation/domain/Device.kt | 3 +- .../domain/DeviceRepository.kt | 2 + .../engine/HardwareButtonMapper.kt | 295 +++++++++++++----- .../engine/HardwareRemoteForegroundService.kt | 267 ++++++++++++++++ .../networking/NetworkingManager.kt | 118 ++++++- .../presentation/DeviceManagementScreen.kt | 38 ++- .../presentation/DeviceManagementViewModel.kt | 6 + .../presentation/HardwareRemoteSheet.kt | 6 +- 12 files changed, 681 insertions(+), 103 deletions(-) create mode 100644 app/src/main/java/com/autonion/automationcompanion/features/cross_device_automation/engine/HardwareRemoteForegroundService.kt 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( From 40974e0177fe4ee171a794111ba40d6775dad55f Mon Sep 17 00:00:00 2001 From: Guru Preetam Bodapati Date: Thu, 23 Apr 2026 10:17:57 +0530 Subject: [PATCH 2/5] Updated AGP to 9.2.0 --- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 From 9f6bcdff6bd754bb9b9c849c7aa18c76f31f3029 Mon Sep 17 00:00:00 2001 From: Guru Preetam Bodapati Date: Thu, 23 Apr 2026 10:49:35 +0530 Subject: [PATCH 3/5] Fixed Notification bug. --- .../engine/BatteryMonitoringService.kt | 20 ++++++++++ .../location_receiver/StopTrackingReceiver.kt | 30 +++++++------- .../TrackingForegroundService.kt | 39 ++++++++++++++----- 3 files changed, 66 insertions(+), 23 deletions(-) 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") From c1f244d552cefa841a697d233d967658eba2fa47 Mon Sep 17 00:00:00 2001 From: Guru Preetam Bodapati Date: Thu, 23 Apr 2026 10:49:53 +0530 Subject: [PATCH 4/5] Update gson version. --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ddbb7ef..667858d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) From 39c6ff0bed4ab1672c96c03dc83dbaa37b4cbf26 Mon Sep 17 00:00:00 2001 From: Guru Preetam Bodapati Date: Thu, 23 Apr 2026 10:57:41 +0530 Subject: [PATCH 5/5] Added Version Number to the Footer. --- app/build.gradle.kts | 2 +- .../automationcompanion/ui/HomeScreen.kt | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 667858d..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" 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") + } + ) + } + } } } }