Files
bitchat/bitchat/Services/NotificationService.swift
T
jackandClaude Opus 5 dc7475a0ba Harden what a locked or seized device gives away
The realistic compromise for many of the people this app is built for is
not interception but a phone taken, and often unlocked under coercion.
Content encryption is in good shape; these are the gaps around it.

Hide notification previews, by default. Notification content is rendered
by the system on the lock screen, so it was readable without unlocking:
DM alerts carried the sender's nickname and the full message body, and
geohash alerts put the geohash in the title. Alerts now state that a DM,
mention, or location-channel activity arrived and withhold the rest until
the app is opened. userInfo still carries the routing peer ID and deep
link, neither of which the system displays, so taps land where they did.
A settings toggle restores full previews for anyone who wants them.
Default-on is the deliberate part: a phone face-up on a table should not
narrate conversations, and someone who wants previews can say so.

Cover the window on willResignActive, so the snapshot iOS stores for the
app switcher shows a placeholder rather than an open conversation. Opaque
rather than blurred, because blurred large text stays partly legible and
the snapshot goes to disk. Added synchronously from a UIKit notification
with queue: nil, since the capture follows shortly after and an
OperationQueue hop or a SwiftUI state change can lose that race. Panic
wipe already deleted snapshots already on disk; this stops new ones from
being worth deleting.

Bound media by age as well as size. The 100 MB quota only ever considered
incoming files, so outgoing media had no lifetime at all and a received
photo could outlive its conversation indefinitely. A launch-time sweep now
deletes managed media older than seven days, incoming and outgoing, with
the same exemptions quota eviction honors: in-flight live captures and
files reserved by a delivery or deletion in progress.

Make /clear tell the truth on the mesh timeline. It recorded an echo
watermark and left the gossip archive on disk for up to 6 hours, so
someone who cleared before a police stop had deleted nothing. Clearing now
erases the archive too. The watermark still matters: it suppresses
pre-clear messages this device hears again from peers. The cost is that
the device stops serving recent public backlog until it hears fresh
traffic, which is a fair reading of what clearing a timeline means.

Documented in PRIVACY_POLICY.md and the privacy assessment, including a
new section on what is deliberately NOT addressed: there is still no
duress mechanism of any kind (no decoy passphrase, no wipe-on-failed-auth,
no app lock), macOS gets no file-protection classes, and media is not
sealed at the app layer. The duress question is a product decision as much
as an engineering one, since in some jurisdictions destroying data on
demand is itself an offence and hiding may protect someone better than
destroying, so it is called out rather than guessed at.

Three findings from the audit that prompted this work turned out to be
already fixed on main and are not included: keychain accessibility is
AfterFirstUnlockThisDeviceOnly with a retrying migration, the panic media
wipe uses a two-location durable marker transaction, and panic already
discards staged share-extension content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:31:30 +02:00

268 lines
10 KiB
Swift

