Skip to content

Commit 261aead

Browse files
committed
fix(notifications): say why Not spam was refused where the alert was
Android 12 and later draw a toast in SystemUI's text_toast layout, which keeps two lines. The refusal toast for a number the user blocked ran three to six lines at the largest font, so "You can remove it in CallShield" never showed, and the crash log and allow toasts ran past two lines too. The refusal toast is now one short line, and the blocked-call alert is replaced by a notification that gives the reason and opens the number's screen, where Unblock is. The allow and empty crash log messages are shorter in both locales. Tests: SpamActionReceiverRobolectricTest gains a case for the refused Not spam (short toast, replacement notification with the full reason and an open_number intent). ToastTextFitTest is new: it measures the toast strings with native text layout at font scale 2.0 in a 234dp line, in English and Chinese, with the refusal's full reason as the positive control. No existing test changed. With the old strings and no replacement notification, both new checks fail (10 tests, 2 failed).
1 parent 39aa9fe commit 261aead

8 files changed

Lines changed: 189 additions & 21 deletions

File tree

‎CHANGELOG.md‎

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

55
## Unreleased
66

7+
- Tapping Not spam on a blocked call alert for a number you blocked yourself now shows a short "Your own block still applies." and turns the alert into a notification that explains why. Tap it and the number opens in CallShield, where you can remove the block. The old message was a toast, and Android 12 and later cut it off after two lines, so the part about where to remove the block never showed. The Not spam and empty crash log messages are shorter too, so they fit at the largest font size.
78
- The caller ID popup no longer stays up for 20 seconds after CallShield rejects a call it first showed as possible spam. A rejected call never rings, so nothing told the popup the call was over, and it kept offering Block for a call that was already gone. It closes as soon as the call is rejected now.
89
- When Android stops asking for a permission or a default app role, CallShield's note about where to turn it on now shows in full at the bottom of the screen, with an Open App info or Open Default apps button. It used to be a toast that Android 12 and later cut off after two lines, and the settings page opened on top of it before you could read the rest.
910
- A single Not spam vote no longer keeps a number off the community watch list for 90 days. Reports from people on later days count again, so a number that starts getting reported after someone vouched for it can still show the warning. Reports from the day of the vote, or earlier, still don't count.

‎README.md‎

Lines changed: 5 additions & 5 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%2C403-f38ba8?style=flat-square" alt="51,403 Numbers">
15-
<img src="https://img.shields.io/badge/JVM%20unit%20tests-2062-94e2d5?style=flat-square" alt="2062 JVM unit tests">
15+
<img src="https://img.shields.io/badge/JVM%20unit%20tests-2065-94e2d5?style=flat-square" alt="2065 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 # 2062 tests
595+
./gradlew testDebugUnitTest # 2065 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 **2062 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
600+
The suite is **2065 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,8 +634,8 @@ 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 | 2062 JVM unit tests (JUnit) |
638-
| Strings | 1794 string resources and 49 plural groups (translation-ready) |
637+
| Tests | 2065 JVM unit tests (JUnit) |
638+
| Strings | 1796 string resources and 49 plural groups (translation-ready) |
639639
| Accessibility | 100+ content descriptions, 48dp touch targets |
640640
| Min SDK | 29 (Android 10) |
641641
| Target SDK | 36 |

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

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -507,16 +507,7 @@ object NotificationHelper {
507507
)
508508
} ?: context.getString(reasonLabelRes(BlockReasonCode.fromMatchSource(reason)))
509509

510-
val openIntent =
511-
PendingIntent.getActivity(
512-
context,
513-
stableId(number, 40),
514-
Intent(context, MainActivity::class.java).apply {
515-
putExtra("open_number", number)
516-
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
517-
},
518-
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
519-
)
510+
val openIntent = openNumberIntent(context, number)
520511

521512
// Fold isCall into the action request-code salts so a blocked-call and a
522513
// blocked-SMS from the same number get DISTINCT Block/Report PendingIntents.
@@ -587,6 +578,43 @@ object NotificationHelper {
587578
updateSummary(context)
588579
}
589580

