#1817

Port the Android spam filter to iOS

Opencallebtcwants to merge intofrom

Summary

Ports the rule-based spam filter from bitchat-android#999 to iOS, with the same rules, defaults and module layout. It covers the public mesh chat and geohash channels. Messages it flags are dropped before they reach the timeline, so they also trigger no mention notification or haptic. Private messages, my own messages and local notices are never judged.

Rules (per sender)

All limits live in SpamFilterConfig. The values are the same as Android's.

  • Messages: a burst of 6, then 1 every 10 s.
  • Characters: 1200 per minute, refilled continuously.
  • Links: 2 per minute, refilled continuously. Scheme URLs, www. hosts and bare domains count (same pattern as Android).
  • Repeated message: a third near-copy (≥ 80% match) from one sender within 10 minutes is hidden.
  • Copied across users: once 2 other senders posted a near-copy of a 16+ character message within 10 minutes, the next one is hidden. Rotating identities doesn't get around it.
  • Repeated characters: messages of 32+ characters whose autocorrelation is ≥ 85% (AAAA…, buy now buy now …) are hidden.

Similarity is the same 3-peak cross-correlation as Android. Text is NFKD-normalized first, and combining marks and invisible characters are dropped, so copies disguised with zero-width spaces, accents, or math-bold or fullwidth letters still match. Buckets run on message timestamps (TokenBucketSchedule), clamped to [now − 1h, now], so relay backfill and gossip sync aren't mistaken for floods. Verdicts are cached by message ID, so a message that arrives more than once is judged and counted once.

Where it hooks in

  • ChatPublicConversationCoordinator.handlePublicMessage, the single intake for mesh, bridged and geohash messages. It runs after the existing block, rate-limit, content and archived-echo checks, and before the pipeline enqueue. The sender key is the coordinator's existing normalizedSenderKey (noise: stable key, nostr: full pubkey, or mesh: peer ID).
  • checkForMentions / sendHapticFeedback skip messages the filter hid (SpamFilter.wasHidden(messageID:), which reads the cached verdict without counting again).
  • GeoPresenceTracker's sampled geohashes: spam neither feeds the nearby-activity hint nor triggers the activity notification. The open channel's copy of an event has the same ID, so it isn't counted twice.
  • Attachments: a received mesh image, voice note or file ([image] <filename>, the rows the timeline renders as media) only spends a message token; its filename isn't judged as text. The same line from a geohash or bridged sender stays text.
  • Panic wipe resets the toggle to its default and forgets the filter's history (recent text and sender keys).

Settings

iOS has no debug settings, so there are no tuning sliders and no bypass. The settings sheet gets a new public chats section with a spam filter toggle, on by default. Toggling it starts every sender with a full budget. The three new strings are translated into all 30 locales.

Differences from Android

  • Main-actor cost. On Android the geohash check runs on a background dispatcher. Here the intake runs on the main actor, so I bounded the worst case. Before, it was ~104 ms per message (release build), from texts built from a small vocabulary: each passed the trigram pre-check against all 200 stored texts and ran a full alignment.
    • RepetitionDetector.alignmentUpperBound sums the 3 fullest diagonals. Every peak sits on its own shift, so this is a true upper bound, and pairs below 80% skip the alignment without changing any verdict.
    • The cross-user scan runs newest first and stops after 64 comparisons or 8 full alignments.
    • An inverted trigram index means a message only meets the stored texts it shares trigrams with, instead of merging trigram lists with all 200.
    • Cheaper per-message work (an early exit in the repeated-character check, a cached bucket state for in-order messages, direct buffer access in the hot loops) keeps the debug-build performance benchmark above its floor.
    • Measured with -O: ordinary chat ~0.16 ms per message, a small-vocabulary flood ~0.4 ms, random two-letter junk (the worst case found) ~7 ms.
  • Sender LRU is a counter-based eviction down to 3/4 of maxTrackedSenders, in place of a LinkedHashMap.
  • No PoW exemption. Same as Android: a sender's PoW relaxes the existing per-sender limiter, but not the spam filter.

