Skip to content

Commit 7460d25

Browse files
committed
fix(home): count only runnable engines and explain sync failures
Home counted every detection toggle plus the database as an engine, so a phone with no screener role, no SMS permission and no notification access still read eight engines. A toggle now counts only when a screened call, a received text or an RCS notification can reach it. Home counted three setup steps (access, database, screener) while Settings and setup counted two. All three now share requiredSetupProgress; Home checks the database separately. Home also asks for notification access when RCS filtering, push-alert caller trust or meeting mode is on without it. A failed sync showed the raw exception ("Sync unavailable (Unable to resolve host ...)"). SyncFailureText maps offline, DNS, TLS, HTTP, signature and parse failures to a sentence and the raw error goes to the log. The retry decision reads the HTTP code from the exception instead of the message.
1 parent a21d6ab commit 7460d25

14 files changed

Lines changed: 488 additions & 89 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ All notable changes to CallShield will be documented in this file.
44

55
## Unreleased
66

7+
- Home counts only the engines that can run. A toggle whose permission,
8+
screener role or notification access is missing no longer adds to the
9+
count. Home and Settings count the same two setup steps, and Home asks for
10+
notification access when RCS filtering, push-alert caller trust or meeting
11+
mode is on without it. A failed sync says what went wrong in a sentence
12+
(offline, lookup, secure connection, a server error, an unsigned or damaged
13+
update) instead of the raw error.
714
- The caller ID popup works with TalkBack. Its header and verdict are read out
815
as they change, its text is at least 14sp, every button is a 48dp target,
916
the verdict line says why a call looks risky, and Search looks the number up

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,7 @@ language in [issue #7](https://github.com/SysAdminDoc/CallShield/issues/7).
553553
| URL Safety | Local spam-domain data; optional PhishTank and OpenPhish |
554554
| Verification | Local Gradle, lint, and release-artifact checks |
555555
| Tests | 1594 JVM unit tests (JUnit) |
556-
| Strings | 1670 string resources and 38 plural groups (translation-ready) |
556+
| Strings | 1678 string resources and 38 plural groups (translation-ready) |
557557
| Accessibility | 100+ content descriptions, 48dp touch targets |
558558
| Min SDK | 29 (Android 10) |
559559
| Target SDK | 36 |
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package com.sysadmindoc.callshield.data.repository
2+
3+
import android.content.Context
4+
import com.squareup.moshi.JsonDataException
5+
import com.squareup.moshi.JsonEncodingException
6+
import com.sysadmindoc.callshield.R
7+
import com.sysadmindoc.callshield.data.remote.GitHubFeedFailureReason
8+
import com.sysadmindoc.callshield.data.remote.GitHubFeedHttpException
9+
import com.sysadmindoc.callshield.data.remote.GitHubFeedValidationException
10+
import java.io.IOException
11+
import java.net.UnknownHostException
12+
import javax.net.ssl.SSLException
13+
14+
internal enum class SyncFailureKind {
15+
OFFLINE,
16+
DNS,
17+
SECURE_CONNECTION,
18+
HTTP,
19+
SIGNATURE,
20+
PARSE,
21+
UNKNOWN,
22+
}
23+
24+
/**
25+
* Turns a failed database sync into one plain sentence. The exception text
26+
* ("Unable to resolve host ...", "HTTP 404: Not Found") used to be the message
27+
* Home showed, so SyncRepository now logs it and shows this instead.
28+
*/
29+
internal object SyncFailureText {
30+
private const val MAX_CAUSE_DEPTH = 8
31+
private const val EMPTY_BODY = "Empty response body"
32+
private val httpStatus = Regex("""\bHTTP (\d{3})\b""")
33+
34+
/** A feed host that refuses the request this way will refuse the retry too. */
35+
private val permanentHttpCodes = setOf(400, 401, 403, 404)
36+
37+
fun kind(error: Throwable?): SyncFailureKind = causes(error).firstNotNullOfOrNull(::kindOf) ?: SyncFailureKind.UNKNOWN
38+
39+
fun httpCode(error: Throwable?): Int? =
40+
causes(error).firstNotNullOfOrNull { cause ->
41+
(cause as? GitHubFeedHttpException)?.code
42+
?: cause.message?.let { message ->
43+
httpStatus
44+
.find(message)
45+
?.groupValues
46+
?.get(1)
47+
?.toIntOrNull()
48+
}
49+
}
50+
51+
fun shouldRetry(error: Throwable?): Boolean = httpCode(error) !in permanentHttpCodes
52+
53+
fun sentence(
54+
context: Context,
55+
error: Throwable?,
56+
): String =
57+
when (kind(error)) {
58+
SyncFailureKind.OFFLINE -> context.getString(R.string.sync_failure_offline)
59+
SyncFailureKind.DNS -> context.getString(R.string.sync_failure_dns)
60+
SyncFailureKind.SECURE_CONNECTION -> context.getString(R.string.sync_failure_secure_connection)
61+
SyncFailureKind.HTTP -> context.getString(R.string.sync_failure_http, httpCode(error) ?: 0)
62+
SyncFailureKind.SIGNATURE -> context.getString(R.string.sync_failure_signature)
63+
SyncFailureKind.PARSE -> context.getString(R.string.sync_failure_parse)
64+
SyncFailureKind.UNKNOWN -> context.getString(R.string.sync_unknown_error)
65+
}
66+
67+
private fun causes(error: Throwable?): List<Throwable> = generateSequence(error) { it.cause }.take(MAX_CAUSE_DEPTH).toList()
68+
69+
// Moshi's JsonEncodingException is an IOException, so parse failures are
70+
// matched before the network ones.
71+
private fun kindOf(error: Throwable): SyncFailureKind? =
72+
when (error) {
73+
is GitHubFeedValidationException -> {
74+
if (error.reason == GitHubFeedFailureReason.SIGNATURE) SyncFailureKind.SIGNATURE else SyncFailureKind.PARSE
75+
}
76+
77+
is SpamFeedManifestRejectedException, is JsonDataException, is JsonEncodingException -> {
78+
SyncFailureKind.PARSE
79+
}
80+
81+
is GitHubFeedHttpException -> {
82+
SyncFailureKind.HTTP
83+
}
84+
85+
is UnknownHostException -> {
86+
SyncFailureKind.DNS
87+
}
88+
89+
is SSLException -> {
90+
SyncFailureKind.SECURE_CONNECTION
91+
}
92+
93+
is IOException -> {
94+
SyncFailureKind.OFFLINE
95+
}
96+
97+
else -> {
98+
val message = error.message.orEmpty()
99+
when {
100+
httpStatus.containsMatchIn(message) -> SyncFailureKind.HTTP
101+
message == EMPTY_BODY -> SyncFailureKind.PARSE
102+
else -> null
103+
}
104+
}
105+
}
106+
}

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

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,9 @@ class SyncRepository(
134134
)
135135
}
136136