581+
/**
582+
* Takes the place of a blocked-call alert whose Not spam the user's own
583+
* block refused. Android 12 and later cut a toast at two lines, so the
584+
* toast only says the block stands and this says why and where to undo it.
585+
*/
586+
fun notifyNotSpamRefused(
587+
context: Context,
588+
number: String,
589+
notifId: Int,
590+
): Boolean {
591+
val text = context.getString(R.string.notif_not_spam_refused_text)
592+
val builder =
593+
NotificationCompat
594+
.Builder(context, CHANNEL_BLOCKED)
595+
.setSmallIcon(R.drawable.ic_launcher_monochrome)
596+
.setContentTitle(context.getString(R.string.notif_not_spam_refused_title, PhoneFormatter.formatIsolated(number)))
597+
.setContentText(text)
598+
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
599+
.setContentIntent(openNumberIntent(context, number))
600+
.setAutoCancel(true)
601+
return safeNotify(context, notifId.takeIf { it >= 0 } ?: stableId(number, 1), builder)
602+
}
603+
604+
private fun openNumberIntent(
605+
context: Context,
606+
number: String,
607+
): PendingIntent =
608+
PendingIntent.getActivity(
609+
context,
610+
stableId(number, 40),
611+
Intent(context, MainActivity::class.java).apply {
612+
putExtra("open_number", number)
613+
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
614+
},
615+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
616+
)
617+
590618
/**
591619
* The detection a Not spam answers. A category policy only changes how a
592620
* detection is handled, so a blocked call's alert carries the detection

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ class SpamActionReceiver : BroadcastReceiver() {
139139
val message = if (allowed) R.string.notif_not_spam_allowed else R.string.notif_not_spam_refused
140140
Toast.makeText(appContext, appContext.getString(message), Toast.LENGTH_SHORT).show()
141141
}
142+
if (!allowed) {
143+
NotificationHelper.notifyNotSpamRefused(appContext, number, notifId)
144+
}
142145
if (allowed && NotificationHelper.notSpamReachesCommunity(reasonCode, rowSource)) {
143146
CommunityContributor.reportNotSpam(appContext, repo.normalizeNumber(number))
144147
}

‎app/src/main/res/values-zh-rCN/strings.xml‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -784,7 +784,7 @@
784784
<string name="more_star_github">在 GitHub 上点赞</string>
785785
<string name="more_report_spam_number">举报垃圾号码</string>
786786
<string name="more_share_crash_log">分享最近崩溃日志</string>
787-
<string name="more_no_crash_logs">无崩溃记录 — CallShield 尚未记录未捕获异常。</string>
787+
<string name="more_no_crash_logs">没有崩溃记录。</string>
788788
<string name="more_section_tools">防护工具</string>
789789
<string name="more_section_release">发布与诊断</string>
790790
<string name="more_section_support">项目与支持</string>
@@ -1131,8 +1131,10 @@
11311131
<string name="notif_type_sms">短信</string>
11321132
<string name="notif_action_block_forever">永久拦截</string>
11331133
<string name="desc_allowed_from_notification">从拦截通知中放行</string>
1134-
<string name="notif_not_spam_allowed">接下来 24 小时内,此号码的来电会正常响铃。</string>
1135-
<string name="notif_not_spam_refused">你对此号码设置的拦截仍然有效,因此不会响铃。可以在 CallShield 中移除。</string>
1134+
<string name="notif_not_spam_allowed">此号码接下来 24 小时内可以响铃。</string>
1135+
<string name="notif_not_spam_refused">你设置的拦截仍然有效。</string>
1136+
<string name="notif_not_spam_refused_title">%1$s 仍被拦截</string>
1137+
<string name="notif_not_spam_refused_text">你对此号码设置的拦截仍然有效,因此不会响铃。点按即可在 CallShield 中打开此号码,并在那里移除拦截。</string>
11361138
<string name="notif_action_not_spam">不是骚扰</string>
11371139
<string name="notif_action_report">举报</string>
11381140
<string name="url_threat_phishing">钓鱼</string>

‎app/src/main/res/values/strings.xml‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,7 +1009,7 @@
10091009
<string name="more_star_github">Star on GitHub</string>
10101010
<string name="more_report_spam_number">Report spam number</string>
10111011
<string name="more_share_crash_log">Share crash log</string>
1012-
<string name="more_no_crash_logs">No crashes logged. CallShield hasn\'t recorded an uncaught exception.</string>
1012+
<string name="more_no_crash_logs">No crashes logged.</string>
10131013
<string name="more_section_tools">Protection tools</string>
10141014
<string name="more_section_release">Release &amp; diagnostics</string>
10151015
<string name="more_section_support">Project &amp; support</string>
@@ -1591,8 +1591,10 @@
15911591
<string name="notif_type_sms">SMS</string>
15921592
<string name="notif_action_block_forever">Block forever</string>
15931593
<string name="desc_allowed_from_notification">Allowed from a blocked-call alert</string>
1594-
<string name="notif_not_spam_allowed">Calls from this number will ring for the next 24 hours.</string>
1595-
<string name="notif_not_spam_refused">Your own block on this number still applies, so it won\'t ring. You can remove it in CallShield.</string>
1594+
<string name="notif_not_spam_allowed">This number can ring for 24 hours.</string>
1595+
<string name="notif_not_spam_refused">Your own block still applies.</string>
1596+
<string name="notif_not_spam_refused_title">%1$s is still blocked</string>
1597+
<string name="notif_not_spam_refused_text">Your own block on this number still applies, so it won\'t ring. Tap to open it in CallShield, where you can remove the block.</string>
15961598
<string name="notif_action_not_spam">Not spam</string>
15971599
<string name="notif_action_report">Report</string>
15981600
<!-- Kinds of unsafe link; each fills "contains a %s link" and "flagged as %s". -->

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

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

3+
import android.Manifest
34
import android.app.Application
5+
import android.app.Notification
46
import android.app.NotificationChannel
57
import android.app.NotificationManager
68
import android.content.Context
@@ -220,6 +222,48 @@ class SpamActionReceiverRobolectricTest {
220222
assertFalse(shadowOf(nm).getNotification(id) != null)
221223
}
222224

225+
/**
226+
* A toast holds two lines on Android 12 and later, too few to say why a
227+
* refused Not spam changed nothing, so the reason takes the alert's place
228+
* and opens the number, where the block can be removed.
229+
*/
230+
@Test
231+
fun `a Not spam the user's own block refuses leaves the reason where the alert was`() {
232+
shadowOf(context as Application).grantPermissions(Manifest.permission.POST_NOTIFICATIONS)
233+
ShadowToast.reset()
234+
val number = "+15554440002"
235+
val repository = SpamRepository.getInstance(context)
236+
runBlocking { repository.blockNumber(number, "spam", "blocked in test") }
237+
val id = postFeedbackNotification(number)
238+
val appJob = CallShieldApp.appScope.coroutineContext.job
239+
val before = appJob.children.toSet()
240+
try {
241+
SpamActionReceiver().onReceive(
242+
context,
243+
Intent(NotificationHelper.ACTION_NOT_SPAM)
244+
.putExtra(NotificationHelper.EXTRA_NUMBER, number)
245+
.putExtra(NotificationHelper.EXTRA_NOTIF_ID, id)
246+
.putExtra(NotificationHelper.EXTRA_REASON_CODE, "database"),
247+
)
248+
val launched = appJob.children.toSet() - before
249+
assertEquals(1, launched.size)
250+
// The work posts its toast to this thread, so it's idled until the work ends.
251+
val deadline = System.currentTimeMillis() + 10_000
252+
while (launched.any { it.isActive } && System.currentTimeMillis() < deadline) {
253+
shadowOf(Looper.getMainLooper()).idle()
254+
Thread.sleep(20)
255+
}
256+
shadowOf(Looper.getMainLooper()).idle()
257+
258+
assertEquals(context.getString(R.string.notif_not_spam_refused), ShadowToast.getTextOfLatestToast())
259+
val reason = shadowOf(nm).getNotification(id) ?: error("nothing took the alert's place")
260+
assertEquals(context.getString(R.string.notif_not_spam_refused_text), reason.extras.getCharSequence(Notification.EXTRA_BIG_TEXT).toString())
261+
assertEquals(number, shadowOf(reason.contentIntent).savedIntent.getStringExtra("open_number"))
262+
} finally {
263+
runBlocking { repository.unblockByNumber(number) }
264+
}
265+
}
266+
223267
@Test
224268
fun `unknown action is a no-op and leaves notifications intact`() {
225269
val number = "+15550000000"
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package com.sysadmindoc.callshield.service
2+
3+
import android.content.Context
4+
import android.content.res.Configuration
5+
import android.graphics.Typeface
6+
import android.text.StaticLayout
7+
import android.text.TextPaint
8+
import android.util.TypedValue
9+
import androidx.annotation.StringRes
10+
import androidx.test.core.app.ApplicationProvider
11+
import com.sysadmindoc.callshield.R
12+
import org.junit.Assert.assertTrue
13+
import org.junit.Test
14+
import org.junit.runner.RunWith
15+
import org.robolectric.RobolectricTestRunner
16+
import org.robolectric.annotation.Config
17+
import org.robolectric.annotation.GraphicsMode
18+
import java.util.Locale
19+
20+
/**
21+
* Android 12 and later draw a toast in SystemUI's text_toast layout, which
22+
* shows two lines of 14sp text and cuts the rest. Measured with native text
23+
* layout at Android 14's largest font size, in both shipped locales.
24+
*/
25+
@RunWith(RobolectricTestRunner::class)
26+
@Config(sdk = [34], qualifiers = "w360dp-h640dp")
27+
@GraphicsMode(GraphicsMode.Mode.NATIVE)
28+
class ToastTextFitTest {
29+
private val base: Context = ApplicationProvider.getApplicationContext()
30+
31+
@Test
32+
fun `the Not spam and crash log toasts fit two lines at the largest font`() {
33+
for (locale in LOCALES) {
34+
val context = largestFont(locale)
35+
for (message in listOf(R.string.notif_not_spam_allowed, R.string.notif_not_spam_refused, R.string.more_no_crash_logs)) {
36+
val text = context.getString(message)
37+
assertTrue("$locale: $text", toastLines(context, message) <= 2)
38+
}
39+
}
40+
}
41+
42+
/** The reason moved into a notification because it can't fit, which also shows the measure rejects long text. */
43+
@Test
44+
fun `the refusal's full reason would be cut off in a toast`() {
45+
for (locale in LOCALES) {
46+
assertTrue(locale.toString(), toastLines(largestFont(locale), R.string.notif_not_spam_refused_text) > 2)
47+
}
48+
}
49+
50+
private fun largestFont(locale: Locale): Context =
51+
base.createConfigurationContext(
52+
Configuration(base.resources.configuration).apply {
53+
fontScale = LARGEST_FONT_SCALE
54+
setLocale(locale)
55+
},
56+
)
57+
58+
private fun toastLines(
59+
context: Context,
60+
@StringRes message: Int,
61+
): Int {
62+
val text = context.getString(message)
63+
val metrics = context.resources.displayMetrics
64+
val paint =
65+
TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply {
66+
textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, TOAST_TEXT_SP, metrics)
67+
typeface = Typeface.create("sans-serif", Typeface.NORMAL)
68+
}
69+
val width = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, TOAST_TEXT_WIDTH_DP, metrics).toInt()
70+
return StaticLayout.Builder
71+
.obtain(text, 0, text.length, paint, width)
72+
.build()
73+
.lineCount
74+
}
75+
76+
private companion object {
77+
val LOCALES = listOf(Locale.US, Locale.SIMPLIFIED_CHINESE)
78+
79+
// Android 14's top Font size step; earlier versions stop at 1.3.
80+
const val LARGEST_FONT_SCALE = 2.0f
81+
const val TOAST_TEXT_SP = 14f
82+
83+
// The toast's 300dp maxWidth less 16dp padding on each side and the
84+
// 24dp app icon with its 10dp gap. A 360dp screen leaves 262dp
85+
// without the max, so this is the narrower of the two.
86+
const val TOAST_TEXT_WIDTH_DP = 234f
87+
}
88+
}

0 commit comments

Comments
 (0)