Examples

Each example was run through this branch's SpamFilter (default config):

ScenarioSpam hiddenOther messages shown
One sender, a new line every 2 s for 40 s11 / 20 (burst of 6, then 1 per 10 s)14 / 14
Same ad every 15 s4 / 6 (3rd copy on)14 / 14
Same ad with small edits2 / 414 / 14
5 identities, identical text2 / 5 (4th on)14 / 14
6 identities, edited copies3 / 614 / 14
3 plain copies, then zero-width / accent / math-bold / fullwidth copiesall 4 disguised copies14 / 14
4 messages with a link, 6 s apart, then one without2 / 5 (the 3rd and 4th link)14 / 14
AAAA…, lol lol …, 🚀🚀…, hey!!!!…, then a normal message4 / 5 (the normal one is shown)14 / 14
~450-char rants every 10 s1 / 514 / 14
Excited real user, 9 messages in 25 s—23 / 24 (only their 9th message is hidden)
5 people, same topic, own words—5 / 5
One hour of backfill, newest first—16 / 16

Risks and limitations

Same as Android:

  • A single message over 1200 characters is always hidden.
  • A 4th person posting the same 16+ character sentence within 10 minutes (e.g. "happy new year everyone") is hidden.
  • Hidden messages are dropped, not stored, so turning the filter off only affects new messages.

Specific to iOS:

  • Under a deliberate flood of look-alike junk, the comparison caps can let a cross-user copy through if more than 64 look-alikes arrived after the earlier copies.
  • Two existing tests needed changes because their synthetic messages are near-copies the filter now hides:
    • ChatViewModelRateLimitingTests sends rate-msg-0…5 from one sender; it now turns the filter off to measure the older limiter alone.
    • testPipelinePublicIngest sent numbered copies of one template from 50 peers; it now sends sentences of made-up words. Sentences built from one small word list share far more trigrams than real chat (31% of pairs pass the trigram check, against 0.6% for natural chat lines) and would measure the filter's worst case instead of ordinary ingest. Locally the benchmark runs at ~8400 msg/s, against ~13600 with the filter off.

Testing

  • SpamFilterTests (54 tests): the 44 Android SpamFilterTest cases, plus:
    • the alignment bound never undercounts (randomized, 2- to 26-letter alphabets)
    • recent copies are still caught behind a flood of look-alikes
    • sender eviction and reset
    • mesh attachments: filenames aren't judged, an attachment flood still hits the message rate, a geohash [image] line stays text
    • the shortcuts match the full computation: the early-exit repeated-character check and the cached bucket state (randomized)
  • SpamFilterSettingsTests (4): default on, toggle persists and applies, toggling refills budgets, panic reset.
  • ChatPublicConversationCoordinatorContextTests (4 new):
    • a one-sender flood is hidden along with its mention notification
    • a 5-identity copy swarm in a geohash
    • own echoes are not judged
    • the filter off lets everything through
  • Full suite: swift test passes (2257 tests), including LocalizationCoverageTests and the performance baselines.
  • Simulator: the iOS app builds for the iPhone 17 Pro simulator. There, the new toggle shows in settings, switches off, and the setting persists across a relaunch.
  • macOS: swift build compiles the app code for macOS; the macOS Xcode target was not built.
  • Not done: a live test on devices or in a real geohash channel, and a two-device mesh test.

Activity

10
chatgpt-codex-connector[bot]5d ago

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

ReviewStatusCommitReview trigger
📝 Code Review✅ Completed 2026-10-26T08:44:59.956917Z0ae6905Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

chatgpt-codex-connector[bot]reviewed5d ago
chatgpt-codex-connector[bot]5d ago

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 938619fb5d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

callebtcreviewed5d ago
callebtc4d ago

@codex review

chatgpt-codex-connector[bot]4d ago

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 0ae6905d1a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".