Skip to content

Commit 94babc8

Browse files
tikkamasallaAows
andauthored
feat: answer and hang up blocked calls so spam can't leave voicemail (#26)
An "Answer & hang up blocked calls" switch in Settings, off by default. A call CallShield would reject is answered audio-only and ended after a delay the user sets (1 to 10 seconds, default 1), so spam can't leave a voicemail. - Applies only where the screening service would reject outright. Silent voicemail, low-confidence auto-mute and category voicemail actions keep their silence; with the switch off nothing changes. - The screening response is sent first. A process-wide AnswerHangUpController arms for that number only, and a manifest PHONE_STATE receiver answers the matching ring and ends the call once it's off-hook, backed by a 250 ms call-state poll. - Plain rejection is the fallback for a missing permission, an active call, audio in call or communication mode, a call already mid-answer, or any error. An outgoing call cancels a pending hang-up. - ANSWER_PHONE_CALLS is requested by the switch, not at setup. - Answered-caller trust and the repeated-urgent allow ignore incoming call-log rows under 15 seconds while the switch is on, so the short answered calls can't earn a spammer trust. - Backup and restore carry both settings, with the delay clamped. Tested by the contributor on an Android 15 emulator (adb emu gsm call) and a Galaxy S23 Ultra on Android 14. Refs #22. Co-authored-by: Aows <nulledsender@gmail.com>
1 parent 2cd6114 commit 94babc8

20 files changed

Lines changed: 1212 additions & 18 deletions

‎app/src/main/AndroidManifest.xml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,14 @@
155155
android:taskAffinity=""
156156
android:theme="@android:style/Theme.NoDisplay" />
157157

158+
<receiver
159+
android:name=".service.AnswerHangUpReceiver"
160+
android:exported="true">
161+
<intent-filter>
162+
<action android:name="android.intent.action.PHONE_STATE" />
163+
</intent-filter>
164+
</receiver>
165+
158166
<receiver
159167
android:name=".service.SmsReceiver"
160168
android:permission="android.permission.BROADCAST_SMS"

