Skip to content

Commit f96810f

Browse files
committed
fix: stop broadcasting to our own receivers for post-call spam and widget refresh
Android 17 QPR2 no longer raises the sender's process importance for a broadcast an app sends to itself, and drops it when the process dies first. Two paths depended on that: - PostCallActivity's Spam button sent FEEDBACK_SPAM to SpamActionReceiver and finished at once, so the block and report could be lost. The receiver's FEEDBACK_SPAM work is now a shared function the activity calls directly, wrapped in runAction so a failure shows the failure toast instead of escaping into appScope after the screen closed. - CallShieldWidget.refreshAll broadcast ACTION_APPWIDGET_UPDATE to its own provider from background syncs. It now renders placed widgets directly through AppWidgetManager on appScope. The widget is a RemoteViews AppWidgetProvider, not Glance, so its own update path replaces the broadcast. onUpdate tolerates a null goAsync() result for hosts that call it directly. NoSelfBroadcastTest fails if any main source calls sendBroadcast again. Tests: SpamActionReceiverRobolectricTest gains the shared-path and failure-toast cases. Its existing tests leave receiver work running on appScope that reports through the same fake transport, so the new case compares only its own number's reports and uses the shared repository those tests already write to. CallShieldWidgetTest is new.
1 parent e491d5a commit f96810f

8 files changed

