Skip to content

Commit 6fa0e0e

Browse files
committed
fix(ui): show the won't-ask-again hints as a snackbar, not a toast
The App info and Default apps hints were toasts, which Android 12 and later stop at two lines, and the settings page opened over them at once, so the end of the instruction was never readable on a narrow phone. rememberPermissionRequest and rememberRoleRequest now post the hint to a SnackbarHostState from LocalPermissionHints (MainActivity provides one for every tab, onboarding passes its own) with an Open App info or Open Default apps action, and the page opens only when that is tapped. PermissionHintsHost puts a hint's action on its own line so a large font can't squeeze the text into a column beside it. With no host, the page opens straight away as before. New PermissionHintTest checks both hints at 2x font on a 360dp screen under native graphics (no overflow, more than two lines, inside the screen), that no toast shows and nothing opens until the action is tapped, and the no-host fallback. A control run with the old toast and open put back fails both hint tests. No existing test changed.
1 parent 9152607 commit 6fa0e0e

11 files changed

Lines changed: 334 additions & 41 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+
- 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.
78
- 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.
89
- A Report or Not spam result that came in just as the previous message closed, in Lookup or on a number's screen, could be skipped without showing. It shows now.
910
- When GitHub's rate limit stops a report, the report service now passes on how long GitHub asked it to wait, instead of always saying a minute. When GitHub refuses the service's access token, it answers that it's unavailable rather than rate limited, and the app keeps the report and tries again later. This takes effect when the report Worker is next deployed.

