From 0fca31be9e8bba73781e2308497e441c3a98785e Mon Sep 17 00:00:00 2001 From: Aows Date: Sun, 13 Sep 2026 00:02:38 -0600 Subject: [PATCH 1/9] Add answer-and-hang-up blocking Co-Authored-By: Claude Code --- app/src/main/AndroidManifest.xml | 8 + .../callshield/data/SpamRepository.kt | 2 + .../service/AnswerHangUpController.kt | 340 ++++++++++++++++++ .../service/AnswerHangUpReceiver.kt | 21 ++ .../service/CallShieldScreeningService.kt | 86 ++++- .../service/AnswerHangUpControllerTest.kt | 216 +++++++++++ ...lShieldScreeningServiceAnswerHangUpTest.kt | 27 ++ 7 files changed, 698 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpController.kt create mode 100644 app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpReceiver.kt create mode 100644 app/src/test/java/com/sysadmindoc/callshield/service/AnswerHangUpControllerTest.kt create mode 100644 app/src/test/java/com/sysadmindoc/callshield/service/CallShieldScreeningServiceAnswerHangUpTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f6c578953..eb4ae39b5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -94,6 +94,14 @@ android:name=".service.CallerIdOverlayService" android:exported="false" /> + + + + + + Unit, + ) + + fun cancelAll() + } + + private data class PendingCall( + val number: String, + val armedAt: Long, + val delayMillis: Long, + var phase: Phase, + var acceptedAt: Long? = null, + var hangUpDeadline: Long? = null, + ) + + private val lock = Any() + private val timerToken = Any() + private val mainHandler = Handler(Looper.getMainLooper()) + + private val productionScheduler = + object : Scheduler { + override fun postDelayed( + delayMillis: Long, + task: () -> Unit, + ) { + mainHandler.postAtTime( + Runnable { task() }, + timerToken, + SystemClock.uptimeMillis() + delayMillis, + ) + } + + override fun cancelAll() { + mainHandler.removeCallbacksAndMessages(timerToken) + } + } + + private var applicationContext: Context? = null + private var clock: () -> Long = SystemClock::elapsedRealtime + private var scheduler: Scheduler = productionScheduler + private var callControl: CallControl = productionCallControl() + private var pendingCall: PendingCall? = null + + /** Stores the context used by the production telecom implementation. */ + internal fun configure(context: Context) { + applicationContext = context.applicationContext + } + + fun clampDelaySeconds(value: Int?): Int = value?.coerceIn(MIN_DELAY_SECONDS, MAX_DELAY_SECONDS) ?: DEFAULT_DELAY_SECONDS + + /** Arms this process for the next matching PHONE_STATE broadcast. */ + fun arm( + rawNumber: String?, + delaySeconds: Int, + ) { + synchronized(lock) { + clearLocked() + pendingCall = + PendingCall( + number = digitsOnly(rawNumber), + armedAt = clock(), + delayMillis = clampDelaySeconds(delaySeconds) * 1_000L, + phase = Phase.ARMED, + ) + scheduleArmedExpiryLocked() + } + } + + fun disarm() { + synchronized(lock) { + clearLocked() + } + } + + /** A new screening callback invalidates a stale pending, unaccepted call. */ + fun onIncomingScreeningStarted() { + synchronized(lock) { + if (pendingCall?.phase == Phase.ARMED) { + clearLocked() + } + } + } + + fun hasPendingCall(): Boolean = synchronized(lock) { pendingCall != null } + + fun isBusy(): Boolean = + synchronized(lock) { + pendingCall?.phase == Phase.ANSWERING || pendingCall?.phase == Phase.HANGING_UP + } + + /** Processes the protected PHONE_STATE broadcast. */ + fun onPhoneState( + state: String?, + incomingNumber: String?, + ) { + synchronized(lock) { + val pending = pendingCall ?: return + when (state) { + TelephonyManager.EXTRA_STATE_RINGING -> handleRingingLocked(pending, incomingNumber) + TelephonyManager.EXTRA_STATE_OFFHOOK -> handleOffhookLocked(pending, incomingNumber) + TelephonyManager.EXTRA_STATE_IDLE -> clearLocked() + } + } + } + + private fun handleRingingLocked( + pending: PendingCall, + incomingNumber: String?, + ) { + if (pending.phase == Phase.HANGING_UP) { + // A second incoming call must never be ended by a timer belonging to the first. + clearLocked() + return + } + if (pending.phase != Phase.ARMED) return + if (!isWithinArmWindow(pending)) { + clearLocked() + return + } + if (!numbersMatch(pending.number, digitsOnly(incomingNumber))) return + + pending.phase = Phase.ANSWERING + pending.acceptedAt = clock() + try { + callControl.acceptRingingCall() + } catch (exception: SecurityException) { + Log.w(TAG, "Unable to answer screened call", exception) + clearLocked() + return + } + schedulePollLocked() + } + + private fun handleOffhookLocked( + pending: PendingCall, + incomingNumber: String?, + ) { + if (pending.phase == Phase.ANSWERING && + (digitsOnly(incomingNumber).isEmpty() || numbersMatch(pending.number, digitsOnly(incomingNumber))) + ) { + enterHangingUpLocked(pending) + } + } + + private fun enterHangingUpLocked(pending: PendingCall) { + if (pending.phase != Phase.ANSWERING) return + pending.phase = Phase.HANGING_UP + pending.hangUpDeadline = clock() + pending.delayMillis + schedulePollLocked() + } + + private fun scheduleArmedExpiryLocked() { + scheduler.cancelAll() + scheduler.postDelayed(ARM_EXPIRY_MS) { + synchronized(lock) { + val pending = pendingCall + if (pending?.phase == Phase.ARMED && !isWithinArmWindow(pending)) { + clearLocked() + } + } + } + } + + private fun schedulePollLocked() { + scheduler.cancelAll() + scheduler.postDelayed(POLL_INTERVAL_MS) { pollCallState() } + } + + private fun pollCallState() { + synchronized(lock) { + val pending = pendingCall ?: return + val callState = + try { + callControl.currentCallState() + } catch (exception: SecurityException) { + Log.w(TAG, "Unable to read screened call state", exception) + clearLocked() + return + } + when (pending.phase) { + Phase.ANSWERING -> { + when (callState) { + TelephonyManager.CALL_STATE_OFFHOOK -> { + enterHangingUpLocked(pending) + } + + TelephonyManager.CALL_STATE_IDLE -> { + clearLocked() + } + + else -> { + val acceptedAt = pending.acceptedAt ?: pending.armedAt + if (clock() - acceptedAt >= ANSWER_TIMEOUT_MS) { + clearLocked() + } + } + } + } + + Phase.HANGING_UP -> { + when (callState) { + TelephonyManager.CALL_STATE_IDLE, + TelephonyManager.CALL_STATE_RINGING, + -> { + clearLocked() + } + + else -> { + if (clock() >= (pending.hangUpDeadline ?: Long.MAX_VALUE)) { + try { + callControl.endCall() + } catch (exception: SecurityException) { + Log.w(TAG, "Unable to hang up screened call", exception) + } + clearLocked() + } + } + } + } + + Phase.NONE, + Phase.ARMED, + -> { + Unit + } + } + if (pendingCall?.phase == Phase.ANSWERING || pendingCall?.phase == Phase.HANGING_UP) { + schedulePollLocked() + } + } + } + + private fun isWithinArmWindow(pending: PendingCall): Boolean = clock() - pending.armedAt < ARM_EXPIRY_MS + + private fun clearLocked() { + scheduler.cancelAll() + pendingCall = null + } + + private fun productionCallControl(): CallControl = + object : CallControl { + @Suppress("DEPRECATION") + override fun acceptRingingCall() { + telecomManager().acceptRingingCall() + } + + @Suppress("DEPRECATION") + override fun endCall(): Boolean = telecomManager().endCall() + + @Suppress("DEPRECATION") + override fun currentCallState(): Int = applicationContext?.getSystemService(TelephonyManager::class.java)?.callState ?: -1 + + private fun telecomManager(): TelecomManager = + requireNotNull(applicationContext) { "AnswerHangUpController is not configured" } + .getSystemService(TelecomManager::class.java) + } + + private fun digitsOnly(value: String?): String = value.orEmpty().filter(Char::isDigit) + + private fun numbersMatch( + first: String, + second: String, + ): Boolean { + if (first.isEmpty() && second.isEmpty()) return true + if (first.isEmpty() || second.isEmpty()) return false + if (first == second) return true + val shorter = if (first.length <= second.length) first else second + val longer = if (first.length <= second.length) second else first + return shorter.length >= 7 && longer.endsWith(shorter) + } + + internal fun setTestDependencies( + clock: () -> Long, + callControl: CallControl, + scheduler: Scheduler, + ) { + synchronized(lock) { + clearLocked() + this.clock = clock + this.callControl = callControl + this.scheduler = scheduler + } + } + + internal fun resetForTest() { + synchronized(lock) { + clearLocked() + applicationContext = null + clock = SystemClock::elapsedRealtime + callControl = productionCallControl() + scheduler = productionScheduler + } + } + + internal fun phaseForTest(): Phase = synchronized(lock) { pendingCall?.phase ?: Phase.NONE } +} diff --git a/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpReceiver.kt b/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpReceiver.kt new file mode 100644 index 000000000..52dc9401f --- /dev/null +++ b/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpReceiver.kt @@ -0,0 +1,21 @@ +package com.sysadmindoc.callshield.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.telephony.TelephonyManager + +/** Forwards protected PHONE_STATE broadcasts to the pending answer-and-hang-up sequence. */ +class AnswerHangUpReceiver : BroadcastReceiver() { + @Suppress("DEPRECATION") + override fun onReceive( + context: Context, + intent: Intent, + ) { + if (!AnswerHangUpController.hasPendingCall()) return + AnswerHangUpController.onPhoneState( + state = intent.getStringExtra(TelephonyManager.EXTRA_STATE), + incomingNumber = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER), + ) + } +} diff --git a/app/src/main/java/com/sysadmindoc/callshield/service/CallShieldScreeningService.kt b/app/src/main/java/com/sysadmindoc/callshield/service/CallShieldScreeningService.kt index c905743cc..ac7314ff6 100644 --- a/app/src/main/java/com/sysadmindoc/callshield/service/CallShieldScreeningService.kt +++ b/app/src/main/java/com/sysadmindoc/callshield/service/CallShieldScreeningService.kt @@ -118,6 +118,8 @@ class CallShieldScreeningService : CallScreeningService() { } } + AnswerHangUpController.onIncomingScreeningStarted() + // Run on the process-wide appScope instead of a service-scoped one. // CallScreeningService is frequently unbound moments after we reply, // and a service-scoped coroutine could be cancelled mid-decision — @@ -364,8 +366,50 @@ class CallShieldScreeningService : CallScreeningService() { val categoryAction = CategoryCallPolicy.parseMatchSource(reason)?.action ?: CategoryCallAction.INHERIT - val response = buildBlockResponse(prefs, confidence, categoryAction) - responseGate.respond(response) + val silenceWins = + shouldSilence( + silentVoicemailEnabled = prefs[SpamRepository.KEY_SILENT_VOICEMAIL] ?: false, + autoMuteLowConfidenceEnabled = prefs[SpamRepository.KEY_AUTOMUTE_LOW_CONFIDENCE] ?: false, + confidence = confidence, + categoryAction = categoryAction, + ) + val answerHangUpEnabled = prefs[SpamRepository.KEY_ANSWER_HANG_UP] ?: false + val answerAndHangUp = + if (answerHangUpEnabled && !silenceWins) { + val permissionsGranted = hasAnswerHangUpPermissions() + shouldAnswerAndHangUp( + enabled = true, + silenceWins = false, + permissionsGranted = permissionsGranted, + busy = !permissionsGranted || isAnswerHangUpBusy(), + ) + } else { + false + } + val response = + if (answerAndHangUp) { + buildSilenceResponse() + } else { + // Keep the existing response construction untouched for every setting-off path. + buildBlockResponse(prefs, confidence, categoryAction) + } + if (answerAndHangUp) { + AnswerHangUpController.configure(applicationContext) + AnswerHangUpController.arm( + rawNumber = number, + delaySeconds = + AnswerHangUpController.clampDelaySeconds( + prefs[SpamRepository.KEY_HANG_UP_DELAY_SECONDS], + ), + ) + if (responseGate.hasResponded) { + AnswerHangUpController.disarm() + } else { + responseGate.respond(response) + } + } else { + responseGate.respond(response) + } applicationScope.launch { try { @@ -458,6 +502,37 @@ class CallShieldScreeningService : CallScreeningService() { } } + private fun buildSilenceResponse(): CallResponse = + CallResponse + .Builder() + .setSilenceCall(true) + .setSkipCallLog(false) + .setSkipNotification(false) + .build() + + private fun hasAnswerHangUpPermissions(): Boolean = + listOf( + android.Manifest.permission.ANSWER_PHONE_CALLS, + android.Manifest.permission.READ_PHONE_STATE, + android.Manifest.permission.READ_CALL_LOG, + ).all { permission -> + checkSelfPermission(permission) == android.content.pm.PackageManager.PERMISSION_GRANTED + } + + private fun isAnswerHangUpBusy(): Boolean = + try { + @Suppress("DEPRECATION") + val callState = getSystemService(android.telephony.TelephonyManager::class.java)?.callState + val telephonyBusy = callState == android.telephony.TelephonyManager.CALL_STATE_OFFHOOK + val audioMode = getSystemService(android.media.AudioManager::class.java)?.mode + val audioBusy = + audioMode == android.media.AudioManager.MODE_IN_CALL || + audioMode == android.media.AudioManager.MODE_IN_COMMUNICATION + telephonyBusy || audioBusy || AnswerHangUpController.isBusy() + } catch (_: SecurityException) { + true + } + private fun handleDirectBootCall( callDetails: Call.Details, responseGate: ScreeningResponseGate, @@ -609,6 +684,13 @@ class CallShieldScreeningService : CallScreeningService() { } } + internal fun shouldAnswerAndHangUp( + enabled: Boolean, + silenceWins: Boolean, + permissionsGranted: Boolean, + busy: Boolean, + ): Boolean = enabled && !silenceWins && permissionsGranted && !busy + fun shouldSuppressAfterCallFeedback(matchSource: String): Boolean = matchSource == "emergency_callback" } diff --git a/app/src/test/java/com/sysadmindoc/callshield/service/AnswerHangUpControllerTest.kt b/app/src/test/java/com/sysadmindoc/callshield/service/AnswerHangUpControllerTest.kt new file mode 100644 index 000000000..edda903e5 --- /dev/null +++ b/app/src/test/java/com/sysadmindoc/callshield/service/AnswerHangUpControllerTest.kt @@ -0,0 +1,216 @@ +package com.sysadmindoc.callshield.service + +import android.telephony.TelephonyManager +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class AnswerHangUpControllerTest { + private lateinit var clock: FakeClock + private lateinit var callControl: FakeCallControl + private lateinit var scheduler: FakeScheduler + + @Before + fun setUp() { + clock = FakeClock() + callControl = FakeCallControl() + scheduler = FakeScheduler(clock) + AnswerHangUpController.setTestDependencies(clock::now, callControl, scheduler) + } + + @After + fun tearDown() { + AnswerHangUpController.resetForTest() + } + + @Test + fun `matching ringing accepts once and duplicate ringing is ignored`() { + AnswerHangUpController.arm("5551234567", 1) + + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") + + assertEquals(1, callControl.acceptCalls) + assertEquals(AnswerHangUpController.Phase.ANSWERING, AnswerHangUpController.phaseForTest()) + } + + @Test + fun `different ringing number does not answer`() { + AnswerHangUpController.arm("5551234567", 1) + + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5557654321") + + assertEquals(0, callControl.acceptCalls) + assertTrue(AnswerHangUpController.hasPendingCall()) + } + + @Test + fun `ringing after arm expiry does not answer`() { + AnswerHangUpController.arm("5551234567", 1) + clock.advanceBy(5_000) + + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") + + assertEquals(0, callControl.acceptCalls) + assertFalse(AnswerHangUpController.hasPendingCall()) + } + + @Test + fun `offhook ends exactly once at the delay deadline`() { + AnswerHangUpController.arm("5551234567", 2) + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_OFFHOOK, "5551234567") + callControl.currentState = TelephonyManager.CALL_STATE_OFFHOOK + + repeat(7) { scheduler.advanceBy(250) } + assertEquals(0, callControl.endCalls) + + scheduler.advanceBy(250) + assertEquals(1, callControl.endCalls) + scheduler.advanceBy(1_000) + assertEquals(1, callControl.endCalls) + } + + @Test + fun `idle before deadline never ends the call`() { + AnswerHangUpController.arm("5551234567", 1) + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_OFFHOOK, "5551234567") + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_IDLE, null) + callControl.currentState = TelephonyManager.CALL_STATE_IDLE + + scheduler.advanceBy(2_000) + + assertEquals(0, callControl.endCalls) + assertFalse(AnswerHangUpController.hasPendingCall()) + } + + @Test + fun `ringing during hangup aborts without ending either call`() { + AnswerHangUpController.arm("5551234567", 1) + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_OFFHOOK, "5551234567") + + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5557654321") + scheduler.advanceBy(2_000) + + assertEquals(0, callControl.endCalls) + assertFalse(AnswerHangUpController.hasPendingCall()) + } + + @Test + fun `polled offhook without broadcast still hangs up`() { + AnswerHangUpController.arm("5551234567", 1) + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") + callControl.currentState = TelephonyManager.CALL_STATE_OFFHOOK + + scheduler.advanceBy(250) + repeat(4) { scheduler.advanceBy(250) } + + assertEquals(1, callControl.endCalls) + } + + @Test + fun `withheld number accepts null ringing number`() { + AnswerHangUpController.arm(null, 1) + + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, null) + + assertEquals(1, callControl.acceptCalls) + } + + @Test + fun `north american prefix variants match on digits`() { + AnswerHangUpController.arm("+1 (555) 123-4567", 1) + + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") + + assertEquals(1, callControl.acceptCalls) + } + + @Test + fun `new incoming screening clears an unaccepted arm`() { + AnswerHangUpController.arm("5551234567", 1) + + AnswerHangUpController.onIncomingScreeningStarted() + + assertFalse(AnswerHangUpController.hasPendingCall()) + } + + @Test + fun `delay clamp enforces supported range`() { + assertEquals(1, AnswerHangUpController.clampDelaySeconds(0)) + assertEquals(10, AnswerHangUpController.clampDelaySeconds(99)) + assertEquals(1, AnswerHangUpController.clampDelaySeconds(null)) + } + + private class FakeClock { + private var currentMillis = 0L + + fun now(): Long = currentMillis + + fun advanceBy(millis: Long) { + currentMillis += millis + } + } + + private class FakeCallControl : AnswerHangUpController.CallControl { + var acceptCalls = 0 + var endCalls = 0 + var currentState = TelephonyManager.CALL_STATE_RINGING + + override fun acceptRingingCall() { + acceptCalls += 1 + } + + override fun endCall(): Boolean { + endCalls += 1 + return true + } + + override fun currentCallState(): Int = currentState + } + + private class FakeScheduler( + private val clock: FakeClock, + ) : AnswerHangUpController.Scheduler { + private data class ScheduledTask( + val dueAt: Long, + val task: () -> Unit, + ) + + private val scheduled = mutableListOf() + + override fun postDelayed( + delayMillis: Long, + task: () -> Unit, + ) { + scheduled += ScheduledTask(clock.now() + delayMillis, task) + } + + override fun cancelAll() { + scheduled.clear() + } + + fun advanceBy(millis: Long) { + clock.advanceBy(millis) + runDueTasks() + } + + private fun runDueTasks() { + while (true) { + val next = scheduled.filter { it.dueAt <= clock.now() }.minByOrNull { it.dueAt } ?: return + scheduled.remove(next) + next.task() + } + } + } +} diff --git a/app/src/test/java/com/sysadmindoc/callshield/service/CallShieldScreeningServiceAnswerHangUpTest.kt b/app/src/test/java/com/sysadmindoc/callshield/service/CallShieldScreeningServiceAnswerHangUpTest.kt new file mode 100644 index 000000000..5c04b80c8 --- /dev/null +++ b/app/src/test/java/com/sysadmindoc/callshield/service/CallShieldScreeningServiceAnswerHangUpTest.kt @@ -0,0 +1,27 @@ +package com.sysadmindoc.callshield.service + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CallShieldScreeningServiceAnswerHangUpTest { + @Test + fun `answer and hang up decision has full truth table`() { + listOf(false, true).forEach { enabled -> + listOf(false, true).forEach { silenceWins -> + listOf(false, true).forEach { permissionsGranted -> + listOf(false, true).forEach { busy -> + assertEquals( + enabled && !silenceWins && permissionsGranted && !busy, + CallShieldScreeningService.shouldAnswerAndHangUp( + enabled = enabled, + silenceWins = silenceWins, + permissionsGranted = permissionsGranted, + busy = busy, + ), + ) + } + } + } + } + } +} From 9e4e1af0df959109779cb9d396e6da41b76743be Mon Sep 17 00:00:00 2001 From: Aows Date: Sun, 13 Sep 2026 00:09:23 -0600 Subject: [PATCH 2/9] Add answer hang up settings UI Co-Authored-By: Claude Opus 5 --- .../callshield/data/BackupRestore.kt | 9 +++ .../callshield/data/SpamRepository.kt | 6 ++ .../data/repository/SettingsRepository.kt | 14 +++++ .../callshield/ui/MainViewModel.kt | 12 ++++ .../ui/screens/settings/SettingsScreen.kt | 56 +++++++++++++++++++ app/src/main/res/values/strings.xml | 7 +++ .../data/BackupRestoreAnswerHangUpTest.kt | 54 ++++++++++++++++++ 7 files changed, 158 insertions(+) create mode 100644 app/src/test/java/com/sysadmindoc/callshield/data/BackupRestoreAnswerHangUpTest.kt diff --git a/app/src/main/java/com/sysadmindoc/callshield/data/BackupRestore.kt b/app/src/main/java/com/sysadmindoc/callshield/data/BackupRestore.kt index deb108567..c22a2b54a 100644 --- a/app/src/main/java/com/sysadmindoc/callshield/data/BackupRestore.kt +++ b/app/src/main/java/com/sysadmindoc/callshield/data/BackupRestore.kt @@ -18,6 +18,7 @@ import com.sysadmindoc.callshield.data.model.RestoreJournal import com.sysadmindoc.callshield.data.model.SmsKeywordRule import com.sysadmindoc.callshield.data.model.WildcardRule import com.sysadmindoc.callshield.domain.model.BlockReasonCode +import com.sysadmindoc.callshield.service.AnswerHangUpController import com.sysadmindoc.callshield.util.filterAsciiDigits import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first @@ -183,6 +184,8 @@ object BackupRestore { val rcsFilterEnabled: Boolean = true, val postCallScreenEnabled: Boolean = false, val silentVoicemailEnabled: Boolean = false, + val answerHangUpEnabled: Boolean = false, + val hangUpDelaySeconds: Int = AnswerHangUpController.DEFAULT_DELAY_SECONDS, val pushAlertEnabled: Boolean = true, val pushAlertDisabledPackages: List = emptyList(), val regionBlockEnabled: Boolean = false, @@ -1148,6 +1151,9 @@ object BackupRestore { rcsFilterEnabled = this[SpamRepository.KEY_RCS_FILTER] ?: true, postCallScreenEnabled = this[SpamRepository.KEY_POST_CALL_SCREEN] ?: false, silentVoicemailEnabled = this[SpamRepository.KEY_SILENT_VOICEMAIL] ?: false, + answerHangUpEnabled = this[SpamRepository.KEY_ANSWER_HANG_UP] ?: false, + hangUpDelaySeconds = + AnswerHangUpController.clampDelaySeconds(this[SpamRepository.KEY_HANG_UP_DELAY_SECONDS]), pushAlertEnabled = this[SpamRepository.KEY_PUSH_ALERT] ?: true, pushAlertDisabledPackages = (this[SpamRepository.KEY_PUSH_ALERT_DISABLED] ?: emptySet()).sorted(), regionBlockEnabled = this[SpamRepository.KEY_REGION_BLOCK] ?: false, @@ -1184,6 +1190,7 @@ object BackupRestore { timeBlockEndHour = sanitizeScheduleHour(timeBlockEndHour), frequencyThreshold = frequencyThreshold.coerceIn(1, 25), cleanupDays = cleanupDays.coerceIn(1, 365), + hangUpDelaySeconds = AnswerHangUpController.clampDelaySeconds(hangUpDelaySeconds), pushAlertDisabledPackages = pushAlertDisabledPackages .map { it.trim() } @@ -1248,6 +1255,8 @@ object BackupRestore { preferences[SpamRepository.KEY_RCS_FILTER] = rcsFilterEnabled preferences[SpamRepository.KEY_POST_CALL_SCREEN] = postCallScreenEnabled preferences[SpamRepository.KEY_SILENT_VOICEMAIL] = silentVoicemailEnabled + preferences[SpamRepository.KEY_ANSWER_HANG_UP] = answerHangUpEnabled + preferences[SpamRepository.KEY_HANG_UP_DELAY_SECONDS] = hangUpDelaySeconds preferences[SpamRepository.KEY_PUSH_ALERT] = pushAlertEnabled if (pushAlertDisabledPackages.isEmpty()) { preferences.remove(SpamRepository.KEY_PUSH_ALERT_DISABLED) diff --git a/app/src/main/java/com/sysadmindoc/callshield/data/SpamRepository.kt b/app/src/main/java/com/sysadmindoc/callshield/data/SpamRepository.kt index 837806f26..b9d5ac9b4 100644 --- a/app/src/main/java/com/sysadmindoc/callshield/data/SpamRepository.kt +++ b/app/src/main/java/com/sysadmindoc/callshield/data/SpamRepository.kt @@ -369,6 +369,8 @@ class SpamRepository( val postCallScreenEnabled: Flow = settingsRepository.postCallScreenEnabled val notificationScreeningPackages: Flow> = settingsRepository.notificationScreeningPackages val silentVoicemailEnabled: Flow = settingsRepository.silentVoicemailEnabled + val answerHangUpEnabled: Flow = settingsRepository.answerHangUpEnabled + val hangUpDelaySeconds: Flow = settingsRepository.hangUpDelaySeconds val pushAlertEnabled: Flow = settingsRepository.pushAlertEnabled val pushAlertDisabledPackages: Flow> = settingsRepository.pushAlertDisabledPackages internal val smsMessageCapabilityStatus: Flow = settingsRepository.smsMessageCapabilityStatus @@ -461,6 +463,10 @@ class SpamRepository( suspend fun setSilentVoicemail(enabled: Boolean) = settingsRepository.setSilentVoicemail(enabled) + suspend fun setAnswerHangUpEnabled(enabled: Boolean) = settingsRepository.setAnswerHangUpEnabled(enabled) + + suspend fun setHangUpDelaySeconds(seconds: Int) = settingsRepository.setHangUpDelaySeconds(seconds) + suspend fun setPushAlert(enabled: Boolean) = settingsRepository.setPushAlert(enabled) suspend fun togglePushAlertPackage( diff --git a/app/src/main/java/com/sysadmindoc/callshield/data/repository/SettingsRepository.kt b/app/src/main/java/com/sysadmindoc/callshield/data/repository/SettingsRepository.kt index 0695c5c81..87dd259cc 100644 --- a/app/src/main/java/com/sysadmindoc/callshield/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sysadmindoc/callshield/data/repository/SettingsRepository.kt @@ -21,6 +21,7 @@ import com.sysadmindoc.callshield.data.NotificationScreeningSources import com.sysadmindoc.callshield.data.RegionRules import com.sysadmindoc.callshield.data.SpamRepository import com.sysadmindoc.callshield.data.model.ExternalBlocklistSubscription +import com.sysadmindoc.callshield.service.AnswerHangUpController import com.sysadmindoc.callshield.data.model.HotDataHealth import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -155,6 +156,12 @@ class SettingsRepository( } val silentVoicemailEnabled: Flow = dataStore.data.map { it[SpamRepository.KEY_SILENT_VOICEMAIL] ?: false } + val answerHangUpEnabled: Flow = + dataStore.data.map { it[SpamRepository.KEY_ANSWER_HANG_UP] ?: false } + val hangUpDelaySeconds: Flow = + dataStore.data.map { + AnswerHangUpController.clampDelaySeconds(it[SpamRepository.KEY_HANG_UP_DELAY_SECONDS]) + } val pushAlertEnabled: Flow = dataStore.data.map { it[SpamRepository.KEY_PUSH_ALERT] ?: true } val pushAlertDisabledPackages: Flow> = dataStore.data.map { it[SpamRepository.KEY_PUSH_ALERT_DISABLED] ?: emptySet() } @@ -286,6 +293,13 @@ class SettingsRepository( suspend fun setSilentVoicemail(enabled: Boolean) = dataStore.edit { it[SpamRepository.KEY_SILENT_VOICEMAIL] = enabled } + suspend fun setAnswerHangUpEnabled(enabled: Boolean) = dataStore.edit { it[SpamRepository.KEY_ANSWER_HANG_UP] = enabled } + + suspend fun setHangUpDelaySeconds(seconds: Int) = + dataStore.edit { + it[SpamRepository.KEY_HANG_UP_DELAY_SECONDS] = AnswerHangUpController.clampDelaySeconds(seconds) + } + suspend fun setPushAlert(enabled: Boolean) = dataStore.edit { it[SpamRepository.KEY_PUSH_ALERT] = enabled } internal suspend fun recordMessageCapability(status: MessageCapabilityStatus) = diff --git a/app/src/main/java/com/sysadmindoc/callshield/ui/MainViewModel.kt b/app/src/main/java/com/sysadmindoc/callshield/ui/MainViewModel.kt index c5b4e2b7c..d23ff5239 100644 --- a/app/src/main/java/com/sysadmindoc/callshield/ui/MainViewModel.kt +++ b/app/src/main/java/com/sysadmindoc/callshield/ui/MainViewModel.kt @@ -49,6 +49,7 @@ import com.sysadmindoc.callshield.domain.model.SpamCheckResult import com.sysadmindoc.callshield.domain.usecase.ExportLogsUseCase import com.sysadmindoc.callshield.domain.usecase.ManageBlocklistUseCase import com.sysadmindoc.callshield.domain.usecase.SyncDatabaseUseCase +import com.sysadmindoc.callshield.service.AnswerHangUpController import com.sysadmindoc.callshield.service.AppUpdateWorker import com.sysadmindoc.callshield.service.CallLogScanner import com.sysadmindoc.callshield.service.NotificationHelper @@ -457,6 +458,13 @@ class MainViewModel com.sysadmindoc.callshield.data.NotificationScreeningSources.defaultEnabledPackages, ) val silentVoicemailEnabled = repo.silentVoicemailEnabled.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val answerHangUpEnabled = repo.answerHangUpEnabled.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val hangUpDelaySeconds = + repo.hangUpDelaySeconds.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + AnswerHangUpController.DEFAULT_DELAY_SECONDS, + ) val pushAlertEnabled = repo.pushAlertEnabled.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) val pushAlertDisabledPackages = repo.pushAlertDisabledPackages @@ -1090,6 +1098,10 @@ class MainViewModel fun setSilentVoicemail(v: Boolean) = viewModelScope.launch { repo.setSilentVoicemail(v) } + fun setAnswerHangUpEnabled(v: Boolean) = viewModelScope.launch { repo.setAnswerHangUpEnabled(v) } + + fun setHangUpDelaySeconds(seconds: Int) = viewModelScope.launch { repo.setHangUpDelaySeconds(seconds) } + fun setPushAlert(v: Boolean) = viewModelScope.launch { repo.setPushAlert(v) } /** Per-package opt-in/out for the A3 allowlist editor. */ diff --git a/app/src/main/java/com/sysadmindoc/callshield/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/sysadmindoc/callshield/ui/screens/settings/SettingsScreen.kt index 1e4c07eaf..e82755a18 100644 --- a/app/src/main/java/com/sysadmindoc/callshield/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/sysadmindoc/callshield/ui/screens/settings/SettingsScreen.kt @@ -57,6 +57,7 @@ import com.sysadmindoc.callshield.BuildConfig import com.sysadmindoc.callshield.R import com.sysadmindoc.callshield.data.BackupRestore import com.sysadmindoc.callshield.data.CallCategory +import com.sysadmindoc.callshield.service.AnswerHangUpController import com.sysadmindoc.callshield.data.CategoryCallAction import com.sysadmindoc.callshield.data.MessageCapabilityState import com.sysadmindoc.callshield.data.MessageCapabilityStatus @@ -151,6 +152,8 @@ fun SettingsScreen(viewModel: MainViewModel) { val postCallScreen by viewModel.postCallScreenEnabled.collectAsStateWithLifecycle() val notificationScreeningPackages by viewModel.notificationScreeningPackages.collectAsStateWithLifecycle() val silentVoicemail by viewModel.silentVoicemailEnabled.collectAsStateWithLifecycle() + val answerHangUpEnabled by viewModel.answerHangUpEnabled.collectAsStateWithLifecycle() + val hangUpDelaySeconds by viewModel.hangUpDelaySeconds.collectAsStateWithLifecycle() val pushAlertEnabled by viewModel.pushAlertEnabled.collectAsStateWithLifecycle() val pushAlertDisabledPackages by viewModel.pushAlertDisabledPackages.collectAsStateWithLifecycle() val externalBlocklists by viewModel.externalBlocklistSubscriptions.collectAsStateWithLifecycle() @@ -202,6 +205,22 @@ fun SettingsScreen(viewModel: MainViewModel) { smsEnabled = blockSms, ) } + val answerHangUpPermissions = + remember { + listOf( + Manifest.permission.ANSWER_PHONE_CALLS, + Manifest.permission.READ_PHONE_STATE, + Manifest.permission.READ_CALL_LOG, + ) + } + val answerHangUpPermissionLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> + val allGranted = + answerHangUpPermissions.all { permission -> + grants[permission] == true || CallShieldPermissions.isPermissionGranted(context, permission) + } + viewModel.setAnswerHangUpEnabled(allGranted) + } val notificationLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { notificationsGranted = CallShieldPermissions.hasNotificationPermission(context) @@ -747,6 +766,43 @@ fun SettingsScreen(viewModel: MainViewModel) { Icons.Default.Voicemail, autoMuteLowConfidence, ) { viewModel.setAutoMuteLowConfidence(it) } + GradientDivider() + SettingsToggle( + stringResource(R.string.settings_answer_hang_up), + stringResource(R.string.settings_answer_hang_up_desc), + Icons.Default.CallEnd, + answerHangUpEnabled, + ) { enabled -> + if (!enabled) { + viewModel.setAnswerHangUpEnabled(false) + return@SettingsToggle + } + val missingPermissions = + answerHangUpPermissions.filterNot { permission -> + CallShieldPermissions.isPermissionGranted(context, permission) + } + if (missingPermissions.isEmpty()) { + viewModel.setAnswerHangUpEnabled(true) + } else { + answerHangUpPermissionLauncher.launch(missingPermissions.toTypedArray()) + } + } + if (answerHangUpEnabled) { + Spacer(Modifier.height(8.dp)) + SettingsNumberStepper( + label = stringResource(R.string.settings_hang_up_delay), + valueText = + pluralStringResource( + R.plurals.settings_hang_up_delay_value, + hangUpDelaySeconds, + hangUpDelaySeconds, + ), + value = hangUpDelaySeconds, + minValue = AnswerHangUpController.MIN_DELAY_SECONDS, + maxValue = AnswerHangUpController.MAX_DELAY_SECONDS, + onValueChange = viewModel::setHangUpDelaySeconds, + ) + } } // Feature 9: Time-based blocking diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6e4c676b2..af242e29b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -652,6 +652,9 @@ Done Silent voicemail mode Blocked calls go to voicemail silently. Your phone stays quiet while callers hear normal ringing. + Answer & hang up blocked calls + Instead of rejecting, CallShield answers blocked calls and hangs up so they can't leave a voicemail. Silent Voicemail and low-confidence auto-mute still send calls to voicemail. If you're already on a call, the blocked call is rejected. Your screen may light up briefly. + Hang up after Quiet hours Block unknowns during quiet hours Block all non-contact calls during set hours @@ -1300,6 +1303,10 @@ %1$d call %1$d calls + + %d second + %d seconds + %1$d day %1$d days diff --git a/app/src/test/java/com/sysadmindoc/callshield/data/BackupRestoreAnswerHangUpTest.kt b/app/src/test/java/com/sysadmindoc/callshield/data/BackupRestoreAnswerHangUpTest.kt new file mode 100644 index 000000000..4592f7a65 --- /dev/null +++ b/app/src/test/java/com/sysadmindoc/callshield/data/BackupRestoreAnswerHangUpTest.kt @@ -0,0 +1,54 @@ +package com.sysadmindoc.callshield.data + +import com.sysadmindoc.callshield.data.BackupRestore.Backup +import com.sysadmindoc.callshield.data.BackupRestore.BackupSettings +import com.sysadmindoc.callshield.service.AnswerHangUpController +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BackupRestoreAnswerHangUpTest { + @Test + fun `answer hang up settings round trip through backup JSON`() { + val backup = + Backup( + settings = + BackupSettings( + answerHangUpEnabled = true, + hangUpDelaySeconds = 7, + ), + ) + + val validation = BackupRestore.validateBackupJson(BackupRestore.backupToJson(backup)) + + assertTrue(validation is BackupRestore.RestoreValidation.Valid) + val settings = (validation as BackupRestore.RestoreValidation.Valid).payload.settings + assertEquals(true, settings?.answerHangUpEnabled) + assertEquals(7, settings?.hangUpDelaySeconds) + } + + @Test + fun `older backup without answer hang up settings restores defaults`() { + val validation = + BackupRestore.validateBackupJson( + """{"version":9,"app":"CallShield","settings":{"blockCallsEnabled":false}}""", + ) + + assertTrue(validation is BackupRestore.RestoreValidation.Valid) + val settings = (validation as BackupRestore.RestoreValidation.Valid).payload.settings + assertFalse(settings?.answerHangUpEnabled ?: true) + assertEquals(AnswerHangUpController.DEFAULT_DELAY_SECONDS, settings?.hangUpDelaySeconds) + } + + @Test + fun `restored hang up delay is clamped`() { + val backup = Backup(settings = BackupSettings(hangUpDelaySeconds = 100)) + + val validation = BackupRestore.validateBackupJson(BackupRestore.backupToJson(backup)) + + assertTrue(validation is BackupRestore.RestoreValidation.Valid) + val settings = (validation as BackupRestore.RestoreValidation.Valid).payload.settings + assertEquals(AnswerHangUpController.MAX_DELAY_SECONDS, settings?.hangUpDelaySeconds) + } +} From b0d437640607956e88a587485f9f0bf6ece6dcfc Mon Sep 17 00:00:00 2001 From: Aows Date: Sun, 13 Sep 2026 00:14:16 -0600 Subject: [PATCH 3/9] Exclude auto-answered calls from trust Co-Authored-By: Claude Code --- .../callshield/data/CallbackDetector.kt | 44 ++++++++++- .../callshield/data/checker/Checkers.kt | 31 +++++++- .../data/CallbackDetectorAnswerHangUpTest.kt | 74 +++++++++++++++++++ 3 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/com/sysadmindoc/callshield/data/CallbackDetectorAnswerHangUpTest.kt diff --git a/app/src/main/java/com/sysadmindoc/callshield/data/CallbackDetector.kt b/app/src/main/java/com/sysadmindoc/callshield/data/CallbackDetector.kt index 9dee87539..85b634533 100644 --- a/app/src/main/java/com/sysadmindoc/callshield/data/CallbackDetector.kt +++ b/app/src/main/java/com/sysadmindoc/callshield/data/CallbackDetector.kt @@ -83,6 +83,7 @@ class CallbackDetector windowMinutes: Int = 5, threshold: Int = 2, minRetryIntervalMillis: Long = MIN_URGENT_RETRY_INTERVAL_MILLIS, + minIncomingDurationSeconds: Int = 0, ): Boolean = withContext(Dispatchers.IO) { val digits = filterAsciiDigitsLast(number, 10) @@ -90,7 +91,13 @@ class CallbackDetector val last7 = digits.takeLast(7) try { - val query = buildRepeatedUrgentCallQuery(System.currentTimeMillis(), windowMinutes, last7) + val query = + buildRepeatedUrgentCallQuery( + nowMillis = System.currentTimeMillis(), + windowMinutes = windowMinutes, + last7Digits = last7, + minIncomingDurationSeconds = minIncomingDurationSeconds, + ) val cursor = context.contentResolver.query( CallLog.Calls.CONTENT_URI, @@ -251,15 +258,32 @@ class CallbackDetector nowMillis: Long, windowMinutes: Int, last7Digits: String, + minIncomingDurationSeconds: Int = 0, ): CallLogQuery { val safeWindowMinutes = windowMinutes.coerceAtLeast(1) val cutoff = (nowMillis - safeWindowMinutes * 60_000L).toString() + if (minIncomingDurationSeconds <= 0) { + return CallLogQuery( + selection = "${CallLog.Calls.TYPE} IN (?, ?) AND ${CallLog.Calls.DATE} > ? AND ${CallLog.Calls.NUMBER} LIKE ?", + selectionArgs = + arrayOf( + CallLog.Calls.INCOMING_TYPE.toString(), + CallLog.Calls.MISSED_TYPE.toString(), + cutoff, + "%$last7Digits", + ), + ) + } return CallLogQuery( - selection = "${CallLog.Calls.TYPE} IN (?, ?) AND ${CallLog.Calls.DATE} > ? AND ${CallLog.Calls.NUMBER} LIKE ?", + selection = + "(${CallLog.Calls.TYPE} = ? OR (${CallLog.Calls.TYPE} = ? AND " + + "${CallLog.Calls.DURATION} >= ?)) AND ${CallLog.Calls.DATE} > ? " + + "AND ${CallLog.Calls.NUMBER} LIKE ?", selectionArgs = arrayOf( - CallLog.Calls.INCOMING_TYPE.toString(), CallLog.Calls.MISSED_TYPE.toString(), + CallLog.Calls.INCOMING_TYPE.toString(), + minIncomingDurationSeconds.toString(), cutoff, "%$last7Digits", ), @@ -320,6 +344,9 @@ class CallbackDetector const val DEFAULT_ANSWERED_CALLER_THRESHOLD = 2 const val DEFAULT_EMERGENCY_CALLBACK_WINDOW_MINUTES = 240 const val MIN_ANSWERED_CALL_DURATION_SECONDS = 1 + + // Answer-and-hang-up creates 1-11 second incoming rows; SpamBlocker's trust rule uses this 15-second floor. + const val AUTO_ANSWER_MIN_TRUSTED_SECONDS = 15 const val MIN_URGENT_RETRY_INTERVAL_MILLIS = 15_000L private const val MILLIS_PER_DAY = 86_400_000L private const val MILLIS_PER_MINUTE = 60_000L @@ -341,6 +368,7 @@ class CallbackDetector windowMinutes: Int = 5, threshold: Int = 2, minRetryIntervalMillis: Long = MIN_URGENT_RETRY_INTERVAL_MILLIS, + minIncomingDurationSeconds: Int = 0, ): Boolean = shared.isRepeatedUrgentCall( context, @@ -348,6 +376,7 @@ class CallbackDetector windowMinutes, threshold, minRetryIntervalMillis, + minIncomingDurationSeconds, ) suspend fun wasAnsweredRepeatedly( @@ -373,7 +402,14 @@ class CallbackDetector nowMillis: Long, windowMinutes: Int, last7Digits: String, - ): CallLogQuery = shared.buildRepeatedUrgentCallQuery(nowMillis, windowMinutes, last7Digits) + minIncomingDurationSeconds: Int = 0, + ): CallLogQuery = + shared.buildRepeatedUrgentCallQuery( + nowMillis, + windowMinutes, + last7Digits, + minIncomingDurationSeconds, + ) internal fun hasUrgentRetrySpacing( timestamps: List, diff --git a/app/src/main/java/com/sysadmindoc/callshield/data/checker/Checkers.kt b/app/src/main/java/com/sysadmindoc/callshield/data/checker/Checkers.kt index 2d2ad1b10..78537e9fc 100644 --- a/app/src/main/java/com/sysadmindoc/callshield/data/checker/Checkers.kt +++ b/app/src/main/java/com/sysadmindoc/callshield/data/checker/Checkers.kt @@ -460,7 +460,23 @@ internal class AnsweredCallerChecker( ?: CallbackDetector.DEFAULT_ANSWERED_CALLER_WINDOW_DAYS ).coerceAtLeast(1) - return if (callbackDetector.wasAnsweredRepeatedly(appContext, ctx.number, windowDays, threshold)) { + val wasAnsweredRepeatedly = + if (ctx.prefs[SpamRepository.KEY_ANSWER_HANG_UP] == true) { + callbackDetector.wasAnsweredRepeatedly( + appContext, + ctx.number, + windowDays, + threshold, + maxOf( + CallbackDetector.MIN_ANSWERED_CALL_DURATION_SECONDS, + CallbackDetector.AUTO_ANSWER_MIN_TRUSTED_SECONDS, + ), + ) + } else { + callbackDetector.wasAnsweredRepeatedly(appContext, ctx.number, windowDays, threshold) + } + + return if (wasAnsweredRepeatedly) { BlockResult.allow("answered_caller") } else { null @@ -500,7 +516,18 @@ internal class RepeatedUrgentChecker( override val name = "repeated_urgent" override suspend fun check(ctx: CheckContext): BlockResult? = - if (callbackDetector.isRepeatedUrgentCall(appContext, ctx.number)) { + if ( + callbackDetector.isRepeatedUrgentCall( + appContext, + ctx.number, + minIncomingDurationSeconds = + if (ctx.prefs[SpamRepository.KEY_ANSWER_HANG_UP] == true) { + CallbackDetector.AUTO_ANSWER_MIN_TRUSTED_SECONDS + } else { + 0 + }, + ) + ) { BlockResult.allow("repeated_urgent") } else { null diff --git a/app/src/test/java/com/sysadmindoc/callshield/data/CallbackDetectorAnswerHangUpTest.kt b/app/src/test/java/com/sysadmindoc/callshield/data/CallbackDetectorAnswerHangUpTest.kt new file mode 100644 index 000000000..c29a54279 --- /dev/null +++ b/app/src/test/java/com/sysadmindoc/callshield/data/CallbackDetectorAnswerHangUpTest.kt @@ -0,0 +1,74 @@ +package com.sysadmindoc.callshield.data + +import android.provider.CallLog +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class CallbackDetectorAnswerHangUpTest { + @Test + fun `repeated urgent query remains unchanged when answer hang up is off`() { + val query = + CallbackDetector.buildRepeatedUrgentCallQuery( + nowMillis = 120_000L, + windowMinutes = 5, + last7Digits = "5551234", + minIncomingDurationSeconds = 0, + ) + + assertEquals( + "${CallLog.Calls.TYPE} IN (?, ?) AND ${CallLog.Calls.DATE} > ? AND ${CallLog.Calls.NUMBER} LIKE ?", + query.selection, + ) + assertArrayEquals( + arrayOf( + CallLog.Calls.INCOMING_TYPE.toString(), + CallLog.Calls.MISSED_TYPE.toString(), + (120_000L - 300_000L).toString(), + "%5551234", + ), + query.selectionArgs, + ) + } + + @Test + fun `answered caller query uses fifteen second floor while answer hang up is on`() { + val query = + CallbackDetector.buildAnsweredCallerQuery( + nowMillis = 3_000_000_000L, + windowDays = 30, + minDurationSeconds = CallbackDetector.AUTO_ANSWER_MIN_TRUSTED_SECONDS, + last7Digits = "5551234", + ) + + assertEquals("15", query.selectionArgs[2]) + } + + @Test + fun `repeated urgent query preserves missed calls and filters short incoming calls`() { + val query = + CallbackDetector.buildRepeatedUrgentCallQuery( + nowMillis = 120_000L, + windowMinutes = 5, + last7Digits = "5551234", + minIncomingDurationSeconds = CallbackDetector.AUTO_ANSWER_MIN_TRUSTED_SECONDS, + ) + + assertEquals( + "(${CallLog.Calls.TYPE} = ? OR (${CallLog.Calls.TYPE} = ? AND " + + "${CallLog.Calls.DURATION} >= ?)) AND ${CallLog.Calls.DATE} > ? " + + "AND ${CallLog.Calls.NUMBER} LIKE ?", + query.selection, + ) + assertArrayEquals( + arrayOf( + CallLog.Calls.MISSED_TYPE.toString(), + CallLog.Calls.INCOMING_TYPE.toString(), + "15", + (120_000L - 300_000L).toString(), + "%5551234", + ), + query.selectionArgs, + ) + } +} From 28aea014e764282e27e2039a661698175b0b0692 Mon Sep 17 00:00:00 2001 From: Aows Date: Sun, 13 Sep 2026 00:22:58 -0600 Subject: [PATCH 4/9] Fix answer hang up settings checks Co-Authored-By: Claude Opus 5 --- .../com/sysadmindoc/callshield/data/BackupRestore.kt | 11 ++++++++--- .../callshield/data/repository/SettingsRepository.kt | 2 +- .../callshield/ui/screens/settings/SettingsScreen.kt | 2 +- app/src/main/res/values/strings.xml | 2 +- .../callshield/data/BackupRestoreAnswerHangUpTest.kt | 5 ++--- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/sysadmindoc/callshield/data/BackupRestore.kt b/app/src/main/java/com/sysadmindoc/callshield/data/BackupRestore.kt index c22a2b54a..131ee9a39 100644 --- a/app/src/main/java/com/sysadmindoc/callshield/data/BackupRestore.kt +++ b/app/src/main/java/com/sysadmindoc/callshield/data/BackupRestore.kt @@ -1152,8 +1152,7 @@ object BackupRestore { postCallScreenEnabled = this[SpamRepository.KEY_POST_CALL_SCREEN] ?: false, silentVoicemailEnabled = this[SpamRepository.KEY_SILENT_VOICEMAIL] ?: false, answerHangUpEnabled = this[SpamRepository.KEY_ANSWER_HANG_UP] ?: false, - hangUpDelaySeconds = - AnswerHangUpController.clampDelaySeconds(this[SpamRepository.KEY_HANG_UP_DELAY_SECONDS]), + hangUpDelaySeconds = clampHangUpDelaySeconds(this[SpamRepository.KEY_HANG_UP_DELAY_SECONDS]), pushAlertEnabled = this[SpamRepository.KEY_PUSH_ALERT] ?: true, pushAlertDisabledPackages = (this[SpamRepository.KEY_PUSH_ALERT_DISABLED] ?: emptySet()).sorted(), regionBlockEnabled = this[SpamRepository.KEY_REGION_BLOCK] ?: false, @@ -1181,6 +1180,12 @@ object BackupRestore { ?.let { NotificationScreeningSources.enabledPackages(it).sorted() }, ) + private fun clampHangUpDelaySeconds(value: Int?): Int = + value?.coerceIn( + AnswerHangUpController.MIN_DELAY_SECONDS, + AnswerHangUpController.MAX_DELAY_SECONDS, + ) ?: AnswerHangUpController.DEFAULT_DELAY_SECONDS + private fun BackupSettings.sanitized(): BackupSettings = copy( answeredCallerThreshold = answeredCallerThreshold.coerceIn(1, 10), @@ -1190,7 +1195,7 @@ object BackupRestore { timeBlockEndHour = sanitizeScheduleHour(timeBlockEndHour), frequencyThreshold = frequencyThreshold.coerceIn(1, 25), cleanupDays = cleanupDays.coerceIn(1, 365), - hangUpDelaySeconds = AnswerHangUpController.clampDelaySeconds(hangUpDelaySeconds), + hangUpDelaySeconds = clampHangUpDelaySeconds(hangUpDelaySeconds), pushAlertDisabledPackages = pushAlertDisabledPackages .map { it.trim() } diff --git a/app/src/main/java/com/sysadmindoc/callshield/data/repository/SettingsRepository.kt b/app/src/main/java/com/sysadmindoc/callshield/data/repository/SettingsRepository.kt index 87dd259cc..278fc864f 100644 --- a/app/src/main/java/com/sysadmindoc/callshield/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sysadmindoc/callshield/data/repository/SettingsRepository.kt @@ -21,8 +21,8 @@ import com.sysadmindoc.callshield.data.NotificationScreeningSources import com.sysadmindoc.callshield.data.RegionRules import com.sysadmindoc.callshield.data.SpamRepository import com.sysadmindoc.callshield.data.model.ExternalBlocklistSubscription -import com.sysadmindoc.callshield.service.AnswerHangUpController import com.sysadmindoc.callshield.data.model.HotDataHealth +import com.sysadmindoc.callshield.service.AnswerHangUpController import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map diff --git a/app/src/main/java/com/sysadmindoc/callshield/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/sysadmindoc/callshield/ui/screens/settings/SettingsScreen.kt index e82755a18..44308acfe 100644 --- a/app/src/main/java/com/sysadmindoc/callshield/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/sysadmindoc/callshield/ui/screens/settings/SettingsScreen.kt @@ -57,7 +57,6 @@ import com.sysadmindoc.callshield.BuildConfig import com.sysadmindoc.callshield.R import com.sysadmindoc.callshield.data.BackupRestore import com.sysadmindoc.callshield.data.CallCategory -import com.sysadmindoc.callshield.service.AnswerHangUpController import com.sysadmindoc.callshield.data.CategoryCallAction import com.sysadmindoc.callshield.data.MessageCapabilityState import com.sysadmindoc.callshield.data.MessageCapabilityStatus @@ -73,6 +72,7 @@ import com.sysadmindoc.callshield.data.repository.EMERGENCY_CALLBACK_WINDOW_MINU import com.sysadmindoc.callshield.data.repository.FREQ_THRESHOLD_MAX import com.sysadmindoc.callshield.data.repository.FREQ_THRESHOLD_MIN import com.sysadmindoc.callshield.permissions.CallShieldPermissions +import com.sysadmindoc.callshield.service.AnswerHangUpController import com.sysadmindoc.callshield.ui.AppLanguage import com.sysadmindoc.callshield.ui.DurationTtsText import com.sysadmindoc.callshield.ui.MainViewModel diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af242e29b..e364d9b25 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -653,7 +653,7 @@ Silent voicemail mode Blocked calls go to voicemail silently. Your phone stays quiet while callers hear normal ringing. Answer & hang up blocked calls - Instead of rejecting, CallShield answers blocked calls and hangs up so they can't leave a voicemail. Silent Voicemail and low-confidence auto-mute still send calls to voicemail. If you're already on a call, the blocked call is rejected. Your screen may light up briefly. + Instead of rejecting, CallShield answers blocked calls and hangs up so they can\'t leave a voicemail. Silent Voicemail and low-confidence auto-mute still send calls to voicemail. If you\'re already on a call, the blocked call is rejected. Your screen may light up briefly. Hang up after Quiet hours Block unknowns during quiet hours diff --git a/app/src/test/java/com/sysadmindoc/callshield/data/BackupRestoreAnswerHangUpTest.kt b/app/src/test/java/com/sysadmindoc/callshield/data/BackupRestoreAnswerHangUpTest.kt index 4592f7a65..7699d424f 100644 --- a/app/src/test/java/com/sysadmindoc/callshield/data/BackupRestoreAnswerHangUpTest.kt +++ b/app/src/test/java/com/sysadmindoc/callshield/data/BackupRestoreAnswerHangUpTest.kt @@ -2,7 +2,6 @@ package com.sysadmindoc.callshield.data import com.sysadmindoc.callshield.data.BackupRestore.Backup import com.sysadmindoc.callshield.data.BackupRestore.BackupSettings -import com.sysadmindoc.callshield.service.AnswerHangUpController import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -38,7 +37,7 @@ class BackupRestoreAnswerHangUpTest { assertTrue(validation is BackupRestore.RestoreValidation.Valid) val settings = (validation as BackupRestore.RestoreValidation.Valid).payload.settings assertFalse(settings?.answerHangUpEnabled ?: true) - assertEquals(AnswerHangUpController.DEFAULT_DELAY_SECONDS, settings?.hangUpDelaySeconds) + assertEquals(1, settings?.hangUpDelaySeconds) } @Test @@ -49,6 +48,6 @@ class BackupRestoreAnswerHangUpTest { assertTrue(validation is BackupRestore.RestoreValidation.Valid) val settings = (validation as BackupRestore.RestoreValidation.Valid).payload.settings - assertEquals(AnswerHangUpController.MAX_DELAY_SECONDS, settings?.hangUpDelaySeconds) + assertEquals(10, settings?.hangUpDelaySeconds) } } From 56463f6505152306bef3ae92f4e7fa1fb2c3967d Mon Sep 17 00:00:00 2001 From: Aows Date: Sun, 13 Sep 2026 00:52:50 -0600 Subject: [PATCH 5/9] Harden answer-and-hang-up controls Co-Authored-By: Claude Code --- .../service/AnswerHangUpController.kt | 79 +++++--- .../service/AnswerHangUpReceiver.kt | 7 +- .../service/CallShieldScreeningService.kt | 48 +++-- .../service/AnswerHangUpControllerTest.kt | 174 +++++++++++++++--- 4 files changed, 244 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpController.kt b/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpController.kt index 9a187e26f..8caddb0e0 100644 --- a/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpController.kt +++ b/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpController.kt @@ -1,10 +1,12 @@ package com.sysadmindoc.callshield.service +import android.annotation.SuppressLint import android.content.Context import android.os.Handler import android.os.Looper import android.os.SystemClock import android.telecom.TelecomManager +import android.telecom.VideoProfile import android.telephony.TelephonyManager import android.util.Log @@ -32,7 +34,7 @@ object AnswerHangUpController { } internal interface CallControl { - fun acceptRingingCall() + fun acceptRingingCallAudioOnly(): Boolean fun endCall(): Boolean @@ -92,12 +94,15 @@ object AnswerHangUpController { fun clampDelaySeconds(value: Int?): Int = value?.coerceIn(MIN_DELAY_SECONDS, MAX_DELAY_SECONDS) ?: DEFAULT_DELAY_SECONDS - /** Arms this process for the next matching PHONE_STATE broadcast. */ - fun arm( + /** Arms this process for the next matching PHONE_STATE broadcast when no call is in flight. */ + fun tryArm( rawNumber: String?, delaySeconds: Int, - ) { + ): Boolean = synchronized(lock) { + if (pendingCall?.phase == Phase.ANSWERING || pendingCall?.phase == Phase.HANGING_UP) { + return@synchronized false + } clearLocked() pendingCall = PendingCall( @@ -107,8 +112,8 @@ object AnswerHangUpController { phase = Phase.ARMED, ) scheduleArmedExpiryLocked() + true } - } fun disarm() { synchronized(lock) { @@ -125,6 +130,13 @@ object AnswerHangUpController { } } + /** A user-initiated outgoing call must never inherit a pending hang-up timer. */ + fun onOutgoingCallStarted() { + synchronized(lock) { + clearLocked() + } + } + fun hasPendingCall(): Boolean = synchronized(lock) { pendingCall != null } fun isBusy(): Boolean = @@ -136,13 +148,26 @@ object AnswerHangUpController { fun onPhoneState( state: String?, incomingNumber: String?, + hasIncomingNumber: Boolean = true, ) { synchronized(lock) { val pending = pendingCall ?: return when (state) { - TelephonyManager.EXTRA_STATE_RINGING -> handleRingingLocked(pending, incomingNumber) - TelephonyManager.EXTRA_STATE_OFFHOOK -> handleOffhookLocked(pending, incomingNumber) - TelephonyManager.EXTRA_STATE_IDLE -> clearLocked() + TelephonyManager.EXTRA_STATE_RINGING -> { + if (pending.phase == Phase.ARMED) { + handleRingingLocked(pending, incomingNumber, hasIncomingNumber) + } + } + + TelephonyManager.EXTRA_STATE_OFFHOOK -> { + handleOffhookLocked(pending, incomingNumber) + } + + TelephonyManager.EXTRA_STATE_IDLE -> { + if (pending.phase == Phase.ARMED) { + clearLocked() + } + } } } } @@ -150,13 +175,9 @@ object AnswerHangUpController { private fun handleRingingLocked( pending: PendingCall, incomingNumber: String?, + hasIncomingNumber: Boolean, ) { - if (pending.phase == Phase.HANGING_UP) { - // A second incoming call must never be ended by a timer belonging to the first. - clearLocked() - return - } - if (pending.phase != Phase.ARMED) return + if (!hasIncomingNumber) return if (!isWithinArmWindow(pending)) { clearLocked() return @@ -166,8 +187,11 @@ object AnswerHangUpController { pending.phase = Phase.ANSWERING pending.acceptedAt = clock() try { - callControl.acceptRingingCall() - } catch (exception: SecurityException) { + if (!callControl.acceptRingingCallAudioOnly()) { + clearLocked() + return + } + } catch (exception: RuntimeException) { Log.w(TAG, "Unable to answer screened call", exception) clearLocked() return @@ -216,7 +240,7 @@ object AnswerHangUpController { val callState = try { callControl.currentCallState() - } catch (exception: SecurityException) { + } catch (exception: RuntimeException) { Log.w(TAG, "Unable to read screened call state", exception) clearLocked() return @@ -253,7 +277,7 @@ object AnswerHangUpController { if (clock() >= (pending.hangUpDeadline ?: Long.MAX_VALUE)) { try { callControl.endCall() - } catch (exception: SecurityException) { + } catch (exception: RuntimeException) { Log.w(TAG, "Unable to hang up screened call", exception) } clearLocked() @@ -283,17 +307,30 @@ object AnswerHangUpController { private fun productionCallControl(): CallControl = object : CallControl { + // The preceding permission gate is real, but lint does not trace this helper. + @SuppressLint("MissingPermission") @Suppress("DEPRECATION") - override fun acceptRingingCall() { - telecomManager().acceptRingingCall() + override fun acceptRingingCallAudioOnly(): Boolean { + if (!hasAnswerPhoneCallsPermission()) return false + telecomManager().acceptRingingCall(VideoProfile.STATE_AUDIO_ONLY) + return true } + // The preceding permission gate is real, but lint does not trace this helper. + @SuppressLint("MissingPermission") @Suppress("DEPRECATION") - override fun endCall(): Boolean = telecomManager().endCall() + override fun endCall(): Boolean { + if (!hasAnswerPhoneCallsPermission()) return false + return telecomManager().endCall() + } @Suppress("DEPRECATION") override fun currentCallState(): Int = applicationContext?.getSystemService(TelephonyManager::class.java)?.callState ?: -1 + private fun hasAnswerPhoneCallsPermission(): Boolean = + applicationContext?.checkSelfPermission(android.Manifest.permission.ANSWER_PHONE_CALLS) == + android.content.pm.PackageManager.PERMISSION_GRANTED + private fun telecomManager(): TelecomManager = requireNotNull(applicationContext) { "AnswerHangUpController is not configured" } .getSystemService(TelecomManager::class.java) diff --git a/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpReceiver.kt b/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpReceiver.kt index 52dc9401f..8a4968448 100644 --- a/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpReceiver.kt +++ b/app/src/main/java/com/sysadmindoc/callshield/service/AnswerHangUpReceiver.kt @@ -12,10 +12,15 @@ class AnswerHangUpReceiver : BroadcastReceiver() { context: Context, intent: Intent, ) { - if (!AnswerHangUpController.hasPendingCall()) return + if (intent.action != TelephonyManager.ACTION_PHONE_STATE_CHANGED || + !AnswerHangUpController.hasPendingCall() + ) { + return + } AnswerHangUpController.onPhoneState( state = intent.getStringExtra(TelephonyManager.EXTRA_STATE), incomingNumber = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER), + hasIncomingNumber = intent.hasExtra(TelephonyManager.EXTRA_INCOMING_NUMBER), ) } } diff --git a/app/src/main/java/com/sysadmindoc/callshield/service/CallShieldScreeningService.kt b/app/src/main/java/com/sysadmindoc/callshield/service/CallShieldScreeningService.kt index ac7314ff6..0b4fe396b 100644 --- a/app/src/main/java/com/sysadmindoc/callshield/service/CallShieldScreeningService.kt +++ b/app/src/main/java/com/sysadmindoc/callshield/service/CallShieldScreeningService.kt @@ -100,6 +100,7 @@ class CallShieldScreeningService : CallScreeningService() { // feedback, or respond to an outgoing call. when (callDetails.callDirection) { Call.Details.DIRECTION_OUTGOING -> { + AnswerHangUpController.onOutgoingCallStarted() handleOutgoingCall(callDetails) return } @@ -376,13 +377,17 @@ class CallShieldScreeningService : CallScreeningService() { val answerHangUpEnabled = prefs[SpamRepository.KEY_ANSWER_HANG_UP] ?: false val answerAndHangUp = if (answerHangUpEnabled && !silenceWins) { - val permissionsGranted = hasAnswerHangUpPermissions() - shouldAnswerAndHangUp( - enabled = true, - silenceWins = false, - permissionsGranted = permissionsGranted, - busy = !permissionsGranted || isAnswerHangUpBusy(), - ) + try { + val permissionsGranted = hasAnswerHangUpPermissions() + shouldAnswerAndHangUp( + enabled = true, + silenceWins = false, + permissionsGranted = permissionsGranted, + busy = !permissionsGranted || isAnswerHangUpBusy(), + ) + } catch (_: RuntimeException) { + false + } } else { false } @@ -395,17 +400,20 @@ class CallShieldScreeningService : CallScreeningService() { } if (answerAndHangUp) { AnswerHangUpController.configure(applicationContext) - AnswerHangUpController.arm( - rawNumber = number, - delaySeconds = - AnswerHangUpController.clampDelaySeconds( - prefs[SpamRepository.KEY_HANG_UP_DELAY_SECONDS], - ), - ) - if (responseGate.hasResponded) { + val armed = + AnswerHangUpController.tryArm( + rawNumber = number, + delaySeconds = + AnswerHangUpController.clampDelaySeconds( + prefs[SpamRepository.KEY_HANG_UP_DELAY_SECONDS], + ), + ) + if (armed && responseGate.hasResponded) { AnswerHangUpController.disarm() - } else { + } else if (armed) { responseGate.respond(response) + } else { + responseGate.respond(buildBlockResponse(prefs, confidence, categoryAction)) } } else { responseGate.respond(response) @@ -524,11 +532,13 @@ class CallShieldScreeningService : CallScreeningService() { @Suppress("DEPRECATION") val callState = getSystemService(android.telephony.TelephonyManager::class.java)?.callState val telephonyBusy = callState == android.telephony.TelephonyManager.CALL_STATE_OFFHOOK - val audioMode = getSystemService(android.media.AudioManager::class.java)?.mode - val audioBusy = + if (telephonyBusy || AnswerHangUpController.isBusy()) { + true + } else { + val audioMode = getSystemService(android.media.AudioManager::class.java)?.mode audioMode == android.media.AudioManager.MODE_IN_CALL || audioMode == android.media.AudioManager.MODE_IN_COMMUNICATION - telephonyBusy || audioBusy || AnswerHangUpController.isBusy() + } } catch (_: SecurityException) { true } diff --git a/app/src/test/java/com/sysadmindoc/callshield/service/AnswerHangUpControllerTest.kt b/app/src/test/java/com/sysadmindoc/callshield/service/AnswerHangUpControllerTest.kt index edda903e5..e6c6038f3 100644 --- a/app/src/test/java/com/sysadmindoc/callshield/service/AnswerHangUpControllerTest.kt +++ b/app/src/test/java/com/sysadmindoc/callshield/service/AnswerHangUpControllerTest.kt @@ -1,6 +1,8 @@ package com.sysadmindoc.callshield.service +import android.content.Intent import android.telephony.TelephonyManager +import androidx.test.core.app.ApplicationProvider import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -32,40 +34,40 @@ class AnswerHangUpControllerTest { } @Test - fun `matching ringing accepts once and duplicate ringing is ignored`() { - AnswerHangUpController.arm("5551234567", 1) + fun `matching ringing accepts audio only once and duplicate ringing is ignored`() { + arm("5551234567", 1) AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") - assertEquals(1, callControl.acceptCalls) + assertEquals(1, callControl.audioOnlyAcceptCalls) assertEquals(AnswerHangUpController.Phase.ANSWERING, AnswerHangUpController.phaseForTest()) } @Test fun `different ringing number does not answer`() { - AnswerHangUpController.arm("5551234567", 1) + arm("5551234567", 1) AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5557654321") - assertEquals(0, callControl.acceptCalls) + assertEquals(0, callControl.audioOnlyAcceptCalls) assertTrue(AnswerHangUpController.hasPendingCall()) } @Test fun `ringing after arm expiry does not answer`() { - AnswerHangUpController.arm("5551234567", 1) + arm("5551234567", 1) clock.advanceBy(5_000) AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") - assertEquals(0, callControl.acceptCalls) + assertEquals(0, callControl.audioOnlyAcceptCalls) assertFalse(AnswerHangUpController.hasPendingCall()) } @Test fun `offhook ends exactly once at the delay deadline`() { - AnswerHangUpController.arm("5551234567", 2) + arm("5551234567", 2) AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_OFFHOOK, "5551234567") callControl.currentState = TelephonyManager.CALL_STATE_OFFHOOK @@ -81,7 +83,7 @@ class AnswerHangUpControllerTest { @Test fun `idle before deadline never ends the call`() { - AnswerHangUpController.arm("5551234567", 1) + arm("5551234567", 1) AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_OFFHOOK, "5551234567") AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_IDLE, null) @@ -94,21 +96,23 @@ class AnswerHangUpControllerTest { } @Test - fun `ringing during hangup aborts without ending either call`() { - AnswerHangUpController.arm("5551234567", 1) + fun `stale ringing and idle broadcasts during hangup do not prevent the deadline hangup`() { + arm("5551234567", 1) AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_OFFHOOK, "5551234567") + callControl.currentState = TelephonyManager.CALL_STATE_OFFHOOK AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5557654321") - scheduler.advanceBy(2_000) + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_IDLE, null) + repeat(4) { scheduler.advanceBy(250) } - assertEquals(0, callControl.endCalls) + assertEquals(1, callControl.endCalls) assertFalse(AnswerHangUpController.hasPendingCall()) } @Test fun `polled offhook without broadcast still hangs up`() { - AnswerHangUpController.arm("5551234567", 1) + arm("5551234567", 1) AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") callControl.currentState = TelephonyManager.CALL_STATE_OFFHOOK @@ -120,31 +124,139 @@ class AnswerHangUpControllerTest { @Test fun `withheld number accepts null ringing number`() { - AnswerHangUpController.arm(null, 1) + arm(null, 1) AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, null) - assertEquals(1, callControl.acceptCalls) + assertEquals(1, callControl.audioOnlyAcceptCalls) } @Test fun `north american prefix variants match on digits`() { - AnswerHangUpController.arm("+1 (555) 123-4567", 1) + arm("+1 (555) 123-4567", 1) AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") - assertEquals(1, callControl.acceptCalls) + assertEquals(1, callControl.audioOnlyAcceptCalls) } @Test fun `new incoming screening clears an unaccepted arm`() { - AnswerHangUpController.arm("5551234567", 1) + arm("5551234567", 1) AnswerHangUpController.onIncomingScreeningStarted() assertFalse(AnswerHangUpController.hasPendingCall()) } + @Test + @Suppress("DEPRECATION") + fun `numberless ringing duplicate does not accept but numbered ringing does`() { + arm("5551234567", 1) + val context = ApplicationProvider.getApplicationContext() + + AnswerHangUpReceiver().onReceive( + context, + Intent(TelephonyManager.ACTION_PHONE_STATE_CHANGED).apply { + putExtra(TelephonyManager.EXTRA_STATE, TelephonyManager.EXTRA_STATE_RINGING) + }, + ) + assertEquals(0, callControl.audioOnlyAcceptCalls) + + AnswerHangUpReceiver().onReceive( + context, + Intent(TelephonyManager.ACTION_PHONE_STATE_CHANGED).apply { + putExtra(TelephonyManager.EXTRA_STATE, TelephonyManager.EXTRA_STATE_RINGING) + putExtra(TelephonyManager.EXTRA_INCOMING_NUMBER, "5551234567") + }, + ) + assertEquals(1, callControl.audioOnlyAcceptCalls) + } + + @Test + fun `extra present empty number accepts a withheld call`() { + arm(null, 1) + + AnswerHangUpController.onPhoneState( + state = TelephonyManager.EXTRA_STATE_RINGING, + incomingNumber = null, + hasIncomingNumber = true, + ) + + assertEquals(1, callControl.audioOnlyAcceptCalls) + } + + @Test + fun `outgoing call clears every phase without ending a call`() { + arm("5551234567", 1) + AnswerHangUpController.onOutgoingCallStarted() + assertFalse(AnswerHangUpController.hasPendingCall()) + + arm("5551234567", 1) + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") + AnswerHangUpController.onOutgoingCallStarted() + assertFalse(AnswerHangUpController.hasPendingCall()) + + arm("5551234567", 1) + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_OFFHOOK, "5551234567") + AnswerHangUpController.onOutgoingCallStarted() + assertFalse(AnswerHangUpController.hasPendingCall()) + assertEquals(0, callControl.endCalls) + } + + @Test + fun `try arm refuses while answering or hanging up`() { + arm("5551234567", 1) + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") + assertFalse(AnswerHangUpController.tryArm("5557654321", 1)) + assertEquals(AnswerHangUpController.Phase.ANSWERING, AnswerHangUpController.phaseForTest()) + + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_OFFHOOK, "5551234567") + assertFalse(AnswerHangUpController.tryArm("5557654321", 1)) + assertEquals(AnswerHangUpController.Phase.HANGING_UP, AnswerHangUpController.phaseForTest()) + } + + @Test + fun `call control runtime exceptions clear without propagating`() { + callControl.throwOnAccept = true + arm("5551234567", 1) + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") + assertFalse(AnswerHangUpController.hasPendingCall()) + + callControl.throwOnAccept = false + arm("5551234567", 1) + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") + callControl.throwOnStateRead = true + scheduler.advanceBy(250) + assertFalse(AnswerHangUpController.hasPendingCall()) + + callControl.throwOnStateRead = false + arm("5551234567", 1) + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_RINGING, "5551234567") + AnswerHangUpController.onPhoneState(TelephonyManager.EXTRA_STATE_OFFHOOK, "5551234567") + callControl.currentState = TelephonyManager.CALL_STATE_OFFHOOK + callControl.throwOnEnd = true + repeat(4) { scheduler.advanceBy(250) } + assertFalse(AnswerHangUpController.hasPendingCall()) + } + + @Test + @Suppress("DEPRECATION") + fun `receiver ignores an action other than phone state`() { + arm("5551234567", 1) + val intent = + Intent("com.sysadmindoc.callshield.UNRELATED").apply { + putExtra(TelephonyManager.EXTRA_STATE, TelephonyManager.EXTRA_STATE_RINGING) + putExtra(TelephonyManager.EXTRA_INCOMING_NUMBER, "5551234567") + } + + AnswerHangUpReceiver().onReceive(ApplicationProvider.getApplicationContext(), intent) + + assertEquals(0, callControl.audioOnlyAcceptCalls) + assertEquals(AnswerHangUpController.Phase.ARMED, AnswerHangUpController.phaseForTest()) + } + @Test fun `delay clamp enforces supported range`() { assertEquals(1, AnswerHangUpController.clampDelaySeconds(0)) @@ -152,6 +264,13 @@ class AnswerHangUpControllerTest { assertEquals(1, AnswerHangUpController.clampDelaySeconds(null)) } + private fun arm( + number: String?, + delaySeconds: Int, + ) { + assertTrue(AnswerHangUpController.tryArm(number, delaySeconds)) + } + private class FakeClock { private var currentMillis = 0L @@ -163,20 +282,29 @@ class AnswerHangUpControllerTest { } private class FakeCallControl : AnswerHangUpController.CallControl { - var acceptCalls = 0 + var audioOnlyAcceptCalls = 0 var endCalls = 0 var currentState = TelephonyManager.CALL_STATE_RINGING + var throwOnAccept = false + var throwOnEnd = false + var throwOnStateRead = false - override fun acceptRingingCall() { - acceptCalls += 1 + override fun acceptRingingCallAudioOnly(): Boolean { + if (throwOnAccept) error("accept") + audioOnlyAcceptCalls += 1 + return true } override fun endCall(): Boolean { + if (throwOnEnd) error("end") endCalls += 1 return true } - override fun currentCallState(): Int = currentState + override fun currentCallState(): Int { + if (throwOnStateRead) error("state") + return currentState + } } private class FakeScheduler( From b3c13087ff45ade9f0b3973172cb50e8fe860737 Mon Sep 17 00:00:00 2001 From: Aows Date: Sun, 13 Sep 2026 03:57:23 -0600 Subject: [PATCH 6/9] Fix certificate pins for GitHub raw sync Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude Code --- .../com/sysadmindoc/callshield/data/remote/HttpClient.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/com/sysadmindoc/callshield/data/remote/HttpClient.kt b/app/src/main/java/com/sysadmindoc/callshield/data/remote/HttpClient.kt index 0eac4df3d..86983efbb 100644 --- a/app/src/main/java/com/sysadmindoc/callshield/data/remote/HttpClient.kt +++ b/app/src/main/java/com/sysadmindoc/callshield/data/remote/HttpClient.kt @@ -33,6 +33,9 @@ object HttpClient { listOf( "sha256/W+jBdq3o4qj8cXXBURwKqofJk8BG59NEPXOEgMh53sA=", "sha256/kZwN96eHtZftBWrOZUsd6cA4es80n3NzSk/XtYz2EqQ=", + // 2026-09-13: GitHub now chains through Let's Encrypt Root YR to ISRG Root X1. + "sha256/C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=", + "sha256/fk6IOKit1ild5647BH06ujSIq5XbCgqlbYl6ANhhi88=", ), "callshield-reports.snafumatthew.workers.dev" to listOf( @@ -50,6 +53,9 @@ object HttpClient { listOf( "sha256/QSCRpv+KcUv9sLsdsMT4utQr9dOiwcGQXplf7Nc7Igw=", "sha256/y7xVm0TVJNahMr2sZydE2jQH8SquXV9yLF9seROHHHU=", + // 2026-09-13: phoneblock.net now chains through Let's Encrypt Roots X2 and X1. + "sha256/diGVwiVYbubAI3RW4hB9xU8e/CH2GnkuvVFZE8zmgzI=", + "sha256/C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M=", ), "www.whocalledme.com" to listOf( From 033a7364a61260c2d713b964eb2304213c85a97d Mon Sep 17 00:00:00 2001 From: tikkamasalla <104959287+tikkamasalla@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:28:47 -0600 Subject: [PATCH 7/9] Gate ANSWER_PHONE_CALLS on the answer-hang-up toggle, document trade-offs --- .../callshield/permissions/CallShieldPermissions.kt | 8 +++++++- app/src/main/res/values/strings.xml | 2 +- .../permissions/CallShieldPermissionsTest.kt | 13 +++++++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/sysadmindoc/callshield/permissions/CallShieldPermissions.kt b/app/src/main/java/com/sysadmindoc/callshield/permissions/CallShieldPermissions.kt index 543952922..bc9b3f079 100644 --- a/app/src/main/java/com/sysadmindoc/callshield/permissions/CallShieldPermissions.kt +++ b/app/src/main/java/com/sysadmindoc/callshield/permissions/CallShieldPermissions.kt @@ -104,8 +104,14 @@ object CallShieldPermissions { * Runtime grants requested during first-run setup. Keeping this list next * to the capability contract prevents onboarding from silently omitting a * permission that the live call path expects on some Android builds. + * + * ANSWER_PHONE_CALLS is deliberately NOT requested here: it is asked for + * only when the user enables "Answer & hang up blocked calls" in + * Settings, so users who never enable that feature are never prompted + * for it. */ - val onboardingRuntimePermissions = (corePermissions + compatibilityPermissions).distinct() + val onboardingRuntimePermissions = + (corePermissions + listOf(Manifest.permission.READ_PHONE_STATE)).distinct() val permissionCapabilityContracts = listOf( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e364d9b25..09797bbd7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -653,7 +653,7 @@ Silent voicemail mode Blocked calls go to voicemail silently. Your phone stays quiet while callers hear normal ringing. Answer & hang up blocked calls - Instead of rejecting, CallShield answers blocked calls and hangs up so they can\'t leave a voicemail. Silent Voicemail and low-confidence auto-mute still send calls to voicemail. If you\'re already on a call, the blocked call is rejected. Your screen may light up briefly. + Instead of rejecting, CallShield answers blocked calls and hangs up so they can\'t leave a voicemail. Silent Voicemail and low-confidence auto-mute still send calls to voicemail. If you\'re already on a call, the blocked call is rejected. Your screen may light up briefly. Trade-offs: answering tells autodialers the number is live, so some callers redial; some carriers need a longer delay to skip voicemail, raise it if spam still lands in voicemail. Hang up after Quiet hours Block unknowns during quiet hours diff --git a/app/src/test/java/com/sysadmindoc/callshield/permissions/CallShieldPermissionsTest.kt b/app/src/test/java/com/sysadmindoc/callshield/permissions/CallShieldPermissionsTest.kt index e125316f7..0051058b9 100644 --- a/app/src/test/java/com/sysadmindoc/callshield/permissions/CallShieldPermissionsTest.kt +++ b/app/src/test/java/com/sysadmindoc/callshield/permissions/CallShieldPermissionsTest.kt @@ -11,11 +11,20 @@ import org.junit.Test class CallShieldPermissionsTest { @Test fun `first-run runtime bundle covers core and compatibility grants`() { + // ANSWER_PHONE_CALLS is requested only when "Answer & hang up + // blocked calls" is enabled in Settings, never during onboarding. assertEquals( - (CallShieldPermissions.corePermissions + CallShieldPermissions.compatibilityPermissions).toSet(), + ( + CallShieldPermissions.corePermissions + + listOf(android.Manifest.permission.READ_PHONE_STATE) + ).toSet(), CallShieldPermissions.onboardingRuntimePermissions.toSet(), ) - assertEquals(6, CallShieldPermissions.onboardingRuntimePermissions.size) + assertEquals(5, CallShieldPermissions.onboardingRuntimePermissions.size) + assertFalse( + android.Manifest.permission.ANSWER_PHONE_CALLS in + CallShieldPermissions.onboardingRuntimePermissions, + ) } @Test From e183914c9534958e8e6fd2d88a8893777c7bca25 Mon Sep 17 00:00:00 2001 From: tikkamasalla <104959287+tikkamasalla@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:51:17 -0600 Subject: [PATCH 8/9] chore: regenerate dependency locks for merged catalog --- app/gradle.lockfile | 64 +++++++++++++++------------------------------ 1 file changed, 21 insertions(+), 43 deletions(-) diff --git a/app/gradle.lockfile b/app/gradle.lockfile index e12389934..7cb77a04a 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -9,10 +9,8 @@ androidx.annotation:annotation-jvm:1.10.0=debugAndroidTestCompileClasspath,debug androidx.annotation:annotation-jvm:1.9.1=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath androidx.annotation:annotation:1.10.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.annotation:annotation:1.9.1=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath -androidx.appcompat:appcompat-resources:1.7.1=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.appcompat:appcompat-resources:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath -androidx.appcompat:appcompat:1.7.1=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.appcompat:appcompat:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath +androidx.appcompat:appcompat-resources:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.appcompat:appcompat:1.8.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.arch.core:core-runtime:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.autofill:autofill:1.0.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath @@ -138,42 +136,26 @@ androidx.navigationevent:navigationevent-android:1.0.0=debugAndroidTestCompileCl androidx.navigationevent:navigationevent-compose-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.navigationevent:navigationevent-compose:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.navigationevent:navigationevent:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.paging:paging-common-android:3.4.2=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.paging:paging-common-android:3.5.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath -androidx.paging:paging-common:3.4.2=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.paging:paging-common:3.5.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath -androidx.paging:paging-compose-android:3.4.2=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.paging:paging-compose-android:3.5.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath -androidx.paging:paging-compose:3.4.2=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.paging:paging-compose:3.5.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath +androidx.paging:paging-common-android:3.5.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.paging:paging-common:3.5.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.paging:paging-compose-android:3.5.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.paging:paging-compose:3.5.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.print:print:1.0.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.recyclerview:recyclerview:1.1.0=debugAndroidTestRuntimeClasspath androidx.resourceinspection:resourceinspection-annotation:1.0.1=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.room:room-common-jvm:2.8.4=_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspReleaseKotlinProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.room:room-common-jvm:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorDebug,kspDebugKotlinProcessorClasspath -androidx.room:room-common:2.8.4=_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspReleaseKotlinProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.room:room-common:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorDebug,kspDebugKotlinProcessorClasspath -androidx.room:room-compiler-processing:2.8.4=_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspReleaseKotlinProcessorClasspath -androidx.room:room-compiler-processing:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,hiltAnnotationProcessorDebug,kspDebugKotlinProcessorClasspath -androidx.room:room-compiler:2.8.4=_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspReleaseKotlinProcessorClasspath -androidx.room:room-compiler:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,hiltAnnotationProcessorDebug,kspDebugKotlinProcessorClasspath -androidx.room:room-external-antlr:2.8.4=_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspReleaseKotlinProcessorClasspath -androidx.room:room-external-antlr:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,hiltAnnotationProcessorDebug,kspDebugKotlinProcessorClasspath -androidx.room:room-ktx:2.8.4=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.room:room-ktx:2.8.5=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath -androidx.room:room-migration-jvm:2.8.4=_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspReleaseKotlinProcessorClasspath -androidx.room:room-migration-jvm:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,debugAndroidTestRuntimeClasspath,hiltAnnotationProcessorDebug,kspDebugKotlinProcessorClasspath -androidx.room:room-migration:2.8.4=_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspReleaseKotlinProcessorClasspath -androidx.room:room-migration:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,debugAndroidTestRuntimeClasspath,hiltAnnotationProcessorDebug,kspDebugKotlinProcessorClasspath -androidx.room:room-paging-android:2.8.4=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.room:room-paging-android:2.8.5=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath -androidx.room:room-paging:2.8.4=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.room:room-paging:2.8.5=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath -androidx.room:room-runtime-android:2.8.4=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.room:room-runtime-android:2.8.5=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath -androidx.room:room-runtime:2.8.4=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.room:room-runtime:2.8.5=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath +androidx.room:room-common-jvm:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-common:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-compiler-processing:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath +androidx.room:room-compiler:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath +androidx.room:room-external-antlr:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath +androidx.room:room-ktx:2.8.5=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-migration-jvm:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,debugAndroidTestRuntimeClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath +androidx.room:room-migration:2.8.5=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,debugAndroidTestRuntimeClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath +androidx.room:room-paging-android:2.8.5=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-paging:2.8.5=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-runtime-android:2.8.5=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.room:room-runtime:2.8.5=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.room:room-testing-android:2.8.5=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath androidx.room:room-testing:2.8.5=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath androidx.savedstate:savedstate-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath @@ -205,7 +187,6 @@ androidx.viewpager:viewpager:1.0.0=debugAndroidTestCompileClasspath,debugAndroid androidx.window:window-core-android:1.5.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.window:window-core:1.5.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.window:window:1.5.0=debugAndroidTestRuntimeClasspath,debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.work:work-runtime-ktx:2.11.2=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.work:work-runtime:2.11.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.work:work-testing:2.11.2=debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath ch.qos.logback:logback-classic:1.3.16=ktlint @@ -267,8 +248,7 @@ com.google.dagger:hilt-android:2.58=debugAndroidTestCompileClasspath,debugCompil com.google.dagger:hilt-compiler:2.58=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath com.google.dagger:hilt-core:2.58=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.google.devtools.ksp:symbol-processing-api:2.2.20-2.0.3=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath -com.google.devtools.ksp:symbol-processing-api:2.3.11=kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kspPluginClasspath,kspPluginClasspathNonEmbeddable -com.google.devtools.ksp:symbol-processing-api:2.3.12=kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest +com.google.devtools.ksp:symbol-processing-api:2.3.12=kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kspPluginClasspath,kspPluginClasspathNonEmbeddable com.google.errorprone:error_prone_annotation:2.41.0=debugUnitTestRuntimeClasspath,releaseUnitTestRuntimeClasspath com.google.errorprone:error_prone_annotations:2.23.0=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher com.google.errorprone:error_prone_annotations:2.26.1=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath @@ -323,10 +303,8 @@ com.squareup.moshi:moshi-kotlin:1.15.2=debugAndroidTestCompileClasspath,debugCom com.squareup.moshi:moshi:1.15.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.squareup.okhttp3:okhttp-android:5.4.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.squareup.okhttp3:okhttp:5.4.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -com.squareup.okio:okio-jvm:3.17.0=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -com.squareup.okio:okio-jvm:3.18.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath -com.squareup.okio:okio:3.17.0=releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -com.squareup.okio:okio:3.18.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath +com.squareup.okio:okio-jvm:3.18.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +com.squareup.okio:okio:3.18.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath com.squareup:javapoet:1.13.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath com.squareup:kotlinpoet-javapoet:2.0.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath com.squareup:kotlinpoet-jvm:2.0.0=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebug,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorRelease,hiltAnnotationProcessorReleaseUnitTest,kspDebugKotlinProcessorClasspath,kspReleaseKotlinProcessorClasspath From fab15a6cc9198d7415b7ffe31ed315de72d6af6c Mon Sep 17 00:00:00 2001 From: tikkamasalla <104959287+tikkamasalla@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:51:48 -0600 Subject: [PATCH 9/9] chore: regenerate settings dependency locks for merged catalog --- settings-gradle.lockfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/settings-gradle.lockfile b/settings-gradle.lockfile index 709a43f74..968892a98 100644 --- a/settings-gradle.lockfile +++ b/settings-gradle.lockfile @@ -1,4 +1,4 @@ -# This is a Gradle generated file for dependency locking. -# Manual edits can break the build and are not advised. -# This file is expected to be part of source control. -empty=incomingCatalogForLibs0 +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +empty=incomingCatalogForLibs0