From fcbc53c5eec7736bd705a202c890587393fb8b46 Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Wed, 8 Oct 2025 23:05:38 +0300 Subject: [PATCH 01/28] DelayPrefs object with getter/setter methods for screen-on delay configuration --- .gitignore | 5 +- .../main/java/io/github/miclock/data/Prefs.kt | 37 ++++++++++ .../java/io/github/miclock/data/PrefsTest.kt | 70 +++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0fc67f2..fc7cc6f 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,7 @@ captures/ *.keystore # External libraries -libs/ # If you are not managing libraries through Gradle dependencies \ No newline at end of file +libs/ # If you are not managing libraries through Gradle dependencies + +.kiro +.vscode diff --git a/app/src/main/java/io/github/miclock/data/Prefs.kt b/app/src/main/java/io/github/miclock/data/Prefs.kt index 60eea8b..b684de4 100644 --- a/app/src/main/java/io/github/miclock/data/Prefs.kt +++ b/app/src/main/java/io/github/miclock/data/Prefs.kt @@ -8,8 +8,14 @@ object Prefs { private const val KEY_USE_MEDIA_RECORDER = "use_media_recorder" private const val KEY_LAST_RECORDING_METHOD = "last_recording_method" + private const val KEY_SCREEN_ON_DELAY = "screen_on_delay_ms" const val VALUE_AUTO = "auto" + + // Screen-on delay constants + const val DEFAULT_SCREEN_ON_DELAY_MS = 1300L + const val MIN_SCREEN_ON_DELAY_MS = 0L + const val MAX_SCREEN_ON_DELAY_MS = 5000L fun getUseMediaRecorder(ctx: Context): Boolean = ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE) @@ -30,4 +36,35 @@ object Prefs { putString(KEY_LAST_RECORDING_METHOD, method) } } + + /** + * Gets the screen-on delay in milliseconds. + * @return delay in milliseconds, defaults to 1300ms + */ + fun getScreenOnDelayMs(ctx: Context): Long = + ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE) + .getLong(KEY_SCREEN_ON_DELAY, DEFAULT_SCREEN_ON_DELAY_MS) + + /** + * Sets the screen-on delay in milliseconds with validation. + * @param delayMs delay in milliseconds, must be between 0-5000ms + * @throws IllegalArgumentException if delay is outside valid range + */ + fun setScreenOnDelayMs(ctx: Context, delayMs: Long) { + require(delayMs in MIN_SCREEN_ON_DELAY_MS..MAX_SCREEN_ON_DELAY_MS) { + "Screen-on delay must be between ${MIN_SCREEN_ON_DELAY_MS}ms and ${MAX_SCREEN_ON_DELAY_MS}ms, got ${delayMs}ms" + } + + ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE).edit { + putLong(KEY_SCREEN_ON_DELAY, delayMs) + } + } + + /** + * Validates if the given delay value is within acceptable range. + * @param delayMs delay in milliseconds to validate + * @return true if valid, false otherwise + */ + fun isValidScreenOnDelay(delayMs: Long): Boolean = + delayMs in MIN_SCREEN_ON_DELAY_MS..MAX_SCREEN_ON_DELAY_MS } diff --git a/app/src/test/java/io/github/miclock/data/PrefsTest.kt b/app/src/test/java/io/github/miclock/data/PrefsTest.kt index a530248..61c87a1 100644 --- a/app/src/test/java/io/github/miclock/data/PrefsTest.kt +++ b/app/src/test/java/io/github/miclock/data/PrefsTest.kt @@ -72,4 +72,74 @@ class PrefsTest { // Then assertThat(Prefs.getLastRecordingMethod(context)).isNull() } + + @Test + fun `getScreenOnDelayMs default value should be 1300ms`() { + // Given: Fresh preferences (cleared in setUp) + + // Then + assertThat(Prefs.getScreenOnDelayMs(context)).isEqualTo(1300L) + } + + @Test + fun `setScreenOnDelayMs valid value should getScreenOnDelayMs return same value`() { + // When + Prefs.setScreenOnDelayMs(context, 2000L) + + // Then + assertThat(Prefs.getScreenOnDelayMs(context)).isEqualTo(2000L) + } + + @Test + fun `setScreenOnDelayMs minimum value 0 should work`() { + // When + Prefs.setScreenOnDelayMs(context, 0L) + + // Then + assertThat(Prefs.getScreenOnDelayMs(context)).isEqualTo(0L) + } + + @Test + fun `setScreenOnDelayMs maximum value 5000 should work`() { + // When + Prefs.setScreenOnDelayMs(context, 5000L) + + // Then + assertThat(Prefs.getScreenOnDelayMs(context)).isEqualTo(5000L) + } + + @Test + fun `setScreenOnDelayMs negative value should throw IllegalArgumentException`() { + // When/Then + try { + Prefs.setScreenOnDelayMs(context, -1L) + assertThat(false).isTrue() // Should not reach here + } catch (e: IllegalArgumentException) { + assertThat(e.message).contains("Screen-on delay must be between 0ms and 5000ms") + } + } + + @Test + fun `setScreenOnDelayMs value above maximum should throw IllegalArgumentException`() { + // When/Then + try { + Prefs.setScreenOnDelayMs(context, 5001L) + assertThat(false).isTrue() // Should not reach here + } catch (e: IllegalArgumentException) { + assertThat(e.message).contains("Screen-on delay must be between 0ms and 5000ms") + } + } + + @Test + fun `isValidScreenOnDelay should return true for valid values`() { + assertThat(Prefs.isValidScreenOnDelay(0L)).isTrue() + assertThat(Prefs.isValidScreenOnDelay(1300L)).isTrue() + assertThat(Prefs.isValidScreenOnDelay(5000L)).isTrue() + } + + @Test + fun `isValidScreenOnDelay should return false for invalid values`() { + assertThat(Prefs.isValidScreenOnDelay(-1L)).isFalse() + assertThat(Prefs.isValidScreenOnDelay(5001L)).isFalse() + } } From 2a86b9a9158e39b50b614eeec78a322587c66a82 Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Wed, 8 Oct 2025 23:42:23 +0300 Subject: [PATCH 02/28] DelayedActivationManager class with coroutine-based delay handling --- .../service/DelayedActivationManager.kt | 253 ++++++++++++ .../github/miclock/service/MicLockService.kt | 23 +- .../service/DelayedActivationManagerTest.kt | 390 ++++++++++++++++++ 3 files changed, 665 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt create mode 100644 app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt diff --git a/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt b/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt new file mode 100644 index 0000000..8fec6d2 --- /dev/null +++ b/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt @@ -0,0 +1,253 @@ +package io.github.miclock.service + +import android.content.Context +import android.util.Log +import io.github.miclock.data.Prefs +import kotlinx.coroutines.* +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +/** + * Manages delayed activation of microphone functionality when screen turns on. + * Handles race conditions, state validation, and proper cleanup of delay operations. + */ +open class DelayedActivationManager( + private val context: Context, + private val service: MicLockService, + private val scope: CoroutineScope +) { + companion object { + private const val TAG = "DelayedActivationManager" + } + + // State tracking + private var delayJob: Job? = null + private val lastScreenOnTime = AtomicLong(0L) + private val delayStartTime = AtomicLong(0L) + private val isActivationPending = AtomicBoolean(false) + + /** + * Schedules a delayed activation after the specified delay period. + * Cancels any existing pending activation before scheduling new one. + * + * @param delayMs delay in milliseconds before activation + * @return true if delay was scheduled, false if conditions don't allow delay + */ + fun scheduleDelayedActivation(delayMs: Long): Boolean { + val currentTime = getCurrentTimeMs() + lastScreenOnTime.set(currentTime) + + Log.d(TAG, "Scheduling delayed activation with ${delayMs}ms delay") + + // Cancel any existing delay operation + cancelDelayedActivation() + + // Validate that delay should be applied + if (!shouldApplyDelay()) { + Log.d(TAG, "Delay not applicable due to service state conditions") + return false + } + + // Start new delay operation + delayStartTime.set(currentTime) + isActivationPending.set(true) + + delayJob = scope.launch { + try { + Log.d(TAG, "Starting delay countdown: ${delayMs}ms") + delay(delayMs) + + // Check if this delay operation is still valid (not superseded by newer screen events) + if (isActivationPending.get() && delayStartTime.get() == currentTime) { + Log.d(TAG, "Delay completed, activating microphone") + + // Validate service state one more time before activation + if (shouldRespectExistingState()) { + Log.d(TAG, "Service state changed during delay, respecting current state") + isActivationPending.set(false) + handleServiceStateConflict() + return@launch + } + + // Clear pending state before activation + isActivationPending.set(false) + + // Activate microphone functionality + service.startMicHolding() + } else { + Log.d(TAG, "Delay operation superseded by newer event, not activating") + isActivationPending.set(false) + } + } catch (e: CancellationException) { + Log.d(TAG, "Delay operation cancelled") + isActivationPending.set(false) + } catch (e: Exception) { + Log.e(TAG, "Error during delayed activation", e) + isActivationPending.set(false) + } + } + + return true + } + + /** + * Cancels any pending delayed activation operation. + * + * @return true if there was a pending operation that was cancelled, false otherwise + */ + fun cancelDelayedActivation(): Boolean { + val wasPending = isActivationPending.get() + + if (wasPending) { + Log.d(TAG, "Cancelling delayed activation") + } + + delayJob?.cancel() + delayJob = null + isActivationPending.set(false) + delayStartTime.set(0L) + + return wasPending + } + + /** + * Checks if there is currently a delayed activation pending. + * + * @return true if activation is pending, false otherwise + */ + fun isActivationPending(): Boolean = isActivationPending.get() + + /** + * Gets the remaining time in milliseconds for the current delay operation. + * + * @return remaining milliseconds, or 0 if no delay is pending + */ + fun getRemainingDelayMs(): Long { + if (!isActivationPending.get()) return 0L + + val startTime = delayStartTime.get() + val delayMs = Prefs.getScreenOnDelayMs(context) + val currentTime = getCurrentTimeMs() + val elapsed = currentTime - startTime + val remaining = (delayMs - elapsed).coerceAtLeast(0L) + + return remaining + } + + /** + * Gets the current time in milliseconds. Can be overridden for testing. + */ + protected open fun getCurrentTimeMs(): Long = System.currentTimeMillis() + + /** + * Determines if the current service state should prevent delayed activation. + * This method checks for conditions that should be respected (manual stops, active sessions, paused states). + * + * @return true if existing state should be respected (delay should not proceed), false otherwise + */ + fun shouldRespectExistingState(): Boolean { + val currentState = service.getCurrentState() + + return when { + // Service is already running - no need for delay + currentState.isRunning -> { + Log.d(TAG, "Service already running, respecting existing state") + true + } + + // Service is paused by silence (another app using mic) - respect the pause + currentState.isPausedBySilence -> { + Log.d(TAG, "Service paused by silence, respecting pause state") + true + } + + // Check if service was manually stopped by user + service.isManuallyStoppedByUser() -> { + Log.d(TAG, "Service manually stopped by user, respecting manual stop") + true + } + + else -> false + } + } + + /** + * Handles conflicts when service state changes during delay period. + * This method determines appropriate action when existing state should be respected. + */ + fun handleServiceStateConflict() { + val currentState = service.getCurrentState() + + Log.d(TAG, "Handling service state conflict - isRunning: ${currentState.isRunning}, isPaused: ${currentState.isPausedBySilence}") + + when { + currentState.isRunning -> { + Log.d(TAG, "Service is already active, no action needed") + } + + currentState.isPausedBySilence -> { + Log.d(TAG, "Service is paused by another app, maintaining pause state") + } + + service.isManuallyStoppedByUser() -> { + Log.d(TAG, "Service was manually stopped, not overriding user choice") + } + + else -> { + Log.d(TAG, "No specific conflict resolution needed") + } + } + } + + /** + * Determines if delay should be applied based on current conditions. + * + * @return true if delay should be applied, false otherwise + */ + fun shouldApplyDelay(): Boolean { + val delayMs = Prefs.getScreenOnDelayMs(context) + + return when { + // Delay is disabled (0ms) + delayMs <= 0L -> { + Log.d(TAG, "Delay disabled (${delayMs}ms)") + false + } + + // Service state should be respected + shouldRespectExistingState() -> { + Log.d(TAG, "Existing service state should be respected") + false + } + + else -> { + Log.d(TAG, "Delay should be applied (${delayMs}ms)") + true + } + } + } + + /** + * Gets the timestamp of the last screen-on event. + * Used for race condition detection and debugging. + * + * @return timestamp in milliseconds + */ + fun getLastScreenOnTime(): Long = lastScreenOnTime.get() + + /** + * Gets the timestamp when the current delay operation started. + * + * @return timestamp in milliseconds, or 0 if no delay is active + */ + fun getDelayStartTime(): Long = delayStartTime.get() + + /** + * Cleanup method to be called when the manager is no longer needed. + * Cancels any pending operations and cleans up resources. + */ + fun cleanup() { + Log.d(TAG, "Cleaning up DelayedActivationManager") + cancelDelayedActivation() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/miclock/service/MicLockService.kt b/app/src/main/java/io/github/miclock/service/MicLockService.kt index 359d617..43db3bd 100644 --- a/app/src/main/java/io/github/miclock/service/MicLockService.kt +++ b/app/src/main/java/io/github/miclock/service/MicLockService.kt @@ -344,7 +344,7 @@ class MicLockService : Service() { @RequiresApi(Build.VERSION_CODES.P) @RequiresPermission(Manifest.permission.RECORD_AUDIO) - private fun startMicHolding() { + internal fun startMicHolding() { // This check is important. If the loop is active, we don't need to do anything. if (loopJob?.isActive == true) { Log.d(TAG, "Mic holding is already active.") @@ -848,6 +848,27 @@ class MicLockService : Service() { } } + /** + * Gets the current service state. + * Used by DelayedActivationManager for state validation. + * + * @return current ServiceState + */ + fun getCurrentState(): ServiceState = state.value + + /** + * Checks if the service was manually stopped by the user. + * This is determined by checking if the service is not running and was explicitly stopped. + * + * @return true if manually stopped by user, false otherwise + */ + fun isManuallyStoppedByUser(): Boolean { + val currentState = state.value + // Service is considered manually stopped if it's not running and not paused by silence + // This indicates user intentionally stopped it rather than system pausing it + return !currentState.isRunning && !currentState.isPausedBySilence + } + companion object { private val _state = MutableStateFlow(ServiceState()) val state: StateFlow = _state.asStateFlow() diff --git a/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt b/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt new file mode 100644 index 0000000..cb6b1cd --- /dev/null +++ b/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt @@ -0,0 +1,390 @@ +package io.github.miclock.service + +import android.content.Context +import io.github.miclock.data.Prefs +import io.github.miclock.service.model.ServiceState +import kotlinx.coroutines.* +import kotlinx.coroutines.test.* +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** + * Unit tests for DelayedActivationManager focusing on delay scheduling, + * cancellation logic, race condition handling, and service state validation. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class DelayedActivationManagerTest { + + @Mock + private lateinit var mockService: MicLockService + + private lateinit var context: Context + private lateinit var testScope: TestScope + private lateinit var delayedActivationManager: TestableDelayedActivationManager + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + context = RuntimeEnvironment.getApplication() + testScope = TestScope() + + // Setup default mock behavior + whenever(mockService.getCurrentState()).thenReturn(ServiceState()) + whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) + + delayedActivationManager = TestableDelayedActivationManager(context, mockService, testScope) + } + + @Test + fun testScheduleDelayedActivation_withValidDelay_schedulesSuccessfully() = testScope.runTest { + // Given: Valid delay configuration + Prefs.setScreenOnDelayMs(context, 1000L) + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false)) + whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) + + // When: Scheduling delayed activation + val result = delayedActivationManager.scheduleDelayedActivation(1000L) + + // Then: Should schedule successfully + assertTrue("Should schedule delay successfully", result) + assertTrue("Should have pending activation", delayedActivationManager.isActivationPending()) + assertEquals("Should have valid start time", 0L, delayedActivationManager.getDelayStartTime()) + } + + @Test + fun testScheduleDelayedActivation_withZeroDelay_doesNotSchedule() = testScope.runTest { + // Given: Zero delay (disabled) + Prefs.setScreenOnDelayMs(context, 0L) + + // When: Attempting to schedule with zero delay + val result = delayedActivationManager.scheduleDelayedActivation(0L) + + // Then: Should not schedule + assertFalse("Should not schedule with zero delay", result) + assertFalse("Should not have pending activation", delayedActivationManager.isActivationPending()) + } + + @Test + fun testScheduleDelayedActivation_serviceAlreadyRunning_doesNotSchedule() = testScope.runTest { + // Given: Service is already running + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = true)) + + // When: Attempting to schedule delay + val result = delayedActivationManager.scheduleDelayedActivation(1000L) + + // Then: Should not schedule + assertFalse("Should not schedule when service already running", result) + assertFalse("Should not have pending activation", delayedActivationManager.isActivationPending()) + } + + @Test + fun testScheduleDelayedActivation_serviceManuallyStoppedByUser_doesNotSchedule() = testScope.runTest { + // Given: Service was manually stopped by user + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false)) + whenever(mockService.isManuallyStoppedByUser()).thenReturn(true) + + // When: Attempting to schedule delay + val result = delayedActivationManager.scheduleDelayedActivation(1000L) + + // Then: Should not schedule + assertFalse("Should not schedule when manually stopped by user", result) + assertFalse("Should not have pending activation", delayedActivationManager.isActivationPending()) + } + + @Test + fun testScheduleDelayedActivation_servicePausedBySilence_doesNotSchedule() = testScope.runTest { + // Given: Service is paused by silence (another app using mic) + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isPausedBySilence = true)) + + // When: Attempting to schedule delay + val result = delayedActivationManager.scheduleDelayedActivation(1000L) + + // Then: Should not schedule + assertFalse("Should not schedule when paused by silence", result) + assertFalse("Should not have pending activation", delayedActivationManager.isActivationPending()) + } + + @Test + fun testDelayCompletion_activatesMicrophone() = testScope.runTest { + // Given: Valid delay setup + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false)) + whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) + + // When: Scheduling and waiting for delay completion + delayedActivationManager.scheduleDelayedActivation(100L) + assertTrue("Should have pending activation", delayedActivationManager.isActivationPending()) + + // Advance time to complete delay and run pending coroutines + advanceTimeBy(100L) + runCurrent() // Execute any pending coroutines + + // Then: Should activate microphone + verify(mockService).startMicHolding() + assertFalse("Should not have pending activation after completion", delayedActivationManager.isActivationPending()) + } + + @Test + fun testCancelDelayedActivation_cancelsSuccessfully() = testScope.runTest { + // Given: Scheduled delay + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false)) + whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) + delayedActivationManager.scheduleDelayedActivation(1000L) + assertTrue("Should have pending activation", delayedActivationManager.isActivationPending()) + + // When: Cancelling delay + val result = delayedActivationManager.cancelDelayedActivation() + + // Then: Should cancel successfully + assertTrue("Should return true when cancelling existing delay", result) + assertFalse("Should not have pending activation after cancellation", delayedActivationManager.isActivationPending()) + assertEquals("Should reset delay start time", 0L, delayedActivationManager.getDelayStartTime()) + } + + @Test + fun testCancelDelayedActivation_noPendingDelay_returnsFalse() = testScope.runTest { + // Given: No pending delay + assertFalse("Should not have pending activation initially", delayedActivationManager.isActivationPending()) + + // When: Attempting to cancel non-existent delay + val result = delayedActivationManager.cancelDelayedActivation() + + // Then: Should return false + assertFalse("Should return false when no delay to cancel", result) + } + + @Test + fun testRaceCondition_rapidScreenStateChanges_handlesLatestEventWins() = testScope.runTest { + // Given: Service state allows delays + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false)) + whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) + + // When: Rapid screen state changes (multiple delay schedules) + delayedActivationManager.scheduleDelayedActivation(1000L) + val firstStartTime = delayedActivationManager.getDelayStartTime() + + // Simulate rapid second screen-on event + advanceTimeBy(50L) // Small delay to ensure different timestamps + delayedActivationManager.scheduleDelayedActivation(1000L) + val secondStartTime = delayedActivationManager.getDelayStartTime() + + // Then: Latest event should win + assertTrue("Second start time should be later or equal", secondStartTime >= firstStartTime) + assertTrue("Should still have pending activation", delayedActivationManager.isActivationPending()) + + // Verify that the delay mechanism is working correctly + val remainingTime = delayedActivationManager.getRemainingDelayMs() + assertTrue("Should have remaining time close to 1000ms", remainingTime >= 950L) + + // Test that cancellation works + val cancelled = delayedActivationManager.cancelDelayedActivation() + assertTrue("Should successfully cancel delay", cancelled) + assertFalse("Should not have pending activation after cancellation", delayedActivationManager.isActivationPending()) + } + + @Test + fun testServiceStateChangesDuringDelay_respectsNewState() = testScope.runTest { + // Given: Initial state allows delay + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false)) + whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) + delayedActivationManager.scheduleDelayedActivation(1000L) + + // When: Service state changes during delay (e.g., manually stopped) + advanceTimeBy(500L) // Halfway through delay + whenever(mockService.isManuallyStoppedByUser()).thenReturn(true) + + // Complete the delay + advanceTimeBy(500L) + runCurrent() // Execute any pending coroutines + + // Then: Should not activate microphone due to state change + verify(mockService, never()).startMicHolding() + // Note: The pending activation flag may still be true until the coroutine completes and checks state + } + + @Test + fun testServiceStateChangesDuringDelay_serviceBecomesActive() = testScope.runTest { + // Given: Initial state allows delay + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false)) + whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) + delayedActivationManager.scheduleDelayedActivation(1000L) + + // When: Service becomes active during delay + advanceTimeBy(500L) // Halfway through delay + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = true)) + + // Complete the delay + advanceTimeBy(500L) + runCurrent() // Execute any pending coroutines + + // Then: Should not activate microphone since service is already active + verify(mockService, never()).startMicHolding() + // Note: The pending activation flag may still be true until the coroutine completes and checks state + } + + @Test + fun testGetRemainingDelayMs_calculatesCorrectly() = testScope.runTest { + // Given: Scheduled delay + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false, isPausedBySilence = false)) + whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) + Prefs.setScreenOnDelayMs(context, 1000L) + + // Debug: Check individual conditions + val currentState = mockService.getCurrentState() + val isManuallyStoppedByUser = mockService.isManuallyStoppedByUser() + val shouldRespectExisting = delayedActivationManager.shouldRespectExistingState() + val prefDelay = Prefs.getScreenOnDelayMs(context) + val shouldApply = delayedActivationManager.shouldApplyDelay() + + assertFalse("Service should not be running", currentState.isRunning) + assertFalse("Service should not be paused by silence", currentState.isPausedBySilence) + assertFalse("Service should not be manually stopped", isManuallyStoppedByUser) + assertFalse("Should not respect existing state", shouldRespectExisting) + assertEquals("Preference delay should be 1000ms", 1000L, prefDelay) + assertTrue("Should apply delay with valid conditions", shouldApply) + + val scheduled = delayedActivationManager.scheduleDelayedActivation(1000L) + assertTrue("Should successfully schedule delay", scheduled) + assertTrue("Should have pending activation", delayedActivationManager.isActivationPending()) + + // When: Checking remaining time at start + val initialRemaining = delayedActivationManager.getRemainingDelayMs() + assertEquals("Initial remaining should be 1000ms", 1000L, initialRemaining) + } + + @Test + fun testGetRemainingDelayMs_noPendingDelay_returnsZero() = testScope.runTest { + // Given: No pending delay + assertFalse("Should not have pending activation", delayedActivationManager.isActivationPending()) + + // When: Checking remaining time + val remaining = delayedActivationManager.getRemainingDelayMs() + + // Then: Should return zero + assertEquals("Should return 0 when no delay pending", 0L, remaining) + } + + @Test + fun testShouldRespectExistingState_variousStates() = testScope.runTest { + // Test case 1: Service running + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = true)) + assertTrue("Should respect state when service is running", delayedActivationManager.shouldRespectExistingState()) + + // Test case 2: Service paused by silence + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isPausedBySilence = true)) + assertTrue("Should respect state when paused by silence", delayedActivationManager.shouldRespectExistingState()) + + // Test case 3: Manually stopped by user + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false)) + whenever(mockService.isManuallyStoppedByUser()).thenReturn(true) + assertTrue("Should respect state when manually stopped", delayedActivationManager.shouldRespectExistingState()) + + // Test case 4: Normal state (not running, not paused, not manually stopped) + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false)) + whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) + assertFalse("Should not respect state in normal conditions", delayedActivationManager.shouldRespectExistingState()) + } + + @Test + fun testShouldApplyDelay_variousConditions() = testScope.runTest { + // Test case 1: Delay disabled (0ms) + Prefs.setScreenOnDelayMs(context, 0L) + assertFalse("Should not apply delay when disabled", delayedActivationManager.shouldApplyDelay()) + + // Test case 2: Valid delay but existing state should be respected + Prefs.setScreenOnDelayMs(context, 1000L) + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = true)) + assertFalse("Should not apply delay when existing state should be respected", delayedActivationManager.shouldApplyDelay()) + + // Test case 3: Valid delay and normal state + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false)) + whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) + assertTrue("Should apply delay in normal conditions", delayedActivationManager.shouldApplyDelay()) + } + + @Test + fun testHandleServiceStateConflict_logsAppropriately() = testScope.runTest { + // This test verifies that the conflict handler doesn't crash and handles different states + // Since it mainly logs, we test that it executes without exceptions + + // Test with running service + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = true)) + assertDoesNotThrow { delayedActivationManager.handleServiceStateConflict() } + + // Test with paused service + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isPausedBySilence = true)) + assertDoesNotThrow { delayedActivationManager.handleServiceStateConflict() } + + // Test with manually stopped service + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false)) + whenever(mockService.isManuallyStoppedByUser()).thenReturn(true) + assertDoesNotThrow { delayedActivationManager.handleServiceStateConflict() } + } + + @Test + fun testCleanup_cancelsAllOperations() = testScope.runTest { + // Given: Scheduled delay + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false)) + whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) + delayedActivationManager.scheduleDelayedActivation(1000L) + assertTrue("Should have pending activation", delayedActivationManager.isActivationPending()) + + // When: Cleaning up + delayedActivationManager.cleanup() + + // Then: Should cancel all operations + assertFalse("Should not have pending activation after cleanup", delayedActivationManager.isActivationPending()) + assertEquals("Should reset delay start time", 0L, delayedActivationManager.getDelayStartTime()) + } + + @Test + fun testTimestampTracking_accuratelyTracksEvents() = testScope.runTest { + // Given: Initial state + val initialScreenOnTime = delayedActivationManager.getLastScreenOnTime() + assertEquals("Initial screen-on time should be 0", 0L, initialScreenOnTime) + + // When: Scheduling delay + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false)) + whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) + delayedActivationManager.scheduleDelayedActivation(1000L) + + // Then: Should track timestamps accurately using test scheduler time + val screenOnTime = delayedActivationManager.getLastScreenOnTime() + val delayStartTime = delayedActivationManager.getDelayStartTime() + + assertEquals("Screen-on time should be test scheduler time", 0L, screenOnTime) + assertEquals("Delay start time should be test scheduler time", 0L, delayStartTime) + assertEquals("Screen-on time and delay start time should be equal", + screenOnTime, delayStartTime) + } + + private fun assertDoesNotThrow(block: () -> Unit) { + try { + block() + } catch (e: Exception) { + fail("Expected no exception but got: ${e.message}") + } + } +} + +/** + * Testable version of DelayedActivationManager that uses test scheduler time + * instead of system time for accurate testing of timing-dependent behavior. + */ +class TestableDelayedActivationManager( + context: Context, + service: MicLockService, + private val testScope: TestScope +) : DelayedActivationManager(context, service, testScope) { + + override fun getCurrentTimeMs(): Long { + return testScope.testScheduler.currentTime + } +} \ No newline at end of file From 578986381cc029c3810a330357188a1e5080c2ae Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Wed, 8 Oct 2025 23:55:24 +0300 Subject: [PATCH 03/28] Enhance MicLockService with delay integration --- .../github/miclock/service/MicLockService.kt | 90 ++++++++++++++++++- .../miclock/service/model/ServiceState.kt | 2 + 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/io/github/miclock/service/MicLockService.kt b/app/src/main/java/io/github/miclock/service/MicLockService.kt index 43db3bd..813baf2 100644 --- a/app/src/main/java/io/github/miclock/service/MicLockService.kt +++ b/app/src/main/java/io/github/miclock/service/MicLockService.kt @@ -67,6 +67,9 @@ class MicLockService : Service() { // Optional: light CPU wake while actively recording private val wakeLockManager by lazy { WakeLockManager(this, "MicLockService") } + // Delayed activation manager for screen-on delay handling + private lateinit var delayedActivationManager: DelayedActivationManager + private var loopJob: Job? = null private val stopFlag = AtomicBoolean(false) @@ -98,6 +101,10 @@ class MicLockService : Service() { super.onCreate() createChannels() + // Initialize delayed activation manager + delayedActivationManager = DelayedActivationManager(this, this, scope) + Log.d(TAG, "DelayedActivationManager initialized") + // Register screen state receiver dynamically screenStateReceiver = ScreenStateReceiver() val filter = IntentFilter().apply { @@ -144,6 +151,13 @@ class MicLockService : Service() { super.onDestroy() val wasRunning = state.value.isRunning stopFlag.set(true) + + // Cleanup delayed activation manager + if (::delayedActivationManager.isInitialized) { + delayedActivationManager.cleanup() + Log.d(TAG, "DelayedActivationManager cleaned up") + } + scope.cancel() wakeLockManager.release() try { recCallback?.let { audioManager.unregisterAudioRecordingCallback(it) } } catch (_: Throwable) {} @@ -176,6 +190,15 @@ class MicLockService : Service() { notifManager.cancel(RESTART_NOTIF_ID) Log.i(TAG, "Received ACTION_START_USER_INITIATED - user-initiated start") + // Cancel any pending delayed activation when user manually starts service + if (::delayedActivationManager.isInitialized) { + val wasCancelled = delayedActivationManager.cancelDelayedActivation() + if (wasCancelled) { + Log.d(TAG, "Cancelled pending delayed activation due to manual user start") + updateServiceState(delayPending = false, delayRemainingMs = 0) + } + } + val isFromTile = intent?.getBooleanExtra("from_tile", false) ?: false if (!state.value.isRunning) { @@ -251,7 +274,33 @@ class MicLockService : Service() { private fun handleStartHolding() { Log.i(TAG, "Received ACTION_START_HOLDING, isRunning: ${state.value.isRunning}") if (state.value.isRunning) { - startMicHolding() + // Get configured delay + val delayMs = Prefs.getScreenOnDelayMs(this) + + // Check if delay should be applied + if (delayMs > 0 && delayedActivationManager.shouldApplyDelay()) { + Log.d(TAG, "Applying screen-on delay of ${delayMs}ms") + + // Schedule delayed activation + val scheduled = delayedActivationManager.scheduleDelayedActivation(delayMs) + + if (scheduled) { + // Update state to reflect pending activation + updateServiceState( + delayPending = true, + delayRemainingMs = delayMs + ) + Log.d(TAG, "Delayed activation scheduled successfully") + } else { + // Delay not applicable, start immediately + Log.d(TAG, "Delay not applicable, starting immediately") + startMicHolding() + } + } else { + // No delay configured or delay not applicable, start immediately + Log.d(TAG, "No delay configured (${delayMs}ms), starting immediately") + startMicHolding() + } } else { Log.w(TAG, "Service not running, ignoring START_HOLDING action. (Consider starting service first)") } @@ -259,11 +308,32 @@ class MicLockService : Service() { private fun handleStopHolding() { Log.i(TAG, "Received ACTION_STOP_HOLDING") + + // Cancel any pending delayed activation + if (::delayedActivationManager.isInitialized) { + val wasCancelled = delayedActivationManager.cancelDelayedActivation() + if (wasCancelled) { + Log.d(TAG, "Cancelled pending delayed activation due to screen-off") + updateServiceState( + delayPending = false, + delayRemainingMs = 0 + ) + } + } + stopMicHolding() } private fun handleStop(): Int { - updateServiceState(running = false) + // Cancel any pending delayed activation when service is manually stopped + if (::delayedActivationManager.isInitialized) { + val wasCancelled = delayedActivationManager.cancelDelayedActivation() + if (wasCancelled) { + Log.d(TAG, "Cancelled pending delayed activation due to manual stop") + } + } + + updateServiceState(running = false, delayPending = false, delayRemainingMs = 0) stopMicHolding() stopSelf() // Full stop from user return START_NOT_STICKY @@ -352,6 +422,12 @@ class MicLockService : Service() { } // Change the log message to be more generic for screen on or user start Log.i(TAG, "Starting or resuming mic holding logic.") + + // Clear any delay state since we're actually starting now + if (::delayedActivationManager.isInitialized && delayedActivationManager.isActivationPending()) { + Log.d(TAG, "Clearing delay state as mic holding is starting") + updateServiceState(delayPending = false, delayRemainingMs = 0) + } // Try to start foreground service based on current conditions if (canStartForegroundService()) { @@ -838,12 +914,20 @@ class MicLockService : Service() { notifManager.notify(NOTIF_ID, buildNotification(text)) } - private fun updateServiceState(running: Boolean? = null, paused: Boolean? = null, deviceAddr: String? = null) { + private fun updateServiceState( + running: Boolean? = null, + paused: Boolean? = null, + deviceAddr: String? = null, + delayPending: Boolean? = null, + delayRemainingMs: Long? = null + ) { _state.update { currentState -> currentState.copy( isRunning = running ?: currentState.isRunning, isPausedBySilence = paused ?: currentState.isPausedBySilence, currentDeviceAddress = deviceAddr ?: currentState.currentDeviceAddress, + isDelayedActivationPending = delayPending ?: currentState.isDelayedActivationPending, + delayedActivationRemainingMs = delayRemainingMs ?: currentState.delayedActivationRemainingMs, ) } } diff --git a/app/src/main/java/io/github/miclock/service/model/ServiceState.kt b/app/src/main/java/io/github/miclock/service/model/ServiceState.kt index 9262c9b..17c9fa5 100644 --- a/app/src/main/java/io/github/miclock/service/model/ServiceState.kt +++ b/app/src/main/java/io/github/miclock/service/model/ServiceState.kt @@ -4,4 +4,6 @@ data class ServiceState( val isRunning: Boolean = false, val isPausedBySilence: Boolean = false, val currentDeviceAddress: String? = null, + val isDelayedActivationPending: Boolean = false, + val delayedActivationRemainingMs: Long = 0, ) From aeec535368f504d7783b90c8ad2471764eb080b0 Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 00:05:50 +0300 Subject: [PATCH 04/28] modified ScreenStateReceiver for delay support and added unit tests --- .../miclock/receiver/ScreenStateReceiver.kt | 84 +++++- .../github/miclock/service/MicLockService.kt | 21 +- .../receiver/ScreenStateReceiverTest.kt | 256 ++++++++++++++++++ 3 files changed, 352 insertions(+), 9 deletions(-) create mode 100644 app/src/test/java/io/github/miclock/receiver/ScreenStateReceiverTest.kt diff --git a/app/src/main/java/io/github/miclock/receiver/ScreenStateReceiver.kt b/app/src/main/java/io/github/miclock/receiver/ScreenStateReceiver.kt index b3ac92f..03b238f 100644 --- a/app/src/main/java/io/github/miclock/receiver/ScreenStateReceiver.kt +++ b/app/src/main/java/io/github/miclock/receiver/ScreenStateReceiver.kt @@ -5,24 +5,76 @@ import android.content.Context import android.content.Intent import android.util.Log import io.github.miclock.service.MicLockService +import java.util.concurrent.atomic.AtomicLong class ScreenStateReceiver : BroadcastReceiver() { companion object { private const val TAG = "ScreenStateReceiver" + + // Timestamp tracking for race condition detection + private val lastScreenOnTimestamp = AtomicLong(0L) + private val lastScreenOffTimestamp = AtomicLong(0L) + private val lastProcessedEventTimestamp = AtomicLong(0L) + + // Extra key for passing timestamp to service + const val EXTRA_EVENT_TIMESTAMP = "event_timestamp" + + // Debouncing threshold - ignore events within this window (milliseconds) + private const val DEBOUNCE_THRESHOLD_MS = 50L } override fun onReceive(context: Context, intent: Intent) { - Log.i(TAG, "Received broadcast: ${intent.action}") + val eventTimestamp = System.currentTimeMillis() + Log.i(TAG, "Received broadcast: ${intent.action} at timestamp: $eventTimestamp") + + // Debouncing: Check if this event is too close to the last processed event + val lastProcessed = lastProcessedEventTimestamp.get() + val timeSinceLastEvent = eventTimestamp - lastProcessed + + if (timeSinceLastEvent < DEBOUNCE_THRESHOLD_MS && lastProcessed > 0) { + Log.d( + TAG, + "Debouncing: Ignoring event (${timeSinceLastEvent}ms since last event, threshold: ${DEBOUNCE_THRESHOLD_MS}ms)" + ) + return + } val serviceIntent = Intent(context, MicLockService::class.java) + serviceIntent.putExtra(EXTRA_EVENT_TIMESTAMP, eventTimestamp) when (intent.action) { Intent.ACTION_SCREEN_ON -> { - Log.i(TAG, "Screen turned ON - sending START_HOLDING action") + // Check for rapid screen state changes + val lastScreenOff = lastScreenOffTimestamp.get() + if (lastScreenOff > 0) { + val timeSinceScreenOff = eventTimestamp - lastScreenOff + Log.d(TAG, "Screen ON: ${timeSinceScreenOff}ms since last screen-off") + } + + // Update timestamp for race condition detection + lastScreenOnTimestamp.set(eventTimestamp) + + Log.i( + TAG, + "Screen turned ON - sending START_HOLDING action (timestamp: $eventTimestamp)" + ) serviceIntent.action = MicLockService.ACTION_START_HOLDING } Intent.ACTION_SCREEN_OFF -> { - Log.i(TAG, "Screen turned OFF - sending STOP_HOLDING action") + // Check for rapid screen state changes + val lastScreenOn = lastScreenOnTimestamp.get() + if (lastScreenOn > 0) { + val timeSinceScreenOn = eventTimestamp - lastScreenOn + Log.d(TAG, "Screen OFF: ${timeSinceScreenOn}ms since last screen-on") + } + + // Update timestamp for race condition detection + lastScreenOffTimestamp.set(eventTimestamp) + + Log.i( + TAG, + "Screen turned OFF - sending STOP_HOLDING action (timestamp: $eventTimestamp)" + ) serviceIntent.action = MicLockService.ACTION_STOP_HOLDING } else -> { @@ -33,9 +85,35 @@ class ScreenStateReceiver : BroadcastReceiver() { try { context.startService(serviceIntent) + lastProcessedEventTimestamp.set(eventTimestamp) Log.d(TAG, "Successfully sent action to running service: ${serviceIntent.action}") } catch (e: Exception) { Log.e(TAG, "Failed to send action to service: ${e.message}", e) } } + + /** + * Gets the timestamp of the last screen-on event. Used for race condition detection and + * debugging. + */ + fun getLastScreenOnTimestamp(): Long = lastScreenOnTimestamp.get() + + /** + * Gets the timestamp of the last screen-off event. Used for race condition detection and + * debugging. + */ + fun getLastScreenOffTimestamp(): Long = lastScreenOffTimestamp.get() + + /** Gets the timestamp of the last processed event. Used for debouncing logic. */ + fun getLastProcessedEventTimestamp(): Long = lastProcessedEventTimestamp.get() + + /** + * Resets all timestamps to zero. For testing purposes only. + * @VisibleForTesting + */ + fun resetTimestamps() { + lastScreenOnTimestamp.set(0L) + lastScreenOffTimestamp.set(0L) + lastProcessedEventTimestamp.set(0L) + } } diff --git a/app/src/main/java/io/github/miclock/service/MicLockService.kt b/app/src/main/java/io/github/miclock/service/MicLockService.kt index 813baf2..4a8b9ca 100644 --- a/app/src/main/java/io/github/miclock/service/MicLockService.kt +++ b/app/src/main/java/io/github/miclock/service/MicLockService.kt @@ -271,8 +271,10 @@ class MicLockService : Service() { @RequiresPermission(Manifest.permission.RECORD_AUDIO) @RequiresApi(Build.VERSION_CODES.P) - private fun handleStartHolding() { - Log.i(TAG, "Received ACTION_START_HOLDING, isRunning: ${state.value.isRunning}") + private fun handleStartHolding(intent: Intent? = null) { + val eventTimestamp = intent?.getLongExtra(ScreenStateReceiver.EXTRA_EVENT_TIMESTAMP, 0L) ?: 0L + Log.i(TAG, "Received ACTION_START_HOLDING, isRunning: ${state.value.isRunning}, timestamp: $eventTimestamp") + if (state.value.isRunning) { // Get configured delay val delayMs = Prefs.getScreenOnDelayMs(this) @@ -281,6 +283,12 @@ class MicLockService : Service() { if (delayMs > 0 && delayedActivationManager.shouldApplyDelay()) { Log.d(TAG, "Applying screen-on delay of ${delayMs}ms") + // Cancel any existing pending activation (latest-event-wins strategy) + if (delayedActivationManager.isActivationPending()) { + Log.d(TAG, "Cancelling previous pending activation - restarting delay from beginning") + delayedActivationManager.cancelDelayedActivation() + } + // Schedule delayed activation val scheduled = delayedActivationManager.scheduleDelayedActivation(delayMs) @@ -306,8 +314,9 @@ class MicLockService : Service() { } } - private fun handleStopHolding() { - Log.i(TAG, "Received ACTION_STOP_HOLDING") + private fun handleStopHolding(intent: Intent? = null) { + val eventTimestamp = intent?.getLongExtra(ScreenStateReceiver.EXTRA_EVENT_TIMESTAMP, 0L) ?: 0L + Log.i(TAG, "Received ACTION_STOP_HOLDING, timestamp: $eventTimestamp") // Cancel any pending delayed activation if (::delayedActivationManager.isInitialized) { @@ -375,8 +384,8 @@ class MicLockService : Service() { when (intent?.action) { ACTION_START_USER_INITIATED -> handleStartUserInitiated(intent) - ACTION_START_HOLDING -> handleStartHolding() - ACTION_STOP_HOLDING -> handleStopHolding() + ACTION_START_HOLDING -> handleStartHolding(intent) + ACTION_STOP_HOLDING -> handleStopHolding(intent) ACTION_STOP -> return handleStop() ACTION_RECONFIGURE -> handleReconfigure() null -> handleBootStart() diff --git a/app/src/test/java/io/github/miclock/receiver/ScreenStateReceiverTest.kt b/app/src/test/java/io/github/miclock/receiver/ScreenStateReceiverTest.kt new file mode 100644 index 0000000..c4955b0 --- /dev/null +++ b/app/src/test/java/io/github/miclock/receiver/ScreenStateReceiverTest.kt @@ -0,0 +1,256 @@ +package io.github.miclock.receiver + +import android.content.Context +import android.content.Intent +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** + * Unit tests for ScreenStateReceiver focusing on timestamp tracking, debouncing logic, and proper + * intent handling for screen state changes. + */ +@RunWith(RobolectricTestRunner::class) +class ScreenStateReceiverTest { + + @Mock private lateinit var mockContext: Context + + private lateinit var context: Context + private lateinit var receiver: ScreenStateReceiver + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + context = RuntimeEnvironment.getApplication() + receiver = ScreenStateReceiver() + // Reset timestamps before each test to ensure clean state + receiver.resetTimestamps() + } + + @Test + fun testOnReceive_screenOn_sendsStartHoldingAction() { + // Given: Screen ON intent + val intent = Intent(Intent.ACTION_SCREEN_ON) + + // When: Receiver processes the intent + receiver.onReceive(context, intent) + + // Then: Should send START_HOLDING action to service + // Note: We can't easily verify startService call with Robolectric, + // but we can verify timestamp was updated + val timestamp = receiver.getLastScreenOnTimestamp() + assertTrue("Screen ON timestamp should be set", timestamp > 0) + } + + @Test + fun testOnReceive_screenOff_sendsStopHoldingAction() { + // Given: Screen OFF intent + val intent = Intent(Intent.ACTION_SCREEN_OFF) + + // When: Receiver processes the intent + receiver.onReceive(context, intent) + + // Then: Should send STOP_HOLDING action to service + val timestamp = receiver.getLastScreenOffTimestamp() + assertTrue("Screen OFF timestamp should be set", timestamp > 0) + } + + @Test + fun testOnReceive_unknownAction_doesNothing() { + // Given: Unknown action intent + val intent = Intent("UNKNOWN_ACTION") + val initialScreenOnTime = receiver.getLastScreenOnTimestamp() + val initialScreenOffTime = receiver.getLastScreenOffTimestamp() + + // When: Receiver processes the intent + receiver.onReceive(context, intent) + + // Then: Should not update timestamps + assertEquals( + "Screen ON timestamp should not change", + initialScreenOnTime, + receiver.getLastScreenOnTimestamp() + ) + assertEquals( + "Screen OFF timestamp should not change", + initialScreenOffTime, + receiver.getLastScreenOffTimestamp() + ) + } + + @Test + fun testTimestampTracking_screenOn_updatesTimestamp() { + // Given: Initial state + val initialTimestamp = receiver.getLastScreenOnTimestamp() + + // When: Screen turns ON + val intent = Intent(Intent.ACTION_SCREEN_ON) + receiver.onReceive(context, intent) + + // Then: Timestamp should be updated + val newTimestamp = receiver.getLastScreenOnTimestamp() + assertTrue("Timestamp should be updated", newTimestamp > initialTimestamp) + } + + @Test + fun testTimestampTracking_screenOff_updatesTimestamp() { + // Given: Initial state + val initialTimestamp = receiver.getLastScreenOffTimestamp() + + // When: Screen turns OFF + val intent = Intent(Intent.ACTION_SCREEN_OFF) + receiver.onReceive(context, intent) + + // Then: Timestamp should be updated + val newTimestamp = receiver.getLastScreenOffTimestamp() + assertTrue("Timestamp should be updated", newTimestamp > initialTimestamp) + } + + @Test + fun testTimestampTracking_multipleScreenOnEvents_updatesEachTime() { + // Given: Initial state + val intent = Intent(Intent.ACTION_SCREEN_ON) + + // When: Multiple screen ON events + receiver.onReceive(context, intent) + val firstTimestamp = receiver.getLastScreenOnTimestamp() + + Thread.sleep(100) // Delay longer than debounce threshold (50ms) + + receiver.onReceive(context, intent) + val secondTimestamp = receiver.getLastScreenOnTimestamp() + + // Then: Timestamps should be different + assertTrue("Second timestamp should be later", secondTimestamp > firstTimestamp) + } + + @Test + fun testDebouncing_rapidEvents_ignoresWithinThreshold() { + // Given: Initial screen ON event + val intent = Intent(Intent.ACTION_SCREEN_ON) + receiver.onReceive(context, intent) + val firstProcessedTime = receiver.getLastProcessedEventTimestamp() + + // When: Rapid second event (within debounce threshold) + // Note: This test is timing-sensitive and may need adjustment + receiver.onReceive(context, intent) + val secondProcessedTime = receiver.getLastProcessedEventTimestamp() + + // Then: Second event should be debounced (same processed timestamp) + // or processed if enough time passed + assertTrue("Processed timestamp should be valid", secondProcessedTime >= firstProcessedTime) + } + + @Test + fun testRaceConditionDetection_screenOnAfterScreenOff_tracksTimings() { + // Given: Screen OFF event + val offIntent = Intent(Intent.ACTION_SCREEN_OFF) + receiver.onReceive(context, offIntent) + val screenOffTime = receiver.getLastScreenOffTimestamp() + + Thread.sleep(100) // Simulate time passing + + // When: Screen ON event + val onIntent = Intent(Intent.ACTION_SCREEN_ON) + receiver.onReceive(context, onIntent) + val screenOnTime = receiver.getLastScreenOnTimestamp() + + // Then: Screen ON should be after screen OFF + assertTrue("Screen ON should be after screen OFF", screenOnTime > screenOffTime) + } + + @Test + fun testRaceConditionDetection_rapidScreenToggle_maintainsCorrectOrder() { + // Given: Initial state + val onIntent = Intent(Intent.ACTION_SCREEN_ON) + val offIntent = Intent(Intent.ACTION_SCREEN_OFF) + + // When: Rapid screen state changes (with delays longer than debounce threshold) + receiver.onReceive(context, onIntent) + val firstOnTime = receiver.getLastScreenOnTimestamp() + + Thread.sleep(100) // Delay longer than debounce threshold + + receiver.onReceive(context, offIntent) + val firstOffTime = receiver.getLastScreenOffTimestamp() + + Thread.sleep(100) // Delay longer than debounce threshold + + receiver.onReceive(context, onIntent) + val secondOnTime = receiver.getLastScreenOnTimestamp() + + // Then: Timestamps should be in correct order + assertTrue("First OFF should be after first ON", firstOffTime > firstOnTime) + assertTrue("Second ON should be after first OFF", secondOnTime > firstOffTime) + } + + @Test + fun testProcessedEventTimestamp_updatesOnSuccessfulProcessing() { + // Given: Initial state + val initialProcessedTime = receiver.getLastProcessedEventTimestamp() + + // When: Processing screen ON event + val intent = Intent(Intent.ACTION_SCREEN_ON) + receiver.onReceive(context, intent) + + // Then: Processed timestamp should be updated + val newProcessedTime = receiver.getLastProcessedEventTimestamp() + assertTrue("Processed timestamp should be updated", newProcessedTime > initialProcessedTime) + } + + @Test + fun testProcessedEventTimestamp_notUpdatedForUnknownAction() { + // Given: Initial state with a valid event + val validIntent = Intent(Intent.ACTION_SCREEN_ON) + receiver.onReceive(context, validIntent) + val processedTimeAfterValid = receiver.getLastProcessedEventTimestamp() + + Thread.sleep(100) // Delay longer than debounce threshold + + // When: Processing unknown action + val unknownIntent = Intent("UNKNOWN_ACTION") + receiver.onReceive(context, unknownIntent) + + // Then: Processed timestamp should not change + assertEquals( + "Processed timestamp should not change for unknown action", + processedTimeAfterValid, + receiver.getLastProcessedEventTimestamp() + ) + } + + @Test + fun testIntentExtras_screenOn_includesTimestamp() { + // This test verifies the intent structure that would be sent to the service + // In a real scenario, we'd need to mock Context.startService to capture the intent + + // Given: Screen ON intent + val intent = Intent(Intent.ACTION_SCREEN_ON) + + // When: Receiver processes the intent + receiver.onReceive(context, intent) + + // Then: Timestamp should be tracked (we verify this indirectly) + val timestamp = receiver.getLastScreenOnTimestamp() + assertTrue("Timestamp should be set and valid", timestamp > 0) + } + + @Test + fun testIntentExtras_screenOff_includesTimestamp() { + // Given: Screen OFF intent + val intent = Intent(Intent.ACTION_SCREEN_OFF) + + // When: Receiver processes the intent + receiver.onReceive(context, intent) + + // Then: Timestamp should be tracked + val timestamp = receiver.getLastScreenOffTimestamp() + assertTrue("Timestamp should be set and valid", timestamp > 0) + } +} From 3250553a0678d12ce4804c169dbfea4b4d38a7b5 Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 00:35:21 +0300 Subject: [PATCH 05/28] updated UI to set delay --- .../java/io/github/miclock/ui/MainActivity.kt | 65 +++++++++++++++++++ app/src/main/res/layout/activity_main.xml | 48 +++++++++++++- app/src/main/res/values/strings.xml | 7 +- 3 files changed, 118 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/miclock/ui/MainActivity.kt b/app/src/main/java/io/github/miclock/ui/MainActivity.kt index c86321f..262098b 100644 --- a/app/src/main/java/io/github/miclock/ui/MainActivity.kt +++ b/app/src/main/java/io/github/miclock/ui/MainActivity.kt @@ -19,6 +19,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import com.google.android.material.button.MaterialButton +import com.google.android.material.slider.Slider import com.google.android.material.switchmaterial.SwitchMaterial import io.github.miclock.R import io.github.miclock.data.Prefs @@ -48,6 +49,9 @@ class MainActivity : ComponentActivity() { private lateinit var mediaRecorderToggle: SwitchMaterial private lateinit var mediaRecorderBatteryWarningText: TextView + private lateinit var screenOnDelaySlider: Slider + private lateinit var screenOnDelaySummary: TextView + private val audioPerms = arrayOf(Manifest.permission.RECORD_AUDIO) private val notifPerms = if (Build.VERSION.SDK_INT >= 33) { arrayOf(Manifest.permission.POST_NOTIFICATIONS) @@ -79,6 +83,9 @@ class MainActivity : ComponentActivity() { mediaRecorderToggle = findViewById(R.id.mediaRecorderToggle) mediaRecorderBatteryWarningText = findViewById(R.id.mediaRecorderBatteryWarningText) + screenOnDelaySlider = findViewById(R.id.screenOnDelaySlider) + screenOnDelaySummary = findViewById(R.id.screenOnDelaySummary) + // Initialize compatibility mode toggle mediaRecorderToggle.isChecked = Prefs.getUseMediaRecorder(this) mediaRecorderToggle.setOnCheckedChangeListener { _, isChecked -> @@ -91,6 +98,18 @@ class MainActivity : ComponentActivity() { updateCompatibilityModeUi() } + // Initialize screen-on delay slider + val currentDelay = Prefs.getScreenOnDelayMs(this) + screenOnDelaySlider.value = currentDelay.toFloat() + updateDelayConfigurationUi(currentDelay) + + screenOnDelaySlider.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + val delayMs = value.toLong() + handleDelayPreferenceChange(delayMs) + } + } + startBtn.setOnClickListener { if (!hasAllPerms()) { reqPerms.launch(audioPerms + notifPerms) @@ -238,6 +257,7 @@ class MainActivity : ComponentActivity() { private fun updateAllUi() { updateMainStatus() updateCompatibilityModeUi() + updateDelayConfigurationUi(Prefs.getScreenOnDelayMs(this)) } private fun updateMainStatus() { @@ -303,4 +323,49 @@ class MainActivity : ComponentActivity() { } } } + + /** + * Updates the delay configuration UI to reflect the current delay value. + * Shows appropriate summary text based on whether delay is enabled or disabled. + */ + private fun updateDelayConfigurationUi(delayMs: Long) { + if (delayMs <= 0L) { + screenOnDelaySummary.text = getString(R.string.screen_on_delay_disabled) + } else { + val delaySeconds = delayMs / 1000.0 + screenOnDelaySummary.text = getString(R.string.screen_on_delay_summary, delaySeconds) + } + } + + /** + * Handles changes to the delay preference from the UI. + * Validates the input, saves the preference, and updates any pending delay operations. + * Provides user feedback for configuration changes. + */ + private fun handleDelayPreferenceChange(delayMs: Long) { + try { + // Validate the delay value + if (!Prefs.isValidScreenOnDelay(delayMs)) { + Log.w("MainActivity", "Invalid delay value: ${delayMs}ms") + return + } + + // Save the preference + Prefs.setScreenOnDelayMs(this, delayMs) + + // Update UI to reflect the change + updateDelayConfigurationUi(delayMs) + + Log.d("MainActivity", "Screen-on delay updated to ${delayMs}ms") + + // Preference changes take effect immediately: + // - The new delay value will be used for the next screen-on event + // - Any currently pending delay operation will complete with its original delay + // - No service restart is required + // - This provides predictable behavior where in-flight operations complete as scheduled + + } catch (e: IllegalArgumentException) { + Log.e("MainActivity", "Failed to set screen-on delay: ${e.message}") + } + } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index aa0b4d3..71719aa 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -146,6 +146,52 @@ + + + + + + + + + + + + + @@ -186,4 +232,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5a7ac59..d749221 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,4 +2,9 @@ mic-lock MicLock Quick toggle for microphone protection - \ No newline at end of file + + + Screen-On Delay + Wait %1$.1f seconds before activating when screen turns on + Disabled (activates immediately) + From b665fb171855b9b404d23d59f1691319bfc6b235 Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 00:46:27 +0300 Subject: [PATCH 06/28] Fix delay logic to distinguish between service running and mic actively held --- .../service/DelayedActivationManager.kt | 13 +++---- .../github/miclock/service/MicLockService.kt | 10 ++++++ .../service/DelayedActivationManagerTest.kt | 34 +++++++++++-------- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt b/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt index 8fec6d2..ed1c1ac 100644 --- a/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt +++ b/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt @@ -149,9 +149,10 @@ open class DelayedActivationManager( val currentState = service.getCurrentState() return when { - // Service is already running - no need for delay - currentState.isRunning -> { - Log.d(TAG, "Service already running, respecting existing state") + // Check if mic is actively being held (not just service running) + // This allows delay when service is running but paused (screen off scenario) + service.isMicActivelyHeld() -> { + Log.d(TAG, "Mic is actively being held, respecting existing state") true } @@ -178,11 +179,11 @@ open class DelayedActivationManager( fun handleServiceStateConflict() { val currentState = service.getCurrentState() - Log.d(TAG, "Handling service state conflict - isRunning: ${currentState.isRunning}, isPaused: ${currentState.isPausedBySilence}") + Log.d(TAG, "Handling service state conflict - isMicActivelyHeld: ${service.isMicActivelyHeld()}, isPaused: ${currentState.isPausedBySilence}") when { - currentState.isRunning -> { - Log.d(TAG, "Service is already active, no action needed") + service.isMicActivelyHeld() -> { + Log.d(TAG, "Mic is actively being held, no action needed") } currentState.isPausedBySilence -> { diff --git a/app/src/main/java/io/github/miclock/service/MicLockService.kt b/app/src/main/java/io/github/miclock/service/MicLockService.kt index 4a8b9ca..76de7e5 100644 --- a/app/src/main/java/io/github/miclock/service/MicLockService.kt +++ b/app/src/main/java/io/github/miclock/service/MicLockService.kt @@ -962,6 +962,16 @@ class MicLockService : Service() { return !currentState.isRunning && !currentState.isPausedBySilence } + /** + * Checks if the microphone is actively being held (recording loop is active). + * This is different from service running - service can be running but paused (screen off). + * + * @return true if mic is actively held, false otherwise + */ + fun isMicActivelyHeld(): Boolean { + return loopJob?.isActive == true + } + companion object { private val _state = MutableStateFlow(ServiceState()) val state: StateFlow = _state.asStateFlow() diff --git a/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt b/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt index cb6b1cd..8fd8619 100644 --- a/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt +++ b/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt @@ -39,6 +39,7 @@ class DelayedActivationManagerTest { // Setup default mock behavior whenever(mockService.getCurrentState()).thenReturn(ServiceState()) whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) + whenever(mockService.isMicActivelyHeld()).thenReturn(false) delayedActivationManager = TestableDelayedActivationManager(context, mockService, testScope) } @@ -74,14 +75,14 @@ class DelayedActivationManagerTest { @Test fun testScheduleDelayedActivation_serviceAlreadyRunning_doesNotSchedule() = testScope.runTest { - // Given: Service is already running - whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = true)) + // Given: Mic is actively being held + whenever(mockService.isMicActivelyHeld()).thenReturn(true) // When: Attempting to schedule delay val result = delayedActivationManager.scheduleDelayedActivation(1000L) // Then: Should not schedule - assertFalse("Should not schedule when service already running", result) + assertFalse("Should not schedule when mic is actively held", result) assertFalse("Should not have pending activation", delayedActivationManager.isActivationPending()) } @@ -214,17 +215,18 @@ class DelayedActivationManagerTest { // Given: Initial state allows delay whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false)) whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) + whenever(mockService.isMicActivelyHeld()).thenReturn(false) delayedActivationManager.scheduleDelayedActivation(1000L) - // When: Service becomes active during delay + // When: Mic becomes actively held during delay advanceTimeBy(500L) // Halfway through delay - whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = true)) + whenever(mockService.isMicActivelyHeld()).thenReturn(true) // Complete the delay advanceTimeBy(500L) runCurrent() // Execute any pending coroutines - // Then: Should not activate microphone since service is already active + // Then: Should not activate microphone since mic is already actively held verify(mockService, never()).startMicHolding() // Note: The pending activation flag may still be true until the coroutine completes and checks state } @@ -273,11 +275,12 @@ class DelayedActivationManagerTest { @Test fun testShouldRespectExistingState_variousStates() = testScope.runTest { - // Test case 1: Service running - whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = true)) - assertTrue("Should respect state when service is running", delayedActivationManager.shouldRespectExistingState()) + // Test case 1: Mic actively held + whenever(mockService.isMicActivelyHeld()).thenReturn(true) + assertTrue("Should respect state when mic is actively held", delayedActivationManager.shouldRespectExistingState()) // Test case 2: Service paused by silence + whenever(mockService.isMicActivelyHeld()).thenReturn(false) whenever(mockService.getCurrentState()).thenReturn(ServiceState(isPausedBySilence = true)) assertTrue("Should respect state when paused by silence", delayedActivationManager.shouldRespectExistingState()) @@ -286,7 +289,8 @@ class DelayedActivationManagerTest { whenever(mockService.isManuallyStoppedByUser()).thenReturn(true) assertTrue("Should respect state when manually stopped", delayedActivationManager.shouldRespectExistingState()) - // Test case 4: Normal state (not running, not paused, not manually stopped) + // Test case 4: Normal state (not actively held, not paused, not manually stopped) + whenever(mockService.isMicActivelyHeld()).thenReturn(false) whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false)) whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) assertFalse("Should not respect state in normal conditions", delayedActivationManager.shouldRespectExistingState()) @@ -298,12 +302,13 @@ class DelayedActivationManagerTest { Prefs.setScreenOnDelayMs(context, 0L) assertFalse("Should not apply delay when disabled", delayedActivationManager.shouldApplyDelay()) - // Test case 2: Valid delay but existing state should be respected + // Test case 2: Valid delay but existing state should be respected (mic actively held) Prefs.setScreenOnDelayMs(context, 1000L) - whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = true)) + whenever(mockService.isMicActivelyHeld()).thenReturn(true) assertFalse("Should not apply delay when existing state should be respected", delayedActivationManager.shouldApplyDelay()) // Test case 3: Valid delay and normal state + whenever(mockService.isMicActivelyHeld()).thenReturn(false) whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false)) whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) assertTrue("Should apply delay in normal conditions", delayedActivationManager.shouldApplyDelay()) @@ -314,11 +319,12 @@ class DelayedActivationManagerTest { // This test verifies that the conflict handler doesn't crash and handles different states // Since it mainly logs, we test that it executes without exceptions - // Test with running service - whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = true)) + // Test with mic actively held + whenever(mockService.isMicActivelyHeld()).thenReturn(true) assertDoesNotThrow { delayedActivationManager.handleServiceStateConflict() } // Test with paused service + whenever(mockService.isMicActivelyHeld()).thenReturn(false) whenever(mockService.getCurrentState()).thenReturn(ServiceState(isPausedBySilence = true)) assertDoesNotThrow { delayedActivationManager.handleServiceStateConflict() } From bc6b943d741945ea9656cc3fed22470196861242 Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 01:22:52 +0300 Subject: [PATCH 07/28] Fix state management to distinguish screen-off pause from silence pause --- .../service/DelayedActivationManager.kt | 23 +++- .../miclock/service/MicActivationService.kt | 40 +++++++ .../github/miclock/service/MicLockService.kt | 110 ++++++++++++------ .../miclock/service/model/ServiceState.kt | 1 + .../service/DelayedActivationManagerTest.kt | 12 +- 5 files changed, 142 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/io/github/miclock/service/MicActivationService.kt diff --git a/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt b/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt index ed1c1ac..68ecf05 100644 --- a/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt +++ b/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt @@ -1,7 +1,11 @@ package io.github.miclock.service +import android.Manifest import android.content.Context +import android.os.Build import android.util.Log +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission import io.github.miclock.data.Prefs import kotlinx.coroutines.* import java.util.concurrent.atomic.AtomicBoolean @@ -13,7 +17,7 @@ import java.util.concurrent.atomic.AtomicLong */ open class DelayedActivationManager( private val context: Context, - private val service: MicLockService, + private val service: MicActivationService, private val scope: CoroutineScope ) { companion object { @@ -33,6 +37,8 @@ open class DelayedActivationManager( * @param delayMs delay in milliseconds before activation * @return true if delay was scheduled, false if conditions don't allow delay */ + @RequiresApi(Build.VERSION_CODES.P) + @RequiresPermission(Manifest.permission.RECORD_AUDIO) fun scheduleDelayedActivation(delayMs: Long): Boolean { val currentTime = getCurrentTimeMs() lastScreenOnTime.set(currentTime) @@ -73,7 +79,8 @@ open class DelayedActivationManager( isActivationPending.set(false) // Activate microphone functionality - service.startMicHolding() + // Pass fromDelayCompletion=true to skip startForeground (already started in handleStartHolding) + service.startMicHolding(fromDelayCompletion = true) } else { Log.d(TAG, "Delay operation superseded by newer event, not activating") isActivationPending.set(false) @@ -162,6 +169,12 @@ open class DelayedActivationManager( true } + // Service is paused by screen-off - this is normal and delay should be applied + currentState.isPausedByScreenOff -> { + Log.d(TAG, "Service paused by screen-off, delay can be applied") + false + } + // Check if service was manually stopped by user service.isManuallyStoppedByUser() -> { Log.d(TAG, "Service manually stopped by user, respecting manual stop") @@ -179,7 +192,7 @@ open class DelayedActivationManager( fun handleServiceStateConflict() { val currentState = service.getCurrentState() - Log.d(TAG, "Handling service state conflict - isMicActivelyHeld: ${service.isMicActivelyHeld()}, isPaused: ${currentState.isPausedBySilence}") + Log.d(TAG, "Handling service state conflict - isMicActivelyHeld: ${service.isMicActivelyHeld()}, isPausedBySilence: ${currentState.isPausedBySilence}, isPausedByScreenOff: ${currentState.isPausedByScreenOff}") when { service.isMicActivelyHeld() -> { @@ -190,6 +203,10 @@ open class DelayedActivationManager( Log.d(TAG, "Service is paused by another app, maintaining pause state") } + currentState.isPausedByScreenOff -> { + Log.d(TAG, "Service is paused by screen-off, this is expected") + } + service.isManuallyStoppedByUser() -> { Log.d(TAG, "Service was manually stopped, not overriding user choice") } diff --git a/app/src/main/java/io/github/miclock/service/MicActivationService.kt b/app/src/main/java/io/github/miclock/service/MicActivationService.kt new file mode 100644 index 0000000..b33482c --- /dev/null +++ b/app/src/main/java/io/github/miclock/service/MicActivationService.kt @@ -0,0 +1,40 @@ +package io.github.miclock.service + +import android.Manifest +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import io.github.miclock.service.model.ServiceState + +/** + * Interface for services that can activate microphone functionality. + * This interface breaks the circular dependency between DelayedActivationManager and MicLockService. + */ +interface MicActivationService { + + /** + * Gets the current service state. + * @return current ServiceState + */ + fun getCurrentState(): ServiceState + + /** + * Checks if the microphone is actively being held (recording loop is active). + * @return true if mic is actively held, false otherwise + */ + fun isMicActivelyHeld(): Boolean + + /** + * Checks if the service was manually stopped by the user. + * @return true if manually stopped by user, false otherwise + */ + fun isManuallyStoppedByUser(): Boolean + + /** + * Starts microphone holding functionality. + * @param fromDelayCompletion true if called from delay completion, false otherwise + */ + @RequiresApi(Build.VERSION_CODES.P) + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + fun startMicHolding(fromDelayCompletion: Boolean) +} \ No newline at end of file diff --git a/app/src/main/java/io/github/miclock/service/MicLockService.kt b/app/src/main/java/io/github/miclock/service/MicLockService.kt index 76de7e5..5c15da2 100644 --- a/app/src/main/java/io/github/miclock/service/MicLockService.kt +++ b/app/src/main/java/io/github/miclock/service/MicLockService.kt @@ -26,6 +26,7 @@ import io.github.miclock.audio.AudioSelector import io.github.miclock.audio.MediaRecorderHolder import io.github.miclock.data.Prefs import io.github.miclock.receiver.ScreenStateReceiver +import io.github.miclock.service.DelayedActivationManager import io.github.miclock.service.model.ServiceState import io.github.miclock.ui.MainActivity import io.github.miclock.util.WakeLockManager @@ -55,7 +56,7 @@ import kotlinx.coroutines.flow.update * * This solves the problem where apps default to a broken bottom microphone and record silence. */ -class MicLockService : Service() { +class MicLockService : Service(), MicActivationService { override fun onBind(intent: Intent?): IBinder? = null @@ -175,7 +176,7 @@ class MicLockService : Service() { } screenStateReceiver = null - updateServiceState(running = false, paused = false, deviceAddr = null) + updateServiceState(running = false, paused = false, pausedByScreenOff = false, deviceAddr = null) if (wasRunning && !suppressRestartNotification) { createRestartNotification() @@ -221,7 +222,7 @@ class MicLockService : Service() { startFailureReason = null suppressRestartNotification = false - startMicHolding() + startMicHolding(fromDelayCompletion = false) updateServiceState(running = true) } catch (e: Exception) { Log.w(TAG, "Could not start foreground service: ${e.message}") @@ -289,6 +290,35 @@ class MicLockService : Service() { delayedActivationManager.cancelDelayedActivation() } + // CRITICAL: Start foreground service BEFORE delay to satisfy Android 14+ FGS restrictions + // The service must be started from an eligible state/context (screen-on event) + if (canStartForegroundService()) { + try { + val countdownText = "Starting in ${delayMs / 1000.0}s…" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIF_ID, + buildNotification(countdownText), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ) + } else { + startForeground(NOTIF_ID, buildNotification(countdownText)) + } + serviceHealthy = true + Log.d(TAG, "Foreground service started with countdown notification") + } catch (e: Exception) { + Log.w(TAG, "Could not start foreground service during delay: ${e.message}") + serviceHealthy = false + updateServiceState(running = false) + createRestartNotification() + stopSelf() + return + } + } else { + Log.d(TAG, "Delaying foreground service start due to boot restrictions") + scheduleDelayedForegroundStart() + } + // Schedule delayed activation val scheduled = delayedActivationManager.scheduleDelayedActivation(delayMs) @@ -302,12 +332,12 @@ class MicLockService : Service() { } else { // Delay not applicable, start immediately Log.d(TAG, "Delay not applicable, starting immediately") - startMicHolding() + startMicHolding(fromDelayCompletion = false) } } else { // No delay configured or delay not applicable, start immediately Log.d(TAG, "No delay configured (${delayMs}ms), starting immediately") - startMicHolding() + startMicHolding(fromDelayCompletion = false) } } else { Log.w(TAG, "Service not running, ignoring START_HOLDING action. (Consider starting service first)") @@ -423,14 +453,14 @@ class MicLockService : Service() { @RequiresApi(Build.VERSION_CODES.P) @RequiresPermission(Manifest.permission.RECORD_AUDIO) - internal fun startMicHolding() { + override fun startMicHolding(fromDelayCompletion: Boolean) { // This check is important. If the loop is active, we don't need to do anything. if (loopJob?.isActive == true) { Log.d(TAG, "Mic holding is already active.") return } // Change the log message to be more generic for screen on or user start - Log.i(TAG, "Starting or resuming mic holding logic.") + Log.i(TAG, "Starting or resuming mic holding logic. fromDelayCompletion=$fromDelayCompletion") // Clear any delay state since we're actually starting now if (::delayedActivationManager.isInitialized && delayedActivationManager.isActivationPending()) { @@ -438,39 +468,47 @@ class MicLockService : Service() { updateServiceState(delayPending = false, delayRemainingMs = 0) } - // Try to start foreground service based on current conditions - if (canStartForegroundService()) { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - NOTIF_ID, - buildNotification("Starting…"), - ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, - ) - } else { - startForeground(NOTIF_ID, buildNotification("Starting…")) + // Only start foreground service if NOT called from delay completion + // When called from delay completion, foreground service was already started in handleStartHolding + if (!fromDelayCompletion) { + // Try to start foreground service based on current conditions + if (canStartForegroundService()) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIF_ID, + buildNotification("Starting…"), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ) + } else { + startForeground(NOTIF_ID, buildNotification("Starting…")) + } + serviceHealthy = true + Log.d(TAG, "Foreground service started successfully") + } catch (e: Exception) { + Log.w(TAG, "Could not start foreground service: ${e.message}") + serviceHealthy = false + updateServiceState(running = false) + createRestartNotification() + stopSelf() + return } - serviceHealthy = true - Log.d(TAG, "Foreground service started successfully") - } catch (e: Exception) { - Log.w(TAG, "Could not start foreground service: ${e.message}") - serviceHealthy = false - updateServiceState(running = false) - createRestartNotification() - stopSelf() - return + } else { + Log.d(TAG, "Delaying foreground service start due to boot restrictions") + scheduleDelayedForegroundStart() } } else { - Log.d(TAG, "Delaying foreground service start due to boot restrictions") - scheduleDelayedForegroundStart() + Log.d(TAG, "Skipping startForeground call - service already in foreground from delay scheduling") + // Update notification to show activation is complete + updateNotification("Recording active") } stopFlag.set(false) // Only update state if service is healthy if (serviceHealthy) { - updateServiceState(paused = false, running = true) + updateServiceState(paused = false, pausedByScreenOff = false, running = true) } else { - updateServiceState(paused = false, running = false) + updateServiceState(paused = false, pausedByScreenOff = false, running = false) } loopJob = scope.launch { holdSelectedMicLoop() } } @@ -488,7 +526,7 @@ class MicLockService : Service() { recCallback = null mediaRecorderHolder?.stopRecording() mediaRecorderHolder = null - updateServiceState(deviceAddr = null, paused = true) // Mark as paused + updateServiceState(deviceAddr = null, pausedByScreenOff = true) // Mark as paused by screen-off updateNotification("Paused (Screen off)") } @@ -926,6 +964,7 @@ class MicLockService : Service() { private fun updateServiceState( running: Boolean? = null, paused: Boolean? = null, + pausedByScreenOff: Boolean? = null, deviceAddr: String? = null, delayPending: Boolean? = null, delayRemainingMs: Long? = null @@ -934,6 +973,7 @@ class MicLockService : Service() { currentState.copy( isRunning = running ?: currentState.isRunning, isPausedBySilence = paused ?: currentState.isPausedBySilence, + isPausedByScreenOff = pausedByScreenOff ?: currentState.isPausedByScreenOff, currentDeviceAddress = deviceAddr ?: currentState.currentDeviceAddress, isDelayedActivationPending = delayPending ?: currentState.isDelayedActivationPending, delayedActivationRemainingMs = delayRemainingMs ?: currentState.delayedActivationRemainingMs, @@ -947,7 +987,7 @@ class MicLockService : Service() { * * @return current ServiceState */ - fun getCurrentState(): ServiceState = state.value + override fun getCurrentState(): ServiceState = state.value /** * Checks if the service was manually stopped by the user. @@ -955,7 +995,7 @@ class MicLockService : Service() { * * @return true if manually stopped by user, false otherwise */ - fun isManuallyStoppedByUser(): Boolean { + override fun isManuallyStoppedByUser(): Boolean { val currentState = state.value // Service is considered manually stopped if it's not running and not paused by silence // This indicates user intentionally stopped it rather than system pausing it @@ -968,7 +1008,7 @@ class MicLockService : Service() { * * @return true if mic is actively held, false otherwise */ - fun isMicActivelyHeld(): Boolean { + override fun isMicActivelyHeld(): Boolean { return loopJob?.isActive == true } diff --git a/app/src/main/java/io/github/miclock/service/model/ServiceState.kt b/app/src/main/java/io/github/miclock/service/model/ServiceState.kt index 17c9fa5..b8d9f3f 100644 --- a/app/src/main/java/io/github/miclock/service/model/ServiceState.kt +++ b/app/src/main/java/io/github/miclock/service/model/ServiceState.kt @@ -3,6 +3,7 @@ package io.github.miclock.service.model data class ServiceState( val isRunning: Boolean = false, val isPausedBySilence: Boolean = false, + val isPausedByScreenOff: Boolean = false, val currentDeviceAddress: String? = null, val isDelayedActivationPending: Boolean = false, val delayedActivationRemainingMs: Long = 0, diff --git a/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt b/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt index 8fd8619..8e5d2ac 100644 --- a/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt +++ b/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt @@ -24,7 +24,7 @@ import org.robolectric.RuntimeEnvironment class DelayedActivationManagerTest { @Mock - private lateinit var mockService: MicLockService + private lateinit var mockService: MicActivationService private lateinit var context: Context private lateinit var testScope: TestScope @@ -127,8 +127,8 @@ class DelayedActivationManagerTest { advanceTimeBy(100L) runCurrent() // Execute any pending coroutines - // Then: Should activate microphone - verify(mockService).startMicHolding() + // Then: Should activate microphone with fromDelayCompletion=true + verify(mockService).startMicHolding(fromDelayCompletion = true) assertFalse("Should not have pending activation after completion", delayedActivationManager.isActivationPending()) } @@ -206,7 +206,7 @@ class DelayedActivationManagerTest { runCurrent() // Execute any pending coroutines // Then: Should not activate microphone due to state change - verify(mockService, never()).startMicHolding() + verify(mockService, never()).startMicHolding(any()) // Note: The pending activation flag may still be true until the coroutine completes and checks state } @@ -227,7 +227,7 @@ class DelayedActivationManagerTest { runCurrent() // Execute any pending coroutines // Then: Should not activate microphone since mic is already actively held - verify(mockService, never()).startMicHolding() + verify(mockService, never()).startMicHolding(any()) // Note: The pending activation flag may still be true until the coroutine completes and checks state } @@ -386,7 +386,7 @@ class DelayedActivationManagerTest { */ class TestableDelayedActivationManager( context: Context, - service: MicLockService, + service: MicActivationService, private val testScope: TestScope ) : DelayedActivationManager(context, service, testScope) { From b61515379886d473fde8ebe1f4f1fea19c118cbe Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 01:45:38 +0300 Subject: [PATCH 08/28] Update Quick Settings tile for delay states --- .../github/miclock/service/MicLockService.kt | 24 +++++ .../github/miclock/tile/MicLockTileService.kt | 97 ++++++++++++++----- 2 files changed, 98 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/io/github/miclock/service/MicLockService.kt b/app/src/main/java/io/github/miclock/service/MicLockService.kt index 5c15da2..69593c4 100644 --- a/app/src/main/java/io/github/miclock/service/MicLockService.kt +++ b/app/src/main/java/io/github/miclock/service/MicLockService.kt @@ -6,6 +6,7 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service +import android.content.ComponentName import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager @@ -14,6 +15,7 @@ import android.media.* import android.os.Build import android.os.Handler import android.os.IBinder +import android.service.quicksettings.TileService import android.os.Looper import android.os.SystemClock import android.util.Log @@ -28,6 +30,7 @@ import io.github.miclock.data.Prefs import io.github.miclock.receiver.ScreenStateReceiver import io.github.miclock.service.DelayedActivationManager import io.github.miclock.service.model.ServiceState +import io.github.miclock.tile.MicLockTileService import io.github.miclock.ui.MainActivity import io.github.miclock.util.WakeLockManager import java.util.concurrent.atomic.AtomicBoolean @@ -191,6 +194,12 @@ class MicLockService : Service(), MicActivationService { notifManager.cancel(RESTART_NOTIF_ID) Log.i(TAG, "Received ACTION_START_USER_INITIATED - user-initiated start") + // Check if this is a manual override from tile during delay + val isCancelDelay = intent?.getBooleanExtra("cancel_delay", false) ?: false + if (isCancelDelay) { + Log.i(TAG, "Manual override requested - cancelling delay and starting immediately") + } + // Cancel any pending delayed activation when user manually starts service if (::delayedActivationManager.isInitialized) { val wasCancelled = delayedActivationManager.cancelDelayedActivation() @@ -979,6 +988,21 @@ class MicLockService : Service(), MicActivationService { delayedActivationRemainingMs = delayRemainingMs ?: currentState.delayedActivationRemainingMs, ) } + + // Request tile update whenever service state changes + requestTileUpdate() + } + + private fun requestTileUpdate() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + try { + val componentName = ComponentName(this, MicLockTileService::class.java) + TileService.requestListeningState(this, componentName) + Log.d(TAG, "Requested tile update") + } catch (e: Exception) { + Log.w(TAG, "Failed to request tile update: ${e.message}") + } + } } /** diff --git a/app/src/main/java/io/github/miclock/tile/MicLockTileService.kt b/app/src/main/java/io/github/miclock/tile/MicLockTileService.kt index 2d5887f..51ab481 100644 --- a/app/src/main/java/io/github/miclock/tile/MicLockTileService.kt +++ b/app/src/main/java/io/github/miclock/tile/MicLockTileService.kt @@ -36,9 +36,23 @@ class MicLockTileService : TileService() { private const val TAG = "MicLockTileService" } + override fun onCreate() { + super.onCreate() + Log.d(TAG, "Tile service created") + + // Initialize tile state immediately when service is created + // This helps with initial state display + val initialState = getCurrentAppState() + updateTileState(initialState) + } + override fun onStartListening() { super.onStartListening() Log.d(TAG, "Tile started listening") + + // Force immediate state update when listening starts + val currentState = getCurrentAppState() + updateTileState(currentState) failureReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -140,29 +154,48 @@ class MicLockTileService : TileService() { val currentState = getCurrentAppState() - if (currentState.isRunning) { - val intent = Intent(this, MicLockService::class.java) - intent.action = MicLockService.ACTION_STOP - Log.d(TAG, "Stopping MicLock service via tile") - try { - startService(intent) - Log.d(TAG, "Successfully sent stop intent to running service") - } catch (e: Exception) { - Log.e(TAG, "Failed to send stop intent to service: ${e.message}", e) + when { + currentState.isDelayedActivationPending -> { + // Manual override: cancel delay and activate immediately + val intent = Intent(this, MicLockService::class.java).apply { + action = MicLockService.ACTION_START_USER_INITIATED + putExtra("from_tile", true) + putExtra("cancel_delay", true) // Signal to cancel any pending delay + } + Log.d(TAG, "Cancelling delay and starting service immediately via tile") + try { + ContextCompat.startForegroundService(this, intent) + Log.d(TAG, "Manual override request sent - delay cancelled, service starting") + } catch (e: Exception) { + Log.e(TAG, "Failed to override delay and start service: ${e.message}", e) + createTileFailureNotification("Service failed to start: ${e.message}") + } } - } else { - val intent = Intent(this, MicLockService::class.java).apply { - action = MicLockService.ACTION_START_USER_INITIATED - putExtra("from_tile", true) + currentState.isRunning -> { + val intent = Intent(this, MicLockService::class.java) + intent.action = MicLockService.ACTION_STOP + Log.d(TAG, "Stopping MicLock service via tile") + try { + startService(intent) + Log.d(TAG, "Successfully sent stop intent to running service") + } catch (e: Exception) { + Log.e(TAG, "Failed to send stop intent to service: ${e.message}", e) + } } + else -> { + val intent = Intent(this, MicLockService::class.java).apply { + action = MicLockService.ACTION_START_USER_INITIATED + putExtra("from_tile", true) + } - Log.d(TAG, "Attempting direct service start from tile") - try { - ContextCompat.startForegroundService(this, intent) - Log.d(TAG, "Direct service start request sent") - } catch (e: Exception) { - Log.e(TAG, "Failed to start service directly: ${e.message}", e) - createTileFailureNotification("Service failed to start: ${e.message}") + Log.d(TAG, "Attempting direct service start from tile") + try { + ContextCompat.startForegroundService(this, intent) + Log.d(TAG, "Direct service start request sent") + } catch (e: Exception) { + Log.e(TAG, "Failed to start service directly: ${e.message}", e) + createTileFailureNotification("Service failed to start: ${e.message}") + } } } } @@ -236,7 +269,7 @@ class MicLockTileService : TileService() { val hasPerms = hasAllPerms() Log.d( TAG, - "updateTileState: hasPerms=$hasPerms, isRunning=${state.isRunning}, isPaused=${state.isPausedBySilence}", + "updateTileState: hasPerms=$hasPerms, isRunning=${state.isRunning}, isPaused=${state.isPausedBySilence}, isDelayPending=${state.isDelayedActivationPending}", ) when { @@ -248,6 +281,14 @@ class MicLockTileService : TileService() { tile.icon = Icon.createWithResource(this, R.drawable.ic_mic_off) Log.d(TAG, "Tile set to 'No Permission' state") } + state.isDelayedActivationPending -> { + // Delayed activation is pending - show activating state + tile.state = Tile.STATE_UNAVAILABLE + tile.label = "Activating..." + tile.contentDescription = "Tap to cancel delay and activate immediately" + tile.icon = Icon.createWithResource(this, R.drawable.ic_mic_pause) + Log.d(TAG, "Tile set to 'Activating...' state (delay pending)") + } !state.isRunning -> { // Service is OFF tile.state = Tile.STATE_INACTIVE @@ -277,7 +318,7 @@ class MicLockTileService : TileService() { tile.updateTile() Log.d( TAG, - "Tile updated - Running: ${state.isRunning}, Paused: ${state.isPausedBySilence}, HasPerms: $hasPerms", + "Tile updated - Running: ${state.isRunning}, Paused: ${state.isPausedBySilence}, DelayPending: ${state.isDelayedActivationPending}, HasPerms: $hasPerms", ) } @@ -306,7 +347,17 @@ class MicLockTileService : TileService() { val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager val isRunning = activityManager.getRunningServices(Integer.MAX_VALUE) .any { it.service.className == MicLockService::class.java.name } - ServiceState(isRunning = isRunning, isPausedBySilence = false) + + // Preserve delay state from current StateFlow when checking system state + val currentState = MicLockService.state.value + ServiceState( + isRunning = isRunning, + isPausedBySilence = false, + isPausedByScreenOff = currentState.isPausedByScreenOff, + currentDeviceAddress = currentState.currentDeviceAddress, + isDelayedActivationPending = currentState.isDelayedActivationPending, + delayedActivationRemainingMs = currentState.delayedActivationRemainingMs + ) } catch (e: Exception) { Log.w(TAG, "Failed to check service running state: ${e.message}") ServiceState(isRunning = false, isPausedBySilence = false) From 6f04258408c3de0bf4c13d89909e3659cf134fb1 Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 01:49:12 +0300 Subject: [PATCH 09/28] fixed a bug when the tile wasn't updating back to active after delay --- app/src/main/java/io/github/miclock/service/MicLockService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/miclock/service/MicLockService.kt b/app/src/main/java/io/github/miclock/service/MicLockService.kt index 69593c4..9657ac9 100644 --- a/app/src/main/java/io/github/miclock/service/MicLockService.kt +++ b/app/src/main/java/io/github/miclock/service/MicLockService.kt @@ -472,8 +472,8 @@ class MicLockService : Service(), MicActivationService { Log.i(TAG, "Starting or resuming mic holding logic. fromDelayCompletion=$fromDelayCompletion") // Clear any delay state since we're actually starting now - if (::delayedActivationManager.isInitialized && delayedActivationManager.isActivationPending()) { - Log.d(TAG, "Clearing delay state as mic holding is starting") + if (fromDelayCompletion || (::delayedActivationManager.isInitialized && delayedActivationManager.isActivationPending())) { + Log.d(TAG, "Clearing delay state as mic holding is starting (fromDelayCompletion=$fromDelayCompletion)") updateServiceState(delayPending = false, delayRemainingMs = 0) } From 5223c65a946b5155e354dcba79725095be56fec6 Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 09:31:50 +0300 Subject: [PATCH 10/28] delay slider share the same constant as the code --- app/src/main/java/io/github/miclock/ui/MainActivity.kt | 2 ++ app/src/main/res/layout/activity_main.xml | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/miclock/ui/MainActivity.kt b/app/src/main/java/io/github/miclock/ui/MainActivity.kt index 262098b..547c041 100644 --- a/app/src/main/java/io/github/miclock/ui/MainActivity.kt +++ b/app/src/main/java/io/github/miclock/ui/MainActivity.kt @@ -99,6 +99,8 @@ class MainActivity : ComponentActivity() { } // Initialize screen-on delay slider + screenOnDelaySlider.valueFrom = Prefs.MIN_SCREEN_ON_DELAY_MS.toFloat() + screenOnDelaySlider.valueTo = Prefs.MAX_SCREEN_ON_DELAY_MS.toFloat() val currentDelay = Prefs.getScreenOnDelayMs(this) screenOnDelaySlider.value = currentDelay.toFloat() updateDelayConfigurationUi(currentDelay) diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 71719aa..aa87a7e 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -182,8 +182,6 @@ android:id="@+id/screenOnDelaySlider" android:layout_width="match_parent" android:layout_height="wrap_content" - android:valueFrom="0" - android:valueTo="5000" android:stepSize="100" app:thumbColor="@color/secondary_green" app:trackColorActive="@color/secondary_green" From e9fadd7849054abbdd4035117d75f48e0371f7fa Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 10:08:36 +0300 Subject: [PATCH 11/28] change the logic into screen behavior with option to never and always on --- .../main/java/io/github/miclock/data/Prefs.kt | 117 ++++++++++++++++-- .../service/DelayedActivationManager.kt | 30 +++++ .../github/miclock/service/MicLockService.kt | 6 + .../java/io/github/miclock/ui/MainActivity.kt | 41 ++++-- app/src/main/res/layout/activity_main.xml | 90 +++++++++++++- app/src/main/res/values/strings.xml | 8 +- 6 files changed, 265 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/io/github/miclock/data/Prefs.kt b/app/src/main/java/io/github/miclock/data/Prefs.kt index b684de4..6012163 100644 --- a/app/src/main/java/io/github/miclock/data/Prefs.kt +++ b/app/src/main/java/io/github/miclock/data/Prefs.kt @@ -2,6 +2,7 @@ package io.github.miclock.data import android.content.Context import androidx.core.content.edit +import kotlin.math.round object Prefs { private const val FILE = "miclock_prefs" @@ -11,15 +12,19 @@ object Prefs { private const val KEY_SCREEN_ON_DELAY = "screen_on_delay_ms" const val VALUE_AUTO = "auto" - + // Screen-on delay constants const val DEFAULT_SCREEN_ON_DELAY_MS = 1300L const val MIN_SCREEN_ON_DELAY_MS = 0L const val MAX_SCREEN_ON_DELAY_MS = 5000L + // Special behavior values + const val NEVER_REACTIVATE_VALUE = -1L // Never re-enable after screen-off + const val ALWAYS_KEEP_ON_VALUE = -2L // Always keep mic on (ignore screen state) + fun getUseMediaRecorder(ctx: Context): Boolean = - ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE) - .getBoolean(KEY_USE_MEDIA_RECORDER, false) + ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE) + .getBoolean(KEY_USE_MEDIA_RECORDER, false) fun setUseMediaRecorder(ctx: Context, value: Boolean) { ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE).edit { @@ -28,8 +33,8 @@ object Prefs { } fun getLastRecordingMethod(ctx: Context): String? = - ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE) - .getString(KEY_LAST_RECORDING_METHOD, null) + ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE) + .getString(KEY_LAST_RECORDING_METHOD, null) fun setLastRecordingMethod(ctx: Context, method: String) { ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE).edit { @@ -42,19 +47,20 @@ object Prefs { * @return delay in milliseconds, defaults to 1300ms */ fun getScreenOnDelayMs(ctx: Context): Long = - ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE) - .getLong(KEY_SCREEN_ON_DELAY, DEFAULT_SCREEN_ON_DELAY_MS) + ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE) + .getLong(KEY_SCREEN_ON_DELAY, DEFAULT_SCREEN_ON_DELAY_MS) /** * Sets the screen-on delay in milliseconds with validation. - * @param delayMs delay in milliseconds, must be between 0-5000ms + * @param delayMs delay in milliseconds, must be between 0-5000ms, -1 for never re-enable, or -2 + * for always on * @throws IllegalArgumentException if delay is outside valid range */ fun setScreenOnDelayMs(ctx: Context, delayMs: Long) { - require(delayMs in MIN_SCREEN_ON_DELAY_MS..MAX_SCREEN_ON_DELAY_MS) { - "Screen-on delay must be between ${MIN_SCREEN_ON_DELAY_MS}ms and ${MAX_SCREEN_ON_DELAY_MS}ms, got ${delayMs}ms" + require(isValidScreenOnDelay(delayMs)) { + "Screen-on delay must be between ${MIN_SCREEN_ON_DELAY_MS}ms and ${MAX_SCREEN_ON_DELAY_MS}ms, ${NEVER_REACTIVATE_VALUE} for never re-enable, or ${ALWAYS_KEEP_ON_VALUE} for always on, got ${delayMs}ms" } - + ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE).edit { putLong(KEY_SCREEN_ON_DELAY, delayMs) } @@ -66,5 +72,92 @@ object Prefs { * @return true if valid, false otherwise */ fun isValidScreenOnDelay(delayMs: Long): Boolean = - delayMs in MIN_SCREEN_ON_DELAY_MS..MAX_SCREEN_ON_DELAY_MS + delayMs == NEVER_REACTIVATE_VALUE || + delayMs == ALWAYS_KEEP_ON_VALUE || + delayMs in MIN_SCREEN_ON_DELAY_MS..MAX_SCREEN_ON_DELAY_MS + + // Slider mapping constants + const val SLIDER_MIN = 0f + const val SLIDER_MAX = 100f + const val SLIDER_NEVER_REACTIVATE = 0f // Far left + const val SLIDER_ALWAYS_ON = 100f // Far right + const val SLIDER_DELAY_START = 10f // Start of delay range + const val SLIDER_DELAY_END = 90f // End of delay range + + /** + * Converts slider position (0-100) to delay value in milliseconds with snappy transitions. + * @param sliderValue slider position (0-100) + * @return delay in milliseconds or special behavior value + */ + fun sliderToDelayMs(sliderValue: Float): Long { + return when { + // Snap zone for "Never re-enable" (0-5) + sliderValue <= 5f -> NEVER_REACTIVATE_VALUE + + // Snap zone for "Always on" (95-100) + sliderValue >= 95f -> ALWAYS_KEEP_ON_VALUE + + // Delay range (10-90) with snapping to nearest valid position + else -> { + // Snap to the delay range boundaries if close + val snappedValue = + when { + sliderValue < SLIDER_DELAY_START -> SLIDER_DELAY_START + sliderValue > SLIDER_DELAY_END -> SLIDER_DELAY_END + else -> sliderValue + } + + // Map to delay range (0-5000ms) + val normalizedValue = + (snappedValue - SLIDER_DELAY_START) / + (SLIDER_DELAY_END - SLIDER_DELAY_START) + val delayMs = (normalizedValue * MAX_SCREEN_ON_DELAY_MS).toLong() + // Round to nearest 100ms + (delayMs / 100L) * 100L + } + } + } + + /** + * Snaps slider value to the nearest valid position with clear phase boundaries. + * @param sliderValue current slider position + * @return snapped slider position + */ + fun snapSliderValue(sliderValue: Float): Float { + return when { + // Snap to "Never re-enable" zone + sliderValue <= 5f -> SLIDER_NEVER_REACTIVATE + + // Snap to "Always on" zone + sliderValue >= 95f -> SLIDER_ALWAYS_ON + + // Snap to delay range boundaries if in transition zones + sliderValue < SLIDER_DELAY_START -> SLIDER_DELAY_START + sliderValue > SLIDER_DELAY_END -> SLIDER_DELAY_END + + // Within delay range - round to nearest integer + else -> round(sliderValue) + } + } + + /** + * Converts delay value to slider position (0-100). + * @param delayMs delay in milliseconds or special behavior value + * @return slider position (0-100), rounded to integer + */ + fun delayMsToSlider(delayMs: Long): Float { + return when (delayMs) { + NEVER_REACTIVATE_VALUE -> SLIDER_NEVER_REACTIVATE + ALWAYS_KEEP_ON_VALUE -> SLIDER_ALWAYS_ON + else -> { + // Map delay range (0-5000ms) to slider range (10-90) + val normalizedDelay = delayMs.toFloat() / MAX_SCREEN_ON_DELAY_MS.toFloat() + val sliderValue = + SLIDER_DELAY_START + + (normalizedDelay * (SLIDER_DELAY_END - SLIDER_DELAY_START)) + // Round to nearest integer to align with stepSize + round(sliderValue) + } + } + } } diff --git a/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt b/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt index 68ecf05..05743a2 100644 --- a/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt +++ b/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt @@ -217,6 +217,24 @@ open class DelayedActivationManager( } } + /** + * Checks if the app is configured to never re-enable after screen-off. + * + * @return true if never re-enable mode is active, false otherwise + */ + fun isNeverReactivateMode(): Boolean { + return Prefs.getScreenOnDelayMs(context) == Prefs.NEVER_REACTIVATE_VALUE + } + + /** + * Checks if the app is configured to always keep mic on (ignore screen state). + * + * @return true if always-on mode is active, false otherwise + */ + fun isAlwaysOnMode(): Boolean { + return Prefs.getScreenOnDelayMs(context) == Prefs.ALWAYS_KEEP_ON_VALUE + } + /** * Determines if delay should be applied based on current conditions. * @@ -226,6 +244,18 @@ open class DelayedActivationManager( val delayMs = Prefs.getScreenOnDelayMs(context) return when { + // Never re-enable mode + delayMs == Prefs.NEVER_REACTIVATE_VALUE -> { + Log.d(TAG, "Never re-enable mode active, blocking activation") + false + } + + // Always-on mode (should not apply delay, but should activate immediately) + delayMs == Prefs.ALWAYS_KEEP_ON_VALUE -> { + Log.d(TAG, "Always-on mode active, no delay needed") + false + } + // Delay is disabled (0ms) delayMs <= 0L -> { Log.d(TAG, "Delay disabled (${delayMs}ms)") diff --git a/app/src/main/java/io/github/miclock/service/MicLockService.kt b/app/src/main/java/io/github/miclock/service/MicLockService.kt index 9657ac9..e65dafa 100644 --- a/app/src/main/java/io/github/miclock/service/MicLockService.kt +++ b/app/src/main/java/io/github/miclock/service/MicLockService.kt @@ -357,6 +357,12 @@ class MicLockService : Service(), MicActivationService { val eventTimestamp = intent?.getLongExtra(ScreenStateReceiver.EXTRA_EVENT_TIMESTAMP, 0L) ?: 0L Log.i(TAG, "Received ACTION_STOP_HOLDING, timestamp: $eventTimestamp") + // Check if always-on mode is enabled + if (::delayedActivationManager.isInitialized && delayedActivationManager.isAlwaysOnMode()) { + Log.d(TAG, "Always-on mode enabled, ignoring screen-off event") + return + } + // Cancel any pending delayed activation if (::delayedActivationManager.isInitialized) { val wasCancelled = delayedActivationManager.cancelDelayedActivation() diff --git a/app/src/main/java/io/github/miclock/ui/MainActivity.kt b/app/src/main/java/io/github/miclock/ui/MainActivity.kt index 547c041..d9ca3cd 100644 --- a/app/src/main/java/io/github/miclock/ui/MainActivity.kt +++ b/app/src/main/java/io/github/miclock/ui/MainActivity.kt @@ -98,16 +98,26 @@ class MainActivity : ComponentActivity() { updateCompatibilityModeUi() } - // Initialize screen-on delay slider - screenOnDelaySlider.valueFrom = Prefs.MIN_SCREEN_ON_DELAY_MS.toFloat() - screenOnDelaySlider.valueTo = Prefs.MAX_SCREEN_ON_DELAY_MS.toFloat() + // Initialize screen-on delay slider with logical mapping + screenOnDelaySlider.valueFrom = Prefs.SLIDER_MIN + screenOnDelaySlider.valueTo = Prefs.SLIDER_MAX val currentDelay = Prefs.getScreenOnDelayMs(this) - screenOnDelaySlider.value = currentDelay.toFloat() + screenOnDelaySlider.value = Prefs.delayMsToSlider(currentDelay) updateDelayConfigurationUi(currentDelay) screenOnDelaySlider.addOnChangeListener { _, value, fromUser -> if (fromUser) { - val delayMs = value.toLong() + // Snap to nearest valid position for clear phase boundaries + val snappedValue = Prefs.snapSliderValue(value) + + // Convert snapped position to delay value + val delayMs = Prefs.sliderToDelayMs(snappedValue) + + // Update slider to show the snapped position (creates the snappy feel) + if (screenOnDelaySlider.value != snappedValue) { + screenOnDelaySlider.value = snappedValue + } + handleDelayPreferenceChange(delayMs) } } @@ -328,14 +338,23 @@ class MainActivity : ComponentActivity() { /** * Updates the delay configuration UI to reflect the current delay value. - * Shows appropriate summary text based on whether delay is enabled or disabled. + * Shows appropriate summary text for all behavior modes. */ private fun updateDelayConfigurationUi(delayMs: Long) { - if (delayMs <= 0L) { - screenOnDelaySummary.text = getString(R.string.screen_on_delay_disabled) - } else { - val delaySeconds = delayMs / 1000.0 - screenOnDelaySummary.text = getString(R.string.screen_on_delay_summary, delaySeconds) + when { + delayMs == Prefs.NEVER_REACTIVATE_VALUE -> { + screenOnDelaySummary.text = getString(R.string.screen_on_delay_never_reactivate) + } + delayMs == Prefs.ALWAYS_KEEP_ON_VALUE -> { + screenOnDelaySummary.text = getString(R.string.screen_on_delay_always_on) + } + delayMs <= 0L -> { + screenOnDelaySummary.text = getString(R.string.screen_on_delay_disabled) + } + else -> { + val delaySeconds = delayMs / 1000.0 + screenOnDelaySummary.text = getString(R.string.screen_on_delay_summary, delaySeconds) + } } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index aa87a7e..c7d6010 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -178,16 +178,104 @@ android:textColor="@color/on_surface_variant" android:layout_marginBottom="8dp" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d749221..ee6522c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,7 +4,9 @@ Quick toggle for microphone protection - Screen-On Delay - Wait %1$.1f seconds before activating when screen turns on - Disabled (activates immediately) + Screen Behavior + Wait %1$.1f seconds before activating when screen turns back on + No Delay (activates immediately when screen turns on) + Mic stays inactive after screen was turned off + Always keep microphone active (effects battery usage) From 8e8a6d788c54b2d7141c56f4fe4486b9d8276b6e Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 15:08:42 +0300 Subject: [PATCH 12/28] working on slider UI --- .../main/java/io/github/miclock/data/Prefs.kt | 24 +-- .../res/drawable/slider_track_background.xml | 16 ++ app/src/main/res/layout/activity_main.xml | 158 ++++++++++-------- app/src/main/res/values/colors.xml | 4 +- app/src/main/res/values/strings.xml | 8 +- .../java/io/github/miclock/data/PrefsTest.kt | 74 +++++++- 6 files changed, 192 insertions(+), 92 deletions(-) create mode 100644 app/src/main/res/drawable/slider_track_background.xml diff --git a/app/src/main/java/io/github/miclock/data/Prefs.kt b/app/src/main/java/io/github/miclock/data/Prefs.kt index 6012163..2c7c8ff 100644 --- a/app/src/main/java/io/github/miclock/data/Prefs.kt +++ b/app/src/main/java/io/github/miclock/data/Prefs.kt @@ -79,8 +79,8 @@ object Prefs { // Slider mapping constants const val SLIDER_MIN = 0f const val SLIDER_MAX = 100f - const val SLIDER_NEVER_REACTIVATE = 0f // Far left - const val SLIDER_ALWAYS_ON = 100f // Far right + const val SLIDER_ALWAYS_ON = 0f // Far left (immediate/no delay) + const val SLIDER_NEVER_REACTIVATE = 100f // Far right (maximum restriction) const val SLIDER_DELAY_START = 10f // Start of delay range const val SLIDER_DELAY_END = 90f // End of delay range @@ -91,11 +91,11 @@ object Prefs { */ fun sliderToDelayMs(sliderValue: Float): Long { return when { - // Snap zone for "Never re-enable" (0-5) - sliderValue <= 5f -> NEVER_REACTIVATE_VALUE + // Snap zone for "Always on" (0-5) - far left + sliderValue <= 5f -> ALWAYS_KEEP_ON_VALUE - // Snap zone for "Always on" (95-100) - sliderValue >= 95f -> ALWAYS_KEEP_ON_VALUE + // Snap zone for "Never re-enable" (95-100) - far right + sliderValue >= 95f -> NEVER_REACTIVATE_VALUE // Delay range (10-90) with snapping to nearest valid position else -> { @@ -125,11 +125,11 @@ object Prefs { */ fun snapSliderValue(sliderValue: Float): Float { return when { - // Snap to "Never re-enable" zone - sliderValue <= 5f -> SLIDER_NEVER_REACTIVATE + // Snap to "Always on" zone (far left) + sliderValue <= 5f -> SLIDER_ALWAYS_ON - // Snap to "Always on" zone - sliderValue >= 95f -> SLIDER_ALWAYS_ON + // Snap to "Never re-enable" zone (far right) + sliderValue >= 95f -> SLIDER_NEVER_REACTIVATE // Snap to delay range boundaries if in transition zones sliderValue < SLIDER_DELAY_START -> SLIDER_DELAY_START @@ -147,8 +147,8 @@ object Prefs { */ fun delayMsToSlider(delayMs: Long): Float { return when (delayMs) { - NEVER_REACTIVATE_VALUE -> SLIDER_NEVER_REACTIVATE - ALWAYS_KEEP_ON_VALUE -> SLIDER_ALWAYS_ON + ALWAYS_KEEP_ON_VALUE -> SLIDER_ALWAYS_ON // Position 0 (far left) + NEVER_REACTIVATE_VALUE -> SLIDER_NEVER_REACTIVATE // Position 100 (far right) else -> { // Map delay range (0-5000ms) to slider range (10-90) val normalizedDelay = delayMs.toFloat() / MAX_SCREEN_ON_DELAY_MS.toFloat() diff --git a/app/src/main/res/drawable/slider_track_background.xml b/app/src/main/res/drawable/slider_track_background.xml new file mode 100644 index 0000000..e863b44 --- /dev/null +++ b/app/src/main/res/drawable/slider_track_background.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index c7d6010..cbeec83 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -178,103 +178,119 @@ android:textColor="@color/on_surface_variant" android:layout_marginBottom="8dp" /> - + + android:layout_marginBottom="4dp" + android:paddingStart="12dp" + android:paddingEnd="12dp"> + android:alpha="0.8" + android:textStyle="bold" /> + android:alpha="0.8" + android:textStyle="bold"/> + android:alpha="0.8" + android:textStyle="bold" /> - + - - - - - - - - - - - - - - - - - - - - + android:layout_height="wrap_content"> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 65b19c2..ad63d96 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -3,6 +3,8 @@ #121b2e #5ada9c + #585ADA9C + #5ada9c @@ -32,4 +34,4 @@ #FF018786 #FF000000 #FFFFFFFF - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee6522c..40cb69b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,8 +5,8 @@ Screen Behavior - Wait %1$.1f seconds before activating when screen turns back on - No Delay (activates immediately when screen turns on) - Mic stays inactive after screen was turned off - Always keep microphone active (effects battery usage) + Wait %1$.1f seconds after screen turns on before reactivating + Reactivates immediately when screen turns on + Stays off after screen turns off (manual restart required) + Ignores screen state (stays active even when screen is off) diff --git a/app/src/test/java/io/github/miclock/data/PrefsTest.kt b/app/src/test/java/io/github/miclock/data/PrefsTest.kt index 61c87a1..8e8d952 100644 --- a/app/src/test/java/io/github/miclock/data/PrefsTest.kt +++ b/app/src/test/java/io/github/miclock/data/PrefsTest.kt @@ -109,13 +109,31 @@ class PrefsTest { } @Test - fun `setScreenOnDelayMs negative value should throw IllegalArgumentException`() { + fun `setScreenOnDelayMs never reactivate value should work`() { + // When + Prefs.setScreenOnDelayMs(context, Prefs.NEVER_REACTIVATE_VALUE) + + // Then + assertThat(Prefs.getScreenOnDelayMs(context)).isEqualTo(Prefs.NEVER_REACTIVATE_VALUE) + } + + @Test + fun `setScreenOnDelayMs always on value should work`() { + // When + Prefs.setScreenOnDelayMs(context, Prefs.ALWAYS_KEEP_ON_VALUE) + + // Then + assertThat(Prefs.getScreenOnDelayMs(context)).isEqualTo(Prefs.ALWAYS_KEEP_ON_VALUE) + } + + @Test + fun `setScreenOnDelayMs invalid negative value should throw IllegalArgumentException`() { // When/Then try { - Prefs.setScreenOnDelayMs(context, -1L) + Prefs.setScreenOnDelayMs(context, -3L) // Invalid negative value (not -1 or -2) assertThat(false).isTrue() // Should not reach here } catch (e: IllegalArgumentException) { - assertThat(e.message).contains("Screen-on delay must be between 0ms and 5000ms") + assertThat(e.message).contains("Screen-on delay must be between") } } @@ -135,11 +153,59 @@ class PrefsTest { assertThat(Prefs.isValidScreenOnDelay(0L)).isTrue() assertThat(Prefs.isValidScreenOnDelay(1300L)).isTrue() assertThat(Prefs.isValidScreenOnDelay(5000L)).isTrue() + assertThat(Prefs.isValidScreenOnDelay(Prefs.NEVER_REACTIVATE_VALUE)).isTrue() + assertThat(Prefs.isValidScreenOnDelay(Prefs.ALWAYS_KEEP_ON_VALUE)).isTrue() } @Test fun `isValidScreenOnDelay should return false for invalid values`() { - assertThat(Prefs.isValidScreenOnDelay(-1L)).isFalse() + assertThat(Prefs.isValidScreenOnDelay(-3L)).isFalse() // Invalid negative value assertThat(Prefs.isValidScreenOnDelay(5001L)).isFalse() + assertThat(Prefs.isValidScreenOnDelay(-100L)).isFalse() // Another invalid negative + } + + @Test + fun `sliderToDelayMs should map special values correctly`() { + // Always on (far left) + assertThat(Prefs.sliderToDelayMs(0f)).isEqualTo(Prefs.ALWAYS_KEEP_ON_VALUE) + assertThat(Prefs.sliderToDelayMs(5f)).isEqualTo(Prefs.ALWAYS_KEEP_ON_VALUE) + + // Never reactivate (far right) + assertThat(Prefs.sliderToDelayMs(95f)).isEqualTo(Prefs.NEVER_REACTIVATE_VALUE) + assertThat(Prefs.sliderToDelayMs(100f)).isEqualTo(Prefs.NEVER_REACTIVATE_VALUE) + + // Delay range + assertThat(Prefs.sliderToDelayMs(10f)).isEqualTo(0L) // Start of delay range + assertThat(Prefs.sliderToDelayMs(90f)).isEqualTo(5000L) // End of delay range + assertThat(Prefs.sliderToDelayMs(50f)).isEqualTo(2500L) // Middle of delay range + } + + @Test + fun `delayMsToSlider should map special values correctly`() { + // Special values + assertThat(Prefs.delayMsToSlider(Prefs.ALWAYS_KEEP_ON_VALUE)).isEqualTo(0f) + assertThat(Prefs.delayMsToSlider(Prefs.NEVER_REACTIVATE_VALUE)).isEqualTo(100f) + + // Delay values + assertThat(Prefs.delayMsToSlider(0L)).isEqualTo(10f) // Start of delay range + assertThat(Prefs.delayMsToSlider(5000L)).isEqualTo(90f) // End of delay range + assertThat(Prefs.delayMsToSlider(2500L)).isEqualTo(50f) // Middle of delay range + } + + @Test + fun `snapSliderValue should snap to correct zones`() { + // Always on zone + assertThat(Prefs.snapSliderValue(0f)).isEqualTo(0f) + assertThat(Prefs.snapSliderValue(3f)).isEqualTo(0f) + assertThat(Prefs.snapSliderValue(5f)).isEqualTo(0f) + + // Never reactivate zone + assertThat(Prefs.snapSliderValue(95f)).isEqualTo(100f) + assertThat(Prefs.snapSliderValue(97f)).isEqualTo(100f) + assertThat(Prefs.snapSliderValue(100f)).isEqualTo(100f) + + // Delay range + assertThat(Prefs.snapSliderValue(50f)).isEqualTo(50f) + assertThat(Prefs.snapSliderValue(25.7f)).isEqualTo(26f) // Rounds to nearest integer } } From b15180f3a92160113389b8a51c886af089a19781 Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 15:59:22 +0300 Subject: [PATCH 13/28] some UI tweaks and fixed MicLockService.kt not taking into account Never configuration --- .../main/java/io/github/miclock/data/Prefs.kt | 4 +- .../github/miclock/service/MicLockService.kt | 47 ++--- app/src/main/res/layout/activity_main.xml | 181 +++++++++--------- 3 files changed, 121 insertions(+), 111 deletions(-) diff --git a/app/src/main/java/io/github/miclock/data/Prefs.kt b/app/src/main/java/io/github/miclock/data/Prefs.kt index 2c7c8ff..94f01b1 100644 --- a/app/src/main/java/io/github/miclock/data/Prefs.kt +++ b/app/src/main/java/io/github/miclock/data/Prefs.kt @@ -92,10 +92,10 @@ object Prefs { fun sliderToDelayMs(sliderValue: Float): Long { return when { // Snap zone for "Always on" (0-5) - far left - sliderValue <= 5f -> ALWAYS_KEEP_ON_VALUE + sliderValue <= 9f -> ALWAYS_KEEP_ON_VALUE // Snap zone for "Never re-enable" (95-100) - far right - sliderValue >= 95f -> NEVER_REACTIVATE_VALUE + sliderValue >= 91f -> NEVER_REACTIVATE_VALUE // Delay range (10-90) with snapping to nearest valid position else -> { diff --git a/app/src/main/java/io/github/miclock/service/MicLockService.kt b/app/src/main/java/io/github/miclock/service/MicLockService.kt index e65dafa..bb8404d 100644 --- a/app/src/main/java/io/github/miclock/service/MicLockService.kt +++ b/app/src/main/java/io/github/miclock/service/MicLockService.kt @@ -155,13 +155,13 @@ class MicLockService : Service(), MicActivationService { super.onDestroy() val wasRunning = state.value.isRunning stopFlag.set(true) - + // Cleanup delayed activation manager if (::delayedActivationManager.isInitialized) { delayedActivationManager.cleanup() Log.d(TAG, "DelayedActivationManager cleaned up") } - + scope.cancel() wakeLockManager.release() try { recCallback?.let { audioManager.unregisterAudioRecordingCallback(it) } } catch (_: Throwable) {} @@ -284,21 +284,21 @@ class MicLockService : Service(), MicActivationService { private fun handleStartHolding(intent: Intent? = null) { val eventTimestamp = intent?.getLongExtra(ScreenStateReceiver.EXTRA_EVENT_TIMESTAMP, 0L) ?: 0L Log.i(TAG, "Received ACTION_START_HOLDING, isRunning: ${state.value.isRunning}, timestamp: $eventTimestamp") - + if (state.value.isRunning) { // Get configured delay val delayMs = Prefs.getScreenOnDelayMs(this) - + // Check if delay should be applied if (delayMs > 0 && delayedActivationManager.shouldApplyDelay()) { Log.d(TAG, "Applying screen-on delay of ${delayMs}ms") - + // Cancel any existing pending activation (latest-event-wins strategy) if (delayedActivationManager.isActivationPending()) { Log.d(TAG, "Cancelling previous pending activation - restarting delay from beginning") delayedActivationManager.cancelDelayedActivation() } - + // CRITICAL: Start foreground service BEFORE delay to satisfy Android 14+ FGS restrictions // The service must be started from an eligible state/context (screen-on event) if (canStartForegroundService()) { @@ -327,10 +327,10 @@ class MicLockService : Service(), MicActivationService { Log.d(TAG, "Delaying foreground service start due to boot restrictions") scheduleDelayedForegroundStart() } - + // Schedule delayed activation val scheduled = delayedActivationManager.scheduleDelayedActivation(delayMs) - + if (scheduled) { // Update state to reflect pending activation updateServiceState( @@ -344,9 +344,12 @@ class MicLockService : Service(), MicActivationService { startMicHolding(fromDelayCompletion = false) } } else { - // No delay configured or delay not applicable, start immediately - Log.d(TAG, "No delay configured (${delayMs}ms), starting immediately") - startMicHolding(fromDelayCompletion = false) + if (delayMs == 0L){ + Log.d(TAG, "No delay configured (${delayMs}ms), starting immediately") + startMicHolding(fromDelayCompletion = false) + } + // Always on Or Never + Log.d(TAG, "Always-On or Never configured, skipping reactivation") } } else { Log.w(TAG, "Service not running, ignoring START_HOLDING action. (Consider starting service first)") @@ -356,13 +359,13 @@ class MicLockService : Service(), MicActivationService { private fun handleStopHolding(intent: Intent? = null) { val eventTimestamp = intent?.getLongExtra(ScreenStateReceiver.EXTRA_EVENT_TIMESTAMP, 0L) ?: 0L Log.i(TAG, "Received ACTION_STOP_HOLDING, timestamp: $eventTimestamp") - + // Check if always-on mode is enabled if (::delayedActivationManager.isInitialized && delayedActivationManager.isAlwaysOnMode()) { Log.d(TAG, "Always-on mode enabled, ignoring screen-off event") return } - + // Cancel any pending delayed activation if (::delayedActivationManager.isInitialized) { val wasCancelled = delayedActivationManager.cancelDelayedActivation() @@ -374,7 +377,7 @@ class MicLockService : Service(), MicActivationService { ) } } - + stopMicHolding() } @@ -386,7 +389,7 @@ class MicLockService : Service(), MicActivationService { Log.d(TAG, "Cancelled pending delayed activation due to manual stop") } } - + updateServiceState(running = false, delayPending = false, delayRemainingMs = 0) stopMicHolding() stopSelf() // Full stop from user @@ -476,7 +479,7 @@ class MicLockService : Service(), MicActivationService { } // Change the log message to be more generic for screen on or user start Log.i(TAG, "Starting or resuming mic holding logic. fromDelayCompletion=$fromDelayCompletion") - + // Clear any delay state since we're actually starting now if (fromDelayCompletion || (::delayedActivationManager.isInitialized && delayedActivationManager.isActivationPending())) { Log.d(TAG, "Clearing delay state as mic holding is starting (fromDelayCompletion=$fromDelayCompletion)") @@ -977,8 +980,8 @@ class MicLockService : Service(), MicActivationService { } private fun updateServiceState( - running: Boolean? = null, - paused: Boolean? = null, + running: Boolean? = null, + paused: Boolean? = null, pausedByScreenOff: Boolean? = null, deviceAddr: String? = null, delayPending: Boolean? = null, @@ -994,7 +997,7 @@ class MicLockService : Service(), MicActivationService { delayedActivationRemainingMs = delayRemainingMs ?: currentState.delayedActivationRemainingMs, ) } - + // Request tile update whenever service state changes requestTileUpdate() } @@ -1014,7 +1017,7 @@ class MicLockService : Service(), MicActivationService { /** * Gets the current service state. * Used by DelayedActivationManager for state validation. - * + * * @return current ServiceState */ override fun getCurrentState(): ServiceState = state.value @@ -1022,7 +1025,7 @@ class MicLockService : Service(), MicActivationService { /** * Checks if the service was manually stopped by the user. * This is determined by checking if the service is not running and was explicitly stopped. - * + * * @return true if manually stopped by user, false otherwise */ override fun isManuallyStoppedByUser(): Boolean { @@ -1035,7 +1038,7 @@ class MicLockService : Service(), MicActivationService { /** * Checks if the microphone is actively being held (recording loop is active). * This is different from service running - service can be running but paused (screen off). - * + * * @return true if mic is actively held, false otherwise */ override fun isMicActivelyHeld(): Boolean { diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index cbeec83..4797376 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -175,109 +175,116 @@ android:layout_height="wrap_content" android:text="@string/screen_on_delay_disabled" android:textSize="12sp" - android:textColor="@color/on_surface_variant" + android:textColor="@color/on_surface" android:layout_marginBottom="8dp" /> - - - - - - - - - - - - - + - - - + android:layout_height="wrap_content" + android:orientation="horizontal" + android:layout_marginBottom="10dp" + android:paddingStart="12dp" + android:paddingEnd="12dp"> - - - - - + + - - - - - - + + + android:layout_height="wrap_content" + android:layout_weight="2" + android:text="Never" + android:textSize="9sp" + android:textColor="@color/error_red" + android:gravity="end" + android:alpha="0.8" + android:textStyle="bold" /> + + + + + + + + + + + + + + + + + + + + + + + Date: Thu, 9 Oct 2025 16:48:46 +0300 Subject: [PATCH 14/28] fixed tile functionality and app status on paused by screen off. --- .../github/miclock/service/MicLockService.kt | 6 + .../github/miclock/tile/MicLockTileService.kt | 341 +++++++++++------- .../java/io/github/miclock/ui/MainActivity.kt | 5 +- 3 files changed, 217 insertions(+), 135 deletions(-) diff --git a/app/src/main/java/io/github/miclock/service/MicLockService.kt b/app/src/main/java/io/github/miclock/service/MicLockService.kt index bb8404d..96207f5 100644 --- a/app/src/main/java/io/github/miclock/service/MicLockService.kt +++ b/app/src/main/java/io/github/miclock/service/MicLockService.kt @@ -268,6 +268,12 @@ class MicLockService : Service(), MicActivationService { } serviceHealthy = true Log.d(TAG, "Foreground service started successfully") + + // If service is paused (e.g., by screen-off), resume mic holding + if (state.value.isPausedByScreenOff) { + Log.i(TAG, "Service was paused - resuming mic holding logic") + startMicHolding(fromDelayCompletion = false) + } } catch (e: Exception) { Log.w(TAG, "Could not start foreground service: ${e.message}") serviceHealthy = false diff --git a/app/src/main/java/io/github/miclock/tile/MicLockTileService.kt b/app/src/main/java/io/github/miclock/tile/MicLockTileService.kt index 51ab481..4beede5 100644 --- a/app/src/main/java/io/github/miclock/tile/MicLockTileService.kt +++ b/app/src/main/java/io/github/miclock/tile/MicLockTileService.kt @@ -39,7 +39,7 @@ class MicLockTileService : TileService() { override fun onCreate() { super.onCreate() Log.d(TAG, "Tile service created") - + // Initialize tile state immediately when service is created // This helps with initial state display val initialState = getCurrentAppState() @@ -49,51 +49,68 @@ class MicLockTileService : TileService() { override fun onStartListening() { super.onStartListening() Log.d(TAG, "Tile started listening") - + // Force immediate state update when listening starts val currentState = getCurrentAppState() updateTileState(currentState) - failureReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - ApiGuard.onApi34_UpsideDownCake( - block = { - if (intent.action == MicLockService.ACTION_TILE_START_FAILED) { - val reason = intent.getStringExtra(MicLockService.EXTRA_FAILURE_REASON) - if (reason == MicLockService.FAILURE_REASON_FOREGROUND_RESTRICTION) { - Log.d(TAG, "Service failed due to foreground restrictions - launching MainActivity") - launchMainActivityFallback() - } - } - }, - onUnsupported = { - Log.d(TAG, "Received onReceive on unsupported API. Doing nothing.") - }, - ) - } - } + failureReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + ApiGuard.onApi34_UpsideDownCake( + block = { + if (intent.action == MicLockService.ACTION_TILE_START_FAILED) { + val reason = + intent.getStringExtra( + MicLockService.EXTRA_FAILURE_REASON + ) + if (reason == + MicLockService + .FAILURE_REASON_FOREGROUND_RESTRICTION + ) { + Log.d( + TAG, + "Service failed due to foreground restrictions - launching MainActivity" + ) + launchMainActivityFallback() + } + } + }, + onUnsupported = { + Log.d( + TAG, + "Received onReceive on unsupported API. Doing nothing." + ) + }, + ) + } + } val filter = IntentFilter(MicLockService.ACTION_TILE_START_FAILED) if (ApiGuard.isApi26_O_OrAbove()) { registerReceiver(failureReceiver, filter, Context.RECEIVER_NOT_EXPORTED) } else { - ContextCompat.registerReceiver(this, failureReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + ContextCompat.registerReceiver( + this, + failureReceiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED + ) } val actualState = getCurrentAppState() updateTileState(actualState) - stateCollectionJob = scope.launch { - try { - MicLockService.state.collect { state -> - updateTileState(state) + stateCollectionJob = + scope.launch { + try { + MicLockService.state.collect { state -> updateTileState(state) } + } catch (e: Exception) { + Log.w(TAG, "Failed to observe service state: ${e.message}") + val fallbackState = checkServiceRunningState() + updateTileState(fallbackState) + } } - } catch (e: Exception) { - Log.w(TAG, "Failed to observe service state: ${e.message}") - val fallbackState = checkServiceRunningState() - updateTileState(fallbackState) - } - } } override fun onStopListening() { @@ -114,25 +131,29 @@ class MicLockTileService : TileService() { } private fun hasAllPerms(): Boolean { - val micGranted = try { - checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED - } catch (e: Exception) { - Log.w(TAG, "Error checking RECORD_AUDIO permission: ${e.message}") - false - } + val micGranted = + try { + checkSelfPermission(Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + } catch (e: Exception) { + Log.w(TAG, "Error checking RECORD_AUDIO permission: ${e.message}") + false + } - val notifs = try { - val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - if (Build.VERSION.SDK_INT >= 33) { - nm.areNotificationsEnabled() && - checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED - } else { - nm.areNotificationsEnabled() - } - } catch (e: Exception) { - Log.w(TAG, "Error checking notification permissions: ${e.message}") - false - } + val notifs = + try { + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= 33) { + nm.areNotificationsEnabled() && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + } else { + nm.areNotificationsEnabled() + } + } catch (e: Exception) { + Log.w(TAG, "Error checking notification permissions: ${e.message}") + false + } val hasPerms = micGranted && notifs Log.d(TAG, "Permission check: mic=$micGranted, notifs=$notifs, hasAll=$hasPerms") @@ -157,11 +178,12 @@ class MicLockTileService : TileService() { when { currentState.isDelayedActivationPending -> { // Manual override: cancel delay and activate immediately - val intent = Intent(this, MicLockService::class.java).apply { - action = MicLockService.ACTION_START_USER_INITIATED - putExtra("from_tile", true) - putExtra("cancel_delay", true) // Signal to cancel any pending delay - } + val intent = + Intent(this, MicLockService::class.java).apply { + action = MicLockService.ACTION_START_USER_INITIATED + putExtra("from_tile", true) + putExtra("cancel_delay", true) // Signal to cancel any pending delay + } Log.d(TAG, "Cancelling delay and starting service immediately via tile") try { ContextCompat.startForegroundService(this, intent) @@ -171,7 +193,24 @@ class MicLockTileService : TileService() { createTileFailureNotification("Service failed to start: ${e.message}") } } + currentState.isPausedByScreenOff -> { + // Service is paused by screen-off - resume mic holding + val intent = + Intent(this, MicLockService::class.java).apply { + action = MicLockService.ACTION_START_USER_INITIATED + putExtra("from_tile", true) + } + Log.d(TAG, "Resuming mic holding from screen-off pause via tile") + try { + ContextCompat.startForegroundService(this, intent) + Log.d(TAG, "Resume request sent - mic holding should restart") + } catch (e: Exception) { + Log.e(TAG, "Failed to resume service: ${e.message}", e) + createTileFailureNotification("Service failed to resume: ${e.message}") + } + } currentState.isRunning -> { + // Service is actively running - stop it val intent = Intent(this, MicLockService::class.java) intent.action = MicLockService.ACTION_STOP Log.d(TAG, "Stopping MicLock service via tile") @@ -183,10 +222,12 @@ class MicLockTileService : TileService() { } } else -> { - val intent = Intent(this, MicLockService::class.java).apply { - action = MicLockService.ACTION_START_USER_INITIATED - putExtra("from_tile", true) - } + // Service is not running - start it + val intent = + Intent(this, MicLockService::class.java).apply { + action = MicLockService.ACTION_START_USER_INITIATED + putExtra("from_tile", true) + } Log.d(TAG, "Attempting direct service start from tile") try { @@ -202,61 +243,81 @@ class MicLockTileService : TileService() { private fun launchMainActivityFallback() { ApiGuard.onApi34_UpsideDownCake( - block = { - Log.d(TAG, "Launching MainActivity as fallback for service start") - val activityIntent = Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra(EXTRA_START_SERVICE_FROM_TILE, true) - } - - val pendingIntent = PendingIntent.getActivity( - this, - 0, - activityIntent, - PendingIntent.FLAG_UPDATE_CURRENT or - (if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_IMMUTABLE else 0), - ) + block = { + Log.d(TAG, "Launching MainActivity as fallback for service start") + val activityIntent = + Intent(this, MainActivity::class.java).apply { + flags = + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(EXTRA_START_SERVICE_FROM_TILE, true) + } - try { - @Suppress("NewApi") - ApiGuard.onApi34_UpsideDownCake(block = { - startActivityAndCollapse(pendingIntent) - },) - Log.d(TAG, "MainActivity fallback launched successfully") - } catch (e: Exception) { - Log.e(TAG, "MainActivity fallback also failed: ${e.message}", e) - createTileFailureNotification("Both service start and app launch failed: ${e.message}") - } - }, - onUnsupported = { - Log.e(TAG, "launchMainActivityFallback called on unsupported device. This should not happen.") - createTileFailureNotification("MainActivity fallback not supported on this Android version.") - }, + val pendingIntent = + PendingIntent.getActivity( + this, + 0, + activityIntent, + PendingIntent.FLAG_UPDATE_CURRENT or + (if (Build.VERSION.SDK_INT >= 31) + PendingIntent.FLAG_IMMUTABLE + else 0), + ) + + try { + @Suppress("NewApi") + ApiGuard.onApi34_UpsideDownCake( + block = { startActivityAndCollapse(pendingIntent) }, + ) + Log.d(TAG, "MainActivity fallback launched successfully") + } catch (e: Exception) { + Log.e(TAG, "MainActivity fallback also failed: ${e.message}", e) + createTileFailureNotification( + "Both service start and app launch failed: ${e.message}" + ) + } + }, + onUnsupported = { + Log.e( + TAG, + "launchMainActivityFallback called on unsupported device. This should not happen." + ) + createTileFailureNotification( + "MainActivity fallback not supported on this Android version." + ) + }, ) } private fun createTileFailureNotification(reason: String) { - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val restartIntent = Intent(this, MainActivity::class.java) - val restartPI = PendingIntent.getActivity( - this, - 6, - restartIntent, - PendingIntent.FLAG_UPDATE_CURRENT or - (if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_IMMUTABLE else 0), - ) + val restartPI = + PendingIntent.getActivity( + this, + 6, + restartIntent, + PendingIntent.FLAG_UPDATE_CURRENT or + (if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_IMMUTABLE + else 0), + ) - val notification = NotificationCompat.Builder(this, MicLockService.RESTART_CHANNEL_ID) - .setContentTitle("MicLock Tile Failed Unexpectedly") - .setContentText("Tap to open app and start protection") - .setStyle( - NotificationCompat.BigTextStyle().bigText("$reason. Tap to open app and start protection manually."), - ) - .setSmallIcon(R.mipmap.ic_launcher) - .setContentIntent(restartPI) - .setAutoCancel(true) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .build() + val notification = + NotificationCompat.Builder(this, MicLockService.RESTART_CHANNEL_ID) + .setContentTitle("MicLock Tile Failed Unexpectedly") + .setContentText("Tap to open app and start protection") + .setStyle( + NotificationCompat.BigTextStyle() + .bigText( + "$reason. Tap to open app and start protection manually." + ), + ) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentIntent(restartPI) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() notificationManager.notify(45, notification) Log.d(TAG, "Tile failure notification created: $reason") @@ -268,8 +329,8 @@ class MicLockTileService : TileService() { // Always re-check permissions fresh val hasPerms = hasAllPerms() Log.d( - TAG, - "updateTileState: hasPerms=$hasPerms, isRunning=${state.isRunning}, isPaused=${state.isPausedBySilence}, isDelayPending=${state.isDelayedActivationPending}", + TAG, + "updateTileState: hasPerms=$hasPerms, isRunning=${state.isRunning}, isPausedBySilence=${state.isPausedBySilence}, isPausedByScreenOff=${state.isPausedByScreenOff}, isDelayPending=${state.isDelayedActivationPending}", ) when { @@ -298,12 +359,20 @@ class MicLockTileService : TileService() { Log.d(TAG, "Tile set to INACTIVE state") } state.isPausedBySilence -> { - // Service is PAUSED + // Service is PAUSED by silence - show unavailable (automatic, temporary) tile.state = Tile.STATE_UNAVAILABLE tile.label = TILE_TEXT - tile.contentDescription = "Microphone protection paused" + tile.contentDescription = "Microphone protection paused (other app using mic)" tile.icon = Icon.createWithResource(this, R.drawable.ic_mic_pause) - Log.d(TAG, "Tile set to PAUSED state") + Log.d(TAG, "Tile set to UNAVAILABLE state (paused by silence)") + } + state.isPausedByScreenOff -> { + // Service is PAUSED by screen-off - show inactive (user can reactivate) + tile.state = Tile.STATE_INACTIVE + tile.label = "Paused" + tile.contentDescription = "Tap to resume microphone protection" + tile.icon = Icon.createWithResource(this, R.drawable.ic_mic_pause) + Log.d(TAG, "Tile set to INACTIVE state (paused by screen-off, user can reactivate)") } else -> { // Service is ON @@ -317,8 +386,8 @@ class MicLockTileService : TileService() { tile.updateTile() Log.d( - TAG, - "Tile updated - Running: ${state.isRunning}, Paused: ${state.isPausedBySilence}, DelayPending: ${state.isDelayedActivationPending}, HasPerms: $hasPerms", + TAG, + "Tile updated - Running: ${state.isRunning}, PausedBySilence: ${state.isPausedBySilence}, PausedByScreenOff: ${state.isPausedByScreenOff}, DelayPending: ${state.isDelayedActivationPending}, HasPerms: $hasPerms", ) } @@ -327,17 +396,21 @@ class MicLockTileService : TileService() { val currentState = MicLockService.state.value // If StateFlow says service isn't running, double-check with system services - val actualState = if (!currentState.isRunning) { - val systemState = checkServiceRunningState() - if (systemState.isRunning) { - Log.d(TAG, "StateFlow out of sync - service is actually running according to system") - systemState - } else { - currentState - } - } else { - currentState - } + val actualState = + if (!currentState.isRunning) { + val systemState = checkServiceRunningState() + if (systemState.isRunning) { + Log.d( + TAG, + "StateFlow out of sync - service is actually running according to system" + ) + systemState + } else { + currentState + } + } else { + currentState + } return actualState } @@ -345,18 +418,20 @@ class MicLockTileService : TileService() { private fun checkServiceRunningState(): ServiceState { return try { val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - val isRunning = activityManager.getRunningServices(Integer.MAX_VALUE) - .any { it.service.className == MicLockService::class.java.name } - + val isRunning = + activityManager.getRunningServices(Integer.MAX_VALUE).any { + it.service.className == MicLockService::class.java.name + } + // Preserve delay state from current StateFlow when checking system state val currentState = MicLockService.state.value ServiceState( - isRunning = isRunning, - isPausedBySilence = false, - isPausedByScreenOff = currentState.isPausedByScreenOff, - currentDeviceAddress = currentState.currentDeviceAddress, - isDelayedActivationPending = currentState.isDelayedActivationPending, - delayedActivationRemainingMs = currentState.delayedActivationRemainingMs + isRunning = isRunning, + isPausedBySilence = false, + isPausedByScreenOff = currentState.isPausedByScreenOff, + currentDeviceAddress = currentState.currentDeviceAddress, + isDelayedActivationPending = currentState.isDelayedActivationPending, + delayedActivationRemainingMs = currentState.delayedActivationRemainingMs ) } catch (e: Exception) { Log.w(TAG, "Failed to check service running state: ${e.message}") diff --git a/app/src/main/java/io/github/miclock/ui/MainActivity.kt b/app/src/main/java/io/github/miclock/ui/MainActivity.kt index d9ca3cd..a3a21cb 100644 --- a/app/src/main/java/io/github/miclock/ui/MainActivity.kt +++ b/app/src/main/java/io/github/miclock/ui/MainActivity.kt @@ -274,7 +274,8 @@ class MainActivity : ComponentActivity() { private fun updateMainStatus() { val running = MicLockService.state.value.isRunning - val paused = MicLockService.state.value.isPausedBySilence + val pausedBySilence = MicLockService.state.value.isPausedBySilence + val pausedByScreenOff = MicLockService.state.value.isPausedByScreenOff when { !running -> { @@ -282,7 +283,7 @@ class MainActivity : ComponentActivity() { statusText.setTextColor(ContextCompat.getColor(this, R.color.error_red)) statusText.animate().alpha(1.0f).setDuration(200) } - paused -> { + pausedBySilence || pausedByScreenOff -> { statusText.text = "PAUSED" statusText.setTextColor(ContextCompat.getColor(this, R.color.on_surface_variant)) statusText.animate().alpha(0.6f).setDuration(500).withEndAction { From ac28bfc7a12b56921eae4f3883361bbd8db7756b Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 17:03:15 +0300 Subject: [PATCH 15/28] fixed tile functionality and app status on paused by screen off. --- .../java/io/github/miclock/service/MicLockService.kt | 9 +++++++++ app/src/main/java/io/github/miclock/ui/MainActivity.kt | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/miclock/service/MicLockService.kt b/app/src/main/java/io/github/miclock/service/MicLockService.kt index 96207f5..7d18ffb 100644 --- a/app/src/main/java/io/github/miclock/service/MicLockService.kt +++ b/app/src/main/java/io/github/miclock/service/MicLockService.kt @@ -1055,6 +1055,15 @@ class MicLockService : Service(), MicActivationService { private val _state = MutableStateFlow(ServiceState()) val state: StateFlow = _state.asStateFlow() private const val TAG = "MicLockService" + + /** + * Test helper method to update service state for testing purposes. + * This should only be used in test code. + */ + @androidx.annotation.VisibleForTesting + fun updateStateForTesting(newState: ServiceState) { + _state.value = newState + } private const val CHANNEL_ID = "mic_lock_channel" const val RESTART_CHANNEL_ID = "mic_lock_restart_channel" private const val NOTIF_ID = 42 diff --git a/app/src/main/java/io/github/miclock/ui/MainActivity.kt b/app/src/main/java/io/github/miclock/ui/MainActivity.kt index a3a21cb..f467fc7 100644 --- a/app/src/main/java/io/github/miclock/ui/MainActivity.kt +++ b/app/src/main/java/io/github/miclock/ui/MainActivity.kt @@ -40,7 +40,7 @@ import kotlinx.coroutines.launch * * The activity communicates with MicLockService through intents and observes * service state changes via StateFlow to update the UI accordingly. */ -class MainActivity : ComponentActivity() { +open class MainActivity : ComponentActivity() { private lateinit var statusText: TextView private lateinit var startBtn: MaterialButton @@ -266,7 +266,7 @@ class MainActivity : ComponentActivity() { requestTileUpdate() } - private fun updateAllUi() { + protected open fun updateAllUi() { updateMainStatus() updateCompatibilityModeUi() updateDelayConfigurationUi(Prefs.getScreenOnDelayMs(this)) From a188fdc22b60bb30f0565dc97507a3ea9e2b2f7c Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 17:08:23 +0300 Subject: [PATCH 16/28] added some more unit tests --- .../miclock/service/MicLockServiceUnitTest.kt | 397 ++++++++++++------ .../io/github/miclock/ui/MainActivityTest.kt | 134 ++++++ 2 files changed, 408 insertions(+), 123 deletions(-) create mode 100644 app/src/test/java/io/github/miclock/ui/MainActivityTest.kt diff --git a/app/src/test/java/io/github/miclock/service/MicLockServiceUnitTest.kt b/app/src/test/java/io/github/miclock/service/MicLockServiceUnitTest.kt index 0703b7c..b736737 100644 --- a/app/src/test/java/io/github/miclock/service/MicLockServiceUnitTest.kt +++ b/app/src/test/java/io/github/miclock/service/MicLockServiceUnitTest.kt @@ -21,19 +21,17 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * Unit tests for MicLockService focusing on tile integration behaviors, - * foreground service start failures, and restart notification suppression. + * Unit tests for MicLockService focusing on tile integration behaviors, foreground service start + * failures, and restart notification suppression. */ @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) class MicLockServiceUnitTest { - @Mock - private lateinit var mockContext: Context + @Mock private lateinit var mockContext: Context - @Mock - private lateinit var mockNotificationManager: NotificationManager + @Mock private lateinit var mockNotificationManager: NotificationManager private lateinit var testableMicLockService: TestableMicLockService private lateinit var testScope: TestScope @@ -45,7 +43,7 @@ class MicLockServiceUnitTest { // Setup mock context whenever(mockContext.getSystemService(Context.NOTIFICATION_SERVICE)) - .thenReturn(mockNotificationManager) + .thenReturn(mockNotificationManager) whenever(mockContext.packageName).thenReturn("io.github.miclock") // Setup notification manager @@ -55,77 +53,83 @@ class MicLockServiceUnitTest { } @Test - fun testHandleStartUserInitiated_fgsFailsWithFromTileTrue_broadcastsFailureAndSuppressesNotification() = runTest { - // Given: Intent with from_tile=true and FGS will fail - val intent = Intent().apply { - action = MicLockService.ACTION_START_USER_INITIATED - putExtra("from_tile", true) - } - testableMicLockService.setForegroundServiceException( - RuntimeException("FOREGROUND_SERVICE_MICROPHONE requires permissions"), - ) - testableMicLockService.setPermissionsGranted(true) + fun testHandleStartUserInitiated_fgsFailsWithFromTileTrue_broadcastsFailureAndSuppressesNotification() = + runTest { + // Given: Intent with from_tile=true and FGS will fail + val intent = + Intent().apply { + action = MicLockService.ACTION_START_USER_INITIATED + putExtra("from_tile", true) + } + testableMicLockService.setForegroundServiceException( + RuntimeException("FOREGROUND_SERVICE_MICROPHONE requires permissions"), + ) + testableMicLockService.setPermissionsGranted(true) - // When: handleStartUserInitiated is called - testableMicLockService.testHandleStartUserInitiated(intent) + // When: handleStartUserInitiated is called + testableMicLockService.testHandleStartUserInitiated(intent) - // Then: Should broadcast tile failure and suppress restart notification - assertTrue( - "Should broadcast tile start failure", - testableMicLockService.wasTileFailureBroadcast(), - ) - assertEquals( - "Should broadcast correct failure reason", - MicLockService.FAILURE_REASON_FOREGROUND_RESTRICTION, - testableMicLockService.getLastBroadcastFailureReason(), - ) - assertTrue( - "Should suppress restart notification", - testableMicLockService.isRestartNotificationSuppressed(), - ) - assertFalse( - "Service should not be running after FGS failure", - testableMicLockService.isServiceRunning(), - ) - } + // Then: Should broadcast tile failure and suppress restart notification + assertTrue( + "Should broadcast tile start failure", + testableMicLockService.wasTileFailureBroadcast(), + ) + assertEquals( + "Should broadcast correct failure reason", + MicLockService.FAILURE_REASON_FOREGROUND_RESTRICTION, + testableMicLockService.getLastBroadcastFailureReason(), + ) + assertTrue( + "Should suppress restart notification", + testableMicLockService.isRestartNotificationSuppressed(), + ) + assertFalse( + "Service should not be running after FGS failure", + testableMicLockService.isServiceRunning(), + ) + } @Test - fun testHandleStartUserInitiated_fgsFailsWithFromTileFalse_doesNotBroadcastButSuppresses() = runTest { - // Given: Intent with from_tile=false and FGS will fail - val intent = Intent().apply { - action = MicLockService.ACTION_START_USER_INITIATED - putExtra("from_tile", false) - } - testableMicLockService.setForegroundServiceException( - RuntimeException("FOREGROUND_SERVICE_MICROPHONE requires permissions"), - ) - testableMicLockService.setPermissionsGranted(true) + fun testHandleStartUserInitiated_fgsFailsWithFromTileFalse_doesNotBroadcastButSuppresses() = + runTest { + // Given: Intent with from_tile=false and FGS will fail + val intent = + Intent().apply { + action = MicLockService.ACTION_START_USER_INITIATED + putExtra("from_tile", false) + } + testableMicLockService.setForegroundServiceException( + RuntimeException("FOREGROUND_SERVICE_MICROPHONE requires permissions"), + ) + testableMicLockService.setPermissionsGranted(true) - // When: handleStartUserInitiated is called - testableMicLockService.testHandleStartUserInitiated(intent) + // When: handleStartUserInitiated is called + testableMicLockService.testHandleStartUserInitiated(intent) - // Then: Should not broadcast tile failure but should still suppress restart notification - assertFalse( - "Should not broadcast tile failure for non-tile starts", - testableMicLockService.wasTileFailureBroadcast(), - ) - assertTrue( - "Should still suppress restart notification", - testableMicLockService.isRestartNotificationSuppressed(), - ) - assertFalse( - "Service should not be running after FGS failure", - testableMicLockService.isServiceRunning(), - ) - } + // Then: Should not broadcast tile failure but should still suppress restart + // notification + assertFalse( + "Should not broadcast tile failure for non-tile starts", + testableMicLockService.wasTileFailureBroadcast(), + ) + assertTrue( + "Should still suppress restart notification", + testableMicLockService.isRestartNotificationSuppressed(), + ) + assertFalse( + "Service should not be running after FGS failure", + testableMicLockService.isServiceRunning(), + ) + } @Test fun testHandleStartUserInitiated_fgsSucceeds_clearsFailureState() = runTest { // Given: Intent and FGS will succeed - val intent = Intent().apply { - action = MicLockService.ACTION_START_USER_INITIATED - putExtra("from_tile", true) - } + val intent = + Intent().apply { + action = MicLockService.ACTION_START_USER_INITIATED + putExtra("from_tile", true) + } testableMicLockService.setForegroundServiceWillFail(false) testableMicLockService.setPermissionsGranted(true) @@ -134,16 +138,16 @@ class MicLockServiceUnitTest { // Then: Should clear failure state and start successfully assertFalse( - "Should not broadcast tile failure on success", - testableMicLockService.wasTileFailureBroadcast(), + "Should not broadcast tile failure on success", + testableMicLockService.wasTileFailureBroadcast(), ) assertFalse( - "Should not suppress restart notification on success", - testableMicLockService.isRestartNotificationSuppressed(), + "Should not suppress restart notification on success", + testableMicLockService.isRestartNotificationSuppressed(), ) assertTrue( - "Service should be running after successful start", - testableMicLockService.isServiceRunning(), + "Service should be running after successful start", + testableMicLockService.isServiceRunning(), ) } @@ -158,8 +162,8 @@ class MicLockServiceUnitTest { // Then: Should not create restart notification assertFalse( - "Should not create restart notification when suppressed", - testableMicLockService.wasRestartNotificationCreated(), + "Should not create restart notification when suppressed", + testableMicLockService.wasRestartNotificationCreated(), ) } @@ -174,8 +178,8 @@ class MicLockServiceUnitTest { // Then: Should create restart notification assertTrue( - "Should create restart notification when not suppressed", - testableMicLockService.wasRestartNotificationCreated(), + "Should create restart notification when not suppressed", + testableMicLockService.wasRestartNotificationCreated(), ) } @@ -190,8 +194,8 @@ class MicLockServiceUnitTest { // Then: Should not create restart notification assertFalse( - "Should not create restart notification when service was not running", - testableMicLockService.wasRestartNotificationCreated(), + "Should not create restart notification when service was not running", + testableMicLockService.wasRestartNotificationCreated(), ) } @@ -205,35 +209,38 @@ class MicLockServiceUnitTest { // Then: Should create correct broadcast intent assertTrue( - "Should broadcast tile failure", - testableMicLockService.wasTileFailureBroadcast(), + "Should broadcast tile failure", + testableMicLockService.wasTileFailureBroadcast(), ) assertEquals( - "Should broadcast correct action", - MicLockService.ACTION_TILE_START_FAILED, - testableMicLockService.getLastBroadcastAction(), + "Should broadcast correct action", + MicLockService.ACTION_TILE_START_FAILED, + testableMicLockService.getLastBroadcastAction(), ) assertEquals( - "Should broadcast correct failure reason", - reason, - testableMicLockService.getLastBroadcastFailureReason(), + "Should broadcast correct failure reason", + reason, + testableMicLockService.getLastBroadcastFailureReason(), ) assertEquals( - "Should set correct package name", - "io.github.miclock", - testableMicLockService.getLastBroadcastPackage(), + "Should set correct package name", + "io.github.miclock", + testableMicLockService.getLastBroadcastPackage(), ) } @Test fun testForegroundServiceStartException_setsCorrectFailureReason() = runTest { // Test different FGS exception messages - val testCases = listOf( - "FOREGROUND_SERVICE_MICROPHONE" to MicLockService.FAILURE_REASON_FOREGROUND_RESTRICTION, - "requires permissions" to MicLockService.FAILURE_REASON_FOREGROUND_RESTRICTION, - "eligible state" to MicLockService.FAILURE_REASON_FOREGROUND_RESTRICTION, - "Some other error" to null, - ) + val testCases = + listOf( + "FOREGROUND_SERVICE_MICROPHONE" to + MicLockService.FAILURE_REASON_FOREGROUND_RESTRICTION, + "requires permissions" to + MicLockService.FAILURE_REASON_FOREGROUND_RESTRICTION, + "eligible state" to MicLockService.FAILURE_REASON_FOREGROUND_RESTRICTION, + "Some other error" to null, + ) testCases.forEach { (errorMessage, expectedReason) -> // Reset service state @@ -241,10 +248,11 @@ class MicLockServiceUnitTest { freshService.setPermissionsGranted(true) freshService.setForegroundServiceException(RuntimeException(errorMessage)) - val intent = Intent().apply { - action = MicLockService.ACTION_START_USER_INITIATED - putExtra("from_tile", true) - } + val intent = + Intent().apply { + action = MicLockService.ACTION_START_USER_INITIATED + putExtra("from_tile", true) + } // When: handleStartUserInitiated is called freshService.testHandleStartUserInitiated(intent) @@ -252,31 +260,118 @@ class MicLockServiceUnitTest { // Then: Should set correct failure reason if (expectedReason != null) { assertEquals( - "Should set correct failure reason for: $errorMessage", - expectedReason, - freshService.getStartFailureReason(), + "Should set correct failure reason for: $errorMessage", + expectedReason, + freshService.getStartFailureReason(), ) assertTrue( - "Should suppress restart notification for FGS restriction", - freshService.isRestartNotificationSuppressed(), + "Should suppress restart notification for FGS restriction", + freshService.isRestartNotificationSuppressed(), ) } else { assertNull( - "Should not set failure reason for non-FGS error: $errorMessage", - freshService.getStartFailureReason(), + "Should not set failure reason for non-FGS error: $errorMessage", + freshService.getStartFailureReason(), ) } } } + + @Test + fun testHandleStartUserInitiated_serviceRunningAndPausedByScreenOff_resumesMicHolding() = + runTest { + // Given: Service is running but paused by screen-off + testableMicLockService.setServiceWasRunning(true) + testableMicLockService.setServiceState(running = true, pausedByScreenOff = true) + testableMicLockService.setPermissionsGranted(true) + + val intent = + Intent().apply { + action = MicLockService.ACTION_START_USER_INITIATED + putExtra("from_tile", true) + } + + // When: handleStartUserInitiated is called + testableMicLockService.testHandleStartUserInitiated(intent) + + // Then: Should resume mic holding logic + assertTrue( + "Should start mic holding when service was paused", + testableMicLockService.isMicHoldingActive() + ) + assertFalse( + "Should clear paused by screen-off state", + testableMicLockService.isPausedByScreenOff() + ) + } + + @Test + fun testHandleStartUserInitiated_serviceRunningAndNotPaused_doesNotRestartMicHolding() = + runTest { + // Given: Service is running and not paused + testableMicLockService.setServiceWasRunning(true) + testableMicLockService.setServiceState(running = true, pausedByScreenOff = false) + testableMicLockService.setPermissionsGranted(true) + testableMicLockService.mockStartMicHolding() // Start mic holding initially + + val intent = + Intent().apply { + action = MicLockService.ACTION_START_USER_INITIATED + putExtra("from_tile", true) + } + + // When: handleStartUserInitiated is called + testableMicLockService.testHandleStartUserInitiated(intent) + + // Then: Should not restart already active mic holding + assertTrue( + "Mic holding should remain active", + testableMicLockService.isMicHoldingActive() + ) + // Verify no unnecessary restart occurred + assertEquals( + "Should not have restarted mic holding", + 1, + testableMicLockService.getMicHoldingStartCount() + ) + } + + @Test + fun testHandleStartUserInitiated_serviceRunningAndPausedBySilence_resumesMicHolding() = + runTest { + // Given: Service is running but paused by silence + testableMicLockService.setServiceWasRunning(true) + testableMicLockService.setServiceState(running = true, pausedBySilence = true) + testableMicLockService.setPermissionsGranted(true) + + val intent = + Intent().apply { + action = MicLockService.ACTION_START_USER_INITIATED + putExtra("from_tile", true) + } + + // When: handleStartUserInitiated is called + testableMicLockService.testHandleStartUserInitiated(intent) + + // Then: Should resume mic holding logic + assertTrue( + "Should start mic holding when service was paused by silence", + testableMicLockService.isMicHoldingActive() + ) + assertFalse( + "Should clear paused by silence state", + testableMicLockService.isPausedBySilence() + ) + } } /** - * Testable version of MicLockService that allows mocking of Android framework - * interactions and provides access to internal state for verification. + * Testable version of MicLockService that allows mocking of Android framework interactions and + * provides access to internal state for verification. */ class TestableMicLockService( - private val mockContext: Context, - private val testScope: TestScope, + private val mockContext: Context, + private val testScope: TestScope, ) { private val _state = MutableStateFlow(ServiceState()) @@ -297,6 +392,11 @@ class TestableMicLockService( // Notification tracking private var restartNotificationCreated = false + // Mic holding state tracking + private var micHoldingActive = false + private var micHoldingStartCount = 0 + private var currentServiceState = ServiceState() + // Test control methods fun setForegroundServiceWillFail(willFail: Boolean) { foregroundServiceWillFail = willFail @@ -320,6 +420,21 @@ class TestableMicLockService( suppressRestartNotification = suppress } + fun setServiceState( + running: Boolean = _state.value.isRunning, + pausedByScreenOff: Boolean = _state.value.isPausedByScreenOff, + pausedBySilence: Boolean = _state.value.isPausedBySilence + ) { + val newState = + ServiceState( + isRunning = running, + isPausedByScreenOff = pausedByScreenOff, + isPausedBySilence = pausedBySilence + ) + _state.value = newState + currentServiceState = newState + } + // Mock service methods fun testHandleStartUserInitiated(intent: Intent?) { val isFromTile = intent?.getBooleanExtra("from_tile", false) ?: false @@ -334,17 +449,20 @@ class TestableMicLockService( // Simulate successful start _state.value = _state.value.copy(isRunning = true) + mockStartMicHolding() } catch (e: Exception) { val errorMessage = e.message ?: "" if (errorMessage.contains("FOREGROUND_SERVICE_MICROPHONE") || - errorMessage.contains("requires permissions") || - errorMessage.contains("eligible state") + errorMessage.contains("requires permissions") || + errorMessage.contains("eligible state") ) { startFailureReason = MicLockService.FAILURE_REASON_FOREGROUND_RESTRICTION suppressRestartNotification = true if (isFromTile) { - testBroadcastTileStartFailure(MicLockService.FAILURE_REASON_FOREGROUND_RESTRICTION) + testBroadcastTileStartFailure( + MicLockService.FAILURE_REASON_FOREGROUND_RESTRICTION + ) } } else { // For non-FGS errors, still suppress restart notification @@ -354,12 +472,22 @@ class TestableMicLockService( _state.value = _state.value.copy(isRunning = false) return } + } else { + // Service already running - check if paused and resume if needed + if (_state.value.isPausedByScreenOff || _state.value.isPausedBySilence) { + mockStartMicHolding() + } } } fun testOnDestroy() { val wasRunning = serviceWasRunning || _state.value.isRunning - _state.value = _state.value.copy(isRunning = false, isPausedBySilence = false, currentDeviceAddress = null) + _state.value = + _state.value.copy( + isRunning = false, + isPausedBySilence = false, + currentDeviceAddress = null + ) if (wasRunning && !suppressRestartNotification) { mockCreateRestartNotification() @@ -367,16 +495,18 @@ class TestableMicLockService( } fun testBroadcastTileStartFailure(reason: String) { - val failureIntent = Intent(MicLockService.ACTION_TILE_START_FAILED).apply { - putExtra(MicLockService.EXTRA_FAILURE_REASON, reason) - setPackage(mockContext.packageName) - } + val failureIntent = + Intent(MicLockService.ACTION_TILE_START_FAILED).apply { + putExtra(MicLockService.EXTRA_FAILURE_REASON, reason) + setPackage(mockContext.packageName) + } broadcastIntents.add(failureIntent) } private fun mockStartForeground() { if (foregroundServiceWillFail) { - throw foregroundServiceException ?: RuntimeException("Simulated foreground service failure") + throw foregroundServiceException + ?: RuntimeException("Simulated foreground service failure") } } @@ -384,14 +514,27 @@ class TestableMicLockService( restartNotificationCreated = true } + // Mock the startMicHolding method + fun mockStartMicHolding() { + micHoldingActive = true + micHoldingStartCount++ + // Clear paused states when mic holding starts + setServiceState( + running = _state.value.isRunning, + pausedByScreenOff = false, + pausedBySilence = false + ) + } + // Test assertion methods fun wasTileFailureBroadcast(): Boolean { return broadcastIntents.any { it.action == MicLockService.ACTION_TILE_START_FAILED } } fun getLastBroadcastFailureReason(): String? { - return broadcastIntents.lastOrNull { it.action == MicLockService.ACTION_TILE_START_FAILED } - ?.getStringExtra(MicLockService.EXTRA_FAILURE_REASON) + return broadcastIntents + .lastOrNull { it.action == MicLockService.ACTION_TILE_START_FAILED } + ?.getStringExtra(MicLockService.EXTRA_FAILURE_REASON) } fun getLastBroadcastAction(): String? { @@ -409,4 +552,12 @@ class TestableMicLockService( fun wasRestartNotificationCreated(): Boolean = restartNotificationCreated fun getStartFailureReason(): String? = startFailureReason + + fun isMicHoldingActive(): Boolean = micHoldingActive + + fun isPausedByScreenOff(): Boolean = currentServiceState.isPausedByScreenOff + + fun isPausedBySilence(): Boolean = currentServiceState.isPausedBySilence + + fun getMicHoldingStartCount(): Int = micHoldingStartCount } diff --git a/app/src/test/java/io/github/miclock/ui/MainActivityTest.kt b/app/src/test/java/io/github/miclock/ui/MainActivityTest.kt new file mode 100644 index 0000000..95bea04 --- /dev/null +++ b/app/src/test/java/io/github/miclock/ui/MainActivityTest.kt @@ -0,0 +1,134 @@ +package io.github.miclock.ui + +import android.os.Build +import io.github.miclock.service.MicLockService +import io.github.miclock.service.model.ServiceState +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for MainActivity focusing on UI status display logic. + * Tests the fix for Issue 2: App UI shows "On" when paused by screen-off + * + * These tests verify the logic in updateMainStatus() method by testing + * the service state conditions directly. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.TIRAMISU]) +class MainActivityTest { + + @Test + fun testUpdateMainStatusLogic_pausedByScreenOff_shouldShowPaused() { + // Given: Service is running but paused by screen-off + val state = ServiceState( + isRunning = true, + isPausedBySilence = false, + isPausedByScreenOff = true + ) + + // When: Determining status text based on state + val statusText = determineStatusText(state) + + // Then: Should show PAUSED status (this is the key fix for Issue 2) + assertEquals("PAUSED", statusText) + } + + @Test + fun testUpdateMainStatusLogic_pausedBySilence_shouldShowPaused() { + // Given: Service is running but paused by silence + val state = ServiceState( + isRunning = true, + isPausedBySilence = true, + isPausedByScreenOff = false + ) + + // When: Determining status text based on state + val statusText = determineStatusText(state) + + // Then: Should show PAUSED status + assertEquals("PAUSED", statusText) + } + + @Test + fun testUpdateMainStatusLogic_pausedByBothSilenceAndScreenOff_shouldShowPaused() { + // Given: Service is paused by both conditions + val state = ServiceState( + isRunning = true, + isPausedBySilence = true, + isPausedByScreenOff = true + ) + + // When: Determining status text based on state + val statusText = determineStatusText(state) + + // Then: Should show PAUSED status (not ON) - this tests the fix for Issue 2 + assertEquals("PAUSED", statusText) + } + + @Test + fun testUpdateMainStatusLogic_runningAndNotPaused_shouldShowOn() { + // Given: Service is running and not paused + val state = ServiceState( + isRunning = true, + isPausedBySilence = false, + isPausedByScreenOff = false + ) + + // When: Determining status text based on state + val statusText = determineStatusText(state) + + // Then: Should show ON status + assertEquals("ON", statusText) + } + + @Test + fun testUpdateMainStatusLogic_serviceNotRunning_shouldShowOff() { + // Given: Service is not running + val state = ServiceState( + isRunning = false, + isPausedBySilence = false, + isPausedByScreenOff = false + ) + + // When: Determining status text based on state + val statusText = determineStatusText(state) + + // Then: Should show OFF status + assertEquals("OFF", statusText) + } + + @Test + fun testUpdateMainStatusLogic_serviceNotRunningButPausedFlags_shouldShowOff() { + // Given: Service is not running but has paused flags (edge case) + val state = ServiceState( + isRunning = false, + isPausedBySilence = true, + isPausedByScreenOff = true + ) + + // When: Determining status text based on state + val statusText = determineStatusText(state) + + // Then: Should show OFF status (running takes precedence) + assertEquals("OFF", statusText) + } + + /** + * Helper method that replicates the logic from MainActivity.updateMainStatus() + * This tests the core logic without needing the full Android UI framework + */ + private fun determineStatusText(state: ServiceState): String { + val running = state.isRunning + val pausedBySilence = state.isPausedBySilence + val pausedByScreenOff = state.isPausedByScreenOff + + return when { + !running -> "OFF" + pausedBySilence || pausedByScreenOff -> "PAUSED" // This is the fix for Issue 2 + else -> "ON" + } + } +} \ No newline at end of file From c15c37a3e51745cb085000e88f778c97e4ed0d96 Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 17:27:05 +0300 Subject: [PATCH 17/28] refined feature labels and extracted to strings.xml --- .../java/io/github/miclock/ui/MainActivity.kt | 15 +++++++-------- app/src/main/res/layout/activity_main.xml | 10 +++++----- app/src/main/res/values/strings.xml | 13 ++++++++----- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/io/github/miclock/ui/MainActivity.kt b/app/src/main/java/io/github/miclock/ui/MainActivity.kt index f467fc7..7272639 100644 --- a/app/src/main/java/io/github/miclock/ui/MainActivity.kt +++ b/app/src/main/java/io/github/miclock/ui/MainActivity.kt @@ -27,7 +27,6 @@ import io.github.miclock.service.MicLockService import io.github.miclock.tile.EXTRA_START_SERVICE_FROM_TILE import io.github.miclock.tile.MicLockTileService import io.github.miclock.util.ApiGuard -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch /** @@ -109,15 +108,15 @@ open class MainActivity : ComponentActivity() { if (fromUser) { // Snap to nearest valid position for clear phase boundaries val snappedValue = Prefs.snapSliderValue(value) - + // Convert snapped position to delay value val delayMs = Prefs.sliderToDelayMs(snappedValue) - + // Update slider to show the snapped position (creates the snappy feel) if (screenOnDelaySlider.value != snappedValue) { screenOnDelaySlider.value = snappedValue } - + handleDelayPreferenceChange(delayMs) } } @@ -344,17 +343,17 @@ open class MainActivity : ComponentActivity() { private fun updateDelayConfigurationUi(delayMs: Long) { when { delayMs == Prefs.NEVER_REACTIVATE_VALUE -> { - screenOnDelaySummary.text = getString(R.string.screen_on_delay_never_reactivate) + screenOnDelaySummary.text = getString(R.string.screen_off_stays_off_description) } delayMs == Prefs.ALWAYS_KEEP_ON_VALUE -> { - screenOnDelaySummary.text = getString(R.string.screen_on_delay_always_on) + screenOnDelaySummary.text = getString(R.string.screen_off_always_on_description) } delayMs <= 0L -> { - screenOnDelaySummary.text = getString(R.string.screen_on_delay_disabled) + screenOnDelaySummary.text = getString(R.string.screen_off_no_delay_description) } else -> { val delaySeconds = delayMs / 1000.0 - screenOnDelaySummary.text = getString(R.string.screen_on_delay_summary, delaySeconds) + screenOnDelaySummary.text = getString(R.string.screen_off_delay_description, delaySeconds) } } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 4797376..445e9ec 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -158,7 +158,7 @@ @@ -195,7 +195,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="2" - android:text="Always" + android:text="@string/screen_off_always_on_label" android:textSize="9sp" android:textColor="@color/secondary_green" android:gravity="start" @@ -206,7 +206,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="8" - android:text="Delay (0-5s)" + android:text="@string/screen_off_delay_label" android:textSize="9sp" android:textColor="@color/on_surface_variant" android:gravity="center" @@ -217,7 +217,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="2" - android:text="Never" + android:text="@string/screen_off_stays_off_label" android:textSize="9sp" android:textColor="@color/error_red" android:gravity="end" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 40cb69b..cf30bb1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,9 +4,12 @@ Quick toggle for microphone protection - Screen Behavior - Wait %1$.1f seconds after screen turns on before reactivating - Reactivates immediately when screen turns on - Stays off after screen turns off (manual restart required) - Ignores screen state (stays active even when screen is off) + Screen-Off Behavior + Wait %1$.1f seconds after screen turns back on and reactivates + Reactivates immediately when screen turns on + Stays off after screen turns off (manual restart required) + Ignores screen state (stays active even when screen is off) + Delayed Reactivation (0–5s) + Stays-Off + Always-On From 5222d320f424ef356f063308ec47d24780d0a26b Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 19:30:33 +0300 Subject: [PATCH 18/28] MainActivity start button to handle screen-off pause state --- .../java/io/github/miclock/ui/MainActivity.kt | 72 +++++- .../io/github/miclock/ui/MainActivityTest.kt | 210 +++++++++++++++--- 2 files changed, 249 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/io/github/miclock/ui/MainActivity.kt b/app/src/main/java/io/github/miclock/ui/MainActivity.kt index 7272639..9394bb8 100644 --- a/app/src/main/java/io/github/miclock/ui/MainActivity.kt +++ b/app/src/main/java/io/github/miclock/ui/MainActivity.kt @@ -125,7 +125,7 @@ open class MainActivity : ComponentActivity() { if (!hasAllPerms()) { reqPerms.launch(audioPerms + notifPerms) } else { - startMicLock() + handleStartButtonClick() } } stopBtn.setOnClickListener { stopMicLock() } @@ -239,17 +239,73 @@ open class MainActivity : ComponentActivity() { } } + /** + * Handles start button click with proper screen-off pause state handling. + * Checks service state and routes to appropriate action (start, resume, or request permissions). + */ + private fun handleStartButtonClick() { + try { + val currentState = MicLockService.state.value + + when { + currentState.isPausedByScreenOff -> { + // Resume from screen-off pause (like tile does) + Log.d("MainActivity", "Resuming service from screen-off pause") + val intent = Intent(this, MicLockService::class.java).apply { + action = MicLockService.ACTION_START_USER_INITIATED + putExtra("from_main_activity", true) + } + ContextCompat.startForegroundService(this, intent) + requestTileUpdate() + } + !currentState.isRunning -> { + // Normal start flow + Log.d("MainActivity", "Starting service from stopped state") + startMicLock() + } + else -> { + // Service is running but not paused by screen-off + // This shouldn't happen since start button should be disabled when running + Log.w("MainActivity", "Start button clicked while service is running and not paused by screen-off") + } + } + } catch (e: Exception) { + Log.e("MainActivity", "Failed to handle start button click: ${e.message}", e) + handleStartButtonFailure(e) + } + } + /** * Starts the MicLockService with user-initiated action. * This sends an ACTION_START_USER_INITIATED intent to the service. */ private fun startMicLock() { - val intent = Intent(this, MicLockService::class.java) - intent.action = MicLockService.ACTION_START_USER_INITIATED - ContextCompat.startForegroundService(this, intent) + try { + val intent = Intent(this, MicLockService::class.java) + intent.action = MicLockService.ACTION_START_USER_INITIATED + intent.putExtra("from_main_activity", true) + ContextCompat.startForegroundService(this, intent) + + // Request tile update to reflect service start + requestTileUpdate() + } catch (e: Exception) { + Log.e("MainActivity", "Failed to start MicLock service: ${e.message}", e) + handleStartButtonFailure(e) + } + } - // Request tile update to reflect service start - requestTileUpdate() + /** + * Handles start button failures with user-friendly error messages. + * Logs the error and shows appropriate feedback to the user. + */ + private fun handleStartButtonFailure(e: Exception) { + Log.e("MainActivity", "Start button action failed: ${e.message}", e) + + // Update UI state to reflect failure + updateAllUi() + + // Note: In a real implementation, you might want to show a Snackbar or Toast + // For now, we'll just log the error as the current UI doesn't have error display components } /** @@ -296,7 +352,9 @@ open class MainActivity : ComponentActivity() { } } - startBtn.isEnabled = !running + // Enable start button when service is not running OR when paused by screen-off + // This allows the start button to act as a "Resume" button for screen-off pause + startBtn.isEnabled = !running || pausedByScreenOff stopBtn.isEnabled = running } diff --git a/app/src/test/java/io/github/miclock/ui/MainActivityTest.kt b/app/src/test/java/io/github/miclock/ui/MainActivityTest.kt index 95bea04..9b27e1f 100644 --- a/app/src/test/java/io/github/miclock/ui/MainActivityTest.kt +++ b/app/src/test/java/io/github/miclock/ui/MainActivityTest.kt @@ -12,7 +12,7 @@ import org.robolectric.annotation.Config /** * Unit tests for MainActivity focusing on UI status display logic. * Tests the fix for Issue 2: App UI shows "On" when paused by screen-off - * + * * These tests verify the logic in updateMainStatus() method by testing * the service state conditions directly. */ @@ -24,14 +24,14 @@ class MainActivityTest { fun testUpdateMainStatusLogic_pausedByScreenOff_shouldShowPaused() { // Given: Service is running but paused by screen-off val state = ServiceState( - isRunning = true, - isPausedBySilence = false, + isRunning = true, + isPausedBySilence = false, isPausedByScreenOff = true ) - + // When: Determining status text based on state val statusText = determineStatusText(state) - + // Then: Should show PAUSED status (this is the key fix for Issue 2) assertEquals("PAUSED", statusText) } @@ -40,14 +40,14 @@ class MainActivityTest { fun testUpdateMainStatusLogic_pausedBySilence_shouldShowPaused() { // Given: Service is running but paused by silence val state = ServiceState( - isRunning = true, - isPausedBySilence = true, + isRunning = true, + isPausedBySilence = true, isPausedByScreenOff = false ) - + // When: Determining status text based on state val statusText = determineStatusText(state) - + // Then: Should show PAUSED status assertEquals("PAUSED", statusText) } @@ -56,14 +56,14 @@ class MainActivityTest { fun testUpdateMainStatusLogic_pausedByBothSilenceAndScreenOff_shouldShowPaused() { // Given: Service is paused by both conditions val state = ServiceState( - isRunning = true, - isPausedBySilence = true, + isRunning = true, + isPausedBySilence = true, isPausedByScreenOff = true ) - + // When: Determining status text based on state val statusText = determineStatusText(state) - + // Then: Should show PAUSED status (not ON) - this tests the fix for Issue 2 assertEquals("PAUSED", statusText) } @@ -72,14 +72,14 @@ class MainActivityTest { fun testUpdateMainStatusLogic_runningAndNotPaused_shouldShowOn() { // Given: Service is running and not paused val state = ServiceState( - isRunning = true, - isPausedBySilence = false, + isRunning = true, + isPausedBySilence = false, isPausedByScreenOff = false ) - + // When: Determining status text based on state val statusText = determineStatusText(state) - + // Then: Should show ON status assertEquals("ON", statusText) } @@ -88,14 +88,14 @@ class MainActivityTest { fun testUpdateMainStatusLogic_serviceNotRunning_shouldShowOff() { // Given: Service is not running val state = ServiceState( - isRunning = false, - isPausedBySilence = false, + isRunning = false, + isPausedBySilence = false, isPausedByScreenOff = false ) - + // When: Determining status text based on state val statusText = determineStatusText(state) - + // Then: Should show OFF status assertEquals("OFF", statusText) } @@ -104,18 +104,176 @@ class MainActivityTest { fun testUpdateMainStatusLogic_serviceNotRunningButPausedFlags_shouldShowOff() { // Given: Service is not running but has paused flags (edge case) val state = ServiceState( - isRunning = false, - isPausedBySilence = true, + isRunning = false, + isPausedBySilence = true, isPausedByScreenOff = true ) - + // When: Determining status text based on state val statusText = determineStatusText(state) - + // Then: Should show OFF status (running takes precedence) assertEquals("OFF", statusText) } + @Test + fun testHandleStartButtonClick_pausedByScreenOff_shouldResumeService() { + // Given: Service is running but paused by screen-off + val state = ServiceState( + isRunning = true, + isPausedBySilence = false, + isPausedByScreenOff = true + ) + + // When: Determining start button action based on state + val action = determineStartButtonAction(state) + + // Then: Should resume from screen-off pause + assertEquals(StartButtonAction.RESUME_FROM_SCREEN_OFF_PAUSE, action) + } + + @Test + fun testHandleStartButtonClick_serviceNotRunning_shouldStartService() { + // Given: Service is not running + val state = ServiceState( + isRunning = false, + isPausedBySilence = false, + isPausedByScreenOff = false + ) + + // When: Determining start button action based on state + val action = determineStartButtonAction(state) + + // Then: Should start service normally + assertEquals(StartButtonAction.START_SERVICE, action) + } + + @Test + fun testHandleStartButtonClick_serviceRunningNotPaused_shouldDoNothing() { + // Given: Service is actively running and not paused + val state = ServiceState( + isRunning = true, + isPausedBySilence = false, + isPausedByScreenOff = false + ) + + // When: Determining start button action based on state + val action = determineStartButtonAction(state) + + // Then: Should do nothing (button should be disabled in this state) + assertEquals(StartButtonAction.DO_NOTHING, action) + } + + @Test + fun testHandleStartButtonClick_pausedBySilenceOnly_shouldDoNothing() { + // Given: Service is paused by silence but not screen-off + val state = ServiceState( + isRunning = true, + isPausedBySilence = true, + isPausedByScreenOff = false + ) + + // When: Determining start button action based on state + val action = determineStartButtonAction(state) + + // Then: Should do nothing (only screen-off pause can be resumed via start button) + assertEquals(StartButtonAction.DO_NOTHING, action) + } + + @Test + fun testStartButtonEnabled_serviceNotRunning_shouldBeEnabled() { + // Given: Service is not running + val state = ServiceState( + isRunning = false, + isPausedBySilence = false, + isPausedByScreenOff = false + ) + + // When: Determining if start button should be enabled + val enabled = determineStartButtonEnabled(state) + + // Then: Start button should be enabled + assertTrue(enabled) + } + + @Test + fun testStartButtonEnabled_pausedByScreenOff_shouldBeEnabled() { + // Given: Service is paused by screen-off + val state = ServiceState( + isRunning = true, + isPausedBySilence = false, + isPausedByScreenOff = true + ) + + // When: Determining if start button should be enabled + val enabled = determineStartButtonEnabled(state) + + // Then: Start button should be enabled (acts as resume button) + assertTrue(enabled) + } + + @Test + fun testStartButtonEnabled_serviceRunningNotPaused_shouldBeDisabled() { + // Given: Service is actively running + val state = ServiceState( + isRunning = true, + isPausedBySilence = false, + isPausedByScreenOff = false + ) + + // When: Determining if start button should be enabled + val enabled = determineStartButtonEnabled(state) + + // Then: Start button should be disabled + assertFalse(enabled) + } + + @Test + fun testStartButtonEnabled_pausedBySilenceOnly_shouldBeDisabled() { + // Given: Service is paused by silence but not screen-off + val state = ServiceState( + isRunning = true, + isPausedBySilence = true, + isPausedByScreenOff = false + ) + + // When: Determining if start button should be enabled + val enabled = determineStartButtonEnabled(state) + + // Then: Start button should be disabled (can't resume from silence pause) + assertFalse(enabled) + } + + /** + * Enum representing possible start button actions based on service state + */ + private enum class StartButtonAction { + START_SERVICE, + RESUME_FROM_SCREEN_OFF_PAUSE, + DO_NOTHING + } + + /** + * Helper method that replicates the logic from MainActivity.handleStartButtonClick() + * This tests the core logic without needing the full Android UI framework + */ + private fun determineStartButtonAction(state: ServiceState): StartButtonAction { + return when { + state.isPausedByScreenOff -> StartButtonAction.RESUME_FROM_SCREEN_OFF_PAUSE + !state.isRunning -> StartButtonAction.START_SERVICE + else -> StartButtonAction.DO_NOTHING + } + } + + /** + * Helper method that replicates the logic from MainActivity.updateMainStatus() + * for determining start button enabled state + */ + private fun determineStartButtonEnabled(state: ServiceState): Boolean { + // Enable start button when service is not running OR when paused by screen-off + return !state.isRunning || state.isPausedByScreenOff + } + /** * Helper method that replicates the logic from MainActivity.updateMainStatus() * This tests the core logic without needing the full Android UI framework @@ -131,4 +289,4 @@ class MainActivityTest { else -> "ON" } } -} \ No newline at end of file +} From c26f232ca79bfb97def189229009d29192cddf5c Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 20:25:18 +0300 Subject: [PATCH 19/28] updated docs and version --- .gitignore | 1 + CHANGELOG.md | 31 ++++++++++++++- DEV_SPECS.md | 25 ++++++++++-- README.md | 8 ++-- app/build.gradle.kts | 4 +- app/release/baselineProfiles/0/app-release.dm | Bin 2083 -> 0 bytes app/release/baselineProfiles/1/app-release.dm | Bin 2020 -> 0 bytes app/release/output-metadata.json | 37 ------------------ docs/architecture.md | 37 +++++++++++++++++- 9 files changed, 93 insertions(+), 50 deletions(-) delete mode 100644 app/release/baselineProfiles/0/app-release.dm delete mode 100644 app/release/baselineProfiles/1/app-release.dm delete mode 100644 app/release/output-metadata.json diff --git a/.gitignore b/.gitignore index fc7cc6f..4c2cbf1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *.aab *.aar *.apk +./app/release/ # Files for the Dalvik VM *.dex diff --git a/CHANGELOG.md b/CHANGELOG.md index 1855d39..96421b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.1] - 2025-01-25 + +### Added +- **Intelligent Screen-On Delay**: Configurable delay (default 1.3 seconds) before re-activating microphone when screen turns on, preventing unnecessary battery drain during brief screen interactions like checking notifications or battery level. +- **DelayedActivationManager**: New component for robust delay handling with proper race condition management and coroutine-based implementation. +- **Screen-On Delay Configuration**: User-configurable delay setting with range of 0-5000ms, accessible through the main app interface with real-time slider feedback. + +### Enhanced +- **Battery Optimization**: Significantly reduced power consumption by avoiding microphone operations during brief screen interactions while maintaining responsive behavior for legitimate usage. +- **Race Condition Handling**: Comprehensive handling of rapid screen state changes with automatic delay cancellation and restart logic. +- **Service State Management**: Enhanced state validation to distinguish between different pause reasons (screen-off vs silence-pause vs other-app-pause). +- **Foreground Service Integration**: Improved foreground service lifecycle management during delay operations with proper notification updates. +- **Quick Settings Tile**: Enhanced tile to display "Activating..." state during delay periods with manual override capability. + +### Fixed +- **Screen State Logic**: Fixed critical bug where screen-off events incorrectly set silence pause state, preventing proper delay activation on screen-on. +- **Service State Validation**: Corrected logic to distinguish between service running and microphone actively held, allowing delays when service is paused by screen-off. +- **Foreground Service Timing**: Fixed Android 14+ compatibility by starting foreground service immediately when delay is scheduled, preventing "FGS must be started from eligible state" errors. + +### Technical Improvements +- Coroutine-based delay implementation with proper job cancellation and cleanup +- Atomic state updates and synchronized operations for thread safety +- Timestamp-based race condition detection and latest-event-wins strategy +- Enhanced service lifecycle integration with delay state persistence +- Improved notification system with countdown display during delay periods + ## [1.1.0] - 2025-01-24 ### Added @@ -59,5 +85,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Contributing guidelines for open source development - Issue templates for bug reports and feature requests -[1.1.0]: https://github.com/yourusername/mic-lock/releases/tag/v1.1.0 -[1.0.0]: https://github.com/yourusername/mic-lock/releases/tag/v1.0.0 \ No newline at end of file +[1.1.1]: https://github.com/Dan8Oren/MicLock/releases/tag/v1.1.1 +[1.1.0]: https://github.com/Dan8Oren/MicLock/releases/tag/v1.1.0 +[1.0.0]: https://github.com/Dan8Oren/MicLock/releases/tag/v1.0.0 \ No newline at end of file diff --git a/DEV_SPECS.md b/DEV_SPECS.md index b210dab..23e621c 100644 --- a/DEV_SPECS.md +++ b/DEV_SPECS.md @@ -62,7 +62,7 @@ Mic-Lock should avoid requesting audio modes or flags that might inadvertently b ### 2.6 Proper Foreground Service (FGS) Lifecycle and Android 14+ Compatibility -Mic-Lock must integrate correctly with Android's Foreground Service lifecycle to ensure stable policy classification, while gracefully handling Android 14+ background service restrictions: +Mic-Lock must integrate correctly with Android's Foreground Service lifecycle to ensure stable policy classification, while gracefully handling Android 14+ background service restrictions and implementing intelligent delayed activation: * **Open Input After FGS Start:** The microphone input should only be opened *after* the Foreground Service is fully running and its notification is visible. * **Persistent Notification:** Maintain a clear, ongoing notification that indicates the service status and allows user control. @@ -74,9 +74,26 @@ Mic-Lock must integrate correctly with Android's Foreground Service lifecycle to - Using regular `startService()` for already-running services to avoid background restrictions * **Screen State Integration:** To prevent termination by the OS, the service remains in the foreground at all times when active. - When the screen turns **off**, the service pauses microphone usage to save battery but **does not exit the foreground state**. The notification is updated to show a "Paused (Screen off)" status. - - When the screen turns **on**, the service resumes active microphone holding. + - When the screen turns **on**, the service implements intelligent delayed activation with configurable delays (default 1.3 seconds) to prevent unnecessary battery drain during brief screen interactions. +* **Delayed Activation Management:** The service must properly handle delayed microphone re-activation: + - Start foreground service immediately when delay is scheduled to comply with Android 14+ restrictions + - Cancel pending delays if screen turns off during delay period + - Restart delay from beginning if screen turns on again during existing delay + - Respect existing service states (manual stops, active sessions, paused by other apps) when applying delays -### 2.7 User Interface and Preferences +### 2.7 Intelligent Screen State Management + +Mic-Lock must implement configurable delayed activation to optimize battery usage while maintaining responsive behavior: + +* **Configurable Delay Period:** Provide user-configurable delay (0-5000ms, default 1300ms) before re-activating microphone when screen turns on +* **Smart Cancellation Logic:** Cancel pending activation if screen turns off during delay period, preventing unnecessary operations +* **Race Condition Handling:** Handle rapid screen state changes with latest-event-wins strategy and proper coroutine job management +* **State Validation:** Ensure delays only apply when appropriate (service paused by screen-off, not manually stopped or already active) +* **Foreground Service Coordination:** Start foreground service immediately when delay is scheduled to comply with Android 14+ background restrictions +* **Notification Updates:** Update service notification to reflect delay status with countdown display during delay periods +* **Manual Override Support:** Allow immediate activation through Quick Settings tile or manual service start, cancelling any pending delays + +### 2.8 User Interface and Preferences * **Quick Settings Tile**: A state-aware tile provides at-a-glance status and one-tap control. It must reflect the service's state (On, Off, Paused) and become unavailable if permissions are missing. @@ -90,7 +107,7 @@ Mic-Lock must integrate correctly with Android's Foreground Service lifecycle to * **Battery Usage Awareness:** Clearly communicate to users that MediaRecorder mode uses more battery but provides better compatibility. * **Battery Optimization Exemption:** Upon first launch, the app prompts the user to grant an exemption from battery optimizations. This is critical to prevent the Android system from terminating the service during long periods of device inactivity, ensuring continuous background operation. -### 2.8 Service Resilience and User Experience +### 2.9 Service Resilience and User Experience To ensure the service remains active and is easy to manage, Mic-Lock implements several resilience features: diff --git a/README.md b/README.md index 39f3932..406dfc2 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,10 @@ cd MicLock Mic-Lock acts as a "polite background holder" that: 1. **Detects Faulty Microphone**: Identifies when the default microphone path is compromised (typically the bottom mic on Pixel devices). 2. **Secures Working Mic**: Establishes and holds a connection to your device's *working* earpiece microphone array in a battery-efficient manner. -3. **Graceful Handover**: When other apps start recording, Mic-Lock gracefully releases its hold. -4. **Correct Path Inheritance**: The other app then inherits the correctly routed audio path to the functional microphone instead of defaulting to the broken one. -5. **Seamless Experience**: Your recordings and calls work perfectly without manual intervention! +3. **Intelligent Screen Management**: Uses configurable delays (default 1.3 seconds) before re-activating when screen turns on, preventing unnecessary battery drain during brief interactions like checking notifications. +4. **Graceful Handover**: When other apps start recording, Mic-Lock gracefully releases its hold. +5. **Correct Path Inheritance**: The other app then inherits the correctly routed audio path to the functional microphone instead of defaulting to the broken one. +6. **Seamless Experience**: Your recordings and calls work perfectly without manual intervention! ## 🔒 Security & Privacy @@ -79,6 +80,7 @@ For even easier access, Mic-Lock includes a Quick Settings tile. - **AudioRecord Mode**: (Default) More battery-efficient, optimized for most modern devices. If you experience high battery usage, switch to this mode. - **MediaRecorder Mode**: Offers wider compatibility, especially on older or more problematic devices, but might use slightly more battery. +- **Screen-On Delay**: Configurable delay (0-5000ms, default 1.3 seconds) before re-activating microphone when screen turns on. This prevents unnecessary battery drain during brief screen interactions like checking notifications or battery level. Set to 0 for immediate activation. ## 🛠️ Troubleshooting diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e4a4bfe..249c73e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "io.github.miclock" minSdk = 24 targetSdk = 36 - versionCode = 1 - versionName = "1.1.0" + versionCode = 2 + versionName = "1.1.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/release/baselineProfiles/0/app-release.dm b/app/release/baselineProfiles/0/app-release.dm deleted file mode 100644 index 0c06a05c4c9f1ab3fe633d8da40a5620e742ca02..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2083 zcmZ`)dpy(o8~-ku<(k}1Q8+984#k#2HIzd8P2-#*i_E4>hB3oj52p=B5jiTk99`sk zGUSppY?QJ#*T`*~e}_j>)F&+Gkqp3mp?JkRTS-k(3-PAHkp>Hq`+ z0ccDsH(-M)O5;HCWq%ww+&GX-@&;kSc@k*=z~F}fnXicmsn`A*Z<6|RQlv?t0|Nfe z5lQ1PSpcZ80RRxdD7~BRzbjBb;Ox=N9g&LmBQ|9oEuk3*d=<*ayV`RUIg#Vz^m^yO1&yZy z(&rqmy&f)qe3UXIT2f@InXJgjm~3vQf<9z9`nWr*Kj+muJS_9g((3B=am#+t?ptD( zh&N}Ms{d$i_t>?)MbB1VGg+gK{He3JnckP&PH$Jg8~P(dY-fJszZyoAt{p+plfQM+ zJ#T7Up8x!Cx@|BM32Tp)oxWnaI2g>Foroc=(R6a(7Qri>HOpEXUzx(@GcX>UK7$h4 z%d0P&YgXh-UPbh0baT<=WwV`ihG2_&vP#)W<@K>YGktpu#^vqL`USMIx0N^^ zRMV8SXU^Lb7v2*hKXfl5|0u1vPi7ho>D|1C$AokmM#gtORwyqN-qwwXSi9NT>lM0S z6OGb7Q1i^cS>QK%6T|DRUmJzDH6#s|2VdV~)}J0x>E2SeJoS##8Sjr)UdL}GAOm_v zPYu~!sF|GOoPRS#{M_D8AzZ3uf8vsOdu86FJ3IP0UKKNoO*{CYcf&D+&`z)^=`z>u z{k&z1s&*R>bH{z6+*>y+jgk5wz=araL-;FR_@P2D^^@I7okQEfx#g9)vPJ7<%84?| z!&5bPf9_p=-rA+4efT_|`n$$EHLb2&%BNE@qXVPFLDL8*Nn93FPqhVqFOwG6dhDXW zz$>q|rl#|1{D&u=U$3BMN*6%0cSl#{Ji8f8fHho{m5{4>*UWiN1U^{6X=p zYBDB*_EzWnsw?cFvB;^0K5Df~3Tzb{5`5G@UUfXq+u826wFhPl%2}0ZFd@=1heeRt z1O>JmOL>3I>faw=yRLK{R&Xi{#>&p&CQq)zH1GL3bAUHEsrUSl1Tss|#WCCAtonr)O~n1F!~qwq zF3uwf$}#hB@l>3_l$u>Z3n> z9kPaD?NVf>ku}y__|{{rUF&vQyYAdGHgloDdD_MZ{(QB98RLqwJP|(~lBSe{;Jgqy zhBU_E#+pp5eY+B{6USeK6)30-mD?xc_QbFCKB2+8Bk~>>S-}r!jdW|5j3lPz5Cp6v zNgy?GKf7{rG_{qN8ytIg7@E)Z2=Z(ylxtzS^)pCeAYS-k1sf|WU2d#1^HbU^E=-_c zD=~kPGnk|b)}AR;Fq?{_zCa_4td>(%R^A&2-rFG<4hEsDKN@lSO3YSA{#=!Fk#wY; zTb1Pujd_HM&k*w-ml*TT7b|Cb?cE!NBwSCumElq7xKC0=w%V){>RVA9Mu`p(k&+dv znu1A%_Sd-8Jt16lwirdQW$ueklHIFYp7a82=g!28h?I`rs}0GdZ1!s2RwA_TpLMbh+ zkD_loT5xTxAp~$6iOvu(DvL=lv5K4MjBa0u=CJmwE7&Rla_OVhrwdBCkuol;33W5& z@rOH#P>Pb5^pvgFb&p2kRVkCArKPogF>AZfCTLcr%2%~=fNnv1X0O zLr9A5%ldp?!{pke(9pr>1lAB7`5iQc#djY`aCe<|W05RZxUMtEBk$!)<}-kjX$Zbn zE21pc*~%A6S5ukdkLtP_HYPivX9js80T%I>2^4kn9h@ zULvoF9riKefs*1O_iwd$MF_ijq#HOu6jF+oD*`zN6jzX=XtTBiD9F| zjOMDz#({Df`0M;J^LZ0(^po@;ZnmBdEQc&ib}2XJ?cz+(q;GGutIC6EjaO zEogcWQv5%WRGwfi#L*F%(>O)=#Bhx=)i3o~yi(#Y6cvqdjiBHDh?>GCKC^rcPm9vT zW@Ua2sCBIHu7TXUkA(qMa^`-G6@DCbJ1@dd&pj!T3fZbR4HnF43trJ6sKqoF98 z6UKRa)YeYttv+7LG%$T=*L^eS`4?1={@9Y!>dhE)!jiUH z;8+@Ds6&dp$9#vjTxV~w*mNo-3vmtEEA6`iguw@)#CjEVj=b;l%uN3c8PoQV2nxlu zvGHOf>0V-k)wP`R5|%E`4L#?$w-ra^M7Ln0eAN++L1iXrm~9rU~6AW3*DkCLM>R2?>2tu;wD! zcmjJe1IDI!$b@+BtieYPHZNU$=-Hgk#ATe`*b2wL)sN?mXeuGbb}n-6M})QklQhCH!btYxU&Wo4t~l{$WQ%!>8527u|JI z0rZKrhCG9~p;8Ys(}(gt{jM3_M3E*IYvpN7_=r zdw*2eSn@VNy4UjFn$Df6qO$?LHR`NoOB-D6wA`1Ll zghASFFxRxhwG*z*S*$DlzKuWnuyDEWFJ4dFzlXnzu{U)SE#FHrucl*vg_HGKZHJ} z)&~_*sY$_ya;M%ARPRgJ*y$D*e$Cyxcw`G(v8DdbZO4{IjLnH5uMT8C0=&f1nm4X;zy9)RyNa-)ZT|!r1Ud#5Tfk!(n1Y?yDD!KS zpK|$)1Fiw4dWkvj*G~-}EoQUcKFulXVn7|deEohazRl1uEV>bRG8)~dcW2t*$;x?E zaQ$Z-G^y@}O){w;lBibHa*Qhz!VzDP%F>m1R5{N zs4C4v4KZ_6@vz_vEa=8$yYY zUSu6~`u>5|>^381N7)0z8+`{9g2g=5Y)+GSJNbd^4q6=tY%jdF<5fLEf2YAG?OuXm zBWLE@D`Q@-ed)a(t9I&&AF5q@4&^y#l(OqcWrH1 zlJ=U!M?Pb5Q^|9@C%^MQF3lS9CkuCC>Y2$$E~?LNpLsn*G>J3hQVDgK-gLq>kWRAy zcKc|mu5`+u6q=d}3Lwyb82=rzb8X*{4}4M%ZdlSVqZ~bSM74nzxRLNA zWMguiC?W9^wTX!tJpIU@gGU95W2Q$|NEw3ab}+t_K_;zD&FH&mbH;BQ zLrdH%GbU4q#g3hki2>T~)%xE{vvz;$h274f*}6GPfn*8KWpqW-peZu9|0^%Df0LI; zU?;#~_ZA`&00y4~009O-0H97(6Av_jIRAqTRzQdS=w6>1YUdDb0BW>K!I2+?B9Euv zuu9L|c;-X}SCno>+?AtmYIC&ku>Q>$K+=SkR&QU6GphCHQ=>}kv$fuus`?(8xD`t? zziRq*GCBgeIMY1P#_OFT>^U*=V9D&Wp&Eo(5ItHhXXGy(kyNBJ>s`qng84JBo{6aJ zKkg)8f|RXT-jj+UPK6L@gml*1sQNw$uT7~RSkh+SHW!fFW_v_4cZ$r-87Q_7@RziH tf0b|2`rr6oX#d{%J?pot{RNol!rymBx;aaTQUHLM=*5bDAE|HAzW{wyv2OqX diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json deleted file mode 100644 index 75ff352..0000000 --- a/app/release/output-metadata.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "version": 3, - "artifactType": { - "type": "APK", - "kind": "Directory" - }, - "applicationId": "io.github.miclock", - "variantName": "release", - "elements": [ - { - "type": "SINGLE", - "filters": [], - "attributes": [], - "versionCode": 1, - "versionName": "1.1.0", - "outputFile": "app-release.apk" - } - ], - "elementType": "File", - "baselineProfiles": [ - { - "minApi": 28, - "maxApi": 30, - "baselineProfiles": [ - "baselineProfiles/1/app-release.dm" - ] - }, - { - "minApi": 31, - "maxApi": 2147483647, - "baselineProfiles": [ - "baselineProfiles/0/app-release.dm" - ] - } - ], - "minSdkVersionForDexing": 24 -} \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index 201fde3..5b02d06 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,6 +20,7 @@ The heart of the application - a `ForegroundService` that runs continuously to m - **Persistent Notification**: Displays a persistent notification to indicate active status and reduce the likelihood of system termination - **Microphone Acquisition**: Utilizes Android's audio APIs (`MediaRecorder` or `AudioRecord`) to acquire and hold a functional microphone - **Dual Mode Support**: Supports two distinct modes (`MediaRecorder Mode` and `AudioRecord Mode`) for compatibility across different devices and Android versions +- **Intelligent Screen State Management**: Integrates with DelayedActivationManager for battery-efficient screen-on behavior - **Polite Yielding**: Gracefully releases microphone when other apps need it - **Route Validation**: Validates that the acquired microphone route is functional @@ -65,12 +66,23 @@ Handles persistent storage of user settings and preferences. - Auto-restart preferences - User configuration choices -### 5. MicLockTileService (Quick Settings Tile) +### 5. DelayedActivationManager (Delay Management) + +A specialized component responsible for managing configurable delays before microphone re-activation when the screen turns on. + +**Key Responsibilities:** +- **Delay Scheduling**: Manages coroutine-based delays with proper cancellation and cleanup +- **Race Condition Handling**: Handles rapid screen state changes with latest-event-wins strategy +- **State Validation**: Ensures delays respect existing service states (manual stops, active sessions, paused states) +- **Battery Optimization**: Prevents unnecessary microphone operations during brief screen interactions + +### 6. MicLockTileService (Quick Settings Tile) A `TileService` that acts as a primary remote control for the `MicLockService`. **Key Responsibilities:** -- **State-Aware UI**: Reflects the real-time status of the service (Active, Inactive, Paused, No Permission). +- **State-Aware UI**: Reflects the real-time status of the service (Active, Inactive, Paused, Activating, No Permission). - **One-Tap Control**: Allows the user to start and stop the service directly from the Quick Settings panel. +- **Delay State Display**: Shows "Activating..." state during delay periods with manual override capability. - **Resilient Start Logic**: Implements a robust fallback mechanism to ensure service activation even when the app is in the background, by launching the main activity if a direct start fails. @@ -147,8 +159,29 @@ flowchart TD ### Power Management - **Wake Lock Management**: Minimal CPU wake locks only during active recording - **Screen State Integration**: To maximize reliability, the service remains in the foreground even when the screen is off. When the screen turns off, microphone usage is paused to conserve battery, and the notification is updated to "Paused (Screen off)". This prevents the OS from killing the service. +- **Intelligent Delay System**: Configurable delays (default 1.3 seconds) before re-activating microphone when screen turns on, preventing unnecessary operations during brief screen interactions - **Foreground Service**: Prevents system termination while maintaining low priority +### Delayed Activation Flow + +When the screen turns on, Mic-Lock employs an intelligent delay system to optimize battery usage: + +```mermaid +flowchart TD + A[Screen Turns On] --> B{Service State Check} + B -->|Already Active| C[No Action Needed] + B -->|Manually Stopped| D[No Action - Respect User Choice] + B -->|Paused by Screen Off| E[Schedule Delayed Activation] + E --> F[Start 1.3s Delay Timer] + F --> G{Screen State During Delay} + G -->|Screen Turns Off| H[Cancel Delay] + G -->|Screen Stays On| I[Complete Delay] + I --> J[Activate Microphone] + H --> K[Wait for Next Screen On] +``` + +This system prevents unnecessary microphone activation during brief screen interactions like checking notifications or battery level, while ensuring responsive behavior for legitimate usage. + ## 🛡️ Android Compatibility ### Android 14+ Adaptations From 08ce9679f796a2372189dc1da73813875508d0bf Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 20:27:14 +0300 Subject: [PATCH 20/28] gitignore update --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4c2cbf1..70f6583 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ *.aab *.aar *.apk -./app/release/ +/app/release/ # Files for the Dalvik VM *.dex From 2d9184b98c94984401406a66f91b679d251bf485 Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 20:32:01 +0300 Subject: [PATCH 21/28] updated Changelog --- CHANGELOG.md | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96421b4..38747f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,33 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.1.1] - 2025-01-25 +## [1.1.1] - 2025-10-09 ### Added -- **Intelligent Screen-On Delay**: Configurable delay (default 1.3 seconds) before re-activating microphone when screen turns on, preventing unnecessary battery drain during brief screen interactions like checking notifications or battery level. +- **Configurable Screen-Off Behavior**: + Default configuration is set to 1.3 seconds of Delayed Reactivatoin. configuration options are: + a. **Always-On**: Keeps the microphone usage on even when the screen is off. might be a good usage for thoes who has quick microphone usage even when the screen is off (more battery usage, less recommanded). + b. **Delayed Reactivatoin**: (Recommanded Approach) Microphone turns off when screen is off with a configurable delay for re-activating the microphone when screen turns back on.preventing unnecessary battery drain during brief screen interactions like checking notifications or battery level. + c. **Stays-Off**: Turns the microphone usage off once the screen turns off and does not re-activates it. (recommanded for thoes who wants minimum battery usage, but requires manual re-activation before usage.) + - **DelayedActivationManager**: New component for robust delay handling with proper race condition management and coroutine-based implementation. -- **Screen-On Delay Configuration**: User-configurable delay setting with range of 0-5000ms, accessible through the main app interface with real-time slider feedback. + ### Enhanced - **Battery Optimization**: Significantly reduced power consumption by avoiding microphone operations during brief screen interactions while maintaining responsive behavior for legitimate usage. -- **Race Condition Handling**: Comprehensive handling of rapid screen state changes with automatic delay cancellation and restart logic. -- **Service State Management**: Enhanced state validation to distinguish between different pause reasons (screen-off vs silence-pause vs other-app-pause). -- **Foreground Service Integration**: Improved foreground service lifecycle management during delay operations with proper notification updates. - **Quick Settings Tile**: Enhanced tile to display "Activating..." state during delay periods with manual override capability. -### Fixed -- **Screen State Logic**: Fixed critical bug where screen-off events incorrectly set silence pause state, preventing proper delay activation on screen-on. -- **Service State Validation**: Corrected logic to distinguish between service running and microphone actively held, allowing delays when service is paused by screen-off. -- **Foreground Service Timing**: Fixed Android 14+ compatibility by starting foreground service immediately when delay is scheduled, preventing "FGS must be started from eligible state" errors. ### Technical Improvements - Coroutine-based delay implementation with proper job cancellation and cleanup - Atomic state updates and synchronized operations for thread safety - Timestamp-based race condition detection and latest-event-wins strategy - Enhanced service lifecycle integration with delay state persistence -- Improved notification system with countdown display during delay periods -## [1.1.0] - 2025-01-24 +## [1.1.0] - 2025-10-01 ### Added - **Intelligent Quick Settings Tile**: A new state-aware Quick Settings tile that provides at-a-glance status (On, Off, Paused) and one-tap control of the MicLock service. @@ -41,7 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The tile now displays a "Paused" state when another app is using the microphone, providing clearer feedback to the user. - The tile shows an unavailable state with a "No Permission" label if required permissions have not been granted. -## [1.0.1] - 2025-01-23 +## [1.0.1] - 2025-09-23 ### Enhanced - **Improved Service Reliability (Always-On Foreground Service):** The MicLockService now remains in the foreground at all times when active, even when the screen is off. This significantly improves service reliability by preventing the Android system from terminating the background process. @@ -52,10 +49,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Addressed issues where the service could be terminated by aggressive OEM power management when the screen was off -## [1.0.0] - 2024-01-20 +## [1.0.0] - 2025-09-21 ### Added -- Initial public release of Mic-Lock +- Initial public release of MicLock - Core functionality to reroute audio from faulty bottom microphone to earpiece microphone on Google Pixel devices - Battery-efficient background service with dual recording strategy (MediaRecorder/AudioRecord modes) - Polite background holding mechanism that yields to foreground applications @@ -87,4 +84,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [1.1.1]: https://github.com/Dan8Oren/MicLock/releases/tag/v1.1.1 [1.1.0]: https://github.com/Dan8Oren/MicLock/releases/tag/v1.1.0 +[1.0.1]: https://github.com/Dan8Oren/MicLock/releases/tag/v1.0.1 [1.0.0]: https://github.com/Dan8Oren/MicLock/releases/tag/v1.0.0 \ No newline at end of file From ae07137b050a1f066b3b91ab99a9451ab4eeb956 Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 20:33:27 +0300 Subject: [PATCH 22/28] minor changes --- DEV_SPECS.md | 2 +- docs/architecture.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/DEV_SPECS.md b/DEV_SPECS.md index 23e621c..3c323cd 100644 --- a/DEV_SPECS.md +++ b/DEV_SPECS.md @@ -85,7 +85,7 @@ Mic-Lock must integrate correctly with Android's Foreground Service lifecycle to Mic-Lock must implement configurable delayed activation to optimize battery usage while maintaining responsive behavior: -* **Configurable Delay Period:** Provide user-configurable delay (0-5000ms, default 1300ms) before re-activating microphone when screen turns on +* **Configurable Delay Period:** Provide user-configurable delay (0-5000ms) before re-activating microphone when screen turns on * **Smart Cancellation Logic:** Cancel pending activation if screen turns off during delay period, preventing unnecessary operations * **Race Condition Handling:** Handle rapid screen state changes with latest-event-wins strategy and proper coroutine job management * **State Validation:** Ensure delays only apply when appropriate (service paused by screen-off, not manually stopped or already active) diff --git a/docs/architecture.md b/docs/architecture.md index 5b02d06..766d793 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -159,7 +159,7 @@ flowchart TD ### Power Management - **Wake Lock Management**: Minimal CPU wake locks only during active recording - **Screen State Integration**: To maximize reliability, the service remains in the foreground even when the screen is off. When the screen turns off, microphone usage is paused to conserve battery, and the notification is updated to "Paused (Screen off)". This prevents the OS from killing the service. -- **Intelligent Delay System**: Configurable delays (default 1.3 seconds) before re-activating microphone when screen turns on, preventing unnecessary operations during brief screen interactions +- **Intelligent Delay System**: Configurable delays before re-activating microphone when screen turns on, preventing unnecessary operations during brief screen interactions - **Foreground Service**: Prevents system termination while maintaining low priority ### Delayed Activation Flow From 87dfe6f34e0d72e3683665b55943e721c33cb5c0 Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Thu, 9 Oct 2025 20:43:36 +0300 Subject: [PATCH 23/28] updated versionCode to count past releases --- 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 249c73e..f5ac05a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,7 +16,7 @@ android { applicationId = "io.github.miclock" minSdk = 24 targetSdk = 36 - versionCode = 2 + versionCode = 4 versionName = "1.1.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From d4eb9832352342cc9a36b3828404e202e3042750 Mon Sep 17 00:00:00 2001 From: Dan Oren <94993872+Dan8Oren@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:51:28 +0300 Subject: [PATCH 24/28] Update CHANGELOG.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38747f0..efd45bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Configurable Screen-Off Behavior**: - Default configuration is set to 1.3 seconds of Delayed Reactivatoin. configuration options are: - a. **Always-On**: Keeps the microphone usage on even when the screen is off. might be a good usage for thoes who has quick microphone usage even when the screen is off (more battery usage, less recommanded). - b. **Delayed Reactivatoin**: (Recommanded Approach) Microphone turns off when screen is off with a configurable delay for re-activating the microphone when screen turns back on.preventing unnecessary battery drain during brief screen interactions like checking notifications or battery level. - c. **Stays-Off**: Turns the microphone usage off once the screen turns off and does not re-activates it. (recommanded for thoes who wants minimum battery usage, but requires manual re-activation before usage.) + Default configuration is set to 1.3 seconds of Delayed Reactivation. Configuration options are: + a. **Always-On**: Keeps the microphone usage on even when the screen is off. Might be a good option for those who have quick microphone usage even when the screen is off (more battery usage, less recommended). + b. **Delayed Reactivation**: (Recommended Approach) Microphone turns off when the screen is off with a configurable delay for reactivating the microphone when the screen turns back on, preventing unnecessary battery drain during brief screen interactions like checking notifications or battery level. + c. **Stays-Off**: Turns the microphone usage off once the screen turns off and does not reactivate it. (Recommended for those who want minimum battery usage, but requires manual reactivation before usage.) - **DelayedActivationManager**: New component for robust delay handling with proper race condition management and coroutine-based implementation. From 3c1fa4be0f634fe8c02d5257a3378df317aeafd0 Mon Sep 17 00:00:00 2001 From: Dan Oren <94993872+Dan8Oren@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:54:05 +0300 Subject: [PATCH 25/28] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/src/main/java/io/github/miclock/service/MicLockService.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/io/github/miclock/service/MicLockService.kt b/app/src/main/java/io/github/miclock/service/MicLockService.kt index 7d18ffb..1776d53 100644 --- a/app/src/main/java/io/github/miclock/service/MicLockService.kt +++ b/app/src/main/java/io/github/miclock/service/MicLockService.kt @@ -353,6 +353,7 @@ class MicLockService : Service(), MicActivationService { if (delayMs == 0L){ Log.d(TAG, "No delay configured (${delayMs}ms), starting immediately") startMicHolding(fromDelayCompletion = false) + return } // Always on Or Never Log.d(TAG, "Always-On or Never configured, skipping reactivation") From 76cf9f0495e45ff8a50db3d409e421c18e794908 Mon Sep 17 00:00:00 2001 From: Dan8Oren Date: Fri, 10 Oct 2025 16:50:15 +0300 Subject: [PATCH 26/28] gitignore update --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 70f6583..2e9fba9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.aar *.apk /app/release/ +/app/debug/ # Files for the Dalvik VM *.dex From 043db5dc7cf347add3d895ad15683378bfdf35f9 Mon Sep 17 00:00:00 2001 From: Dan Oren <94993872+Dan8Oren@users.noreply.github.com> Date: Sun, 12 Oct 2025 22:07:52 +0300 Subject: [PATCH 27/28] stuck in isPausedBySilence state bug fix (#10) * implement hybrid solution for stuck isPausedBySilence state * removed pausedBySilenceTimestamp * modified log * Fixes issue where service would remain stuck in silenced state --- .idea/androidTestResultsUserPreferences.xml | 13 + .idea/deploymentTargetSelector.xml | 3 + .../service/SilenceStateIntegrationTest.kt | 127 ++++++++ .../service/DelayedActivationManager.kt | 85 +++--- .../github/miclock/service/MicLockService.kt | 283 +++++++++++++++--- .../miclock/service/model/ServiceState.kt | 1 + .../java/io/github/miclock/TestSupport.kt | 10 +- .../service/DelayedActivationManagerTest.kt | 32 +- .../miclock/service/GlobalCallbackTest.kt | 143 +++++++++ .../service/logic/PoliteYieldingTest.kt | 12 +- 10 files changed, 606 insertions(+), 103 deletions(-) create mode 100644 app/src/androidTest/java/io/github/miclock/service/SilenceStateIntegrationTest.kt create mode 100644 app/src/test/java/io/github/miclock/service/GlobalCallbackTest.kt diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index 7ccb027..a6b4304 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -68,6 +68,19 @@ + + + + + + + diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 79720ae..b02a85c 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -13,6 +13,9 @@ + + \ No newline at end of file diff --git a/app/src/androidTest/java/io/github/miclock/service/SilenceStateIntegrationTest.kt b/app/src/androidTest/java/io/github/miclock/service/SilenceStateIntegrationTest.kt new file mode 100644 index 0000000..b42cbb1 --- /dev/null +++ b/app/src/androidTest/java/io/github/miclock/service/SilenceStateIntegrationTest.kt @@ -0,0 +1,127 @@ +package io.github.miclock.service + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import io.github.miclock.service.model.ServiceState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Integration tests for silence state recovery across screen state transitions. + * Tests the complete end-to-end flow of the hybrid solution. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class SilenceStateIntegrationTest { + + @Test + fun testEndToEnd_silenceStateRecovery() = runTest { + // This integration test validates the complete flow: + // 1. Service running normally + // 2. Another app uses mic (service paused by silence) + // 3. Screen turns off + // 4. Other app releases mic (while screen still off) + // 5. Screen turns on + // 6. Service should resume normally (not blocked by stale silence) + + val context = InstrumentationRegistry.getInstrumentation().targetContext + + // Phase 1: Service running normally + val initialState = ServiceState( + isRunning = true, + isPausedBySilence = false, + isPausedByScreenOff = false + ) + assertTrue("Service should be running", initialState.isRunning) + assertFalse("Service should not be paused by silence", initialState.isPausedBySilence) + + // Phase 2: Another app uses mic - service paused + val silencedState = ServiceState( + isRunning = true, + isPausedBySilence = true, + isPausedByScreenOff = false, + wasSilencedBeforeScreenOff = false + ) + assertTrue("Service should be paused by silence", silencedState.isPausedBySilence) + + + // Phase 3: Screen turns off + val screenOffState = ServiceState( + isRunning = true, + isPausedBySilence = true, + isPausedByScreenOff = true, + wasSilencedBeforeScreenOff = true + ) + assertTrue("Service should be paused by screen-off", screenOffState.isPausedByScreenOff) + assertTrue("wasSilencedBeforeScreenOff should be true", screenOffState.wasSilencedBeforeScreenOff) + + // Phase 4: Other app releases mic (global callback detects this) + val micAvailableState = ServiceState( + isRunning = true, + isPausedBySilence = false, + isPausedByScreenOff = true, + wasSilencedBeforeScreenOff = false + ) + assertFalse("isPausedBySilence should be cleared", micAvailableState.isPausedBySilence) + assertFalse("wasSilencedBeforeScreenOff should be cleared", micAvailableState.wasSilencedBeforeScreenOff) + + + // Phase 5: Screen turns on - service should resume + val resumedState = ServiceState( + isRunning = true, + isPausedBySilence = false, + isPausedByScreenOff = false, + wasSilencedBeforeScreenOff = false + ) + assertTrue("Service should be running", resumedState.isRunning) + assertFalse("Service should not be paused by silence", resumedState.isPausedBySilence) + assertFalse("Service should not be paused by screen-off", resumedState.isPausedByScreenOff) + } + + @Test + fun testLongRecording_notInterrupted() = runTest { + // Tests that user's long recording session is not interrupted + // Scenario: User recording for 2+ hours with screen off + + // Given: Service paused by silence for extended period + val longRecordingState = ServiceState( + isRunning = true, + isPausedBySilence = true, + isPausedByScreenOff = true, + wasSilencedBeforeScreenOff = true + ) + + // When: Global callback continues to detect active recording + // (In real scenario, configs would show other app still recording) + + // Then: Service should remain paused (respecting user's recording) + assertTrue("Service should remain paused by silence", longRecordingState.isPausedBySilence) + assertTrue("wasSilencedBeforeScreenOff should be true", longRecordingState.wasSilencedBeforeScreenOff) + // Note: The global callback would detect active recording and maintain the silence state + } + + @Test + fun testScreenStateTransitions_preserveContext() = runTest { + // Tests that rapid screen on/off transitions preserve state context + + // Given: Service in various states + val states = listOf( + ServiceState(isRunning = true, isPausedByScreenOff = false), + ServiceState(isRunning = true, isPausedByScreenOff = true, wasSilencedBeforeScreenOff = false), + ServiceState(isRunning = true, isPausedByScreenOff = false, isPausedBySilence = false) + ) + + // When: Rapid screen transitions occur + // Then: Each state transition should preserve relevant context + states.forEach { state -> + if (state.isPausedByScreenOff) { + // Screen is off - context should be preserved + assertNotNull("State should be preserved", state.wasSilencedBeforeScreenOff) + } + } + } +} diff --git a/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt b/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt index 05743a2..0606558 100644 --- a/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt +++ b/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt @@ -33,7 +33,7 @@ open class DelayedActivationManager( /** * Schedules a delayed activation after the specified delay period. * Cancels any existing pending activation before scheduling new one. - * + * * @param delayMs delay in milliseconds before activation * @return true if delay was scheduled, false if conditions don't allow delay */ @@ -42,31 +42,31 @@ open class DelayedActivationManager( fun scheduleDelayedActivation(delayMs: Long): Boolean { val currentTime = getCurrentTimeMs() lastScreenOnTime.set(currentTime) - + Log.d(TAG, "Scheduling delayed activation with ${delayMs}ms delay") - + // Cancel any existing delay operation cancelDelayedActivation() - + // Validate that delay should be applied if (!shouldApplyDelay()) { Log.d(TAG, "Delay not applicable due to service state conditions") return false } - + // Start new delay operation delayStartTime.set(currentTime) isActivationPending.set(true) - + delayJob = scope.launch { try { Log.d(TAG, "Starting delay countdown: ${delayMs}ms") delay(delayMs) - + // Check if this delay operation is still valid (not superseded by newer screen events) if (isActivationPending.get() && delayStartTime.get() == currentTime) { Log.d(TAG, "Delay completed, activating microphone") - + // Validate service state one more time before activation if (shouldRespectExistingState()) { Log.d(TAG, "Service state changed during delay, respecting current state") @@ -74,10 +74,10 @@ open class DelayedActivationManager( handleServiceStateConflict() return@launch } - + // Clear pending state before activation isActivationPending.set(false) - + // Activate microphone functionality // Pass fromDelayCompletion=true to skip startForeground (already started in handleStartHolding) service.startMicHolding(fromDelayCompletion = true) @@ -93,51 +93,51 @@ open class DelayedActivationManager( isActivationPending.set(false) } } - + return true } /** * Cancels any pending delayed activation operation. - * + * * @return true if there was a pending operation that was cancelled, false otherwise */ fun cancelDelayedActivation(): Boolean { val wasPending = isActivationPending.get() - + if (wasPending) { Log.d(TAG, "Cancelling delayed activation") } - + delayJob?.cancel() delayJob = null isActivationPending.set(false) delayStartTime.set(0L) - + return wasPending } /** * Checks if there is currently a delayed activation pending. - * + * * @return true if activation is pending, false otherwise */ fun isActivationPending(): Boolean = isActivationPending.get() /** * Gets the remaining time in milliseconds for the current delay operation. - * + * * @return remaining milliseconds, or 0 if no delay is pending */ fun getRemainingDelayMs(): Long { if (!isActivationPending.get()) return 0L - + val startTime = delayStartTime.get() val delayMs = Prefs.getScreenOnDelayMs(context) val currentTime = getCurrentTimeMs() val elapsed = currentTime - startTime val remaining = (delayMs - elapsed).coerceAtLeast(0L) - + return remaining } @@ -149,12 +149,12 @@ open class DelayedActivationManager( /** * Determines if the current service state should prevent delayed activation. * This method checks for conditions that should be respected (manual stops, active sessions, paused states). - * + * * @return true if existing state should be respected (delay should not proceed), false otherwise */ fun shouldRespectExistingState(): Boolean { val currentState = service.getCurrentState() - + return when { // Check if mic is actively being held (not just service running) // This allows delay when service is running but paused (screen off scenario) @@ -162,25 +162,26 @@ open class DelayedActivationManager( Log.d(TAG, "Mic is actively being held, respecting existing state") true } - + // Service is paused by silence (another app using mic) - respect the pause currentState.isPausedBySilence -> { + // The global callback ensures isPausedBySilence is always current Log.d(TAG, "Service paused by silence, respecting pause state") true } - + // Service is paused by screen-off - this is normal and delay should be applied currentState.isPausedByScreenOff -> { Log.d(TAG, "Service paused by screen-off, delay can be applied") false } - + // Check if service was manually stopped by user service.isManuallyStoppedByUser() -> { Log.d(TAG, "Service manually stopped by user, respecting manual stop") true } - + else -> false } } @@ -191,26 +192,26 @@ open class DelayedActivationManager( */ fun handleServiceStateConflict() { val currentState = service.getCurrentState() - + Log.d(TAG, "Handling service state conflict - isMicActivelyHeld: ${service.isMicActivelyHeld()}, isPausedBySilence: ${currentState.isPausedBySilence}, isPausedByScreenOff: ${currentState.isPausedByScreenOff}") - + when { service.isMicActivelyHeld() -> { Log.d(TAG, "Mic is actively being held, no action needed") } - + currentState.isPausedBySilence -> { Log.d(TAG, "Service is paused by another app, maintaining pause state") } - + currentState.isPausedByScreenOff -> { Log.d(TAG, "Service is paused by screen-off, this is expected") } - + service.isManuallyStoppedByUser() -> { Log.d(TAG, "Service was manually stopped, not overriding user choice") } - + else -> { Log.d(TAG, "No specific conflict resolution needed") } @@ -219,7 +220,7 @@ open class DelayedActivationManager( /** * Checks if the app is configured to never re-enable after screen-off. - * + * * @return true if never re-enable mode is active, false otherwise */ fun isNeverReactivateMode(): Boolean { @@ -228,7 +229,7 @@ open class DelayedActivationManager( /** * Checks if the app is configured to always keep mic on (ignore screen state). - * + * * @return true if always-on mode is active, false otherwise */ fun isAlwaysOnMode(): Boolean { @@ -237,37 +238,37 @@ open class DelayedActivationManager( /** * Determines if delay should be applied based on current conditions. - * + * * @return true if delay should be applied, false otherwise */ fun shouldApplyDelay(): Boolean { val delayMs = Prefs.getScreenOnDelayMs(context) - + return when { // Never re-enable mode delayMs == Prefs.NEVER_REACTIVATE_VALUE -> { Log.d(TAG, "Never re-enable mode active, blocking activation") false } - + // Always-on mode (should not apply delay, but should activate immediately) delayMs == Prefs.ALWAYS_KEEP_ON_VALUE -> { Log.d(TAG, "Always-on mode active, no delay needed") false } - + // Delay is disabled (0ms) delayMs <= 0L -> { Log.d(TAG, "Delay disabled (${delayMs}ms)") false } - + // Service state should be respected shouldRespectExistingState() -> { Log.d(TAG, "Existing service state should be respected") false } - + else -> { Log.d(TAG, "Delay should be applied (${delayMs}ms)") true @@ -278,14 +279,14 @@ open class DelayedActivationManager( /** * Gets the timestamp of the last screen-on event. * Used for race condition detection and debugging. - * + * * @return timestamp in milliseconds */ fun getLastScreenOnTime(): Long = lastScreenOnTime.get() /** * Gets the timestamp when the current delay operation started. - * + * * @return timestamp in milliseconds, or 0 if no delay is active */ fun getDelayStartTime(): Long = delayStartTime.get() @@ -298,4 +299,4 @@ open class DelayedActivationManager( Log.d(TAG, "Cleaning up DelayedActivationManager") cancelDelayedActivation() } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/github/miclock/service/MicLockService.kt b/app/src/main/java/io/github/miclock/service/MicLockService.kt index 1776d53..869cb85 100644 --- a/app/src/main/java/io/github/miclock/service/MicLockService.kt +++ b/app/src/main/java/io/github/miclock/service/MicLockService.kt @@ -79,10 +79,12 @@ class MicLockService : Service(), MicActivationService { // Silencing state (per run) @Volatile private var isSilenced: Boolean = false - private var markCooldownStart: Long? = null - private var backoffMs: Long = 500L private var recCallback: AudioManager.AudioRecordingCallback? = null + private var globalRecCallback: AudioManager.AudioRecordingCallback? = null + private var lastRecordingSessionId: Int? = null + private var sessionSilencedBeforeScreenOff: Boolean = false + // MediaRecorder fallback private var mediaRecorderHolder: MediaRecorderHolder? = null @@ -117,6 +119,141 @@ class MicLockService : Service(), MicActivationService { } registerReceiver(screenStateReceiver, filter) Log.d(TAG, "ScreenStateReceiver registered dynamically") + + // Register global callback on service creation + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + registerGlobalCallback() + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun registerGlobalCallback() { + if (globalRecCallback != null) { + Log.w(TAG, "Global callback already registered, skipping") + return + } + + globalRecCallback = object : AudioManager.AudioRecordingCallback() { + override fun onRecordingConfigChanged(configs: MutableList) { + handleGlobalRecordingChange(configs) + } + } + + try { + audioManager.registerAudioRecordingCallback( + globalRecCallback!!, + Handler(Looper.getMainLooper()) + ) + Log.d(TAG, "Global recording callback registered") + } catch (e: Exception) { + Log.e(TAG, "Failed to register global callback: ${e.message}", e) + globalRecCallback = null + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun unregisterGlobalCallback() { + globalRecCallback?.let { + try { + audioManager.unregisterAudioRecordingCallback(it) + Log.d(TAG, "Global recording callback unregistered") + } catch (e: Exception) { + Log.w(TAG, "Error unregistering global callback: ${e.message}") + } + } + globalRecCallback = null + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun handleGlobalRecordingChange(configs: MutableList) { + val currentSessionId = lastRecordingSessionId + val currentState = state.value + + Log.d(TAG, "Global callback triggered: ${configs.size} configs, sessionId=$currentSessionId, " + + "loopActive=${loopJob?.isActive}, isPausedBySilence=${currentState.isPausedBySilence}") + + when { + loopJob?.isActive == true && currentSessionId != null -> { + handleActiveSessionRecordingChange(configs, currentSessionId) + } + sessionSilencedBeforeScreenOff && currentSessionId != null -> { + handleInactiveSessionRecordingChange(configs) + } + else -> { + Log.d(TAG, "No relevant session context, ignoring recording change") + } + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun handleActiveSessionRecordingChange( + configs: MutableList, + sessionId: Int + ) { + val ourSession = configs.firstOrNull { + it.clientAudioSessionId == sessionId + } + + if (ourSession != null) { + val silenced = ourSession.isClientSilenced + handleSessionSilencing(silenced, isLoopActive = true) + } else { + Log.d(TAG, "Our session (ID: $sessionId) not found in active configurations") + } + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun handleInactiveSessionRecordingChange( + configs: MutableList + ) { + val othersStillRecording = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + configs.any { !it.isClientSilenced } + } else { + configs.isNotEmpty() + } + + if (!othersStillRecording) { + Log.d(TAG, "Mic became available while screen off - clearing stuck silence state") + sessionSilencedBeforeScreenOff = false + updateServiceState( + paused = false, + wasSilencedBeforeScreenOff = false + ) + } else { + Log.d(TAG, "Other apps still recording, maintaining silence state") + } + } + + private fun handleSessionSilencing(silenced: Boolean, isLoopActive: Boolean) { + if (silenced && !isSilenced) { + isSilenced = true + sessionSilencedBeforeScreenOff = true + + updateServiceState( + paused = true, + wasSilencedBeforeScreenOff = true + ) + + Log.i(TAG, "Recording silenced by system (other app using mic)") + + if (isLoopActive) { + updateNotification("Paused — mic in use by another app") + } + } else if (!silenced && isSilenced) { + isSilenced = false + sessionSilencedBeforeScreenOff = false + + updateServiceState( + paused = false, + wasSilencedBeforeScreenOff = false + ) + + if (isLoopActive) { + Log.i(TAG, "Mic available again - loop will resume") + } else { + Log.i(TAG, "Mic became available while screen off") + } + } } private fun createRestartNotification() { @@ -164,6 +301,13 @@ class MicLockService : Service(), MicActivationService { scope.cancel() wakeLockManager.release() + + // Unregister global callback on service destruction + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + unregisterGlobalCallback() + } + + // Clean up session-specific callback try { recCallback?.let { audioManager.unregisterAudioRecordingCallback(it) } } catch (_: Throwable) {} recCallback = null mediaRecorderHolder?.stopRecording() @@ -292,6 +436,53 @@ class MicLockService : Service(), MicActivationService { Log.i(TAG, "Received ACTION_START_HOLDING, isRunning: ${state.value.isRunning}, timestamp: $eventTimestamp") if (state.value.isRunning) { + // Check if we were silenced before screen-off - if so, attempt immediate activation to test mic availability + if (state.value.wasSilencedBeforeScreenOff) { + Log.d(TAG, "Was silenced before screen-off - attempting immediate activation to test mic availability") + + // Start foreground service if needed + if (canStartForegroundService()) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIF_ID, + buildNotification("Testing mic availability…"), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ) + } else { + startForeground(NOTIF_ID, buildNotification("Testing mic availability…")) + } + serviceHealthy = true + Log.d(TAG, "Foreground service started for mic availability test") + } catch (e: Exception) { + Log.w(TAG, "Could not start foreground service: ${e.message}") + serviceHealthy = false + updateServiceState(running = false) + createRestartNotification() + stopSelf() + return + } + } else { + Log.d(TAG, "Delaying foreground service start due to boot restrictions") + scheduleDelayedForegroundStart() + } + + // Schedule immediate activation (0L delay) to test if mic is available + val scheduled = delayedActivationManager.scheduleDelayedActivation(0L) + + if (scheduled) { + Log.d(TAG, "Immediate activation scheduled to test mic availability") + updateServiceState( + delayPending = true, + delayRemainingMs = 0L + ) + } else { + Log.d(TAG, "Immediate activation not applicable, starting directly") + startMicHolding(fromDelayCompletion = false) + } + return + } + // Get configured delay val delayMs = Prefs.getScreenOnDelayMs(this) @@ -356,7 +547,7 @@ class MicLockService : Service(), MicActivationService { return } // Always on Or Never - Log.d(TAG, "Always-On or Never configured, skipping reactivation") + Log.d(TAG, "Always-On or Never configured, skipping reactivation - Delay configured = ${delayMs}ms") } } else { Log.w(TAG, "Service not running, ignoring START_HOLDING action. (Consider starting service first)") @@ -541,6 +732,12 @@ class MicLockService : Service(), MicActivationService { private fun stopMicHolding() { if (loopJob == null && !state.value.isPausedBySilence) return // Avoid redundant calls Log.i(TAG, "Screen is OFF. Pausing mic holding logic.") + + sessionSilencedBeforeScreenOff = isSilenced + + Log.d(TAG, "Stopping mic holding: wasSilenced=$isSilenced, " + + "sessionId=$lastRecordingSessionId, " + + "globalCallbackActive=${globalRecCallback != null}") stopFlag.set(true) loopJob?.cancel() loopJob = null @@ -551,7 +748,11 @@ class MicLockService : Service(), MicActivationService { recCallback = null mediaRecorderHolder?.stopRecording() mediaRecorderHolder = null - updateServiceState(deviceAddr = null, pausedByScreenOff = true) // Mark as paused by screen-off + updateServiceState( + deviceAddr = null, + pausedByScreenOff = true, + wasSilencedBeforeScreenOff = sessionSilencedBeforeScreenOff + ) updateNotification("Paused (Screen off)") } @@ -619,33 +820,37 @@ class MicLockService : Service(), MicActivationService { @RequiresApi(Build.VERSION_CODES.P) @RequiresPermission(Manifest.permission.RECORD_AUDIO) private suspend fun holdSelectedMicLoop() { + // Clear wasSilencedBeforeScreenOff flag when loop successfully starts + if (state.value.wasSilencedBeforeScreenOff) { + Log.d(TAG, "Successfully starting recording - clearing wasSilencedBeforeScreenOff flag") + updateServiceState(wasSilencedBeforeScreenOff = false) + } + + // Reset isSilenced flag when loop starts to clear stale state from previous session + isSilenced = false + + var backoffMs = 500L + while (!stopFlag.get()) { if (isSilenced) { - val cooldownDuration = 3000L - val timeSinceSilenced = markCooldownStart?.let { System.currentTimeMillis() - it } ?: 0L - Log.d(TAG, "Silenced state detected. Time since silenced: $timeSinceSilenced ms") - - if (timeSinceSilenced < cooldownDuration) { - Log.d(TAG, "Still in cooldown period. Waiting ${cooldownDuration - timeSinceSilenced} ms.") - delay(300) - continue - } - + // Check if mic is ACTUALLY still in use before waiting if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && othersRecording()) { - Log.d(TAG, "Other active recorders detected. Applying backoff. Current backoff: $backoffMs ms.") + Log.d(TAG, "Silenced state detected, others still recording. Waiting with backoff: ${backoffMs}ms") delay(backoffMs) backoffMs = (backoffMs * 2).coerceAtMost(5000L) continue } else { - Log.d(TAG, "No other active recorders. Resetting silenced state and backoff.") + // Mic is available but isSilenced is stale - clear it + Log.d(TAG, "Silenced flag is stale (no others recording), clearing it") isSilenced = false - updateServiceState(paused = false) backoffMs = 500L } } - isSilenced = false updateServiceState(paused = false) + + // Reset backoff on successful iteration + backoffMs = 500L val useMediaRecorderPref = Prefs.getUseMediaRecorder(this) @@ -656,7 +861,6 @@ class MicLockService : Service(), MicActivationService { Log.d(TAG, "User prefers MediaRecorder. Attempting MediaRecorder mode...") if (tryMediaRecorderMode()) { Prefs.setLastRecordingMethod(this, "MediaRecorder") - backoffMs = 500L primaryAttemptSuccessful = true } else { Log.w(TAG, "MediaRecorder failed. Attempting AudioRecord as fallback...") @@ -680,14 +884,12 @@ class MicLockService : Service(), MicActivationService { when (audioRecordResult) { AudioRecordResult.SUCCESS -> { Prefs.setLastRecordingMethod(this, "AudioRecord") - backoffMs = 500L primaryAttemptSuccessful = true } AudioRecordResult.BAD_ROUTE -> { Log.w(TAG, "AudioRecord landed on bad route. Attempting MediaRecorder as fallback...") if (tryMediaRecorderMode()) { Prefs.setLastRecordingMethod(this, "MediaRecorder") - backoffMs = 500L fallbackAttemptSuccessful = true } else { Log.e(TAG, "MediaRecorder fallback also failed.") @@ -770,7 +972,9 @@ class MicLockService : Service(), MicActivationService { wakeLockManager.acquire() val recordingSessionId = recorder.audioSessionId - Log.d(TAG, "AudioRecord session ID: $recordingSessionId") + lastRecordingSessionId = recordingSessionId + + Log.d(TAG, "AudioRecord session ID: $recordingSessionId (tracked for global callback)") val actualChannelCount = recorder.format.channelCount Log.d( @@ -834,15 +1038,9 @@ class MicLockService : Service(), MicActivationService { override fun onRecordingConfigChanged(configs: MutableList) { val mine = configs.firstOrNull { it.clientAudioSessionId == recordingSessionId } ?: return val silenced = mine.isClientSilenced - if (silenced && !isSilenced) { - isSilenced = true - updateServiceState(paused = true) - Log.i(TAG, "AudioRecord silenced by system (other app using mic).") - markCooldownStart = System.currentTimeMillis() - updateNotification("Paused — mic in use by another app") + handleSessionSilencing(silenced, isLoopActive = true) + if (silenced) { try { recorder.stop() } catch (_: Throwable) {} - } else if (!silenced && isSilenced) { - Log.i(TAG, "AudioRecord unsilenced; will resume (handled by main loop).") } } } @@ -902,7 +1100,6 @@ class MicLockService : Service(), MicActivationService { isSilenced = true updateServiceState(paused = true) Log.i(TAG, "MediaRecorder silenced by system (other app using mic).") - markCooldownStart = System.currentTimeMillis() updateNotification("Paused — mic in use by another app") } else if (!silenced && isSilenced) { Log.i(TAG, "MediaRecorder unsilenced; will resume (handled by main loop).") @@ -992,23 +1189,41 @@ class MicLockService : Service(), MicActivationService { pausedByScreenOff: Boolean? = null, deviceAddr: String? = null, delayPending: Boolean? = null, - delayRemainingMs: Long? = null + delayRemainingMs: Long? = null, + wasSilencedBeforeScreenOff: Boolean? = null ) { _state.update { currentState -> - currentState.copy( + val newState = currentState.copy( isRunning = running ?: currentState.isRunning, isPausedBySilence = paused ?: currentState.isPausedBySilence, isPausedByScreenOff = pausedByScreenOff ?: currentState.isPausedByScreenOff, currentDeviceAddress = deviceAddr ?: currentState.currentDeviceAddress, isDelayedActivationPending = delayPending ?: currentState.isDelayedActivationPending, delayedActivationRemainingMs = delayRemainingMs ?: currentState.delayedActivationRemainingMs, + wasSilencedBeforeScreenOff = wasSilencedBeforeScreenOff ?: currentState.wasSilencedBeforeScreenOff, ) + + enforceStateInvariants(newState) } // Request tile update whenever service state changes requestTileUpdate() } + private fun enforceStateInvariants(state: ServiceState): ServiceState { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && globalRecCallback == null) { + if (state.isPausedBySilence) { + Log.w(TAG, "Clearing isPausedBySilence - global callback not active") + return state.copy( + isPausedBySilence = false, + wasSilencedBeforeScreenOff = false + ) + } + } + + return state + } + private fun requestTileUpdate() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { try { @@ -1056,7 +1271,7 @@ class MicLockService : Service(), MicActivationService { private val _state = MutableStateFlow(ServiceState()) val state: StateFlow = _state.asStateFlow() private const val TAG = "MicLockService" - + /** * Test helper method to update service state for testing purposes. * This should only be used in test code. diff --git a/app/src/main/java/io/github/miclock/service/model/ServiceState.kt b/app/src/main/java/io/github/miclock/service/model/ServiceState.kt index b8d9f3f..23a5675 100644 --- a/app/src/main/java/io/github/miclock/service/model/ServiceState.kt +++ b/app/src/main/java/io/github/miclock/service/model/ServiceState.kt @@ -7,4 +7,5 @@ data class ServiceState( val currentDeviceAddress: String? = null, val isDelayedActivationPending: Boolean = false, val delayedActivationRemainingMs: Long = 0, + val wasSilencedBeforeScreenOff: Boolean = false, ) diff --git a/app/src/test/java/io/github/miclock/TestSupport.kt b/app/src/test/java/io/github/miclock/TestSupport.kt index b0e4ce3..b00e4e5 100644 --- a/app/src/test/java/io/github/miclock/TestSupport.kt +++ b/app/src/test/java/io/github/miclock/TestSupport.kt @@ -43,7 +43,6 @@ class TestableYieldingLogic( private set var lastNotificationText = "" private set - private var silencedTimestamp: Long? = null fun startHolding() { _state.update { it.copy(isRunning = true, isPausedBySilence = false) } @@ -52,7 +51,6 @@ class TestableYieldingLogic( fun simulateRecordingConfigChange(clientSilenced: Boolean) { if (clientSilenced && !isSilenced) { isSilenced = true - silencedTimestamp = System.currentTimeMillis() _state.update { it.copy(isPausedBySilence = true) } lastNotificationText = "Paused — mic in use by another app" } else if (!clientSilenced && isSilenced) { @@ -61,15 +59,11 @@ class TestableYieldingLogic( } fun canAttemptReacquisition(currentTime: Long): Boolean { - val cooldownComplete = silencedTimestamp?.let { currentTime - it >= 3000L } ?: false - - return cooldownComplete && !audioManager.othersRecording() + return !audioManager.othersRecording() } fun getRemainingCooldownMs(currentTime: Long): Long { - return silencedTimestamp?.let { - maxOf(0L, 3000L - (currentTime - it)) - } ?: 0L + return 0L } fun applyBackoff() { diff --git a/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt b/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt index 8e5d2ac..643a75d 100644 --- a/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt +++ b/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt @@ -35,12 +35,12 @@ class DelayedActivationManagerTest { MockitoAnnotations.openMocks(this) context = RuntimeEnvironment.getApplication() testScope = TestScope() - + // Setup default mock behavior whenever(mockService.getCurrentState()).thenReturn(ServiceState()) whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) whenever(mockService.isMicActivelyHeld()).thenReturn(false) - + delayedActivationManager = TestableDelayedActivationManager(context, mockService, testScope) } @@ -103,7 +103,10 @@ class DelayedActivationManagerTest { @Test fun testScheduleDelayedActivation_servicePausedBySilence_doesNotSchedule() = testScope.runTest { // Given: Service is paused by silence (another app using mic) - whenever(mockService.getCurrentState()).thenReturn(ServiceState(isPausedBySilence = true)) + val recentTimestamp = System.currentTimeMillis() - 5000L // 5 seconds ago (fresh) + whenever(mockService.getCurrentState()).thenReturn(ServiceState( + isPausedBySilence = true + )) // When: Attempting to schedule delay val result = delayedActivationManager.scheduleDelayedActivation(1000L) @@ -183,7 +186,7 @@ class DelayedActivationManagerTest { // Verify that the delay mechanism is working correctly val remainingTime = delayedActivationManager.getRemainingDelayMs() assertTrue("Should have remaining time close to 1000ms", remainingTime >= 950L) - + // Test that cancellation works val cancelled = delayedActivationManager.cancelDelayedActivation() assertTrue("Should successfully cancel delay", cancelled) @@ -237,21 +240,21 @@ class DelayedActivationManagerTest { whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false, isPausedBySilence = false)) whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) Prefs.setScreenOnDelayMs(context, 1000L) - + // Debug: Check individual conditions val currentState = mockService.getCurrentState() val isManuallyStoppedByUser = mockService.isManuallyStoppedByUser() val shouldRespectExisting = delayedActivationManager.shouldRespectExistingState() val prefDelay = Prefs.getScreenOnDelayMs(context) val shouldApply = delayedActivationManager.shouldApplyDelay() - + assertFalse("Service should not be running", currentState.isRunning) assertFalse("Service should not be paused by silence", currentState.isPausedBySilence) assertFalse("Service should not be manually stopped", isManuallyStoppedByUser) assertFalse("Should not respect existing state", shouldRespectExisting) assertEquals("Preference delay should be 1000ms", 1000L, prefDelay) assertTrue("Should apply delay with valid conditions", shouldApply) - + val scheduled = delayedActivationManager.scheduleDelayedActivation(1000L) assertTrue("Should successfully schedule delay", scheduled) assertTrue("Should have pending activation", delayedActivationManager.isActivationPending()) @@ -279,9 +282,12 @@ class DelayedActivationManagerTest { whenever(mockService.isMicActivelyHeld()).thenReturn(true) assertTrue("Should respect state when mic is actively held", delayedActivationManager.shouldRespectExistingState()) - // Test case 2: Service paused by silence + // Test case 2: Service paused by silence (with fresh timestamp) whenever(mockService.isMicActivelyHeld()).thenReturn(false) - whenever(mockService.getCurrentState()).thenReturn(ServiceState(isPausedBySilence = true)) + val recentTimestamp = System.currentTimeMillis() - 5000L // 5 seconds ago (fresh) + whenever(mockService.getCurrentState()).thenReturn(ServiceState( + isPausedBySilence = true, + )) assertTrue("Should respect state when paused by silence", delayedActivationManager.shouldRespectExistingState()) // Test case 3: Manually stopped by user @@ -364,10 +370,10 @@ class DelayedActivationManagerTest { // Then: Should track timestamps accurately using test scheduler time val screenOnTime = delayedActivationManager.getLastScreenOnTime() val delayStartTime = delayedActivationManager.getDelayStartTime() - + assertEquals("Screen-on time should be test scheduler time", 0L, screenOnTime) assertEquals("Delay start time should be test scheduler time", 0L, delayStartTime) - assertEquals("Screen-on time and delay start time should be equal", + assertEquals("Screen-on time and delay start time should be equal", screenOnTime, delayStartTime) } @@ -389,8 +395,8 @@ class TestableDelayedActivationManager( service: MicActivationService, private val testScope: TestScope ) : DelayedActivationManager(context, service, testScope) { - + override fun getCurrentTimeMs(): Long { return testScope.testScheduler.currentTime } -} \ No newline at end of file +} diff --git a/app/src/test/java/io/github/miclock/service/GlobalCallbackTest.kt b/app/src/test/java/io/github/miclock/service/GlobalCallbackTest.kt new file mode 100644 index 0000000..5145d3e --- /dev/null +++ b/app/src/test/java/io/github/miclock/service/GlobalCallbackTest.kt @@ -0,0 +1,143 @@ +package io.github.miclock.service + +import android.media.AudioManager +import android.media.AudioRecordingConfiguration +import io.github.miclock.service.model.ServiceState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations + +/** + * Unit tests for global callback functionality in MicLockService. + * Tests the service-level callback that persists across screen state transitions. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class GlobalCallbackTest { + + @Mock + private lateinit var mockAudioManager: AudioManager + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + } + + @Test + fun testGlobalCallback_registeredOnCreate() = runTest { + // This test validates that the global callback is registered when service is created + // In actual implementation, this would be tested with a real service instance + assertTrue("Global callback should be registered in onCreate", true) + } + + @Test + fun testGlobalCallback_unregisteredOnDestroy() = runTest { + // This test validates that the global callback is unregistered when service is destroyed + // In actual implementation, this would verify cleanup + assertTrue("Global callback should be unregistered in onDestroy", true) + } + + @Test + fun testGlobalCallback_detectsMicAvailableWhileScreenOff() = runTest { + // Simulates: Service silenced -> screen off -> other app releases mic + // Expected: isPausedBySilence should be cleared + + // Given: Initial state with service silenced and screen off + val initialState = ServiceState( + isRunning = true, + isPausedBySilence = true, + isPausedByScreenOff = true, + wasSilencedBeforeScreenOff = true + ) + + // When: Global callback detects no other apps recording (mic available) + // (In real test, would simulate callback with empty configs) + + // Then: Silence state should be cleared + val expectedState = ServiceState( + isRunning = true, + isPausedBySilence = false, + isPausedByScreenOff = true, + wasSilencedBeforeScreenOff = false + ) + + // Verify state transition + assertFalse("isPausedBySilence should be cleared", expectedState.isPausedBySilence) + assertFalse("wasSilencedBeforeScreenOff should be cleared", expectedState.wasSilencedBeforeScreenOff) + + assertTrue("isPausedByScreenOff should remain true", expectedState.isPausedByScreenOff) + } + + @Test + fun testGlobalCallback_maintainsSilenceWhileOthersRecording() = runTest { + // Simulates: Service silenced -> screen off -> others still recording + // Expected: Silence state should be maintained + + // Given: Service silenced and screen off + val silencedState = ServiceState( + isRunning = true, + isPausedBySilence = true, + isPausedByScreenOff = true, + wasSilencedBeforeScreenOff = true + ) + + // When: Global callback detects other apps still recording + // (In real test, would simulate callback with active configs) + + // Then: Silence state should be maintained + assertTrue("isPausedBySilence should remain true", silencedState.isPausedBySilence) + assertTrue("wasSilencedBeforeScreenOff should remain true", silencedState.wasSilencedBeforeScreenOff) + + } + + @Test + fun testSessionTracking_preservesAcrossScreenOff() = runTest { + // Validates that session ID and silence state are preserved when screen turns off + + // Given: Active session with ID + val sessionId = 12345 + val wasSilenced = true + + // When: Screen turns off (stopMicHolding called) + // sessionSilencedBeforeScreenOff should be set to isSilenced value + + // Then: State should preserve silence context + val stateAfterScreenOff = ServiceState( + isRunning = true, + isPausedBySilence = true, + isPausedByScreenOff = true, + wasSilencedBeforeScreenOff = wasSilenced + ) + + assertTrue("wasSilencedBeforeScreenOff should be true", stateAfterScreenOff.wasSilencedBeforeScreenOff) + assertTrue("isPausedByScreenOff should be true", stateAfterScreenOff.isPausedByScreenOff) + } + + @Test + fun testStateInvariants_clearsSilenceWhenCallbackNull() = runTest { + // Tests enforceStateInvariants: if globalRecCallback is null, clear silence state + + // Given: State with isPausedBySilence but no active callback + val invalidState = ServiceState( + isRunning = true, + isPausedBySilence = true, + wasSilencedBeforeScreenOff = true + ) + + // When: enforceStateInvariants is called (globalRecCallback == null) + // Then: Silence state should be cleared + val correctedState = ServiceState( + isRunning = true, + isPausedBySilence = false, + wasSilencedBeforeScreenOff = false + ) + + assertFalse("isPausedBySilence should be cleared", correctedState.isPausedBySilence) + assertFalse("wasSilencedBeforeScreenOff should be cleared", correctedState.wasSilencedBeforeScreenOff) + } + + +} diff --git a/app/src/test/java/io/github/miclock/service/logic/PoliteYieldingTest.kt b/app/src/test/java/io/github/miclock/service/logic/PoliteYieldingTest.kt index 07ab7ec..f2af7fc 100644 --- a/app/src/test/java/io/github/miclock/service/logic/PoliteYieldingTest.kt +++ b/app/src/test/java/io/github/miclock/service/logic/PoliteYieldingTest.kt @@ -64,17 +64,17 @@ class PoliteYieldingTest { @Test fun testCooldownPeriod_whileSilenced_waitsBeforeReacquisition() = runTest { - // Given: Service is silenced + // Given: Service is silenced and others are still recording yieldingLogic.startHolding() yieldingLogic.simulateRecordingConfigChange(clientSilenced = true) - val silencedTime = System.currentTimeMillis() + whenever(mockAudioManager.othersRecording()).thenReturn(true) - // When: Checking if ready to re-acquire during cooldown - val canReacquire = yieldingLogic.canAttemptReacquisition(silencedTime + 1000L) // 1 sec later + // When: Checking if ready to re-acquire while others still recording + val canReacquire = yieldingLogic.canAttemptReacquisition(System.currentTimeMillis()) - // Then: Should still be in cooldown (3 second minimum) + // Then: Should not be able to reacquire (others still using mic) assertFalse(canReacquire) - assertEquals(2000L, yieldingLogic.getRemainingCooldownMs(silencedTime + 1000L)) + assertEquals(0L, yieldingLogic.getRemainingCooldownMs(System.currentTimeMillis())) } @Test From 62a4daa9e06176a1998447ef7cffd901434867cf Mon Sep 17 00:00:00 2001 From: Dan Oren <94993872+Dan8Oren@users.noreply.github.com> Date: Mon, 13 Oct 2025 09:25:04 +0300 Subject: [PATCH 28/28] Revert "stuck in isPausedBySilence state bug fix (#10)" (#11) This reverts commit 043db5dc7cf347add3d895ad15683378bfdf35f9. --- .idea/androidTestResultsUserPreferences.xml | 13 - .idea/deploymentTargetSelector.xml | 3 - .../service/SilenceStateIntegrationTest.kt | 127 -------- .../service/DelayedActivationManager.kt | 85 +++--- .../github/miclock/service/MicLockService.kt | 283 +++--------------- .../miclock/service/model/ServiceState.kt | 1 - .../java/io/github/miclock/TestSupport.kt | 10 +- .../service/DelayedActivationManagerTest.kt | 32 +- .../miclock/service/GlobalCallbackTest.kt | 143 --------- .../service/logic/PoliteYieldingTest.kt | 12 +- 10 files changed, 103 insertions(+), 606 deletions(-) delete mode 100644 app/src/androidTest/java/io/github/miclock/service/SilenceStateIntegrationTest.kt delete mode 100644 app/src/test/java/io/github/miclock/service/GlobalCallbackTest.kt diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index a6b4304..7ccb027 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -68,19 +68,6 @@ - - - - - - - diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b02a85c..79720ae 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -13,9 +13,6 @@ - - \ No newline at end of file diff --git a/app/src/androidTest/java/io/github/miclock/service/SilenceStateIntegrationTest.kt b/app/src/androidTest/java/io/github/miclock/service/SilenceStateIntegrationTest.kt deleted file mode 100644 index b42cbb1..0000000 --- a/app/src/androidTest/java/io/github/miclock/service/SilenceStateIntegrationTest.kt +++ /dev/null @@ -1,127 +0,0 @@ -package io.github.miclock.service - -import android.content.Intent -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import io.github.miclock.service.model.ServiceState -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Integration tests for silence state recovery across screen state transitions. - * Tests the complete end-to-end flow of the hybrid solution. - */ -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(AndroidJUnit4::class) -class SilenceStateIntegrationTest { - - @Test - fun testEndToEnd_silenceStateRecovery() = runTest { - // This integration test validates the complete flow: - // 1. Service running normally - // 2. Another app uses mic (service paused by silence) - // 3. Screen turns off - // 4. Other app releases mic (while screen still off) - // 5. Screen turns on - // 6. Service should resume normally (not blocked by stale silence) - - val context = InstrumentationRegistry.getInstrumentation().targetContext - - // Phase 1: Service running normally - val initialState = ServiceState( - isRunning = true, - isPausedBySilence = false, - isPausedByScreenOff = false - ) - assertTrue("Service should be running", initialState.isRunning) - assertFalse("Service should not be paused by silence", initialState.isPausedBySilence) - - // Phase 2: Another app uses mic - service paused - val silencedState = ServiceState( - isRunning = true, - isPausedBySilence = true, - isPausedByScreenOff = false, - wasSilencedBeforeScreenOff = false - ) - assertTrue("Service should be paused by silence", silencedState.isPausedBySilence) - - - // Phase 3: Screen turns off - val screenOffState = ServiceState( - isRunning = true, - isPausedBySilence = true, - isPausedByScreenOff = true, - wasSilencedBeforeScreenOff = true - ) - assertTrue("Service should be paused by screen-off", screenOffState.isPausedByScreenOff) - assertTrue("wasSilencedBeforeScreenOff should be true", screenOffState.wasSilencedBeforeScreenOff) - - // Phase 4: Other app releases mic (global callback detects this) - val micAvailableState = ServiceState( - isRunning = true, - isPausedBySilence = false, - isPausedByScreenOff = true, - wasSilencedBeforeScreenOff = false - ) - assertFalse("isPausedBySilence should be cleared", micAvailableState.isPausedBySilence) - assertFalse("wasSilencedBeforeScreenOff should be cleared", micAvailableState.wasSilencedBeforeScreenOff) - - - // Phase 5: Screen turns on - service should resume - val resumedState = ServiceState( - isRunning = true, - isPausedBySilence = false, - isPausedByScreenOff = false, - wasSilencedBeforeScreenOff = false - ) - assertTrue("Service should be running", resumedState.isRunning) - assertFalse("Service should not be paused by silence", resumedState.isPausedBySilence) - assertFalse("Service should not be paused by screen-off", resumedState.isPausedByScreenOff) - } - - @Test - fun testLongRecording_notInterrupted() = runTest { - // Tests that user's long recording session is not interrupted - // Scenario: User recording for 2+ hours with screen off - - // Given: Service paused by silence for extended period - val longRecordingState = ServiceState( - isRunning = true, - isPausedBySilence = true, - isPausedByScreenOff = true, - wasSilencedBeforeScreenOff = true - ) - - // When: Global callback continues to detect active recording - // (In real scenario, configs would show other app still recording) - - // Then: Service should remain paused (respecting user's recording) - assertTrue("Service should remain paused by silence", longRecordingState.isPausedBySilence) - assertTrue("wasSilencedBeforeScreenOff should be true", longRecordingState.wasSilencedBeforeScreenOff) - // Note: The global callback would detect active recording and maintain the silence state - } - - @Test - fun testScreenStateTransitions_preserveContext() = runTest { - // Tests that rapid screen on/off transitions preserve state context - - // Given: Service in various states - val states = listOf( - ServiceState(isRunning = true, isPausedByScreenOff = false), - ServiceState(isRunning = true, isPausedByScreenOff = true, wasSilencedBeforeScreenOff = false), - ServiceState(isRunning = true, isPausedByScreenOff = false, isPausedBySilence = false) - ) - - // When: Rapid screen transitions occur - // Then: Each state transition should preserve relevant context - states.forEach { state -> - if (state.isPausedByScreenOff) { - // Screen is off - context should be preserved - assertNotNull("State should be preserved", state.wasSilencedBeforeScreenOff) - } - } - } -} diff --git a/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt b/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt index 0606558..05743a2 100644 --- a/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt +++ b/app/src/main/java/io/github/miclock/service/DelayedActivationManager.kt @@ -33,7 +33,7 @@ open class DelayedActivationManager( /** * Schedules a delayed activation after the specified delay period. * Cancels any existing pending activation before scheduling new one. - * + * * @param delayMs delay in milliseconds before activation * @return true if delay was scheduled, false if conditions don't allow delay */ @@ -42,31 +42,31 @@ open class DelayedActivationManager( fun scheduleDelayedActivation(delayMs: Long): Boolean { val currentTime = getCurrentTimeMs() lastScreenOnTime.set(currentTime) - + Log.d(TAG, "Scheduling delayed activation with ${delayMs}ms delay") - + // Cancel any existing delay operation cancelDelayedActivation() - + // Validate that delay should be applied if (!shouldApplyDelay()) { Log.d(TAG, "Delay not applicable due to service state conditions") return false } - + // Start new delay operation delayStartTime.set(currentTime) isActivationPending.set(true) - + delayJob = scope.launch { try { Log.d(TAG, "Starting delay countdown: ${delayMs}ms") delay(delayMs) - + // Check if this delay operation is still valid (not superseded by newer screen events) if (isActivationPending.get() && delayStartTime.get() == currentTime) { Log.d(TAG, "Delay completed, activating microphone") - + // Validate service state one more time before activation if (shouldRespectExistingState()) { Log.d(TAG, "Service state changed during delay, respecting current state") @@ -74,10 +74,10 @@ open class DelayedActivationManager( handleServiceStateConflict() return@launch } - + // Clear pending state before activation isActivationPending.set(false) - + // Activate microphone functionality // Pass fromDelayCompletion=true to skip startForeground (already started in handleStartHolding) service.startMicHolding(fromDelayCompletion = true) @@ -93,51 +93,51 @@ open class DelayedActivationManager( isActivationPending.set(false) } } - + return true } /** * Cancels any pending delayed activation operation. - * + * * @return true if there was a pending operation that was cancelled, false otherwise */ fun cancelDelayedActivation(): Boolean { val wasPending = isActivationPending.get() - + if (wasPending) { Log.d(TAG, "Cancelling delayed activation") } - + delayJob?.cancel() delayJob = null isActivationPending.set(false) delayStartTime.set(0L) - + return wasPending } /** * Checks if there is currently a delayed activation pending. - * + * * @return true if activation is pending, false otherwise */ fun isActivationPending(): Boolean = isActivationPending.get() /** * Gets the remaining time in milliseconds for the current delay operation. - * + * * @return remaining milliseconds, or 0 if no delay is pending */ fun getRemainingDelayMs(): Long { if (!isActivationPending.get()) return 0L - + val startTime = delayStartTime.get() val delayMs = Prefs.getScreenOnDelayMs(context) val currentTime = getCurrentTimeMs() val elapsed = currentTime - startTime val remaining = (delayMs - elapsed).coerceAtLeast(0L) - + return remaining } @@ -149,12 +149,12 @@ open class DelayedActivationManager( /** * Determines if the current service state should prevent delayed activation. * This method checks for conditions that should be respected (manual stops, active sessions, paused states). - * + * * @return true if existing state should be respected (delay should not proceed), false otherwise */ fun shouldRespectExistingState(): Boolean { val currentState = service.getCurrentState() - + return when { // Check if mic is actively being held (not just service running) // This allows delay when service is running but paused (screen off scenario) @@ -162,26 +162,25 @@ open class DelayedActivationManager( Log.d(TAG, "Mic is actively being held, respecting existing state") true } - + // Service is paused by silence (another app using mic) - respect the pause currentState.isPausedBySilence -> { - // The global callback ensures isPausedBySilence is always current Log.d(TAG, "Service paused by silence, respecting pause state") true } - + // Service is paused by screen-off - this is normal and delay should be applied currentState.isPausedByScreenOff -> { Log.d(TAG, "Service paused by screen-off, delay can be applied") false } - + // Check if service was manually stopped by user service.isManuallyStoppedByUser() -> { Log.d(TAG, "Service manually stopped by user, respecting manual stop") true } - + else -> false } } @@ -192,26 +191,26 @@ open class DelayedActivationManager( */ fun handleServiceStateConflict() { val currentState = service.getCurrentState() - + Log.d(TAG, "Handling service state conflict - isMicActivelyHeld: ${service.isMicActivelyHeld()}, isPausedBySilence: ${currentState.isPausedBySilence}, isPausedByScreenOff: ${currentState.isPausedByScreenOff}") - + when { service.isMicActivelyHeld() -> { Log.d(TAG, "Mic is actively being held, no action needed") } - + currentState.isPausedBySilence -> { Log.d(TAG, "Service is paused by another app, maintaining pause state") } - + currentState.isPausedByScreenOff -> { Log.d(TAG, "Service is paused by screen-off, this is expected") } - + service.isManuallyStoppedByUser() -> { Log.d(TAG, "Service was manually stopped, not overriding user choice") } - + else -> { Log.d(TAG, "No specific conflict resolution needed") } @@ -220,7 +219,7 @@ open class DelayedActivationManager( /** * Checks if the app is configured to never re-enable after screen-off. - * + * * @return true if never re-enable mode is active, false otherwise */ fun isNeverReactivateMode(): Boolean { @@ -229,7 +228,7 @@ open class DelayedActivationManager( /** * Checks if the app is configured to always keep mic on (ignore screen state). - * + * * @return true if always-on mode is active, false otherwise */ fun isAlwaysOnMode(): Boolean { @@ -238,37 +237,37 @@ open class DelayedActivationManager( /** * Determines if delay should be applied based on current conditions. - * + * * @return true if delay should be applied, false otherwise */ fun shouldApplyDelay(): Boolean { val delayMs = Prefs.getScreenOnDelayMs(context) - + return when { // Never re-enable mode delayMs == Prefs.NEVER_REACTIVATE_VALUE -> { Log.d(TAG, "Never re-enable mode active, blocking activation") false } - + // Always-on mode (should not apply delay, but should activate immediately) delayMs == Prefs.ALWAYS_KEEP_ON_VALUE -> { Log.d(TAG, "Always-on mode active, no delay needed") false } - + // Delay is disabled (0ms) delayMs <= 0L -> { Log.d(TAG, "Delay disabled (${delayMs}ms)") false } - + // Service state should be respected shouldRespectExistingState() -> { Log.d(TAG, "Existing service state should be respected") false } - + else -> { Log.d(TAG, "Delay should be applied (${delayMs}ms)") true @@ -279,14 +278,14 @@ open class DelayedActivationManager( /** * Gets the timestamp of the last screen-on event. * Used for race condition detection and debugging. - * + * * @return timestamp in milliseconds */ fun getLastScreenOnTime(): Long = lastScreenOnTime.get() /** * Gets the timestamp when the current delay operation started. - * + * * @return timestamp in milliseconds, or 0 if no delay is active */ fun getDelayStartTime(): Long = delayStartTime.get() @@ -299,4 +298,4 @@ open class DelayedActivationManager( Log.d(TAG, "Cleaning up DelayedActivationManager") cancelDelayedActivation() } -} +} \ No newline at end of file diff --git a/app/src/main/java/io/github/miclock/service/MicLockService.kt b/app/src/main/java/io/github/miclock/service/MicLockService.kt index 869cb85..1776d53 100644 --- a/app/src/main/java/io/github/miclock/service/MicLockService.kt +++ b/app/src/main/java/io/github/miclock/service/MicLockService.kt @@ -79,12 +79,10 @@ class MicLockService : Service(), MicActivationService { // Silencing state (per run) @Volatile private var isSilenced: Boolean = false + private var markCooldownStart: Long? = null + private var backoffMs: Long = 500L private var recCallback: AudioManager.AudioRecordingCallback? = null - private var globalRecCallback: AudioManager.AudioRecordingCallback? = null - private var lastRecordingSessionId: Int? = null - private var sessionSilencedBeforeScreenOff: Boolean = false - // MediaRecorder fallback private var mediaRecorderHolder: MediaRecorderHolder? = null @@ -119,141 +117,6 @@ class MicLockService : Service(), MicActivationService { } registerReceiver(screenStateReceiver, filter) Log.d(TAG, "ScreenStateReceiver registered dynamically") - - // Register global callback on service creation - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - registerGlobalCallback() - } - } - - @RequiresApi(Build.VERSION_CODES.Q) - private fun registerGlobalCallback() { - if (globalRecCallback != null) { - Log.w(TAG, "Global callback already registered, skipping") - return - } - - globalRecCallback = object : AudioManager.AudioRecordingCallback() { - override fun onRecordingConfigChanged(configs: MutableList) { - handleGlobalRecordingChange(configs) - } - } - - try { - audioManager.registerAudioRecordingCallback( - globalRecCallback!!, - Handler(Looper.getMainLooper()) - ) - Log.d(TAG, "Global recording callback registered") - } catch (e: Exception) { - Log.e(TAG, "Failed to register global callback: ${e.message}", e) - globalRecCallback = null - } - } - - @RequiresApi(Build.VERSION_CODES.Q) - private fun unregisterGlobalCallback() { - globalRecCallback?.let { - try { - audioManager.unregisterAudioRecordingCallback(it) - Log.d(TAG, "Global recording callback unregistered") - } catch (e: Exception) { - Log.w(TAG, "Error unregistering global callback: ${e.message}") - } - } - globalRecCallback = null - } - - @RequiresApi(Build.VERSION_CODES.Q) - private fun handleGlobalRecordingChange(configs: MutableList) { - val currentSessionId = lastRecordingSessionId - val currentState = state.value - - Log.d(TAG, "Global callback triggered: ${configs.size} configs, sessionId=$currentSessionId, " + - "loopActive=${loopJob?.isActive}, isPausedBySilence=${currentState.isPausedBySilence}") - - when { - loopJob?.isActive == true && currentSessionId != null -> { - handleActiveSessionRecordingChange(configs, currentSessionId) - } - sessionSilencedBeforeScreenOff && currentSessionId != null -> { - handleInactiveSessionRecordingChange(configs) - } - else -> { - Log.d(TAG, "No relevant session context, ignoring recording change") - } - } - } - - @RequiresApi(Build.VERSION_CODES.Q) - private fun handleActiveSessionRecordingChange( - configs: MutableList, - sessionId: Int - ) { - val ourSession = configs.firstOrNull { - it.clientAudioSessionId == sessionId - } - - if (ourSession != null) { - val silenced = ourSession.isClientSilenced - handleSessionSilencing(silenced, isLoopActive = true) - } else { - Log.d(TAG, "Our session (ID: $sessionId) not found in active configurations") - } - } - - @RequiresApi(Build.VERSION_CODES.M) - private fun handleInactiveSessionRecordingChange( - configs: MutableList - ) { - val othersStillRecording = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - configs.any { !it.isClientSilenced } - } else { - configs.isNotEmpty() - } - - if (!othersStillRecording) { - Log.d(TAG, "Mic became available while screen off - clearing stuck silence state") - sessionSilencedBeforeScreenOff = false - updateServiceState( - paused = false, - wasSilencedBeforeScreenOff = false - ) - } else { - Log.d(TAG, "Other apps still recording, maintaining silence state") - } - } - - private fun handleSessionSilencing(silenced: Boolean, isLoopActive: Boolean) { - if (silenced && !isSilenced) { - isSilenced = true - sessionSilencedBeforeScreenOff = true - - updateServiceState( - paused = true, - wasSilencedBeforeScreenOff = true - ) - - Log.i(TAG, "Recording silenced by system (other app using mic)") - - if (isLoopActive) { - updateNotification("Paused — mic in use by another app") - } - } else if (!silenced && isSilenced) { - isSilenced = false - sessionSilencedBeforeScreenOff = false - - updateServiceState( - paused = false, - wasSilencedBeforeScreenOff = false - ) - - if (isLoopActive) { - Log.i(TAG, "Mic available again - loop will resume") - } else { - Log.i(TAG, "Mic became available while screen off") - } - } } private fun createRestartNotification() { @@ -301,13 +164,6 @@ class MicLockService : Service(), MicActivationService { scope.cancel() wakeLockManager.release() - - // Unregister global callback on service destruction - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - unregisterGlobalCallback() - } - - // Clean up session-specific callback try { recCallback?.let { audioManager.unregisterAudioRecordingCallback(it) } } catch (_: Throwable) {} recCallback = null mediaRecorderHolder?.stopRecording() @@ -436,53 +292,6 @@ class MicLockService : Service(), MicActivationService { Log.i(TAG, "Received ACTION_START_HOLDING, isRunning: ${state.value.isRunning}, timestamp: $eventTimestamp") if (state.value.isRunning) { - // Check if we were silenced before screen-off - if so, attempt immediate activation to test mic availability - if (state.value.wasSilencedBeforeScreenOff) { - Log.d(TAG, "Was silenced before screen-off - attempting immediate activation to test mic availability") - - // Start foreground service if needed - if (canStartForegroundService()) { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - NOTIF_ID, - buildNotification("Testing mic availability…"), - ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, - ) - } else { - startForeground(NOTIF_ID, buildNotification("Testing mic availability…")) - } - serviceHealthy = true - Log.d(TAG, "Foreground service started for mic availability test") - } catch (e: Exception) { - Log.w(TAG, "Could not start foreground service: ${e.message}") - serviceHealthy = false - updateServiceState(running = false) - createRestartNotification() - stopSelf() - return - } - } else { - Log.d(TAG, "Delaying foreground service start due to boot restrictions") - scheduleDelayedForegroundStart() - } - - // Schedule immediate activation (0L delay) to test if mic is available - val scheduled = delayedActivationManager.scheduleDelayedActivation(0L) - - if (scheduled) { - Log.d(TAG, "Immediate activation scheduled to test mic availability") - updateServiceState( - delayPending = true, - delayRemainingMs = 0L - ) - } else { - Log.d(TAG, "Immediate activation not applicable, starting directly") - startMicHolding(fromDelayCompletion = false) - } - return - } - // Get configured delay val delayMs = Prefs.getScreenOnDelayMs(this) @@ -547,7 +356,7 @@ class MicLockService : Service(), MicActivationService { return } // Always on Or Never - Log.d(TAG, "Always-On or Never configured, skipping reactivation - Delay configured = ${delayMs}ms") + Log.d(TAG, "Always-On or Never configured, skipping reactivation") } } else { Log.w(TAG, "Service not running, ignoring START_HOLDING action. (Consider starting service first)") @@ -732,12 +541,6 @@ class MicLockService : Service(), MicActivationService { private fun stopMicHolding() { if (loopJob == null && !state.value.isPausedBySilence) return // Avoid redundant calls Log.i(TAG, "Screen is OFF. Pausing mic holding logic.") - - sessionSilencedBeforeScreenOff = isSilenced - - Log.d(TAG, "Stopping mic holding: wasSilenced=$isSilenced, " + - "sessionId=$lastRecordingSessionId, " + - "globalCallbackActive=${globalRecCallback != null}") stopFlag.set(true) loopJob?.cancel() loopJob = null @@ -748,11 +551,7 @@ class MicLockService : Service(), MicActivationService { recCallback = null mediaRecorderHolder?.stopRecording() mediaRecorderHolder = null - updateServiceState( - deviceAddr = null, - pausedByScreenOff = true, - wasSilencedBeforeScreenOff = sessionSilencedBeforeScreenOff - ) + updateServiceState(deviceAddr = null, pausedByScreenOff = true) // Mark as paused by screen-off updateNotification("Paused (Screen off)") } @@ -820,37 +619,33 @@ class MicLockService : Service(), MicActivationService { @RequiresApi(Build.VERSION_CODES.P) @RequiresPermission(Manifest.permission.RECORD_AUDIO) private suspend fun holdSelectedMicLoop() { - // Clear wasSilencedBeforeScreenOff flag when loop successfully starts - if (state.value.wasSilencedBeforeScreenOff) { - Log.d(TAG, "Successfully starting recording - clearing wasSilencedBeforeScreenOff flag") - updateServiceState(wasSilencedBeforeScreenOff = false) - } - - // Reset isSilenced flag when loop starts to clear stale state from previous session - isSilenced = false - - var backoffMs = 500L - while (!stopFlag.get()) { if (isSilenced) { - // Check if mic is ACTUALLY still in use before waiting + val cooldownDuration = 3000L + val timeSinceSilenced = markCooldownStart?.let { System.currentTimeMillis() - it } ?: 0L + Log.d(TAG, "Silenced state detected. Time since silenced: $timeSinceSilenced ms") + + if (timeSinceSilenced < cooldownDuration) { + Log.d(TAG, "Still in cooldown period. Waiting ${cooldownDuration - timeSinceSilenced} ms.") + delay(300) + continue + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && othersRecording()) { - Log.d(TAG, "Silenced state detected, others still recording. Waiting with backoff: ${backoffMs}ms") + Log.d(TAG, "Other active recorders detected. Applying backoff. Current backoff: $backoffMs ms.") delay(backoffMs) backoffMs = (backoffMs * 2).coerceAtMost(5000L) continue } else { - // Mic is available but isSilenced is stale - clear it - Log.d(TAG, "Silenced flag is stale (no others recording), clearing it") + Log.d(TAG, "No other active recorders. Resetting silenced state and backoff.") isSilenced = false + updateServiceState(paused = false) backoffMs = 500L } } + isSilenced = false updateServiceState(paused = false) - - // Reset backoff on successful iteration - backoffMs = 500L val useMediaRecorderPref = Prefs.getUseMediaRecorder(this) @@ -861,6 +656,7 @@ class MicLockService : Service(), MicActivationService { Log.d(TAG, "User prefers MediaRecorder. Attempting MediaRecorder mode...") if (tryMediaRecorderMode()) { Prefs.setLastRecordingMethod(this, "MediaRecorder") + backoffMs = 500L primaryAttemptSuccessful = true } else { Log.w(TAG, "MediaRecorder failed. Attempting AudioRecord as fallback...") @@ -884,12 +680,14 @@ class MicLockService : Service(), MicActivationService { when (audioRecordResult) { AudioRecordResult.SUCCESS -> { Prefs.setLastRecordingMethod(this, "AudioRecord") + backoffMs = 500L primaryAttemptSuccessful = true } AudioRecordResult.BAD_ROUTE -> { Log.w(TAG, "AudioRecord landed on bad route. Attempting MediaRecorder as fallback...") if (tryMediaRecorderMode()) { Prefs.setLastRecordingMethod(this, "MediaRecorder") + backoffMs = 500L fallbackAttemptSuccessful = true } else { Log.e(TAG, "MediaRecorder fallback also failed.") @@ -972,9 +770,7 @@ class MicLockService : Service(), MicActivationService { wakeLockManager.acquire() val recordingSessionId = recorder.audioSessionId - lastRecordingSessionId = recordingSessionId - - Log.d(TAG, "AudioRecord session ID: $recordingSessionId (tracked for global callback)") + Log.d(TAG, "AudioRecord session ID: $recordingSessionId") val actualChannelCount = recorder.format.channelCount Log.d( @@ -1038,9 +834,15 @@ class MicLockService : Service(), MicActivationService { override fun onRecordingConfigChanged(configs: MutableList) { val mine = configs.firstOrNull { it.clientAudioSessionId == recordingSessionId } ?: return val silenced = mine.isClientSilenced - handleSessionSilencing(silenced, isLoopActive = true) - if (silenced) { + if (silenced && !isSilenced) { + isSilenced = true + updateServiceState(paused = true) + Log.i(TAG, "AudioRecord silenced by system (other app using mic).") + markCooldownStart = System.currentTimeMillis() + updateNotification("Paused — mic in use by another app") try { recorder.stop() } catch (_: Throwable) {} + } else if (!silenced && isSilenced) { + Log.i(TAG, "AudioRecord unsilenced; will resume (handled by main loop).") } } } @@ -1100,6 +902,7 @@ class MicLockService : Service(), MicActivationService { isSilenced = true updateServiceState(paused = true) Log.i(TAG, "MediaRecorder silenced by system (other app using mic).") + markCooldownStart = System.currentTimeMillis() updateNotification("Paused — mic in use by another app") } else if (!silenced && isSilenced) { Log.i(TAG, "MediaRecorder unsilenced; will resume (handled by main loop).") @@ -1189,41 +992,23 @@ class MicLockService : Service(), MicActivationService { pausedByScreenOff: Boolean? = null, deviceAddr: String? = null, delayPending: Boolean? = null, - delayRemainingMs: Long? = null, - wasSilencedBeforeScreenOff: Boolean? = null + delayRemainingMs: Long? = null ) { _state.update { currentState -> - val newState = currentState.copy( + currentState.copy( isRunning = running ?: currentState.isRunning, isPausedBySilence = paused ?: currentState.isPausedBySilence, isPausedByScreenOff = pausedByScreenOff ?: currentState.isPausedByScreenOff, currentDeviceAddress = deviceAddr ?: currentState.currentDeviceAddress, isDelayedActivationPending = delayPending ?: currentState.isDelayedActivationPending, delayedActivationRemainingMs = delayRemainingMs ?: currentState.delayedActivationRemainingMs, - wasSilencedBeforeScreenOff = wasSilencedBeforeScreenOff ?: currentState.wasSilencedBeforeScreenOff, ) - - enforceStateInvariants(newState) } // Request tile update whenever service state changes requestTileUpdate() } - private fun enforceStateInvariants(state: ServiceState): ServiceState { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && globalRecCallback == null) { - if (state.isPausedBySilence) { - Log.w(TAG, "Clearing isPausedBySilence - global callback not active") - return state.copy( - isPausedBySilence = false, - wasSilencedBeforeScreenOff = false - ) - } - } - - return state - } - private fun requestTileUpdate() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { try { @@ -1271,7 +1056,7 @@ class MicLockService : Service(), MicActivationService { private val _state = MutableStateFlow(ServiceState()) val state: StateFlow = _state.asStateFlow() private const val TAG = "MicLockService" - + /** * Test helper method to update service state for testing purposes. * This should only be used in test code. diff --git a/app/src/main/java/io/github/miclock/service/model/ServiceState.kt b/app/src/main/java/io/github/miclock/service/model/ServiceState.kt index 23a5675..b8d9f3f 100644 --- a/app/src/main/java/io/github/miclock/service/model/ServiceState.kt +++ b/app/src/main/java/io/github/miclock/service/model/ServiceState.kt @@ -7,5 +7,4 @@ data class ServiceState( val currentDeviceAddress: String? = null, val isDelayedActivationPending: Boolean = false, val delayedActivationRemainingMs: Long = 0, - val wasSilencedBeforeScreenOff: Boolean = false, ) diff --git a/app/src/test/java/io/github/miclock/TestSupport.kt b/app/src/test/java/io/github/miclock/TestSupport.kt index b00e4e5..b0e4ce3 100644 --- a/app/src/test/java/io/github/miclock/TestSupport.kt +++ b/app/src/test/java/io/github/miclock/TestSupport.kt @@ -43,6 +43,7 @@ class TestableYieldingLogic( private set var lastNotificationText = "" private set + private var silencedTimestamp: Long? = null fun startHolding() { _state.update { it.copy(isRunning = true, isPausedBySilence = false) } @@ -51,6 +52,7 @@ class TestableYieldingLogic( fun simulateRecordingConfigChange(clientSilenced: Boolean) { if (clientSilenced && !isSilenced) { isSilenced = true + silencedTimestamp = System.currentTimeMillis() _state.update { it.copy(isPausedBySilence = true) } lastNotificationText = "Paused — mic in use by another app" } else if (!clientSilenced && isSilenced) { @@ -59,11 +61,15 @@ class TestableYieldingLogic( } fun canAttemptReacquisition(currentTime: Long): Boolean { - return !audioManager.othersRecording() + val cooldownComplete = silencedTimestamp?.let { currentTime - it >= 3000L } ?: false + + return cooldownComplete && !audioManager.othersRecording() } fun getRemainingCooldownMs(currentTime: Long): Long { - return 0L + return silencedTimestamp?.let { + maxOf(0L, 3000L - (currentTime - it)) + } ?: 0L } fun applyBackoff() { diff --git a/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt b/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt index 643a75d..8e5d2ac 100644 --- a/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt +++ b/app/src/test/java/io/github/miclock/service/DelayedActivationManagerTest.kt @@ -35,12 +35,12 @@ class DelayedActivationManagerTest { MockitoAnnotations.openMocks(this) context = RuntimeEnvironment.getApplication() testScope = TestScope() - + // Setup default mock behavior whenever(mockService.getCurrentState()).thenReturn(ServiceState()) whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) whenever(mockService.isMicActivelyHeld()).thenReturn(false) - + delayedActivationManager = TestableDelayedActivationManager(context, mockService, testScope) } @@ -103,10 +103,7 @@ class DelayedActivationManagerTest { @Test fun testScheduleDelayedActivation_servicePausedBySilence_doesNotSchedule() = testScope.runTest { // Given: Service is paused by silence (another app using mic) - val recentTimestamp = System.currentTimeMillis() - 5000L // 5 seconds ago (fresh) - whenever(mockService.getCurrentState()).thenReturn(ServiceState( - isPausedBySilence = true - )) + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isPausedBySilence = true)) // When: Attempting to schedule delay val result = delayedActivationManager.scheduleDelayedActivation(1000L) @@ -186,7 +183,7 @@ class DelayedActivationManagerTest { // Verify that the delay mechanism is working correctly val remainingTime = delayedActivationManager.getRemainingDelayMs() assertTrue("Should have remaining time close to 1000ms", remainingTime >= 950L) - + // Test that cancellation works val cancelled = delayedActivationManager.cancelDelayedActivation() assertTrue("Should successfully cancel delay", cancelled) @@ -240,21 +237,21 @@ class DelayedActivationManagerTest { whenever(mockService.getCurrentState()).thenReturn(ServiceState(isRunning = false, isPausedBySilence = false)) whenever(mockService.isManuallyStoppedByUser()).thenReturn(false) Prefs.setScreenOnDelayMs(context, 1000L) - + // Debug: Check individual conditions val currentState = mockService.getCurrentState() val isManuallyStoppedByUser = mockService.isManuallyStoppedByUser() val shouldRespectExisting = delayedActivationManager.shouldRespectExistingState() val prefDelay = Prefs.getScreenOnDelayMs(context) val shouldApply = delayedActivationManager.shouldApplyDelay() - + assertFalse("Service should not be running", currentState.isRunning) assertFalse("Service should not be paused by silence", currentState.isPausedBySilence) assertFalse("Service should not be manually stopped", isManuallyStoppedByUser) assertFalse("Should not respect existing state", shouldRespectExisting) assertEquals("Preference delay should be 1000ms", 1000L, prefDelay) assertTrue("Should apply delay with valid conditions", shouldApply) - + val scheduled = delayedActivationManager.scheduleDelayedActivation(1000L) assertTrue("Should successfully schedule delay", scheduled) assertTrue("Should have pending activation", delayedActivationManager.isActivationPending()) @@ -282,12 +279,9 @@ class DelayedActivationManagerTest { whenever(mockService.isMicActivelyHeld()).thenReturn(true) assertTrue("Should respect state when mic is actively held", delayedActivationManager.shouldRespectExistingState()) - // Test case 2: Service paused by silence (with fresh timestamp) + // Test case 2: Service paused by silence whenever(mockService.isMicActivelyHeld()).thenReturn(false) - val recentTimestamp = System.currentTimeMillis() - 5000L // 5 seconds ago (fresh) - whenever(mockService.getCurrentState()).thenReturn(ServiceState( - isPausedBySilence = true, - )) + whenever(mockService.getCurrentState()).thenReturn(ServiceState(isPausedBySilence = true)) assertTrue("Should respect state when paused by silence", delayedActivationManager.shouldRespectExistingState()) // Test case 3: Manually stopped by user @@ -370,10 +364,10 @@ class DelayedActivationManagerTest { // Then: Should track timestamps accurately using test scheduler time val screenOnTime = delayedActivationManager.getLastScreenOnTime() val delayStartTime = delayedActivationManager.getDelayStartTime() - + assertEquals("Screen-on time should be test scheduler time", 0L, screenOnTime) assertEquals("Delay start time should be test scheduler time", 0L, delayStartTime) - assertEquals("Screen-on time and delay start time should be equal", + assertEquals("Screen-on time and delay start time should be equal", screenOnTime, delayStartTime) } @@ -395,8 +389,8 @@ class TestableDelayedActivationManager( service: MicActivationService, private val testScope: TestScope ) : DelayedActivationManager(context, service, testScope) { - + override fun getCurrentTimeMs(): Long { return testScope.testScheduler.currentTime } -} +} \ No newline at end of file diff --git a/app/src/test/java/io/github/miclock/service/GlobalCallbackTest.kt b/app/src/test/java/io/github/miclock/service/GlobalCallbackTest.kt deleted file mode 100644 index 5145d3e..0000000 --- a/app/src/test/java/io/github/miclock/service/GlobalCallbackTest.kt +++ /dev/null @@ -1,143 +0,0 @@ -package io.github.miclock.service - -import android.media.AudioManager -import android.media.AudioRecordingConfiguration -import io.github.miclock.service.model.ServiceState -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.mockito.Mock -import org.mockito.MockitoAnnotations - -/** - * Unit tests for global callback functionality in MicLockService. - * Tests the service-level callback that persists across screen state transitions. - */ -@OptIn(ExperimentalCoroutinesApi::class) -class GlobalCallbackTest { - - @Mock - private lateinit var mockAudioManager: AudioManager - - @Before - fun setUp() { - MockitoAnnotations.openMocks(this) - } - - @Test - fun testGlobalCallback_registeredOnCreate() = runTest { - // This test validates that the global callback is registered when service is created - // In actual implementation, this would be tested with a real service instance - assertTrue("Global callback should be registered in onCreate", true) - } - - @Test - fun testGlobalCallback_unregisteredOnDestroy() = runTest { - // This test validates that the global callback is unregistered when service is destroyed - // In actual implementation, this would verify cleanup - assertTrue("Global callback should be unregistered in onDestroy", true) - } - - @Test - fun testGlobalCallback_detectsMicAvailableWhileScreenOff() = runTest { - // Simulates: Service silenced -> screen off -> other app releases mic - // Expected: isPausedBySilence should be cleared - - // Given: Initial state with service silenced and screen off - val initialState = ServiceState( - isRunning = true, - isPausedBySilence = true, - isPausedByScreenOff = true, - wasSilencedBeforeScreenOff = true - ) - - // When: Global callback detects no other apps recording (mic available) - // (In real test, would simulate callback with empty configs) - - // Then: Silence state should be cleared - val expectedState = ServiceState( - isRunning = true, - isPausedBySilence = false, - isPausedByScreenOff = true, - wasSilencedBeforeScreenOff = false - ) - - // Verify state transition - assertFalse("isPausedBySilence should be cleared", expectedState.isPausedBySilence) - assertFalse("wasSilencedBeforeScreenOff should be cleared", expectedState.wasSilencedBeforeScreenOff) - - assertTrue("isPausedByScreenOff should remain true", expectedState.isPausedByScreenOff) - } - - @Test - fun testGlobalCallback_maintainsSilenceWhileOthersRecording() = runTest { - // Simulates: Service silenced -> screen off -> others still recording - // Expected: Silence state should be maintained - - // Given: Service silenced and screen off - val silencedState = ServiceState( - isRunning = true, - isPausedBySilence = true, - isPausedByScreenOff = true, - wasSilencedBeforeScreenOff = true - ) - - // When: Global callback detects other apps still recording - // (In real test, would simulate callback with active configs) - - // Then: Silence state should be maintained - assertTrue("isPausedBySilence should remain true", silencedState.isPausedBySilence) - assertTrue("wasSilencedBeforeScreenOff should remain true", silencedState.wasSilencedBeforeScreenOff) - - } - - @Test - fun testSessionTracking_preservesAcrossScreenOff() = runTest { - // Validates that session ID and silence state are preserved when screen turns off - - // Given: Active session with ID - val sessionId = 12345 - val wasSilenced = true - - // When: Screen turns off (stopMicHolding called) - // sessionSilencedBeforeScreenOff should be set to isSilenced value - - // Then: State should preserve silence context - val stateAfterScreenOff = ServiceState( - isRunning = true, - isPausedBySilence = true, - isPausedByScreenOff = true, - wasSilencedBeforeScreenOff = wasSilenced - ) - - assertTrue("wasSilencedBeforeScreenOff should be true", stateAfterScreenOff.wasSilencedBeforeScreenOff) - assertTrue("isPausedByScreenOff should be true", stateAfterScreenOff.isPausedByScreenOff) - } - - @Test - fun testStateInvariants_clearsSilenceWhenCallbackNull() = runTest { - // Tests enforceStateInvariants: if globalRecCallback is null, clear silence state - - // Given: State with isPausedBySilence but no active callback - val invalidState = ServiceState( - isRunning = true, - isPausedBySilence = true, - wasSilencedBeforeScreenOff = true - ) - - // When: enforceStateInvariants is called (globalRecCallback == null) - // Then: Silence state should be cleared - val correctedState = ServiceState( - isRunning = true, - isPausedBySilence = false, - wasSilencedBeforeScreenOff = false - ) - - assertFalse("isPausedBySilence should be cleared", correctedState.isPausedBySilence) - assertFalse("wasSilencedBeforeScreenOff should be cleared", correctedState.wasSilencedBeforeScreenOff) - } - - -} diff --git a/app/src/test/java/io/github/miclock/service/logic/PoliteYieldingTest.kt b/app/src/test/java/io/github/miclock/service/logic/PoliteYieldingTest.kt index f2af7fc..07ab7ec 100644 --- a/app/src/test/java/io/github/miclock/service/logic/PoliteYieldingTest.kt +++ b/app/src/test/java/io/github/miclock/service/logic/PoliteYieldingTest.kt @@ -64,17 +64,17 @@ class PoliteYieldingTest { @Test fun testCooldownPeriod_whileSilenced_waitsBeforeReacquisition() = runTest { - // Given: Service is silenced and others are still recording + // Given: Service is silenced yieldingLogic.startHolding() yieldingLogic.simulateRecordingConfigChange(clientSilenced = true) - whenever(mockAudioManager.othersRecording()).thenReturn(true) + val silencedTime = System.currentTimeMillis() - // When: Checking if ready to re-acquire while others still recording - val canReacquire = yieldingLogic.canAttemptReacquisition(System.currentTimeMillis()) + // When: Checking if ready to re-acquire during cooldown + val canReacquire = yieldingLogic.canAttemptReacquisition(silencedTime + 1000L) // 1 sec later - // Then: Should not be able to reacquire (others still using mic) + // Then: Should still be in cooldown (3 second minimum) assertFalse(canReacquire) - assertEquals(0L, yieldingLogic.getRemainingCooldownMs(System.currentTimeMillis())) + assertEquals(2000L, yieldingLogic.getRemainingCooldownMs(silencedTime + 1000L)) } @Test