‎app/src/main/java/com/sysadmindoc/callshield/data/BackupRestore.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import com.sysadmindoc.callshield.data.model.RestoreJournal
1616
import com.sysadmindoc.callshield.data.model.SmsKeywordRule
1717
import com.sysadmindoc.callshield.data.model.WildcardRule
1818
import com.sysadmindoc.callshield.domain.model.BlockReasonCode
19+
import com.sysadmindoc.callshield.service.AnswerHangUpController
1920
import com.sysadmindoc.callshield.util.filterAsciiDigits
2021
import kotlinx.coroutines.Dispatchers
2122
import kotlinx.coroutines.flow.first
@@ -181,6 +182,8 @@ object BackupRestore {
181182
val rcsFilterEnabled: Boolean = true,
182183
val postCallScreenEnabled: Boolean = false,
183184
val silentVoicemailEnabled: Boolean = false,
185+
val answerHangUpEnabled: Boolean = false,
186+
val hangUpDelaySeconds: Int = AnswerHangUpController.DEFAULT_DELAY_SECONDS,
184187
val pushAlertEnabled: Boolean = true,
185188
val pushAlertDisabledPackages: List<String> = emptyList(),
186189
val regionBlockEnabled: Boolean = false,

‎app/src/main/java/com/sysadmindoc/callshield/data/BackupSettingsPreferences.kt‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@ package com.sysadmindoc.callshield.data
33
import androidx.datastore.preferences.core.MutablePreferences
44
import androidx.datastore.preferences.core.Preferences
55
import com.sysadmindoc.callshield.data.BackupRestore.BackupSettings
6+
import com.sysadmindoc.callshield.service.AnswerHangUpController
67

78
// Settings half of a backup: reading the DataStore snapshot into a
89
// BackupSettings, bounding what a (possibly hostile) backup carries, and
910
// writing it back. Pure functions over Preferences, kept out of BackupRestore
1011
// so each file stays readable.
1112

13+
internal fun clampHangUpDelaySeconds(value: Int?): Int =
14+
value?.coerceIn(
15+
AnswerHangUpController.MIN_DELAY_SECONDS,
16+
AnswerHangUpController.MAX_DELAY_SECONDS,
17+
) ?: AnswerHangUpController.DEFAULT_DELAY_SECONDS
18+
1219
@Suppress("LongMethod")
1320
internal fun Preferences.toBackupSettings(): BackupSettings =
1421
BackupSettings(
@@ -51,6 +58,8 @@ internal fun Preferences.toBackupSettings(): BackupSettings =
5158
rcsFilterEnabled = this[SpamRepository.KEY_RCS_FILTER] ?: true,
5259
postCallScreenEnabled = this[SpamRepository.KEY_POST_CALL_SCREEN] ?: false,
5360
silentVoicemailEnabled = this[SpamRepository.KEY_SILENT_VOICEMAIL] ?: false,
61+
answerHangUpEnabled = this[SpamRepository.KEY_ANSWER_HANG_UP] ?: false,
62+
hangUpDelaySeconds = clampHangUpDelaySeconds(this[SpamRepository.KEY_HANG_UP_DELAY_SECONDS]),
5463
pushAlertEnabled = this[SpamRepository.KEY_PUSH_ALERT] ?: true,
5564
pushAlertDisabledPackages = (this[SpamRepository.KEY_PUSH_ALERT_DISABLED] ?: emptySet()).sorted(),
5665
regionBlockEnabled = this[SpamRepository.KEY_REGION_BLOCK] ?: false,
@@ -94,6 +103,7 @@ internal fun BackupSettings.sanitized(): BackupSettings =
94103
timeBlockEndHour = BackupRestore.sanitizeScheduleHour(timeBlockEndHour),
95104
frequencyThreshold = frequencyThreshold.coerceIn(1, 25),
96105
cleanupDays = cleanupDays.coerceIn(1, 365),
106+
hangUpDelaySeconds = clampHangUpDelaySeconds(hangUpDelaySeconds),
97107
pushAlertDisabledPackages =
98108
pushAlertDisabledPackages
99109
.map { it.trim() }
@@ -162,6 +172,8 @@ internal fun BackupSettings.writeTo(preferences: MutablePreferences) {
162172
preferences[SpamRepository.KEY_RCS_FILTER] = rcsFilterEnabled
163173
preferences[SpamRepository.KEY_POST_CALL_SCREEN] = postCallScreenEnabled
164174
preferences[SpamRepository.KEY_SILENT_VOICEMAIL] = silentVoicemailEnabled
175+
preferences[SpamRepository.KEY_ANSWER_HANG_UP] = answerHangUpEnabled
176+
preferences[SpamRepository.KEY_HANG_UP_DELAY_SECONDS] = hangUpDelaySeconds
165177
preferences[SpamRepository.KEY_PUSH_ALERT] = pushAlertEnabled
166178
if (pushAlertDisabledPackages.isEmpty()) {
167179
preferences.remove(SpamRepository.KEY_PUSH_ALERT_DISABLED)

‎app/src/main/java/com/sysadmindoc/callshield/data/CallbackDetector.kt‎

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,21 @@ class CallbackDetector
8383
windowMinutes: Int = 5,
8484
threshold: Int = 2,
8585
minRetryIntervalMillis: Long = MIN_URGENT_RETRY_INTERVAL_MILLIS,
86+
minIncomingDurationSeconds: Int = 0,
8687
): Boolean =
8788
withContext(Dispatchers.IO) {
8889
val digits = filterAsciiDigitsLast(number, 10)
8990
if (digits.length < 7) return@withContext false
9091
val last7 = digits.takeLast(7)
9192

9293
try {
93-
val query = buildRepeatedUrgentCallQuery(System.currentTimeMillis(), windowMinutes, last7)
94+
val query =
95+
buildRepeatedUrgentCallQuery(
96+
nowMillis = System.currentTimeMillis(),
97+
windowMinutes = windowMinutes,
98+
last7Digits = last7,
99+
minIncomingDurationSeconds = minIncomingDurationSeconds,
100+
)
94101
val cursor =
95102
context.contentResolver.query(
96103
CallLog.Calls.CONTENT_URI,
@@ -251,15 +258,32 @@ class CallbackDetector
251258
nowMillis: Long,
252259
windowMinutes: Int,
253260
last7Digits: String,
261+
minIncomingDurationSeconds: Int = 0,
254262
): CallLogQuery {
255263
val safeWindowMinutes = windowMinutes.coerceAtLeast(1)
256264
val cutoff = (nowMillis - safeWindowMinutes * 60_000L).toString()
265+
if (minIncomingDurationSeconds <= 0) {
266+
return CallLogQuery(
267+
selection = "${CallLog.Calls.TYPE} IN (?, ?) AND ${CallLog.Calls.DATE} > ? AND ${CallLog.Calls.NUMBER} LIKE ?",
268+
selectionArgs =
269+
arrayOf(
270+
CallLog.Calls.INCOMING_TYPE.toString(),
271+
CallLog.Calls.MISSED_TYPE.toString(),
272+
cutoff,
273+
"%$last7Digits",
274+
),
275+
)
276+
}
257277
return CallLogQuery(
258-
selection = "${CallLog.Calls.TYPE} IN (?, ?) AND ${CallLog.Calls.DATE} > ? AND ${CallLog.Calls.NUMBER} LIKE ?",
278+
selection =
279+
"(${CallLog.Calls.TYPE} = ? OR (${CallLog.Calls.TYPE} = ? AND " +
280+
"${CallLog.Calls.DURATION} >= ?)) AND ${CallLog.Calls.DATE} > ? " +
281+
"AND ${CallLog.Calls.NUMBER} LIKE ?",
259282
selectionArgs =
260283
arrayOf(
261-
CallLog.Calls.INCOMING_TYPE.toString(),
262284
CallLog.Calls.MISSED_TYPE.toString(),
285+
CallLog.Calls.INCOMING_TYPE.toString(),
286+
minIncomingDurationSeconds.toString(),
263287
cutoff,
264288
"%$last7Digits",
265289
),
@@ -320,6 +344,9 @@ class CallbackDetector
320344
const val DEFAULT_ANSWERED_CALLER_THRESHOLD = 2
321345
const val DEFAULT_EMERGENCY_CALLBACK_WINDOW_MINUTES = 240
322346
const val MIN_ANSWERED_CALL_DURATION_SECONDS = 1
347+
348+
// Answer-and-hang-up creates 1-11 second incoming rows; SpamBlocker's trust rule uses this 15-second floor.
349+
const val AUTO_ANSWER_MIN_TRUSTED_SECONDS = 15
323350
const val MIN_URGENT_RETRY_INTERVAL_MILLIS = 15_000L
324351
private const val MILLIS_PER_DAY = 86_400_000L
325352
private const val MILLIS_PER_MINUTE = 60_000L
@@ -341,13 +368,15 @@ class CallbackDetector
341368
windowMinutes: Int = 5,
342369
threshold: Int = 2,
343370
minRetryIntervalMillis: Long = MIN_URGENT_RETRY_INTERVAL_MILLIS,
371+
minIncomingDurationSeconds: Int = 0,
344372
): Boolean =
345373
shared.isRepeatedUrgentCall(
346374
context,
347375
number,
348376
windowMinutes,
349377
threshold,
350378
minRetryIntervalMillis,
379+
minIncomingDurationSeconds,
351380
)
352381

353382
suspend fun wasAnsweredRepeatedly(
@@ -373,7 +402,14 @@ class CallbackDetector
373402
nowMillis: Long,
374403
windowMinutes: Int,
375404
last7Digits: String,
376-
): CallLogQuery = shared.buildRepeatedUrgentCallQuery(nowMillis, windowMinutes, last7Digits)
405+
minIncomingDurationSeconds: Int = 0,
406+
): CallLogQuery =
407+
shared.buildRepeatedUrgentCallQuery(
408+
nowMillis,
409+
windowMinutes,
410+
last7Digits,
411+
minIncomingDurationSeconds,
412+
)
377413

378414
internal fun hasUrgentRetrySpacing(
379415
timestamps: List<Long>,

‎app/src/main/java/com/sysadmindoc/callshield/data/SpamRepository.kt‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@ class SpamRepository(
255255
// reaches voicemail, user can review later without the interruption
256256
// or the missed-call entry from a rejection.
257257
val KEY_SILENT_VOICEMAIL = booleanPreferencesKey("silent_voicemail_mode")
258+
val KEY_ANSWER_HANG_UP = booleanPreferencesKey("answer_hang_up_mode")
259+
val KEY_HANG_UP_DELAY_SECONDS = intPreferencesKey("hang_up_delay_seconds")
258260

259261
// A3 push-alert bridge — master toggle. When off, the registry is
260262
// not fed by RcsNotificationListener and PushAlertChecker returns
@@ -429,6 +431,8 @@ class SpamRepository(
429431
val postCallScreenEnabled: Flow<Boolean> = settingsRepository.postCallScreenEnabled
430432
val notificationScreeningPackages: Flow<Set<String>> = settingsRepository.notificationScreeningPackages
431433
val silentVoicemailEnabled: Flow<Boolean> = settingsRepository.silentVoicemailEnabled
434+
val answerHangUpEnabled: Flow<Boolean> = settingsRepository.answerHangUpEnabled
435+
val hangUpDelaySeconds: Flow<Int> = settingsRepository.hangUpDelaySeconds
432436
val pushAlertEnabled: Flow<Boolean> = settingsRepository.pushAlertEnabled
433437
val pushAlertDisabledPackages: Flow<Set<String>> = settingsRepository.pushAlertDisabledPackages
434438
internal val smsMessageCapabilityStatus: Flow<MessageCapabilityStatus> = settingsRepository.smsMessageCapabilityStatus
@@ -526,6 +530,10 @@ class SpamRepository(
526530

527531
suspend fun setSilentVoicemail(enabled: Boolean) = settingsRepository.setSilentVoicemail(enabled)
528532

533+
suspend fun setAnswerHangUpEnabled(enabled: Boolean) = settingsRepository.setAnswerHangUpEnabled(enabled)
534+
535+
suspend fun setHangUpDelaySeconds(seconds: Int) = settingsRepository.setHangUpDelaySeconds(seconds)
536+
529537
suspend fun setPushAlert(enabled: Boolean) = settingsRepository.setPushAlert(enabled)
530538

531539
suspend fun togglePushAlertPackage(

‎app/src/main/java/com/sysadmindoc/callshield/data/checker/Checkers.kt‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,23 @@ internal class AnsweredCallerChecker(
584584
?: CallbackDetector.DEFAULT_ANSWERED_CALLER_WINDOW_DAYS
585585
).coerceAtLeast(1)
586586

587-
return if (callbackDetector.wasAnsweredRepeatedly(appContext, ctx.number, windowDays, threshold)) {
587+
val wasAnsweredRepeatedly =
588+
if (ctx.prefs[SpamRepository.KEY_ANSWER_HANG_UP] == true) {
589+
callbackDetector.wasAnsweredRepeatedly(
590+
appContext,
591+
ctx.number,
592+
windowDays,
593+
threshold,
594+
maxOf(
595+
CallbackDetector.MIN_ANSWERED_CALL_DURATION_SECONDS,
596+
CallbackDetector.AUTO_ANSWER_MIN_TRUSTED_SECONDS,
597+
),
598+
)
599+
} else {
600+
callbackDetector.wasAnsweredRepeatedly(appContext, ctx.number, windowDays, threshold)
601+
}
602+
603+
return if (wasAnsweredRepeatedly) {
588604
BlockResult.allow("answered_caller")
589605
} else {
590606
null
@@ -624,7 +640,18 @@ internal class RepeatedUrgentChecker(
624640
override val name = "repeated_urgent"
625641

626642
override suspend fun check(ctx: CheckContext): BlockResult? =
627-
if (callbackDetector.isRepeatedUrgentCall(appContext, ctx.number)) {
643+
if (
644+
callbackDetector.isRepeatedUrgentCall(
645+
appContext,
646+
ctx.number,
647+
minIncomingDurationSeconds =
648+
if (ctx.prefs[SpamRepository.KEY_ANSWER_HANG_UP] == true) {
649+
CallbackDetector.AUTO_ANSWER_MIN_TRUSTED_SECONDS
650+
} else {
651+
0
652+
},
653+
)
654+
) {
628655
BlockResult.allow("repeated_urgent")
629656
} else {
630657
null

‎app/src/main/java/com/sysadmindoc/callshield/data/repository/SettingsRepository.kt‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import com.sysadmindoc.callshield.data.model.ExternalBlocklistSubscription
2929
import com.sysadmindoc.callshield.data.model.HotDataHealth
3030
import com.sysadmindoc.callshield.data.model.HotDataHealthUpdate
3131
import com.sysadmindoc.callshield.data.remote.FeedMirror
32+
import com.sysadmindoc.callshield.service.AnswerHangUpController
3233
import kotlinx.coroutines.flow.Flow
3334
import kotlinx.coroutines.flow.distinctUntilChanged
3435
import kotlinx.coroutines.flow.first
@@ -193,6 +194,12 @@ class SettingsRepository(
193194
}
194195
val silentVoicemailEnabled: Flow<Boolean> =
195196
dataStore.data.map { it[SpamRepository.KEY_SILENT_VOICEMAIL] ?: false }
197+
val answerHangUpEnabled: Flow<Boolean> =
198+
dataStore.data.map { it[SpamRepository.KEY_ANSWER_HANG_UP] ?: false }
199+
val hangUpDelaySeconds: Flow<Int> =
200+
dataStore.data.map {
201+
AnswerHangUpController.clampDelaySeconds(it[SpamRepository.KEY_HANG_UP_DELAY_SECONDS])
202+
}
196203
val pushAlertEnabled: Flow<Boolean> = dataStore.data.map { it[SpamRepository.KEY_PUSH_ALERT] ?: true }
197204
val pushAlertDisabledPackages: Flow<Set<String>> =
198205
dataStore.data.map { it[SpamRepository.KEY_PUSH_ALERT_DISABLED] ?: emptySet() }
@@ -363,6 +370,13 @@ class SettingsRepository(
363370

364371
suspend fun setSilentVoicemail(enabled: Boolean) = dataStore.edit { it[SpamRepository.KEY_SILENT_VOICEMAIL] = enabled }
365372

373+
suspend fun setAnswerHangUpEnabled(enabled: Boolean) = dataStore.edit { it[SpamRepository.KEY_ANSWER_HANG_UP] = enabled }
374+
375+
suspend fun setHangUpDelaySeconds(seconds: Int) =
376+
dataStore.edit {
377+
it[SpamRepository.KEY_HANG_UP_DELAY_SECONDS] = AnswerHangUpController.clampDelaySeconds(seconds)
378+
}
379+
366380
suspend fun setPushAlert(enabled: Boolean) = dataStore.edit { it[SpamRepository.KEY_PUSH_ALERT] = enabled }
367381

368382
internal suspend fun recordMessageCapability(status: MessageCapabilityStatus) =

‎app/src/main/java/com/sysadmindoc/callshield/permissions/CallShieldPermissions.kt‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,14 @@ object CallShieldPermissions {
104104
* Runtime grants requested during first-run setup. Keeping this list next
105105
* to the capability contract prevents onboarding from silently omitting a
106106
* permission that the live call path expects on some Android builds.
107+
*
108+
* ANSWER_PHONE_CALLS is deliberately NOT requested here: it is asked for
109+
* only when the user enables "Answer & hang up blocked calls" in
110+
* Settings, so users who never enable that feature are never prompted
111+
* for it.
107112
*/
108-
val onboardingRuntimePermissions = (corePermissions + compatibilityPermissions).distinct()
113+
val onboardingRuntimePermissions =
114+
(corePermissions + listOf(Manifest.permission.READ_PHONE_STATE)).distinct()
109115

110116
val permissionCapabilityContracts =
111117
listOf(

0 commit comments

Comments
 (0)