Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 21 additions & 43 deletions app/gradle.lockfile

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@
android:name=".service.CallerIdOverlayService"
android:exported="false" />

<receiver
android:name=".service.AnswerHangUpReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.PHONE_STATE" />
</intent-filter>
</receiver>

<receiver
android:name=".service.SmsReceiver"
android:permission="android.permission.BROADCAST_SMS"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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
Expand Down Expand Up @@ -181,6 +182,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<String> = emptyList(),
val regionBlockEnabled: Boolean = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@ package com.sysadmindoc.callshield.data
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import com.sysadmindoc.callshield.data.BackupRestore.BackupSettings
import com.sysadmindoc.callshield.service.AnswerHangUpController

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

internal fun clampHangUpDelaySeconds(value: Int?): Int =
value?.coerceIn(
AnswerHangUpController.MIN_DELAY_SECONDS,
AnswerHangUpController.MAX_DELAY_SECONDS,
) ?: AnswerHangUpController.DEFAULT_DELAY_SECONDS

@Suppress("LongMethod")
internal fun Preferences.toBackupSettings(): BackupSettings =
BackupSettings(
Expand Down Expand Up @@ -51,6 +58,8 @@ internal fun Preferences.toBackupSettings(): BackupSettings =
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 = 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,
Expand Down Expand Up @@ -94,6 +103,7 @@ internal fun BackupSettings.sanitized(): BackupSettings =
timeBlockEndHour = BackupRestore.sanitizeScheduleHour(timeBlockEndHour),
frequencyThreshold = frequencyThreshold.coerceIn(1, 25),
cleanupDays = cleanupDays.coerceIn(1, 365),
hangUpDelaySeconds = clampHangUpDelaySeconds(hangUpDelaySeconds),
pushAlertDisabledPackages =
pushAlertDisabledPackages
.map { it.trim() }
Expand Down Expand Up @@ -162,6 +172,8 @@ internal fun BackupSettings.writeTo(preferences: MutablePreferences) {
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,21 @@ 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)
if (digits.length < 7) return@withContext false
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,
Expand Down Expand Up @@ -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",
),
Expand Down Expand Up @@ -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
Expand All @@ -341,13 +368,15 @@ class CallbackDetector
windowMinutes: Int = 5,
threshold: Int = 2,
minRetryIntervalMillis: Long = MIN_URGENT_RETRY_INTERVAL_MILLIS,
minIncomingDurationSeconds: Int = 0,
): Boolean =
shared.isRepeatedUrgentCall(
context,
number,
windowMinutes,
threshold,
minRetryIntervalMillis,
minIncomingDurationSeconds,
)

suspend fun wasAnsweredRepeatedly(
Expand All @@ -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<Long>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ class SpamRepository(
// reaches voicemail, user can review later without the interruption
// or the missed-call entry from a rejection.
val KEY_SILENT_VOICEMAIL = booleanPreferencesKey("silent_voicemail_mode")
val KEY_ANSWER_HANG_UP = booleanPreferencesKey("answer_hang_up_mode")
val KEY_HANG_UP_DELAY_SECONDS = intPreferencesKey("hang_up_delay_seconds")

// A3 push-alert bridge — master toggle. When off, the registry is
// not fed by RcsNotificationListener and PushAlertChecker returns
Expand Down Expand Up @@ -422,6 +424,8 @@ class SpamRepository(
val postCallScreenEnabled: Flow<Boolean> = settingsRepository.postCallScreenEnabled
val notificationScreeningPackages: Flow<Set<String>> = settingsRepository.notificationScreeningPackages
val silentVoicemailEnabled: Flow<Boolean> = settingsRepository.silentVoicemailEnabled
val answerHangUpEnabled: Flow<Boolean> = settingsRepository.answerHangUpEnabled
val hangUpDelaySeconds: Flow<Int> = settingsRepository.hangUpDelaySeconds
val pushAlertEnabled: Flow<Boolean> = settingsRepository.pushAlertEnabled
val pushAlertDisabledPackages: Flow<Set<String>> = settingsRepository.pushAlertDisabledPackages
internal val smsMessageCapabilityStatus: Flow<MessageCapabilityStatus> = settingsRepository.smsMessageCapabilityStatus
Expand Down Expand Up @@ -519,6 +523,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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,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
Expand Down Expand Up @@ -619,7 +635,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import com.sysadmindoc.callshield.data.model.ExternalBlocklistSubscription
import com.sysadmindoc.callshield.data.model.HotDataHealth
import com.sysadmindoc.callshield.data.model.HotDataHealthUpdate
import com.sysadmindoc.callshield.data.remote.FeedMirror
import com.sysadmindoc.callshield.service.AnswerHangUpController
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
Expand Down Expand Up @@ -182,6 +183,12 @@ class SettingsRepository(
}
val silentVoicemailEnabled: Flow<Boolean> =
dataStore.data.map { it[SpamRepository.KEY_SILENT_VOICEMAIL] ?: false }
val answerHangUpEnabled: Flow<Boolean> =
dataStore.data.map { it[SpamRepository.KEY_ANSWER_HANG_UP] ?: false }
val hangUpDelaySeconds: Flow<Int> =
dataStore.data.map {
AnswerHangUpController.clampDelaySeconds(it[SpamRepository.KEY_HANG_UP_DELAY_SECONDS])
}
val pushAlertEnabled: Flow<Boolean> = dataStore.data.map { it[SpamRepository.KEY_PUSH_ALERT] ?: true }
val pushAlertDisabledPackages: Flow<Set<String>> =
dataStore.data.map { it[SpamRepository.KEY_PUSH_ALERT_DISABLED] ?: emptySet() }
Expand Down Expand Up @@ -352,6 +359,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) =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading