Files
simplex-chat/apps/ios/Shared/SimpleXApp.swift
Narasimha-sc 717241af5b fix: maintain the profile unread count by the same rule that seeds it
The count is seeded by getUsersInfo, which counts RcvNew items where ntfsEnabled
holds - every item for an unmuted chat, only mentions for a mentions-only one,
none for a muted one. The deltas that maintain it afterwards did not follow that
rule for the active profile: addChatItem incremented for every item regardless
of the chat's notification setting, the mark-read helpers subtracted the full
unread count, removeChat left a deleted chat's unread behind, and changing the
setting moved a chat between rules without moving the count.

Chat.userUnreadCount names what a chat contributes, and it is not a new rule:
totalUnreadCountForAllUsers already computed exactly this inline for the app
badge, and now calls it. Every site that changes a chat's stats, its settings or
its presence reports the contribution before and after, and the count moves by
the difference.

For that to hold the stats it reads have to be maintained, so on Android and
desktop decreaseCounterInPrimaryContext now decrements unreadMentions alongside
unreadCount when the item was a mention. Without it a mentions-only chat's
contribution never moved on any of that function's three paths - a deleted item,
a moderated member item, or an item going from New to Read. iOS already passed
the mention delta on its deletion paths.

This is visible beyond the profile picker, which badges only other profiles: the
desktop tray dot sums the count across profiles including the active one, and on
iOS these same deltas drive the app icon badge. So on iOS, muting a chat with
unread now drops the badge by its contribution, deleting a chat drops it, and a
message in a muted chat no longer raises it - each of which now agrees with
totalUnreadCountForAllUsers, which the badge is also set from.

addChat is deliberately not the mirror of removeChat: adding a chat means it is
being loaded into view, and its items were already counted by the seed, so
counting them again on ChatItemsLoader's addChat would double them. The one
place that removes and re-adds a chat while keeping its items - deleting a
contact in "keep conversation" mode on iOS - now carries its stats across and
restores the contribution explicitly, since the core keeps both the contact row
and its items there and the seed still counts them.

Wherever the chat list is re-read wholesale the counts are re-read with it: both
resume paths, and setting a chat item TTL, which deletes items across every
profile and so leaves every profile's count stale, not just the active one. They
are assigned before the chats, which matters on iOS because updateChats
recomputes the app badge from users there. Reading users is best effort on both
platforms - losing them must not cost us the chats already fetched.

This does not make the count exact. getUsersInfo has no group_scope_tag filter
where the query behind chatStats.unreadCount does, so the seed counts
support-scope items that no client-side rule can, and the count steps up by the
outstanding support unread on each reseed. Two paths also install a whole
chatStats without moving the count: processLoadedChat's updateChatStats on
Android and desktop, and replaceChat on both - from a pre-call snapshot in the
mark-read paths there, and from the server in iOS's contact-request handler.
2026-09-05 11:38:41 +00:00

189 lines
8.3 KiB
Swift

