From 60990509c009b76c700f1d51fa22467b26240381 Mon Sep 17 00:00:00 2001 From: wander wildwood Date: Wed, 26 Aug 2026 20:07:09 -0400 Subject: [PATCH] Add an option to only accept messages from contacts Requested in #1: not wanting a child to receive texts from people who are not in their contacts. The issue asks for a rebase to pick up SpamBlocker, but a blocklist works backwards for this - it requires having already heard from whoever you want to keep out. This is the allow-list version, and it does not need any new dependency. Settings -> Blocking -> Only allow contacts. Off by default. The rule sits in QksmsBlockingClient, which already answers the one question the receive workers ask, so it covers SMS and MMS through the path they both use. The contacts lookup only runs while the setting is on, so the ordinary case is unchanged. Three deliberate choices: isBlacklisted() is untouched. A stranger is not blacklisted, and the conversation UI asks that question to decide whether to show a thread as blocked - answering yes would mark every unknown sender as though it had been blocked by hand. It does nothing without the contacts permission. Without it every lookup returns "not a contact", so the rule would block the entire inbox, and it would present as the network having gone quiet rather than as a setting misfiring. The permission is checked alongside the setting, and the decision is a pure function with a test for exactly that case. The switch is disabled while a third-party blocking manager is selected, since the rule lives in the built-in client and would otherwise silently do nothing. Blocked messages still go to the blocked folder rather than being deleted, unless "Drop messages" is also on - for the case that prompted this, a message that turns out to matter should be recoverable. Six tests cover the truth table. Verified on a Mudita Kompakt. --- .../ink/blocking/QksmsBlockingClient.kt | 39 ++++++++- .../ink/blocking/QksmsBlockingClientTest.kt | 86 +++++++++++++++++++ .../java/com/message/ink/util/Preferences.kt | 1 + .../feature/blocking/BlockingController.kt | 9 ++ .../ink/feature/blocking/BlockingPresenter.kt | 15 ++++ .../ink/feature/blocking/BlockingState.kt | 7 +- .../ink/feature/blocking/BlockingView.kt | 1 + .../main/res/layout/blocking_controller.xml | 8 ++ presentation/src/main/res/values/strings.xml | 3 + 9 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 data/src/test/java/com/message/ink/blocking/QksmsBlockingClientTest.kt diff --git a/data/src/main/java/com/message/ink/blocking/QksmsBlockingClient.kt b/data/src/main/java/com/message/ink/blocking/QksmsBlockingClient.kt index eaf96ff6..aaba71b8 100644 --- a/data/src/main/java/com/message/ink/blocking/QksmsBlockingClient.kt +++ b/data/src/main/java/com/message/ink/blocking/QksmsBlockingClient.kt @@ -18,20 +18,55 @@ */ package com.message.ink.blocking +import com.message.ink.manager.PermissionManager import com.message.ink.repository.BlockingRepository +import com.message.ink.repository.ContactRepository +import com.message.ink.util.Preferences import io.reactivex.Completable import io.reactivex.Single import javax.inject.Inject +/** + * Decides whether a message should be blocked. + * + * Kept as a pure function so the truth table can be tested without Android, a content resolver or + * a preferences store. The permission argument matters: without it, a denied contacts permission + * makes every lookup fail, "not a contact" quietly becomes "everyone", and the rule blocks the + * whole inbox. + */ +internal fun shouldBlockMessage( + isBlacklisted: Boolean, + onlyAllowContacts: Boolean, + canReadContacts: Boolean, + isContact: Boolean, +): Boolean = isBlacklisted || (onlyAllowContacts && canReadContacts && !isContact) + class QksmsBlockingClient @Inject constructor( - private val blockingRepo: BlockingRepository + private val blockingRepo: BlockingRepository, + private val contactRepo: ContactRepository, + private val permissionManager: PermissionManager, + private val prefs: Preferences ) : BlockingClient { override fun isAvailable(): Boolean = true override fun getClientCapability() = BlockingClient.Capability.BLOCK_WITHOUT_PERMISSION - override fun shouldBlock(address: String): Single = isBlacklisted(address) + override fun shouldBlock(address: String): Single = Single.fromCallable { + val onlyAllowContacts = prefs.blockNonContacts.get() + val block = shouldBlockMessage( + isBlacklisted = blockingRepo.isBlocked(address), + onlyAllowContacts = onlyAllowContacts, + canReadContacts = permissionManager.hasContacts(), + // Only looked up when the setting is on, so the usual case stays one lookup rather + // than a contacts-provider query per incoming message. + isContact = onlyAllowContacts && contactRepo.isContact(address), + ) + when (block) { + true -> BlockingClient.Action.Block() + false -> BlockingClient.Action.Unblock + } + } override fun isBlacklisted(address: String): Single = Single.fromCallable { when (blockingRepo.isBlocked(address)) { diff --git a/data/src/test/java/com/message/ink/blocking/QksmsBlockingClientTest.kt b/data/src/test/java/com/message/ink/blocking/QksmsBlockingClientTest.kt new file mode 100644 index 00000000..9d9ec547 --- /dev/null +++ b/data/src/test/java/com/message/ink/blocking/QksmsBlockingClientTest.kt @@ -0,0 +1,86 @@ +package com.message.ink.blocking + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class QksmsBlockingClientTest { + + @Test + fun blacklistedNumbersAreBlocked() { + assertTrue( + shouldBlockMessage( + isBlacklisted = true, + onlyAllowContacts = false, + canReadContacts = true, + isContact = false, + ) + ) + } + + @Test + fun strangersArriveNormallyWhenTheRuleIsOff() { + assertFalse( + shouldBlockMessage( + isBlacklisted = false, + onlyAllowContacts = false, + canReadContacts = true, + isContact = false, + ) + ) + } + + @Test + fun strangersAreBlockedWhenTheRuleIsOn() { + assertTrue( + shouldBlockMessage( + isBlacklisted = false, + onlyAllowContacts = true, + canReadContacts = true, + isContact = false, + ) + ) + } + + @Test + fun contactsStillGetThroughWhenTheRuleIsOn() { + assertFalse( + shouldBlockMessage( + isBlacklisted = false, + onlyAllowContacts = true, + canReadContacts = true, + isContact = true, + ) + ) + } + + /** + * The one that matters. Without the contacts permission every lookup comes back "not a + * contact", so a rule that trusted it alone would block every incoming message - including + * from the people the phone's owner actually talks to - and it would look like the network + * had gone quiet rather than like a setting had misfired. + */ + @Test + fun nothingIsBlockedWhenContactsCannotBeRead() { + assertFalse( + shouldBlockMessage( + isBlacklisted = false, + onlyAllowContacts = true, + canReadContacts = false, + isContact = false, + ) + ) + } + + @Test + fun blacklistStillAppliesWhenContactsCannotBeRead() { + assertTrue( + shouldBlockMessage( + isBlacklisted = true, + onlyAllowContacts = true, + canReadContacts = false, + isContact = false, + ) + ) + } +} diff --git a/domain/src/main/java/com/message/ink/util/Preferences.kt b/domain/src/main/java/com/message/ink/util/Preferences.kt index e5ed3faa..7a472bb3 100644 --- a/domain/src/main/java/com/message/ink/util/Preferences.kt +++ b/domain/src/main/java/com/message/ink/util/Preferences.kt @@ -118,6 +118,7 @@ class Preferences @Inject constructor( val textSize = rxPrefs.getInteger("textSize", TEXT_SIZE_LARGE) val blockingManager = rxPrefs.getInteger("blockingManager", BLOCKING_MANAGER_QKSMS) val drop = rxPrefs.getBoolean("drop", false) + val blockNonContacts = rxPrefs.getBoolean("blockNonContacts", false) val silentNotContact = rxPrefs.getBoolean("silentNotContact", false) val notifAction1 = rxPrefs.getInteger("notifAction1", NOTIFICATION_ACTION_READ) val notifAction2 = rxPrefs.getInteger("notifAction2", NOTIFICATION_ACTION_REPLY) diff --git a/presentation/src/main/java/com/message/ink/feature/blocking/BlockingController.kt b/presentation/src/main/java/com/message/ink/feature/blocking/BlockingController.kt index af01df83..6c470017 100644 --- a/presentation/src/main/java/com/message/ink/feature/blocking/BlockingController.kt +++ b/presentation/src/main/java/com/message/ink/feature/blocking/BlockingController.kt @@ -42,6 +42,7 @@ class BlockingController : QkController activity?.getString(R.string.blocking_manager_title) + !state.canReadContacts -> activity?.getString(R.string.blocking_non_contacts_no_permission) + else -> activity?.getString(R.string.blocking_non_contacts_summary) + } blockedMessages.isEnabled = !state.dropEnabled } diff --git a/presentation/src/main/java/com/message/ink/feature/blocking/BlockingPresenter.kt b/presentation/src/main/java/com/message/ink/feature/blocking/BlockingPresenter.kt index 7a91a14a..475d21d3 100644 --- a/presentation/src/main/java/com/message/ink/feature/blocking/BlockingPresenter.kt +++ b/presentation/src/main/java/com/message/ink/feature/blocking/BlockingPresenter.kt @@ -24,6 +24,7 @@ import com.uber.autodispose.autoDisposable import com.message.ink.R import com.message.ink.blocking.BlockingClient import com.message.ink.common.base.QkPresenter +import com.message.ink.manager.PermissionManager import com.message.ink.util.Preferences import io.reactivex.rxkotlin.plusAssign import javax.inject.Inject @@ -31,6 +32,7 @@ import javax.inject.Inject class BlockingPresenter @Inject constructor( context: Context, private val blockingClient: BlockingClient, + private val permissionManager: PermissionManager, private val prefs: Preferences ) : QkPresenter(BlockingState()) { @@ -49,6 +51,15 @@ class BlockingPresenter @Inject constructor( disposables += prefs.drop.asObservable() .subscribe { enabled -> newState { copy(dropEnabled = enabled) } } + + disposables += prefs.blockNonContacts.asObservable() + .subscribe { enabled -> newState { copy(blockNonContactsEnabled = enabled) } } + + disposables += prefs.blockingManager.asObservable() + .map { manager -> manager == Preferences.BLOCKING_MANAGER_QKSMS } + .subscribe { builtIn -> newState { copy(usingBuiltInBlocking = builtIn) } } + + newState { copy(canReadContacts = permissionManager.hasContacts()) } } override fun bindIntents(view: BlockingView) { @@ -80,6 +91,10 @@ class BlockingPresenter @Inject constructor( view.dropClickedIntent .autoDisposable(view.scope()) .subscribe { prefs.drop.set(!prefs.drop.get()) } + + view.blockNonContactsClickedIntent + .autoDisposable(view.scope()) + .subscribe { prefs.blockNonContacts.set(!prefs.blockNonContacts.get()) } } } diff --git a/presentation/src/main/java/com/message/ink/feature/blocking/BlockingState.kt b/presentation/src/main/java/com/message/ink/feature/blocking/BlockingState.kt index f3a2e433..471d9a46 100644 --- a/presentation/src/main/java/com/message/ink/feature/blocking/BlockingState.kt +++ b/presentation/src/main/java/com/message/ink/feature/blocking/BlockingState.kt @@ -20,5 +20,10 @@ package com.message.ink.feature.blocking data class BlockingState( val blockingManager: String = "", - val dropEnabled: Boolean = false + val dropEnabled: Boolean = false, + val blockNonContactsEnabled: Boolean = false, + val canReadContacts: Boolean = true, + // The rule lives in the built-in blocking client, so it does nothing while a third-party + // manager is selected. Better to say so than to leave a switch that silently has no effect. + val usingBuiltInBlocking: Boolean = true ) diff --git a/presentation/src/main/java/com/message/ink/feature/blocking/BlockingView.kt b/presentation/src/main/java/com/message/ink/feature/blocking/BlockingView.kt index 29511089..b903f0ae 100644 --- a/presentation/src/main/java/com/message/ink/feature/blocking/BlockingView.kt +++ b/presentation/src/main/java/com/message/ink/feature/blocking/BlockingView.kt @@ -28,6 +28,7 @@ interface BlockingView : QkViewContract { val messageContentFiltersIntent: Observable<*> val blockedMessagesIntent: Observable<*> val dropClickedIntent: Observable<*> + val blockNonContactsClickedIntent: Observable<*> fun openBlockingManager() fun openBlockedNumbers() diff --git a/presentation/src/main/res/layout/blocking_controller.xml b/presentation/src/main/res/layout/blocking_controller.xml index 2f9f02bd..d92893b7 100644 --- a/presentation/src/main/res/layout/blocking_controller.xml +++ b/presentation/src/main/res/layout/blocking_controller.xml @@ -60,6 +60,14 @@ app:title="@string/blocking_drop_title" app:widget="@layout/settings_switch_widget" /> + + Drop incoming messages from blocked senders instead of hiding them Blocked conversations + Only allow contacts + Block messages from anyone who is not in your contacts + Needs permission to read your contacts Blocking Manager Built-in blocking functionality in QUIK Call Blocker - Incoming/Outgoing