//
// NotificationService.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import UserNotifications
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
protocol NotificationAuthorizing {
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
)
}
protocol NotificationRequestDelivering {
func add(_ request: UNNotificationRequest)
}
protocol NotificationCategoryRegistering {
func setCategories(_ categories: Set<UNNotificationCategory>)
}
private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing {
private let center: UNUserNotificationCenter
init(center: UNUserNotificationCenter) {
self.center = center
}
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
) {
center.requestAuthorization(options: options, completionHandler: completionHandler)
}
}
private final class NotificationCenterRequestDelivererAdapter: NotificationRequestDelivering {
private let center: UNUserNotificationCenter
init(center: UNUserNotificationCenter) {
self.center = center
}
func add(_ request: UNNotificationRequest) {
Task {
try? await center.add(request)
}
}
}
private final class NotificationCenterCategoryRegistrarAdapter: NotificationCategoryRegistering {
private let center: UNUserNotificationCenter
init(center: UNUserNotificationCenter) {
self.center = center
}
func setCategories(_ categories: Set<UNNotificationCategory>) {
center.setNotificationCategories(categories)
}
}
private struct NoopNotificationAuthorizer: NotificationAuthorizing {
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
) {
completionHandler(false, nil)
}
}
private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering {
func add(_ request: UNNotificationRequest) {}
}
private struct NoopNotificationCategoryRegistrar: NotificationCategoryRegistering {
func setCategories(_ categories: Set<UNNotificationCategory>) {}
}
final class NotificationService {
static let shared = NotificationService()
/// Category for the "bitchatters nearby" notification, carrying the wave quick action.
static let nearbyCategoryID = "chat.bitchat.category.nearby"
static let waveActionID = "chat.bitchat.action.wave"
/// Copy used when `NotificationPrivacySettings.hideMessagePreviews` is on.
/// These say that something arrived without naming who sent it, quoting it,
/// or disclosing which geohash it came from.
private enum Redacted {
static var directMessageTitle: String {
String(localized: "notification.redacted.dm.title", defaultValue: "🔒 new dm", comment: "Lock-screen notification title for a received direct message when message previews are hidden; deliberately names neither the sender nor the content")
}
static var mentionTitle: String {
String(localized: "notification.redacted.mention.title", defaultValue: "🫵 you were mentioned", comment: "Lock-screen notification title telling someone they were mentioned when message previews are hidden; deliberately omits who mentioned them")
}
static var geohashActivityTitle: String {
String(localized: "notification.redacted.geohash.title", defaultValue: "📍 new activity nearby", comment: "Lock-screen notification title for activity in a location channel when message previews are hidden; deliberately omits the geohash")
}
static var body: String {
String(localized: "notification.redacted.body", defaultValue: "open bitchat to read", comment: "Lock-screen notification body shown in place of the message text when message previews are hidden")
}
}
/// Whether delivered alerts must withhold sender, content, and geohash.
private var hidePreviews: Bool {
NotificationPrivacySettings.hideMessagePreviews
}
private let isRunningTestsProvider: () -> Bool
private let authorizer: NotificationAuthorizing
private let requestDeliverer: NotificationRequestDelivering
private let categoryRegistrar: NotificationCategoryRegistering
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
private var isRunningTests: Bool {
isRunningTestsProvider()
}
private init() {
self.isRunningTestsProvider = {
let env = ProcessInfo.processInfo.environment
return NSClassFromString("XCTestCase") != nil ||
env["XCTestConfigurationFilePath"] != nil ||
env["XCTestBundlePath"] != nil ||
env["GITHUB_ACTIONS"] != nil ||
env["CI"] != nil
}
if isRunningTestsProvider() {
self.authorizer = NoopNotificationAuthorizer()
self.requestDeliverer = NoopNotificationRequestDeliverer()
self.categoryRegistrar = NoopNotificationCategoryRegistrar()
} else {
let center = UNUserNotificationCenter.current()
self.authorizer = NotificationCenterAuthorizerAdapter(center: center)
self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center)
self.categoryRegistrar = NotificationCenterCategoryRegistrarAdapter(center: center)
}
}
internal init(
isRunningTestsProvider: @escaping () -> Bool,
authorizer: NotificationAuthorizing,
requestDeliverer: NotificationRequestDelivering,
categoryRegistrar: NotificationCategoryRegistering = NoopNotificationCategoryRegistrar()
) {
self.isRunningTestsProvider = isRunningTestsProvider
self.authorizer = authorizer
self.requestDeliverer = requestDeliverer
self.categoryRegistrar = categoryRegistrar
}
func requestAuthorization() {
guard !isRunningTests else { return }
registerCategories()
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
if granted {
// Permission granted
} else {
// Permission denied
}
}
}
private func registerCategories() {
let wave = UNNotificationAction(
identifier: Self.waveActionID,
title: String(localized: "notification.action.wave", comment: "Title of the notification action button that sends a friendly wave back to a nearby person"),
options: []
)
let nearby = UNNotificationCategory(
identifier: Self.nearbyCategoryID,
actions: [wave],
intentIdentifiers: [],
options: []
)
categoryRegistrar.setCategories([nearby])
}
func sendLocalNotification(
title: String,
body: String,
identifier: String,
userInfo: [String: Any]? = nil,
interruptionLevel: UNNotificationInterruptionLevel = .active,
categoryIdentifier: String? = nil
) {
guard !isRunningTests else { return }
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.interruptionLevel = interruptionLevel
if let categoryIdentifier = categoryIdentifier {
content.categoryIdentifier = categoryIdentifier
}
if let userInfo = userInfo {
content.userInfo = userInfo
}
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: nil // Deliver immediately
)
requestDeliverer.add(request)
}
func sendMentionNotification(from sender: String, message: String) {
let title = hidePreviews ? Redacted.mentionTitle : "🫵 you were mentioned by \(sender)"
let body = hidePreviews ? Redacted.body : message
let identifier = "mention-\(UUID().uuidString)"
sendLocalNotification(title: title, body: body, identifier: identifier)
}
func sendPrivateMessageNotification(from sender: String, message: String, peerID: PeerID) {
let title = hidePreviews ? Redacted.directMessageTitle : "🔒 DM from \(sender)"
let body = hidePreviews ? Redacted.body : message
let identifier = "private-\(UUID().uuidString)"
// Routing payload, not display copy: `userInfo` never reaches the lock
// screen, and the conversation to open still has to be identifiable.
let userInfo = ["peerID": peerID.id, "senderName": sender]
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
}
// Geohash public chat notification with deep link to a specific geohash
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
// The geohash itself is location data, so hiding previews withholds it
// from the alert while leaving the deep link intact for the tap.
let title = hidePreviews ? Redacted.geohashActivityTitle : "\(titlePrefix)\(geohash)"
let body = hidePreviews ? Redacted.body : bodyPreview
let identifier = "geo-activity-\(geohash)-\(Date().timeIntervalSince1970)"
let deeplink = "bitchat://geohash/\(geohash)"
let userInfo: [String: Any] = ["deeplink": deeplink]
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
}
func sendNetworkAvailableNotification(peerCount: Int) {
let title = "👥 bitchatters nearby!"
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
// Fixed identifier so iOS updates the existing notification instead of creating new ones
let identifier = "network-available"
sendLocalNotification(
title: title,
body: body,
identifier: identifier,
interruptionLevel: .timeSensitive,
categoryIdentifier: Self.nearbyCategoryID
)
}
}