Skip to content

Commit 3d24ebc

Browse files
committed
feat(onboarding): add honest protection levels with undo
The old profile tests expected five modes and said Sleep exempted contacts, although its switch blocked only hidden callers. The new checks cover six atomic settings, descriptions, and Undo.
1 parent 84a5d42 commit 3d24ebc

16 files changed

Lines changed: 581 additions & 127 deletions

File tree

‎CHANGELOG.md‎

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

55
## Unreleased
66

7+
- Setup now ends with a choice of Recommended, Strict, or Contacts only. Each
8+
card shows what changes. Home shows the active level, and profile changes
9+
offer Undo. Settings can restore the recommended controls with Undo too.
710
- The report Worker can now deploy from a clean checkout. Wrangler provisions
811
its KV namespace and uses the current rate-limit binding format.
912
- Reviewed domains in `spam_domains_approved.json` now reach the published

‎README.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,9 @@ shard service is unavailable.
5757
## Getting Started
5858

5959
1. **Install the APK** from the [latest release](https://github.com/SysAdminDoc/CallShield/releases/latest). See [Installing](#installing) for signature details.
60-
2. **Run the setup wizard.** Phone and SMS access and the Call Screening role are required when Android supports screening. You can skip notifications, Notification Access (for RCS filtering), and Overlay (for live caller ID). The review shows what each skipped grant disables. Open Settings and tap **Run setup again** whenever you want to enable them.
60+
2. **Run the setup wizard.** Phone and SMS access and the Call Screening role are required when Android supports screening. You can skip notifications, Notification Access (for RCS filtering), and Overlay (for live caller ID). The review shows what each skipped grant disables. The last step lets you choose Recommended, Strict, or Contacts only. Open Settings and tap **Run setup again** whenever you want to change these choices.
6161
3. **Sync the database.** The Home screen runs a first sync on its own. After that, background syncs check every six hours.
62-
4. **Recommended starting profile.** Leave everything at defaults: the database, heuristics, trending feed, and ML scorer all run. Turn on Notification Access if you use Google Messages or Samsung Messages and want SMS filtering through the notification listener too.
62+
4. **Recommended starting profile.** Choose Recommended for the default call and text controls. Strict adds aggressive call checks, blocks hidden callers, and turns on quiet hours. Contacts only lets contacts and trusted numbers ring. Every profile change offers Undo. Turn on Notification Access if you use Google Messages or Samsung Messages and want SMS filtering through the notification listener too.
6363

6464
For a full walkthrough of every toggle with its default, see [docs/getting-started.md](docs/getting-started.md).
6565

@@ -102,7 +102,7 @@ All detection layers implement a shared `IChecker` interface and run in priority
102102
|---------:|-------|---------|-------------|
103103
| 10000 | **Manual Whitelist** | Allow | Numbers you've explicitly marked as always-allow |
104104
| 9000 | **Contact Whitelist** | Allow | Numbers in your phone's contacts always pass through |
105-
| 8800 | **Contacts-Only Mode** | Block | Optional strict mode. Everything not in your contacts is blocked |
105+
| 8800 | **Contacts-Only Mode** | Block | Calls outside your contacts and trusted numbers are blocked |
106106
| 8500 | **STIR/SHAKEN Failed** | Block | Carrier-authenticated caller ID failure gets blocked before heuristic layers |
107107
| 7000 | **User Blocklist** | Block | Your own exact blocks, permanent or temporary |
108108
| 6900 | **System Block List** (A4) | Block | Read-only bridge to Android's `BlockedNumberContract`. Respects stock Phone/Messages blocks |
@@ -248,7 +248,7 @@ by locale and message type without shipping personal data:
248248
- Smart suggestions. Detects area code spam patterns, one-tap block entire area code
249249
- Weekly trend indicator. Shows if spam is increasing or decreasing vs last week
250250
- Last blocked preview card on dashboard with tap-to-inspect
251-
- Blocking profiles: Work / Personal / Sleep / Maximum / Off
251+
- Blocking profiles: Recommended / Strict / Contacts only / Personal / Sleep / Off, with Undo
252252
- Callback detection + repeated urgent caller allow-through
253253
- FTC Do Not Call complaint filing
254254
- After-call "Was this spam?" feedback notification
@@ -538,7 +538,7 @@ language in [issue #7](https://github.com/SysAdminDoc/CallShield/issues/7).
538538
| URL Safety | Local spam-domain data; optional PhishTank and OpenPhish |
539539
| Verification | Local Gradle, lint, and release-artifact checks |
540540
| Tests | 1556 JVM unit tests (JUnit) |
541-
| Strings | 1583 string resources and 38 plural groups (translation-ready) |
541+
| Strings | 1614 string resources and 38 plural groups (translation-ready) |
542542
| Accessibility | 100+ content descriptions, 48dp touch targets |
543543
| Min SDK | 29 (Android 10) |
544544
| Target SDK | 36 |

‎app/src/androidTest/java/com/sysadmindoc/callshield/ui/screens/onboarding/OnboardingTest.kt‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class OnboardingTest {
4545
"Caller ID overlay",
4646
"Notification access",
4747
"Protection is ready",
48+
"How strict?",
4849
)
4950
expectedTitles.forEachIndexed { index, title ->
5051
assertPage(index + 1, title)
@@ -53,6 +54,7 @@ class OnboardingTest {
5354
}
5455
}
5556

57+
composeRule.onNodeWithTag(ONBOARDING_PROFILE_RECOMMENDED_TAG).performClick()
5658
composeRule.onNodeWithTag(ONBOARDING_FINISH_BUTTON_TAG).performClick()
5759
composeRule.runOnIdle { assertEquals(1, completed) }
5860
}
@@ -101,7 +103,7 @@ class OnboardingTest {
101103
)
102104
}
103105