//
// SimpleXApp.swift
// Shared
//
// Created by Evgeny Poberezkin on 17/01/2022.
//
// Spec: spec/architecture.md
import SwiftUI
import OSLog
import SimpleXChat
let logger = Logger()
@main
// Spec: spec/architecture.md#SimpleXApp
struct SimpleXApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var chatModel = ChatModel.shared
@ObservedObject var alertManager = AlertManager.shared
@Environment(\.scenePhase) var scenePhase
@State private var enteredBackgroundAuthenticated: TimeInterval? = nil
init() {
DispatchQueue.global(qos: .background).sync {
haskell_init()
// hs_init(0, nil)
}
UserDefaults.standard.register(defaults: appDefaults)
setGroupDefaults()
registerGroupDefaults()
setDbContainer()
BGManager.shared.register()
NtfManager.shared.registerCategories()
}
var body: some Scene {
WindowGroup {
// contentAccessAuthenticationExtended has to be passed to ContentView on view initialization,
// so that it's computed by the time view renders, and not on event after rendering
ContentView(contentAccessAuthenticationExtended: !authenticationExpired())
.environmentObject(chatModel)
.environmentObject(AppTheme.shared)
.onOpenURL { url in
logger.debug("ContentView.onOpenURL: \(url)")
if AppChatState.shared.value == .active {
chatModel.appOpenUrl = url
} else {
chatModel.appOpenUrlLater = url
}
}
.onAppear() {
// Present screen for continue migration if it wasn't finished yet
if chatModel.migrationState != nil {
// It's important, otherwise, user may be locked in undefined state
onboardingStageDefault.set(.step1_SimpleXInfo)
chatModel.onboardingStage = onboardingStageDefault.get()
} else if kcAppPassword.get() == nil || kcSelfDestructPassword.get() == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
initChatAndMigrate()
}
}
}
// Spec: spec/architecture.md#scenePhaseHandling
.onChange(of: scenePhase) { phase in
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
AppSheetState.shared.scenePhaseActive = phase == .active
switch (phase) {
case .background:
// --- authentication
// see ContentView .onChange(of: scenePhase) for remaining authentication logic
if chatModel.contentViewAccessAuthenticated {
enteredBackgroundAuthenticated = ProcessInfo.processInfo.systemUptime
}
chatModel.contentViewAccessAuthenticated = false
// authentication ---
if CallController.useCallKit() && chatModel.activeCall != nil {
CallController.shared.shouldSuspendChat = true
} else {
suspendChat()
BGManager.shared.schedule()
}
NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers())
case .active:
CallController.shared.shouldSuspendChat = false
let appState = AppChatState.shared.value
if appState != .stopped {
startChatAndActivate {
if chatModel.chatRunning == true {
if let ntfResponse = chatModel.notificationResponse {
chatModel.notificationResponse = nil
NtfManager.shared.processNotificationResponse(ntfResponse)
}
if appState.inactive {
Task {
await updateChats()
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
await updateCallInvitations()
}
if let url = chatModel.appOpenUrlLater {
await MainActor.run {
chatModel.appOpenUrlLater = nil
chatModel.appOpenUrl = url
}
}
}
} else if let url = chatModel.appOpenUrlLater {
chatModel.appOpenUrlLater = nil
chatModel.appOpenUrl = url
}
}
}
}
default:
break
}
}
}
}
private func setDbContainer() {
// Uncomment and run once to open DB in app documents folder:
// dbContainerGroupDefault.set(.documents)
// v3DBMigrationDefault.set(.offer)
// to create database in app documents folder also uncomment:
// let legacyDatabase = true
let legacyDatabase = hasLegacyDatabase()
if legacyDatabase, case .documents = dbContainerGroupDefault.get() {
dbContainerGroupDefault.set(.documents)
setMigrationState(.offer)
logger.debug("SimpleXApp init: using legacy DB in documents folder: \(getAppDatabasePath())*.db")
} else {
dbContainerGroupDefault.set(.group)
setMigrationState(.ready)
logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath())*.db")
logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not") present")
}
}
private func setMigrationState(_ state: V3DBMigrationState) {
if case .migrated = v3DBMigrationDefault.get() { return }
v3DBMigrationDefault.set(state)
}
private func authenticationExpired() -> Bool {
if let enteredBackgroundAuthenticated = enteredBackgroundAuthenticated {
let delay = Double(UserDefaults.standard.integer(forKey: DEFAULT_LA_LOCK_DELAY))
return ProcessInfo.processInfo.systemUptime - enteredBackgroundAuthenticated >= delay
} else {
return true
}
}
private func updateChats() async {
do {
let chats = try await apiGetChatsAsync()
let users = try? await listUsersAsync()
await MainActor.run {
if let users { chatModel.users = users }
chatModel.updateChats(chats)
}
if let id = chatModel.chatId,
let chat = chatModel.getChat(id),
!NtfManager.shared.navigatingToChat {
Task { await loadChat(chat: chat, im: ItemsModel.shared, clearItems: false) }
}
if let ncr = chatModel.ntfContactRequest {
await MainActor.run { chatModel.ntfContactRequest = nil }
if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo {
Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) }
}
}
} catch let error {
logger.error("apiGetChats: cannot update chats \(responseError(error))")
}
}
private func updateCallInvitations() async {
do {
try await refreshCallInvitations()
} catch let error {
logger.error("apiGetCallInvitations: cannot update call invitations \(responseError(error))")
}
}
}