137-
val remoteError =
138-
result.exceptionOrNull()?.message
139-
?: context.getString(R.string.sync_unknown_error)
137+
val remoteFailure = result.exceptionOrNull()
138+
android.util.Log.w("SyncRepository", "Spam database download failed", remoteFailure)
139+
val remoteError = SyncFailureText.sentence(context, remoteFailure)
140140
if (currentCount > 0) {
141141
return@withContext SyncResult(
142142
success = true,
@@ -167,24 +167,17 @@ class SyncRepository(
167167
)
168168
}
169169

170-
val bundledError = bundledDatabase.exceptionOrNull()?.message
171-
val message =
172-
buildString {
173-
append(context.getString(R.string.sync_unavailable_prefix, remoteError))
174-
if (!bundledError.isNullOrBlank()) {
175-
append(context.getString(R.string.sync_bundled_fallback_failed, bundledError))
176-
}
177-
}
178-
170+
android.util.Log.w("SyncRepository", "Bundled spam database failed to load", bundledDatabase.exceptionOrNull())
179171
SyncResult(
180172
success = false,
181-
message = message,
182-
shouldRetry = shouldRetrySync(remoteError),
173+
message = context.getString(R.string.sync_failed_no_fallback, remoteError),
174+
shouldRetry = SyncFailureText.shouldRetry(remoteFailure),
183175
)
184176
} catch (e: Exception) {
177+
android.util.Log.w("SyncRepository", "Spam database sync failed", e)
185178
SyncResult(
186179
success = false,
187-
message = context.getString(R.string.sync_error, e.message ?: ""),
180+
message = context.getString(R.string.sync_failed, SyncFailureText.sentence(context, e)),
188181
shouldRetry = true,
189182
)
190183
}
@@ -585,12 +578,14 @@ class SyncRepository(
585578
return numbers.size to prefixes.size
586579
}
587580

588-
private fun retainedDatabaseWarning(error: Throwable): SyncResult =
589-
SyncResult(
581+
private fun retainedDatabaseWarning(error: Throwable): SyncResult {
582+
android.util.Log.w("SyncRepository", "Spam feed refused; keeping the current database", error)
583+
return SyncResult(
590584
success = true,
591-
message = context.getString(R.string.sync_remote_unavailable_existing, error.message ?: "Invalid feed metadata"),
585+
message = context.getString(R.string.sync_remote_unavailable_existing, SyncFailureText.sentence(context, error)),
592586
warning = true,
593587
)
588+
}
594589

595590
private suspend fun readPreservedUserBlocks(now: Long = System.currentTimeMillis()): Map<String, Long?> =
596591
dao
@@ -966,11 +961,6 @@ class SyncRepository(
966961
path: String,
967962
): String? = preFetchSha.takeUnless { remote.lastServedByMirror(path) }
968963

969-
private fun shouldRetrySync(message: String): Boolean {
970-
val permanentFailureCodes = listOf("HTTP 400", "HTTP 401", "HTTP 403", "HTTP 404")
971-
return permanentFailureCodes.none { code -> message.contains(code) }
972-
}
973-
974964
private class RemovedExternalBlocklist(
975965
val subscription: ExternalBlocklistSubscription,
976966
val position: Int,

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ package com.sysadmindoc.callshield.permissions
22

33
import android.Manifest
44
import android.app.role.RoleManager
5+
import android.content.ComponentName
56
import android.content.Context
7+
import android.content.Intent
68
import android.content.pm.PackageManager
79
import android.os.Build
810
import android.provider.Settings
911
import androidx.core.app.NotificationManagerCompat
1012
import androidx.core.content.ContextCompat
1113
import com.sysadmindoc.callshield.R
14+
import com.sysadmindoc.callshield.service.RcsNotificationListener
1215

1316
enum class PermissionCapabilityId {
1417
CallScreeningRole,
@@ -311,6 +314,17 @@ object CallShieldPermissions {
311314
.getEnabledListenerPackages(context)
312315
.contains(context.packageName)
313316

317+
/** CallShield's own notification access toggle where Android has one (API 30+), else the list of listeners. */
318+
fun notificationAccessIntent(context: Context): Intent =
319+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
320+
Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS).putExtra(
321+
Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME,
322+
ComponentName(context, RcsNotificationListener::class.java).flattenToString(),
323+
)
324+
} else {
325+
Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
326+
}
327+
314328
fun permissionContractStates(
315329
context: Context,
316330
roleManager: RoleManager? = context.getSystemService(Context.ROLE_SERVICE) as? RoleManager,

0 commit comments

Comments
 (0)