Lines changed: 259 additions & 56 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ All notable changes to CallShield will be documented in this file.
4848
- Database v57 adds the FCC complaints published through 2026-10-29 and FTC's through 2026-10-28, for 51,379 numbers, and refreshes the 652 French telemarketing ranges. The community watch list now carries 7 numbers that several people reported.
4949
- The Simplified Chinese translation now covers nearly every screen, with 311 more strings and every plural, from aixtin (#42). Only the two newest strings still show in English.
5050
- The Android post-call screen has a Call back button (#43). It opens your dialer with the number filled in, so a call that dropped is one tap from a redial and CallShield still needs no permission to place calls.
51+
- Spam on the post-call screen now records the block and the report itself, and the home screen widget redraws itself when your lists change. Both used to hand the job to the app's own receiver with a broadcast, which Android 17 QPR2 no longer delivers once the app has left the screen, so the block could have been lost.
5152

5253
## v1.12.0 (2026-10-09)
5354

‎README.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
<p align="center">
1313
<a href="https://github.com/SysAdminDoc/CallShield/releases/latest"><img src="https://img.shields.io/github/v/release/SysAdminDoc/CallShield?style=flat-square&color=a6e3a1" alt="Release"></a>
1414
<img src="https://img.shields.io/badge/Spam%20Numbers-51%2C379-f38ba8?style=flat-square" alt="51,379 Numbers">
15-
<img src="https://img.shields.io/badge/JVM%20unit%20tests-2021-94e2d5?style=flat-square" alt="2021 JVM unit tests">
15+
<img src="https://img.shields.io/badge/JVM%20unit%20tests-2026-94e2d5?style=flat-square" alt="2026 JVM unit tests">
1616
<img src="https://img.shields.io/badge/Android-10%2B-89b4fa?style=flat-square" alt="Android 10+">
1717
<img src="https://img.shields.io/badge/License-MIT-cba6f7?style=flat-square" alt="MIT License">
1818
<img src="https://img.shields.io/badge/API%20Keys-None-fab387?style=flat-square" alt="No required API keys">
@@ -592,12 +592,12 @@ RELEASE_KEY_PASSWORD=...
592592
## Testing
593593

594594
```bash
595-
./gradlew testDebugUnitTest # 2021 tests
595+
./gradlew testDebugUnitTest # 2026 tests
596596
./gradlew connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.sysadmindoc.callshield.platform.TargetSdkBehaviorSmokeTest
597597
./gradlew verifyPipelineTests # Cloudflare Worker (node) + data-pipeline and translation checks (python)
598598
```
599599

600-
The suite is **2021 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
600+
The suite is **2026 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
601601

602602
Two GitHub workflows run without building the app. **Validation** runs the Worker and
603603
pipeline suites on every push except report-only ones (`run-pipeline-tests.ps1 -CorrectnessOnly`),
@@ -634,7 +634,7 @@ language in [issue #7](https://github.com/SysAdminDoc/CallShield/issues/7).
634634
| Community API | Cloudflare Workers |
635635
| URL Safety | Local spam-domain data, with optional PhishTank and OpenPhish |
636636
| Verification | Local Gradle, lint, and release-artifact checks |
637-
| Tests | 2021 JVM unit tests (JUnit) |
637+
| Tests | 2026 JVM unit tests (JUnit) |
638638
| Strings | 1792 string resources and 49 plural groups (translation-ready) |
639639
| Accessibility | 100+ content descriptions, 48dp touch targets |
640640
| Min SDK | 29 (Android 10) |

‎app/src/main/java/com/sysadmindoc/callshield/service/SpamActionReceiver.kt‎

Lines changed: 66 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,7 @@ class SpamActionReceiver : BroadcastReceiver() {
2929
when (intent.action) {
3030
ACTION_FEEDBACK_SPAM -> {
3131
val number = intent.getStringExtra(EXTRA_FEEDBACK_NUMBER) ?: return
32-
notificationManager.cancel(NotificationHelper.feedbackNotificationId(number))
33-
Toast.makeText(appContext, appContext.getString(R.string.feedback_blocked), Toast.LENGTH_SHORT).show()
34-
suspend {
35-
repo.blockNumber(number, "spam", appContext.getString(R.string.desc_blocked_from_after_call_feedback))
36-
CommunityContributor.contribute(appContext, repo.normalizeNumber(number), "spam")
37-
}
32+
spamFeedback(appContext, number)
3833
}
3934

4035
ACTION_FEEDBACK_NOT_SPAM -> {
@@ -109,29 +104,11 @@ class SpamActionReceiver : BroadcastReceiver() {
109104
}
110105
} ?: return
111106

112-
// Previously this created a fresh CoroutineScope(SupervisorJob+IO) per
113-
// broadcast, which was never cancelled and leaked a Job on every
114-
// action. Reuse the process-wide appScope — the work must outlive
115-
// onReceive anyway (goAsync covers the broadcast lifetime, not the
116-
// suspend body).
107+
// The work must outlive onReceive (goAsync covers the broadcast
108+
// lifetime, not the suspend body), so it runs on the process-wide
109+
// appScope rather than a scope created and leaked per broadcast.
117110
val pendingResult = goAsync()
118-
CallShieldApp.appScope.launch {
119-
try {
120-
work.invoke()
121-
} catch (e: Exception) {
122-
// The confirmation toast already fired for responsiveness; a
123-
// failed DB write must not stay silent (and an uncaught throw
124-
// here would take down the process via appScope).
125-
Log.w("SpamActionReceiver", "Notification action failed", e)
126-
withContext(Dispatchers.Main) {
127-
Toast
128-
.makeText(appContext, appContext.getString(R.string.notification_action_failed), Toast.LENGTH_SHORT)
129-
.show()
130-
}
131-
} finally {
132-
pendingResult.finish()
133-
}
134-
}
111+
launchAction(appContext, work) { pendingResult.finish() }
135112
}
136113

137114
/** "Not spam" on a blocked-call alert: let the number ring for a day, and tell the community when its data was wrong. */
@@ -184,5 +161,66 @@ class SpamActionReceiver : BroadcastReceiver() {
184161
const val ACTION_FEEDBACK_SPAM = "com.sysadmindoc.callshield.FEEDBACK_SPAM"
185162
const val ACTION_FEEDBACK_NOT_SPAM = "com.sysadmindoc.callshield.FEEDBACK_NOT_SPAM"
186163
const val EXTRA_FEEDBACK_NUMBER = "number"
164+
private const val TAG = "SpamActionReceiver"
165+
166+
/**
167+
* The after-call "Spam" feedback: cancels the prompt and confirms at
168+
* once, and returns the block plus the community report to run off
169+
* the main thread. Shared with the post-call screen, which calls it
170+
* directly instead of broadcasting to this receiver: Android 17 QPR2
171+
* no longer lets a broadcast an app sends to itself raise the
172+
* sender's priority, and that screen finishes right after the tap.
173+
* Call on the main thread, which the toast needs.
174+
*/
175+
fun spamFeedback(
176+
appContext: Context,
177+
number: String,
178+
): suspend () -> Unit {
179+
val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
180+
notificationManager.cancel(NotificationHelper.feedbackNotificationId(number))
181+
Toast.makeText(appContext, appContext.getString(R.string.feedback_blocked), Toast.LENGTH_SHORT).show()
182+
val repo = SpamRepository.getInstance(appContext)
183+
return {
184+
repo.blockNumber(number, "spam", appContext.getString(R.string.desc_blocked_from_after_call_feedback))
185+
CommunityContributor.contribute(appContext, repo.normalizeNumber(number), "spam")
186+
}
187+
}
188+
189+
/**
190+
* Runs [work] now and ignores what it returns. The confirmation toast
191+
* already fired for responsiveness, so a failed write is logged and
192+
* shown rather than left silent, and never thrown into the caller's
193+
* scope.
194+
*/
195+
suspend fun runAction(
196+
appContext: Context,
197+
work: suspend () -> Any?,
198+
) {
199+
try {
200+
work()
201+
} catch (e: Exception) {
202+
Log.w(TAG, "Notification action failed", e)
203+
withContext(Dispatchers.Main) {
204+
Toast
205+
.makeText(appContext, appContext.getString(R.string.notification_action_failed), Toast.LENGTH_SHORT)
206+
.show()
207+
}
208+
}
209+
}
210+
211+
/** [runAction] on the process scope, then [onDone] whether it succeeded or not. */
212+
fun launchAction(
213+
appContext: Context,
214+
work: suspend () -> Any?,
215+
onDone: () -> Unit,
216+
) {
217+
CallShieldApp.appScope.launch {
218+
try {
219+
runAction(appContext, work)
220+
} finally {
221+
onDone()
222+
}
223+
}
224+
}
187225
}
188226
}

‎app/src/main/java/com/sysadmindoc/callshield/ui/PostCallActivity.kt‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,15 @@ class PostCallActivity : AppCompatActivity() {
149149
.blockNumber(number, "spam", app.getString(R.string.desc_blocked_post_call_local))
150150
}.onFailure { Log.w(TAG, "Local post-call block failed", it) }
151151
} else {
152-
app.sendBroadcast(
153-
Intent(app, SpamActionReceiver::class.java)
154-
.setAction(SpamActionReceiver.ACTION_FEEDBACK_SPAM)
155-
.putExtra(SpamActionReceiver.EXTRA_FEEDBACK_NUMBER, number),
156-
)
152+
// A direct call, not a broadcast to SpamActionReceiver: Android 17
153+
// QPR2 no longer lets a broadcast an app sends to itself raise the
154+
// sender's priority, and this screen finishes right after the tap.
155+
// The prompt and toast go inside runAction too, so a failure there
156+
// is shown instead of thrown into appScope after the screen closed.
157+
SpamActionReceiver.runAction(app) {
158+
val work = withContext(Dispatchers.Main) { SpamActionReceiver.spamFeedback(app, number) }
159+
work()
160+
}
157161
}
158162
}
159163
finishAndRemoveTask()

‎app/src/main/java/com/sysadmindoc/callshield/ui/widget/CallShieldWidget.kt‎

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import com.sysadmindoc.callshield.data.SpamRepository
1414
import com.sysadmindoc.callshield.data.local.AppDatabase
1515
import com.sysadmindoc.callshield.ui.MainActivity
1616
import com.sysadmindoc.callshield.util.relativeTimeSpan
17+
import kotlinx.coroutines.Job
1718
import kotlinx.coroutines.flow.first
1819
import kotlinx.coroutines.launch
1920
import java.text.NumberFormat
@@ -36,19 +37,23 @@ class CallShieldWidget : AppWidgetProvider() {
3637
// Without this the process drops to cached priority as soon as
3738
// onUpdate returns and can be killed mid-query, leaving the widget on
3839
// layout placeholders until the next 30-minute cycle.
39-
val pendingResult = goAsync()
40-
val appContext = context.applicationContext
40+
// Null when onUpdate is called directly rather than from a delivered
41+
// broadcast (Robolectric's widget host does that).
42+
val pendingResult: PendingResult? = goAsync()
43+
render(context.applicationContext, appWidgetIds).invokeOnCompletion { pendingResult?.finish() }
44+
}
45+
46+
/** Loads the counts and pushes every widget in [appWidgetIds]; the job ends once the last one is updated. */
47+
private fun render(
48+
appContext: Context,
49+
appWidgetIds: IntArray,
50+
): Job =
4151
CallShieldApp.appScope.launch {
42-
try {
43-
val manager = AppWidgetManager.getInstance(appContext)
44-
for (id in appWidgetIds) {
45-
updateWidget(appContext, manager, id)
46-
}
47-
} finally {
48-
pendingResult.finish()
52+
val manager = AppWidgetManager.getInstance(appContext)
53+
for (id in appWidgetIds) {
54+
updateWidget(appContext, manager, id)
4955
}
5056
}
51-
}
5257

5358
private suspend fun updateWidget(
5459
appContext: Context,
@@ -163,17 +168,21 @@ class CallShieldWidget : AppWidgetProvider() {
163168
}
164169

165170
companion object {
166-
fun refreshAll(context: Context) {
167-
val intent =
168-
Intent(context, CallShieldWidget::class.java).apply {
169-
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
170-
}
171+
/**
172+
* Re-renders every placed widget directly and returns the job doing
173+
* it, or null when none is placed. This used to broadcast
174+
* ACTION_APPWIDGET_UPDATE to this provider; Android 17 QPR2 no longer
175+
* lets a broadcast an app sends to itself raise the sender's
176+
* priority, and most refreshes come from background syncs.
177+
*/
178+
fun refreshAll(context: Context): Job? {
179+
val appContext = context.applicationContext
171180
val ids =
172181
AppWidgetManager
173-
.getInstance(context)
174-
.getAppWidgetIds(ComponentName(context, CallShieldWidget::class.java))
175-
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
176-
context.sendBroadcast(intent)
182+
.getInstance(appContext)
183+
.getAppWidgetIds(ComponentName(appContext, CallShieldWidget::class.java))
184+
if (ids.isEmpty()) return null
185+
return CallShieldWidget().render(appContext, ids)
177186
}
178187
}
179188

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.sysadmindoc.callshield.service
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Test
5+
import java.io.File
6+
7+
/**
8+
* Android 17 QPR2 stops a broadcast an app sends to itself from raising the
9+
* sender's priority and drops it when the process dies first, so the app
10+
* never hands its own work to a receiver by broadcast: the post-call screen
11+
* calls [SpamActionReceiver.spamFeedback] and the widget renders itself.
12+
* The notification actions still reach [SpamActionReceiver], but those
13+
* broadcasts come from the system through a PendingIntent, not from this
14+
* process. A broadcast meant for another app would need its own entry here.
15+
*/
16+
class NoSelfBroadcastTest {
17+
@Test
18+
fun `no source sends a broadcast`() {
19+
val senders =
20+
File("src/main/java")
21+
.walk()
22+
.filter { it.extension == "kt" }
23+
.filter { Regex("""\bsend(Ordered)?Broadcast(AsUser)?\(""").containsMatchIn(it.readText()) }
24+
.map { it.name }
25+
.toList()
26+
27+
assertEquals(emptyList<String>(), senders)
28+
}
29+
}

‎app/src/test/java/com/sysadmindoc/callshield/service/SpamActionReceiverRobolectricTest.kt‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,32 @@
11
package com.sysadmindoc.callshield.service
22

3+
import android.app.Application
34
import android.app.NotificationChannel
45
import android.app.NotificationManager
56
import android.content.Context
67
import android.content.Intent
8+
import android.os.Looper
79
import androidx.test.core.app.ApplicationProvider
10+
import com.sysadmindoc.callshield.R
811
import com.sysadmindoc.callshield.data.CommunityContributor
912
import com.sysadmindoc.callshield.data.CommunityContributor.ContributeOutcome
1013
import com.sysadmindoc.callshield.data.CommunityContributor.ContributeResult
14+
import com.sysadmindoc.callshield.data.CommunityReport
15+
import com.sysadmindoc.callshield.data.SpamRepository
16+
import kotlinx.coroutines.runBlocking
1117
import org.junit.After
18+
import org.junit.Assert.assertEquals
1219
import org.junit.Assert.assertFalse
20+
import org.junit.Assert.assertNull
1321
import org.junit.Assert.assertTrue
1422
import org.junit.Before
1523
import org.junit.Test
1624
import org.junit.runner.RunWith
1725
import org.robolectric.RobolectricTestRunner
1826
import org.robolectric.Shadows.shadowOf
1927
import org.robolectric.annotation.Config
28+
import org.robolectric.shadows.ShadowToast
29+
import kotlin.concurrent.thread
2030

2131
/**
2232
* Robolectric coverage for [SpamActionReceiver] — drives the real
@@ -81,6 +91,65 @@ class SpamActionReceiverRobolectricTest {
8191
)
8292
}
8393

94+
/**
95+
* The post-call screen calls this directly instead of broadcasting
96+
* FEEDBACK_SPAM to the receiver, so the prompt must go at once, nothing
97+
* may be broadcast, and the returned work must block and report.
98+
*
99+
* The other tests here leave their receiver work running on the app
100+
* scope, and it reports through the same fake transport, so only this
101+
* number's reports are compared. It uses the shared repository like
102+
* those tests do, so their late writes never land on a fixture that is
103+
* being closed.
104+
*/
105+
@Test
106+
fun `spam feedback shared with the post-call screen blocks and reports without a broadcast`() {
107+
val number = "+15553330002"
108+
val id = postFeedbackNotification(number)
109+
val sent = mutableListOf<CommunityReport>()
110+
CommunityContributor.transport = { report ->
111+
synchronized(sent) { sent += report }
112+
ContributeResult(true, "faked in test", ContributeOutcome.REPORTED_SPAM)
113+
}
114+
115+
val work = SpamActionReceiver.spamFeedback(context, number)
116+
117+
assertFalse("the after-call prompt goes at once", shadowOf(nm).getNotification(id) != null)
118+
assertTrue("nothing is broadcast", shadowOf(context as Application).broadcastIntents.isEmpty())
119+
runBlocking { work() }
120+
val repository = SpamRepository.getInstance(context)
121+
assertEquals("user_blocklist", runBlocking { repository.isSpam(number) }.matchSource)
122+
val ours = synchronized(sent) { sent.filter { it.number == number } }
123+
assertEquals(listOf(number to "spam"), ours.map { it.number to it.type })
124+
}
125+
126+
/** The post-call screen has already closed when this runs, so a failed write must show the toast and throw nothing. */
127+
@Test
128+
fun `a failed action shows the failure toast instead of throwing`() {
129+
ShadowToast.reset()
130+
var escaped: Throwable? = null
131+
// Off the main thread, since runAction posts its toast to it.
132+
val worker =
133+
thread {
134+
try {
135+
runBlocking { SpamActionReceiver.runAction(context) { error("disk full") } }
136+
} catch (e: Throwable) {
137+
escaped = e
138+
}
139+
}
140+
val deadline = System.currentTimeMillis() + 5_000
141+
while (worker.isAlive && System.currentTimeMillis() < deadline) {
142+
shadowOf(Looper.getMainLooper()).idle()
143+
Thread.sleep(20)
144+
}
145+
worker.join(1_000)
146+
shadowOf(Looper.getMainLooper()).idle()
147+
148+
assertFalse("runAction finished", worker.isAlive)
149+
assertNull("nothing escaped runAction", escaped)
150+
assertEquals(context.getString(R.string.notification_action_failed), ShadowToast.getTextOfLatestToast())
151+
}
152+
84153
@Test
85154
fun `FEEDBACK_NOT_SPAM cancels the same feedback notification`() {
86155
val number = "+15559876543"

0 commit comments

Comments
 (0)