‎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-2051-94e2d5?style=flat-square" alt="2051 JVM unit tests">
15+
<img src="https://img.shields.io/badge/JVM%20unit%20tests-2054-94e2d5?style=flat-square" alt="2054 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 # 2051 tests
595+
./gradlew testDebugUnitTest # 2054 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 **2051 total JVM unit tests**, run with Robolectric wherever a screen, a service or the database is involved.
600+
The suite is **2054 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 | 2051 JVM unit tests (JUnit) |
638-
| Strings | 1792 string resources and 49 plural groups (translation-ready) |
637+
| Tests | 2054 JVM unit tests (JUnit) |
638+
| Strings | 1794 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/ui/ContactsOnlyPaused.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import com.sysadmindoc.callshield.ui.theme.ShapeMd
2828
/**
2929
* Asks for Contacts access and calls [onResult] once Android answers. Android
3030
* stops showing its dialog after two refusals, so a refusal that leaves no
31-
* dialog for next time opens App info instead, where the permission lives.
31+
* dialog for next time points to App info instead, where the permission lives.
3232
*/
3333
@Composable
3434
fun rememberAllowContacts(onResult: () -> Unit = {}): () -> Unit {

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,12 +182,17 @@ fun CallShieldRoot(
182182

183183
else -> {
184184
rootStateHolder.SaveableStateProvider(ROOT_STATE_APP) {
185-
CallShieldApp(
186-
viewModel = viewModel,
187-
startTab = initialTab,
188-
tabRequestId = launchRequest.id.takeIf { launchRequest.shortcutAction != null },
189-
blockedLogRequestId = launchRequest.blockedLogRequestId(),
190-
)
185+
// Every tab's App info and Default apps hints show here,
186+
// above the navigation bar (CallShieldApp's snackbar slot).
187+
val permissionHints = remember { SnackbarHostState() }
188+
CompositionLocalProvider(LocalPermissionHints provides permissionHints) {
189+
CallShieldApp(
190+
viewModel = viewModel,
191+
startTab = initialTab,
192+
tabRequestId = launchRequest.id.takeIf { launchRequest.shortcutAction != null },
193+
blockedLogRequestId = launchRequest.blockedLogRequestId(),
194+
)
195+
}
191196
}
192197
}
193198
}
@@ -353,6 +358,7 @@ fun CallShieldApp(
353358
}
354359
}
355360
},
361+
snackbarHost = { LocalPermissionHints.current?.let { PermissionHintsHost(it) } },
356362
containerColor = Black,
357363
) { padding ->
358364
Box(modifier = Modifier.padding(padding)) {

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

Lines changed: 89 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,80 @@ import android.content.Intent
77
import android.net.Uri
88
import android.os.SystemClock
99
import android.provider.Settings
10-
import android.widget.Toast
1110
import androidx.activity.compose.rememberLauncherForActivityResult
1211
import androidx.activity.result.contract.ActivityResultContracts
1312
import androidx.annotation.StringRes
13+
import androidx.compose.material3.Snackbar
14+
import androidx.compose.material3.SnackbarDuration
15+
import androidx.compose.material3.SnackbarHost
16+
import androidx.compose.material3.SnackbarHostState
17+
import androidx.compose.material3.SnackbarResult
18+
import androidx.compose.material3.SnackbarVisuals
1419
import androidx.compose.runtime.Composable
1520
import androidx.compose.runtime.getValue
1621
import androidx.compose.runtime.mutableLongStateOf
1722
import androidx.compose.runtime.mutableStateOf
23+
import androidx.compose.runtime.rememberCoroutineScope
1824
import androidx.compose.runtime.saveable.rememberSaveable
1925
import androidx.compose.runtime.setValue
26+
import androidx.compose.runtime.staticCompositionLocalOf
27+
import androidx.compose.ui.Modifier
2028
import androidx.compose.ui.platform.LocalContext
29+
import androidx.compose.ui.res.stringResource
30+
import com.sysadmindoc.callshield.R
2131
import com.sysadmindoc.callshield.util.startActivitySafely
32+
import kotlinx.coroutines.CoroutineScope
33+
import kotlinx.coroutines.launch
34+
35+
/**
36+
* Where [rememberPermissionRequest] and [rememberRoleRequest] show their App
37+
* info and Default apps hints. A snackbar holds the whole hint; the toast they
38+
* used to show stops at two lines on Android 12 and later, and the settings
39+
* page it opened covered the rest. With no host the page opens straight away.
40+
*/
41+
val LocalPermissionHints = staticCompositionLocalOf<SnackbarHostState?> { null }
42+
43+
private class SettingsHintVisuals(
44+
override val message: String,
45+
override val actionLabel: String,
46+
) : SnackbarVisuals {
47+
override val withDismissAction get() = false
48+
override val duration get() = SnackbarDuration.Long
49+
}
50+
51+
/**
52+
* Shows [hints], putting a settings hint's action on its own line so a large
53+
* font can't squeeze the hint into a narrow column beside it.
54+
*/
55+
@Composable
56+
fun PermissionHintsHost(
57+
hints: SnackbarHostState,
58+
modifier: Modifier = Modifier,
59+
) {
60+
SnackbarHost(hints, modifier) { Snackbar(it, actionOnNewLine = it.visuals is SettingsHintVisuals) }
61+
}
62+
63+
/**
64+
* Shows [hint] in [hints] with an [actionLabel] action that opens [intent],
65+
* replacing any message already there. Without [hints] it opens [intent] now.
66+
*/
67+
private fun CoroutineScope.showSettingsHint(
68+
hints: SnackbarHostState?,
69+
hint: String,
70+
actionLabel: String,
71+
context: Context,
72+
intent: Intent,
73+
) {
74+
if (hints == null) {
75+
context.startActivitySafely(intent)
76+
return
77+
}
78+
launch {
79+
hints.currentSnackbarData?.dismiss()
80+
val result = hints.showSnackbar(SettingsHintVisuals(hint, actionLabel))
81+
if (result == SnackbarResult.ActionPerformed) context.startActivitySafely(intent)
82+
}
83+
}
2284

2385
/**
2486
* True when a permission request came back with something still refused and
@@ -56,18 +118,22 @@ internal fun refusalsThatCount(
56118
/**
57119
* Returns a function that asks for the permissions it's given and calls
58120
* [onResult] with Android's answer. When the answer leaves no dialog for next
59-
* time, it calls [onOpensAppInfo], shows [appInfoHint] and opens App info,
60-
* where the permission lives. Only refusals of [appInfoFor] count when it's
61-
* given.
121+
* time, it calls [onAppInfoHint] and shows [appInfoHint] in [hints] with an
122+
* App info action, since App info is where the permission lives now. Only
123+
* refusals of [appInfoFor] count when it's given.
62124
*/
63125
@Composable
64126
fun rememberPermissionRequest(
65127
@StringRes appInfoHint: Int,
66128
appInfoFor: Collection<String>? = null,
67-
onOpensAppInfo: () -> Unit = {},
129+
hints: SnackbarHostState? = LocalPermissionHints.current,
130+
onAppInfoHint: () -> Unit = {},
68131
onResult: (Map<String, Boolean>) -> Unit = {},
69132
): (List<String>) -> Unit {
70133
val context = LocalContext.current
134+
val scope = rememberCoroutineScope()
135+
val hint = stringResource(appInfoHint)
136+
val actionLabel = stringResource(R.string.permission_hint_app_info)
71137
// Saved, since the prompt can outlive the activity (a rotation or a
72138
// reclaimed process), and the answer is read against them.
73139
var launchedAt by rememberSaveable { mutableLongStateOf(0L) }
@@ -79,9 +145,12 @@ fun rememberPermissionRequest(
79145
val stillDenied = refusalsThatCount(grants, appInfoFor)
80146
val promptShown = SystemClock.elapsedRealtime() - launchedAt >= PROMPT_MIN_MILLIS
81147
if (opensAppInfoAfterRequest(stillDenied, promptShown, refusedBefore, activity::shouldShowRequestPermissionRationale)) {
82-
onOpensAppInfo()
83-
Toast.makeText(context, appInfoHint, Toast.LENGTH_LONG).show()
84-
context.startActivitySafely(
148+
onAppInfoHint()
149+
scope.showSettingsHint(
150+
hints,
151+
hint,
152+
actionLabel,
153+
context,
85154
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
86155
)
87156
}
@@ -108,31 +177,35 @@ internal fun opensDefaultAppsAfterRoleRequest(
108177
/** What a role request came back with. */
109178
data class RoleRequestResult(
110179
val held: Boolean,
111-
/** Android showed no prompt, so the hint was shown and Default apps opened. */
112-
val opensDefaultApps: Boolean,
180+
/** Android showed no prompt, so the Default apps hint was shown. */
181+
val defaultAppsHintShown: Boolean,
113182
)
114183

115184
/**
116185
* Returns a function that starts the role request intent it's given and calls
117186
* [onResult] with the answer. When the request comes straight back refused,
118-
* it shows [defaultAppsHint] and opens Default apps, where the role lives.
187+
* it shows [defaultAppsHint] in [hints] with a Default apps action, since
188+
* Default apps is where the role lives now.
119189
*/
120190
@Composable
121191
fun rememberRoleRequest(
122192
@StringRes defaultAppsHint: Int,
123193
roleHeld: () -> Boolean,
194+
hints: SnackbarHostState? = LocalPermissionHints.current,
124195
onResult: (RoleRequestResult) -> Unit = {},
125196
): (Intent) -> Unit {
126197
val context = LocalContext.current
198+
val scope = rememberCoroutineScope()
199+
val hint = stringResource(defaultAppsHint)
200+
val actionLabel = stringResource(R.string.permission_hint_default_apps)
127201
var launchedAt by rememberSaveable { mutableLongStateOf(0L) }
128202
val launcher =
129203
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
130204
val held = roleHeld()
131-
val opensDefaultApps = opensDefaultAppsAfterRoleRequest(held, SystemClock.elapsedRealtime() - launchedAt)
132-
onResult(RoleRequestResult(held, opensDefaultApps))
133-
if (opensDefaultApps) {
134-
Toast.makeText(context, defaultAppsHint, Toast.LENGTH_LONG).show()
135-
context.startActivitySafely(Intent(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS))
205+
val hintShown = opensDefaultAppsAfterRoleRequest(held, SystemClock.elapsedRealtime() - launchedAt)
206+
onResult(RoleRequestResult(held, hintShown))
207+
if (hintShown) {
208+
scope.showSettingsHint(hints, hint, actionLabel, context, Intent(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS))
136209
}
137210
}
138211
return { intent ->

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ fun DashboardScreen(
339339
// Settings, which lands on Basic, and Basic has no overlay control at all.
340340
// Review permissions still opens Settings, where each missing item has a
341341
// Grant or Enable button.
342-
// These open App info, or Default apps for the role, with a hint once
342+
// These point to App info, or Default apps for the role, with a hint once
343343
// Android has stopped showing its dialog, since the request then comes
344344
// back at once with nothing on screen.
345345
val screeningRoleRequest =

‎app/src/main/java/com/sysadmindoc/callshield/ui/screens/onboarding/OnboardingScreen.kt‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ import androidx.compose.material3.Icon
5050
import androidx.compose.material3.MaterialTheme
5151
import androidx.compose.material3.RadioButton
5252
import androidx.compose.material3.SnackbarDuration
53-
import androidx.compose.material3.SnackbarHost
5453
import androidx.compose.material3.SnackbarHostState
5554
import androidx.compose.material3.SnackbarResult
5655
import androidx.compose.material3.Surface
@@ -95,6 +94,7 @@ import com.sysadmindoc.callshield.permissions.InstallSource
9594
import com.sysadmindoc.callshield.permissions.InstallSourceKind
9695
import com.sysadmindoc.callshield.service.FeedTrustNotice
9796
import com.sysadmindoc.callshield.ui.MainViewModel
97+
import com.sysadmindoc.callshield.ui.PermissionHintsHost
9898
import com.sysadmindoc.callshield.ui.rememberPermissionRequest
9999
import com.sysadmindoc.callshield.ui.rememberRoleRequest
100100
import com.sysadmindoc.callshield.ui.theme.CatBlue
@@ -172,25 +172,30 @@ fun OnboardingScreen(
172172
}
173173

174174
// Android answers a request it won't show again at once, with nothing on
175-
// screen, so even the first tap after two refusals in an earlier run opens
176-
// App info, and the button keeps pointing there.
175+
// screen, so even the first tap after two refusals in an earlier run gets
176+
// the App info hint, and the button points there from then on.
177177
val runtimePermissionRequest =
178178
rememberPermissionRequest(
179179
R.string.permission_core_in_app_info,
180180
appInfoFor = CallShieldPermissions.corePermissions,
181-
onOpensAppInfo = { runtimePermissionsBlocked = true },
181+
hints = snackbarHostState,
182+
onAppInfoHint = { runtimePermissionsBlocked = true },
182183
) {
183184
refreshReadiness()
184185
runtimePermissionsBlocked = false
185186
}
186187
val notificationPermissionRequest =
187-
rememberPermissionRequest(R.string.permission_notifications_in_app_info) {
188+
rememberPermissionRequest(R.string.permission_notifications_in_app_info, hints = snackbarHostState) {
188189
refreshReadiness()
189190
}
190-
// Opens Default apps with a hint once Android has stopped showing the role
191-
// prompt, since the request then comes back at once with nothing on screen.
191+
// Points to Default apps with a hint once Android has stopped showing the
192+
// role prompt, since the request then comes back at once with nothing on screen.
192193
val screeningRequest =
193-
rememberRoleRequest(R.string.role_call_screening_in_default_apps, roleHeld = { CallShieldPermissions.hasCallScreeningRole(roleManager) }) {
194+
rememberRoleRequest(
195+
R.string.role_call_screening_in_default_apps,
196+
roleHeld = { CallShieldPermissions.hasCallScreeningRole(roleManager) },
197+
hints = snackbarHostState,
198+
) {
194199
refreshReadiness()
195200
}
196201

@@ -490,7 +495,7 @@ internal fun OnboardingScreenContent(
490495
SetupHint.ReinstallOverThisCopy -> ReinstallHint(installerLabel = installerLabel.orEmpty(), onOpenReleases = onOpenReleases)
491496
null -> Unit
492497
}
493-
SnackbarHost(snackbarHostState)
498+
PermissionHintsHost(snackbarHostState)
494499
Spacer(Modifier.height(8.dp))
495500
if (currentPage > 0) {
496501
TextButton(

‎app/src/main/java/com/sysadmindoc/callshield/ui/screens/settings/SettingsScreen.kt‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ fun SettingsScreen(viewModel: MainViewModel) {
168168
val setupProgress = requiredSetupProgress(corePermissionsGranted, screenerReadyForCurrentMode)
169169
val setupReadyCount = setupProgress.done
170170
val setupTotal = setupProgress.total
171-
// Both open App info with a hint once Android has stopped showing its
171+
// Both point to App info with a hint once Android has stopped showing its
172172
// dialog, since the request then returns at once with nothing on screen.
173173
val corePermissionRequest =
174174
rememberPermissionRequest(R.string.permission_core_in_app_info) {
@@ -183,7 +183,7 @@ fun SettingsScreen(viewModel: MainViewModel) {
183183
rememberPermissionRequest(R.string.permission_notifications_in_app_info) {
184184
notificationsGranted = CallShieldPermissions.hasNotificationPermission(context)
185185
}
186-
// Both open Default apps with a hint once Android has stopped showing the
186+
// Both point to Default apps with a hint once Android has stopped showing the
187187
// role prompt, since the request then comes back at once with nothing on screen.
188188
val screeningRequest =
189189
rememberRoleRequest(R.string.role_call_screening_in_default_apps, roleHeld = { CallShieldPermissions.hasCallScreeningRole(roleManager) }) {
@@ -194,7 +194,7 @@ fun SettingsScreen(viewModel: MainViewModel) {
194194
redirectionRoleHeld = it.held
195195
if (it.held) {
196196
viewModel.setOutgoingCallHold(true)
197-
} else if (!it.opensDefaultApps) {
197+
} else if (!it.defaultAppsHintShown) {
198198
Toast.makeText(context, callHoldNotGranted, Toast.LENGTH_SHORT).show()
199199
}
200200
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,8 @@
485485
<string name="permission_sms_in_app_info">Android 不会再询问。请在应用信息的“权限”中开启“短信”。</string>
486486
<string name="permission_phone_in_app_info">Android 不会再询问。请在应用信息的“权限”中开启“电话”和“通话记录”。</string>
487487
<string name="permission_notifications_in_app_info">Android 不会再询问。请在应用信息中开启“通知”。</string>
488+
<string name="permission_hint_app_info">打开应用信息</string>
489+
<string name="permission_hint_default_apps">打开默认应用</string>
488490
<string name="settings_outgoing_risk_warning">拨出高风险电话前提醒</string>
489491
<string name="settings_outgoing_risk_warning_desc">对垃圾号码库中精确匹配的号码显示本地警告</string>
490492
<string name="settings_outgoing_risk_warning_overlay_required">允许悬浮窗权限以查看拨出提醒。</string>

0 commit comments

Comments
 (0)