104-
repeat(onboardingSteps.lastIndex) {
106+
repeat(onboardingSteps.indexOf(OnboardingSetupStep.Review)) {
105107
composeRule.onNodeWithTag(primaryTag(onboardingSteps[it])).performClick()
106108
}
107109
assertPage(7, "Protection is ready")
@@ -132,6 +134,9 @@ class OnboardingTest {
132134
.onNodeWithText("Floating caller ID and live enrichment cards stay hidden until overlay access is allowed.")
133135
.performScrollTo()
134136
.assertIsDisplayed()
137+
composeRule.onNodeWithTag(primaryTag(OnboardingSetupStep.Review)).performClick()
138+
assertPage(8, "How strict?")
139+
composeRule.onNodeWithTag(ONBOARDING_PROFILE_RECOMMENDED_TAG).performClick()
135140
composeRule.onNodeWithTag(ONBOARDING_FINISH_BUTTON_TAG).performClick()
136141
composeRule.runOnIdle { assertEquals(1, completed) }
137142
}
@@ -168,7 +173,7 @@ class OnboardingTest {
168173
page: Int,
169174
title: String,
170175
) {
171-
composeRule.onNodeWithContentDescription("Onboarding page $page of 7").assertIsDisplayed()
176+
composeRule.onNodeWithContentDescription("Onboarding page $page of 8").assertIsDisplayed()
172177
composeRule.onAllNodesWithText(title)[0].assertIsDisplayed()
173178
}
174179

Lines changed: 51 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,66 @@
11
package com.sysadmindoc.callshield.data
22

33
import android.content.Context
4+
import com.sysadmindoc.callshield.R
45

5-
/**
6-
* Blocking profiles — preset configurations for different scenarios.
7-
* Work: block all spam, allow unknown (clients may call)
8-
* Personal: block spam + unknown numbers
9-
* Sleep: block everything except contacts
10-
* Off: disable all blocking
11-
*/
6+
/** Presets for the call and message controls changed together by a profile. */
127
object BlockingProfiles {
8+
data class Settings(
9+
val blockCalls: Boolean = true,
10+
val analyzeSms: Boolean = true,
11+
val blockHidden: Boolean = false,
12+
val aggressive: Boolean = false,
13+
val quietHours: Boolean = false,
14+
val contactsOnly: Boolean = false,
15+
)
16+
17+
data class Snapshot(
18+
val settings: Settings,
19+
val activeProfileName: String?,
20+
)
21+
1322
enum class Profile(
14-
val label: String,
15-
val description: String,
23+
val labelRes: Int,
24+
val descriptionRes: Int,
25+
val settings: Settings,
1626
) {
17-
WORK("Work", "Block spam, allow unknown callers"),
18-
PERSONAL("Personal", "Block spam + unknown numbers"),
19-
SLEEP("Sleep", "Block everything except contacts"),
20-
MAX("Maximum", "Aggressive mode + block unknowns + quiet hours"),
21-
OFF("Off", "Disable all blocking"),
27+
WORK(R.string.onboarding_profile_recommended, R.string.profile_work_description, Settings()),
28+
PERSONAL(
29+
R.string.dashboard_profile_personal,
30+
R.string.profile_personal_description,
31+
Settings(blockHidden = true),
32+
),
33+
SLEEP(
34+
R.string.dashboard_profile_sleep,
35+
R.string.profile_sleep_description,
36+
Settings(blockHidden = true, quietHours = true),
37+
),
38+
MAX(
39+
R.string.onboarding_profile_strict,
40+
R.string.profile_maximum_description,
41+
Settings(blockHidden = true, aggressive = true, quietHours = true),
42+
),
43+
OFF(
44+
R.string.dashboard_profile_off,
45+
R.string.profile_off_description,
46+
Settings(blockCalls = false, analyzeSms = false),
47+
),
48+
CONTACTS_ONLY(
49+
R.string.dashboard_profile_contacts_only,
50+
R.string.profile_contacts_only_description,
51+
Settings(blockHidden = true, contactsOnly = true),
52+
),
2253
}
2354

2455
suspend fun apply(
2556
context: Context,
2657
profile: Profile,
27-
) {
28-
val repo = SpamRepository.getInstance(context)
29-
when (profile) {
30-
Profile.WORK -> {
31-
repo.setBlockCalls(true)
32-
repo.setBlockSms(true)
33-
repo.setBlockUnknown(false)
34-
repo.setAggressiveMode(false)
35-
repo.setTimeBlock(false)
36-
}
37-
38-
Profile.PERSONAL -> {
39-
repo.setBlockCalls(true)
40-
repo.setBlockSms(true)
41-
repo.setBlockUnknown(true)
42-
repo.setAggressiveMode(false)
43-
repo.setTimeBlock(false)
44-
}
58+
): Snapshot = SpamRepository.getInstance(context).replaceBlockingSettings(profile.settings, profile.name)
4559

46-
Profile.SLEEP -> {
47-
repo.setBlockCalls(true)
48-
repo.setBlockSms(true)
49-
repo.setBlockUnknown(true)
50-
repo.setAggressiveMode(false)
51-
repo.setTimeBlock(true)
52-
}
53-
54-
Profile.MAX -> {
55-
repo.setBlockCalls(true)
56-
repo.setBlockSms(true)
57-
repo.setBlockUnknown(true)
58-
repo.setAggressiveMode(true)
59-
repo.setTimeBlock(true)
60-
}
61-
62-
Profile.OFF -> {
63-
repo.setBlockCalls(false)
64-
repo.setBlockSms(false)
65-
repo.setBlockUnknown(false)
66-
repo.setAggressiveMode(false)
67-
repo.setTimeBlock(false)
68-
}
69-
}
60+
suspend fun restore(
61+
context: Context,
62+
snapshot: Snapshot,
63+
) {
64+
SpamRepository.getInstance(context).replaceBlockingSettings(snapshot.settings, snapshot.activeProfileName)
7065
}
7166
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,11 @@ class SpamRepository(
453453

454454
suspend fun setActiveProfileName(name: String?) = settingsRepository.setActiveProfileName(name)
455455

456+
suspend fun replaceBlockingSettings(
457+
settings: BlockingProfiles.Settings,
458+
profileName: String?,
459+
): BlockingProfiles.Snapshot = settingsRepository.replaceBlockingSettings(settings, profileName)
460+
456461
suspend fun setFeedMirrorUrl(url: String?): Boolean = settingsRepository.setFeedMirrorUrl(url)
457462

458463
suspend fun saveFeedMirrorUrl(url: String): FeedMirrorSave = syncRepository.saveFeedMirrorUrl(url)

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
1111
import com.sysadmindoc.callshield.BuildConfig
1212
import com.sysadmindoc.callshield.data.AppUpdateState
1313
import com.sysadmindoc.callshield.data.AppUpdateStatus
14+
import com.sysadmindoc.callshield.data.BlockingProfiles
1415
import com.sysadmindoc.callshield.data.CallCategory
1516
import com.sysadmindoc.callshield.data.CallbackDetector
1617
import com.sysadmindoc.callshield.data.CallerNameSupport
@@ -298,6 +299,40 @@ class SettingsRepository(
298299
}
299300
}
300301

302+
suspend fun replaceBlockingSettings(
303+
settings: BlockingProfiles.Settings,
304+
profileName: String?,
305+
): BlockingProfiles.Snapshot {
306+
var previous: BlockingProfiles.Snapshot? = null
307+
dataStore.edit { prefs ->
308+
previous =
309+
BlockingProfiles.Snapshot(
310+
settings =
311+
BlockingProfiles.Settings(
312+
blockCalls = prefs[SpamRepository.KEY_BLOCK_CALLS] ?: true,
313+
analyzeSms = prefs[SpamRepository.KEY_BLOCK_SMS] ?: true,
314+
blockHidden = prefs[SpamRepository.KEY_BLOCK_UNKNOWN] ?: false,
315+
aggressive = prefs[SpamRepository.KEY_AGGRESSIVE_MODE] ?: false,
316+
quietHours = prefs[SpamRepository.KEY_TIME_BLOCK] ?: false,
317+
contactsOnly = prefs[SpamRepository.KEY_CONTACTS_ONLY] ?: false,
318+
),
319+
activeProfileName = prefs[SpamRepository.KEY_ACTIVE_PROFILE],
320+
)
321+
prefs[SpamRepository.KEY_BLOCK_CALLS] = settings.blockCalls
322+
prefs[SpamRepository.KEY_BLOCK_SMS] = settings.analyzeSms
323+
prefs[SpamRepository.KEY_BLOCK_UNKNOWN] = settings.blockHidden
324+
prefs[SpamRepository.KEY_AGGRESSIVE_MODE] = settings.aggressive
325+
prefs[SpamRepository.KEY_TIME_BLOCK] = settings.quietHours
326+
prefs[SpamRepository.KEY_CONTACTS_ONLY] = settings.contactsOnly
327+
if (profileName == null) {
328+
prefs.remove(SpamRepository.KEY_ACTIVE_PROFILE)
329+
} else {
330+
prefs[SpamRepository.KEY_ACTIVE_PROFILE] = profileName
331+
}
332+
}
333+
return checkNotNull(previous)
334+
}
335+
301336
suspend fun setAppTheme(theme: String) =
302337
dataStore.edit { preferences ->
303338
val sanitized = sanitizeAppTheme(theme)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ fun CallShieldRoot(
159159
}
160160

161161
onboardingDone == false -> {
162-
OnboardingScreen(onComplete = { viewModel.completeOnboarding() })
162+
OnboardingScreen(viewModel = viewModel, onComplete = { viewModel.completeOnboarding() })
163163
}
164164

165165
selectedNumber != null -> {

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

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1343,15 +1343,10 @@ class MainViewModel
13431343
.map { name -> name?.let { runCatching { BlockingProfiles.Profile.valueOf(it) }.getOrNull() } }
13441344
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
13451345

1346-
fun applyProfile(profile: BlockingProfiles.Profile) {
1347-
viewModelScope.launch {
1348-
try {
1349-
BlockingProfiles.apply(appContext, profile)
1350-
repo.setActiveProfileName(profile.name)
1351-
} catch (_: Exception) {
1352-
repo.setActiveProfileName(null)
1353-
}
1354-
}
1346+
suspend fun applyProfile(profile: BlockingProfiles.Profile): BlockingProfiles.Snapshot = BlockingProfiles.apply(appContext, profile)
1347+
1348+
suspend fun undoProfile(snapshot: BlockingProfiles.Snapshot) {
1349+
BlockingProfiles.restore(appContext, snapshot)
13551350
}
13561351

13571352
// Anonymous community contribution

0 commit comments

Comments
 (0)