fix(ghostgram): fix Always Online + implement Ghost Mode mutual exclusion

Bugs fixed:
- Always Online had no effect after toggle — updatePresence was only called
  on app focus changes, not on settings changes. Fixed by subscribing to
  MiscSettingsManager notifications in AccountPresenceManagerImpl.

- Ghost Mode + Always Online conflict: if Ghost Mode was enabled, the early
  return in updatePresence completely blocked Always Online logic.

Changes:
- ManagedAccountPresence: priority chain Always Online > Ghost Mode > default.
  Subscribes to GhostMode/MiscSettings notifications, refreshes presence
  immediately on any change. 30s keep-alive timer for Always Online.
- GhostModeManager: enabling Ghost Mode auto-disables alwaysOnline via
  disableAlwaysOnlineForMutualExclusion(). No recursion via guard flag.
- MiscSettingsManager: enabling alwaysOnline auto-disables Ghost Mode via
  disableForMutualExclusion(). No recursion via guard flag.
- MiscController: subscribes to GhostModeManager notifications to refresh
  UI when Ghost Mode is auto-disabled by Always Online.
- GhostModeController: subscribes to MiscSettings notifications to refresh
  UI when Ghost Mode is auto-disabled by Always Online.
This commit is contained in:
ichmagmaus 812
2026-02-23 23:17:25 +01:00
parent db53826061
commit 019945f9da
5 changed files with 204 additions and 76 deletions
@@ -300,6 +300,28 @@ public func ghostModeController(context: AccountContext) -> ViewController {
} }
) )
// Refresh UI when Always Online is enabled externally and auto-disables Ghost Mode
// the isEnabled flip happens in GhostModeManager from MiscSettingsManager context,
// so we need to pull fresh values from the manager.
let miscSettingsChangedSignal: Signal<Void, NoError> = Signal { subscriber in
let observer = NotificationCenter.default.addObserver(
forName: MiscSettingsManager.settingsChangedNotification,
object: nil,
queue: .main
) { _ in
updateState { state in
state.isEnabled = GhostModeManager.shared.isEnabled
state.hideReadReceipts = GhostModeManager.shared.hideReadReceipts
state.hideStoryViews = GhostModeManager.shared.hideStoryViews
state.hideOnlineStatus = GhostModeManager.shared.hideOnlineStatus
state.hideTypingIndicator = GhostModeManager.shared.hideTypingIndicator
state.forceOffline = GhostModeManager.shared.forceOffline
}
}
return ActionDisposable { NotificationCenter.default.removeObserver(observer) }
}
let _ = miscSettingsChangedSignal.start()
let signal = combineLatest( let signal = combineLatest(
context.sharedContext.presentationData, context.sharedContext.presentationData,
statePromise.get() statePromise.get()
@@ -291,12 +291,34 @@ public func miscController(context: AccountContext) -> ViewController {
toggleAlwaysOnline: { toggleAlwaysOnline: {
let newValue = !MiscSettingsManager.shared.alwaysOnline let newValue = !MiscSettingsManager.shared.alwaysOnline
MiscSettingsManager.shared.alwaysOnline = newValue MiscSettingsManager.shared.alwaysOnline = newValue
// State will be refreshed via notification if Ghost Mode got auto-disabled
updateState { state in updateState { state in
state.alwaysOnline = newValue state.alwaysOnline = newValue
} }
} }
) )
// Refresh UI when Ghost Mode is auto-disabled by mutual exclusion
// the toggle flip happens externally, so we must pull fresh values from the managers.
let ghostModeChangedSignal: Signal<Void, NoError> = Signal { subscriber in
let observer = NotificationCenter.default.addObserver(
forName: GhostModeManager.settingsChangedNotification,
object: nil,
queue: .main
) { _ in
updateState { state in
state.isEnabled = MiscSettingsManager.shared.isEnabled
state.bypassCopyProtection = MiscSettingsManager.shared.bypassCopyProtection
state.disableViewOnceAutoDelete = MiscSettingsManager.shared.disableViewOnceAutoDelete
state.bypassScreenshotProtection = MiscSettingsManager.shared.bypassScreenshotProtection
state.blockAds = MiscSettingsManager.shared.blockAds
state.alwaysOnline = MiscSettingsManager.shared.alwaysOnline
}
}
return ActionDisposable { NotificationCenter.default.removeObserver(observer) }
}
let _ = ghostModeChangedSignal.start()
let signal = combineLatest( let signal = combineLatest(
context.sharedContext.presentationData, context.sharedContext.presentationData,
statePromise.get() statePromise.get()
@@ -11,25 +11,35 @@ public final class GhostModeManager {
// MARK: - UserDefaults Keys // MARK: - UserDefaults Keys
private enum Keys { private enum Keys {
static let isEnabled = "GhostMode.isEnabled" static let isEnabled = "GhostMode.isEnabled"
static let hideReadReceipts = "GhostMode.hideReadReceipts" static let hideReadReceipts = "GhostMode.hideReadReceipts"
static let hideStoryViews = "GhostMode.hideStoryViews" static let hideStoryViews = "GhostMode.hideStoryViews"
static let hideOnlineStatus = "GhostMode.hideOnlineStatus" static let hideOnlineStatus = "GhostMode.hideOnlineStatus"
static let hideTypingIndicator = "GhostMode.hideTypingIndicator" static let hideTypingIndicator = "GhostMode.hideTypingIndicator"
static let forceOffline = "GhostMode.forceOffline" static let forceOffline = "GhostMode.forceOffline"
} }
// MARK: - Settings Storage // MARK: - Settings Storage
private let defaults = UserDefaults.standard private let defaults = UserDefaults.standard
// Prevents recursive mutual-exclusion calls
private var isApplyingMutualExclusion = false
// MARK: - Properties // MARK: - Properties
/// Master toggle for Ghost Mode /// Master toggle for Ghost Mode.
/// Enabling Ghost Mode automatically disables Always Online in MiscSettingsManager.
public var isEnabled: Bool { public var isEnabled: Bool {
get { defaults.bool(forKey: Keys.isEnabled) } get { defaults.bool(forKey: Keys.isEnabled) }
set { set {
defaults.set(newValue, forKey: Keys.isEnabled) defaults.set(newValue, forKey: Keys.isEnabled)
if newValue && !isApplyingMutualExclusion {
// Ghost Mode ON disable Always Online
isApplyingMutualExclusion = true
MiscSettingsManager.shared.disableAlwaysOnlineForMutualExclusion()
isApplyingMutualExclusion = false
}
notifySettingsChanged() notifySettingsChanged()
} }
} }
@@ -37,7 +47,7 @@ public final class GhostModeManager {
/// Don't send read receipts (blue checkmarks) /// Don't send read receipts (blue checkmarks)
public var hideReadReceipts: Bool { public var hideReadReceipts: Bool {
get { defaults.bool(forKey: Keys.hideReadReceipts) } get { defaults.bool(forKey: Keys.hideReadReceipts) }
set { set {
defaults.set(newValue, forKey: Keys.hideReadReceipts) defaults.set(newValue, forKey: Keys.hideReadReceipts)
notifySettingsChanged() notifySettingsChanged()
} }
@@ -46,7 +56,7 @@ public final class GhostModeManager {
/// Don't send story view notifications /// Don't send story view notifications
public var hideStoryViews: Bool { public var hideStoryViews: Bool {
get { defaults.bool(forKey: Keys.hideStoryViews) } get { defaults.bool(forKey: Keys.hideStoryViews) }
set { set {
defaults.set(newValue, forKey: Keys.hideStoryViews) defaults.set(newValue, forKey: Keys.hideStoryViews)
notifySettingsChanged() notifySettingsChanged()
} }
@@ -55,7 +65,7 @@ public final class GhostModeManager {
/// Don't send online status /// Don't send online status
public var hideOnlineStatus: Bool { public var hideOnlineStatus: Bool {
get { defaults.bool(forKey: Keys.hideOnlineStatus) } get { defaults.bool(forKey: Keys.hideOnlineStatus) }
set { set {
defaults.set(newValue, forKey: Keys.hideOnlineStatus) defaults.set(newValue, forKey: Keys.hideOnlineStatus)
notifySettingsChanged() notifySettingsChanged()
} }
@@ -64,7 +74,7 @@ public final class GhostModeManager {
/// Don't send typing indicator /// Don't send typing indicator
public var hideTypingIndicator: Bool { public var hideTypingIndicator: Bool {
get { defaults.bool(forKey: Keys.hideTypingIndicator) } get { defaults.bool(forKey: Keys.hideTypingIndicator) }
set { set {
defaults.set(newValue, forKey: Keys.hideTypingIndicator) defaults.set(newValue, forKey: Keys.hideTypingIndicator)
notifySettingsChanged() notifySettingsChanged()
} }
@@ -73,7 +83,7 @@ public final class GhostModeManager {
/// Always appear as offline /// Always appear as offline
public var forceOffline: Bool { public var forceOffline: Bool {
get { defaults.bool(forKey: Keys.forceOffline) } get { defaults.bool(forKey: Keys.forceOffline) }
set { set {
defaults.set(newValue, forKey: Keys.forceOffline) defaults.set(newValue, forKey: Keys.forceOffline)
notifySettingsChanged() notifySettingsChanged()
} }
@@ -81,72 +91,82 @@ public final class GhostModeManager {
// MARK: - Computed Properties // MARK: - Computed Properties
/// Check if read receipts should be hidden (master + individual toggle) /// Returns true only when Ghost Mode is enabled AND the individual toggle is on.
/// NOTE: Always Online takes precedence if Always Online is active, online status is never hidden.
public var shouldHideReadReceipts: Bool { public var shouldHideReadReceipts: Bool {
return isEnabled && hideReadReceipts return isEnabled && hideReadReceipts
} }
/// Check if story views should be hidden
public var shouldHideStoryViews: Bool { public var shouldHideStoryViews: Bool {
return isEnabled && hideStoryViews return isEnabled && hideStoryViews
} }
/// Check if online status should be hidden /// Online status is hidden only when Ghost Mode is on AND Always Online is NOT active.
public var shouldHideOnlineStatus: Bool { public var shouldHideOnlineStatus: Bool {
return isEnabled && hideOnlineStatus guard isEnabled && hideOnlineStatus else { return false }
return !MiscSettingsManager.shared.shouldAlwaysBeOnline
} }
/// Check if typing indicator should be hidden
public var shouldHideTypingIndicator: Bool { public var shouldHideTypingIndicator: Bool {
return isEnabled && hideTypingIndicator return isEnabled && hideTypingIndicator
} }
/// Check if should force offline /// Force offline only when Ghost Mode is on AND Always Online is NOT active.
public var shouldForceOffline: Bool { public var shouldForceOffline: Bool {
return isEnabled && forceOffline guard isEnabled && forceOffline else { return false }
return !MiscSettingsManager.shared.shouldAlwaysBeOnline
} }
/// Count of active features (e.g., "5/5") /// Count of active features (e.g., "5/5")
public var activeFeatureCount: Int { public var activeFeatureCount: Int {
var count = 0 var count = 0
if hideReadReceipts { count += 1 } if hideReadReceipts { count += 1 }
if hideStoryViews { count += 1 } if hideStoryViews { count += 1 }
if hideOnlineStatus { count += 1 } if hideOnlineStatus { count += 1 }
if hideTypingIndicator { count += 1 } if hideTypingIndicator { count += 1 }
if forceOffline { count += 1 } if forceOffline { count += 1 }
return count return count
} }
/// Total number of features /// Total number of features
public static let totalFeatureCount = 5 public static let totalFeatureCount = 5
// MARK: - Internal mutual exclusion (called by MiscSettingsManager)
/// Called by MiscSettingsManager when Always Online is turned on.
/// Disables Ghost Mode without triggering mutual exclusion back.
public func disableForMutualExclusion() {
isApplyingMutualExclusion = true
defaults.set(false, forKey: Keys.isEnabled)
notifySettingsChanged()
isApplyingMutualExclusion = false
}
// MARK: - Initialization // MARK: - Initialization
private init() { private init() {
// Set default values if not set
if !defaults.bool(forKey: "GhostMode.initialized") { if !defaults.bool(forKey: "GhostMode.initialized") {
defaults.set(true, forKey: "GhostMode.initialized") defaults.set(true, forKey: "GhostMode.initialized")
// Default: all features enabled when ghost mode is on
defaults.set(true, forKey: Keys.hideReadReceipts) defaults.set(true, forKey: Keys.hideReadReceipts)
defaults.set(true, forKey: Keys.hideStoryViews) defaults.set(true, forKey: Keys.hideStoryViews)
defaults.set(true, forKey: Keys.hideOnlineStatus) defaults.set(true, forKey: Keys.hideOnlineStatus)
defaults.set(true, forKey: Keys.hideTypingIndicator) defaults.set(true, forKey: Keys.hideTypingIndicator)
defaults.set(true, forKey: Keys.forceOffline) defaults.set(true, forKey: Keys.forceOffline)
// Ghost mode itself is off by default
defaults.set(false, forKey: Keys.isEnabled) defaults.set(false, forKey: Keys.isEnabled)
} }
} }
// MARK: - Enable All // MARK: - Enable/Disable All
/// Enable all ghost mode features /// Enable all ghost mode features.
/// Also disables Always Online (mutual exclusion).
public func enableAll() { public func enableAll() {
hideReadReceipts = true hideReadReceipts = true
hideStoryViews = true hideStoryViews = true
hideOnlineStatus = true hideOnlineStatus = true
hideTypingIndicator = true hideTypingIndicator = true
forceOffline = true forceOffline = true
isEnabled = true isEnabled = true // setter handles mutual exclusion
} }
/// Disable all ghost mode features /// Disable all ghost mode features
@@ -1,21 +1,24 @@
import Foundation import Foundation
/// MiscSettingsManager - Central manager for Misc privacy settings /// MiscSettingsManager - Central manager for Misc privacy settings
/// Handles: Forward bypass, View-Once persistence, Screenshot bypass /// Handles: Forward bypass, View-Once persistence, Screenshot bypass, Block Ads, Always Online
public final class MiscSettingsManager { public final class MiscSettingsManager {
public static let shared = MiscSettingsManager() public static let shared = MiscSettingsManager()
private enum Keys { private enum Keys {
static let isEnabled = "MiscSettings.isEnabled" static let isEnabled = "MiscSettings.isEnabled"
static let bypassCopyProtection = "MiscSettings.bypassCopyProtection" static let bypassCopyProtection = "MiscSettings.bypassCopyProtection"
static let disableViewOnceAutoDelete = "MiscSettings.disableViewOnceAutoDelete" static let disableViewOnceAutoDelete = "MiscSettings.disableViewOnceAutoDelete"
static let bypassScreenshotProtection = "MiscSettings.bypassScreenshotProtection" static let bypassScreenshotProtection = "MiscSettings.bypassScreenshotProtection"
static let blockAds = "MiscSettings.blockAds" static let blockAds = "MiscSettings.blockAds"
static let alwaysOnline = "MiscSettings.alwaysOnline" static let alwaysOnline = "MiscSettings.alwaysOnline"
} }
private let defaults = UserDefaults.standard private let defaults = UserDefaults.standard
// Prevents recursive mutual-exclusion calls
private var isApplyingMutualExclusion = false
// MARK: - Main Toggle // MARK: - Main Toggle
public var isEnabled: Bool { public var isEnabled: Bool {
@@ -64,11 +67,18 @@ public final class MiscSettingsManager {
} }
} }
/// Keep online status always active /// Always appear as online.
/// Enabling this automatically disables Ghost Mode (mutual exclusion).
public var alwaysOnline: Bool { public var alwaysOnline: Bool {
get { defaults.bool(forKey: Keys.alwaysOnline) } get { defaults.bool(forKey: Keys.alwaysOnline) }
set { set {
defaults.set(newValue, forKey: Keys.alwaysOnline) defaults.set(newValue, forKey: Keys.alwaysOnline)
if newValue && !isApplyingMutualExclusion {
// Always Online ON disable Ghost Mode
isApplyingMutualExclusion = true
GhostModeManager.shared.disableForMutualExclusion()
isApplyingMutualExclusion = false
}
notifySettingsChanged() notifySettingsChanged()
} }
} }
@@ -99,28 +109,39 @@ public final class MiscSettingsManager {
public var activeFeatureCount: Int { public var activeFeatureCount: Int {
var count = 0 var count = 0
if bypassCopyProtection { count += 1 } if bypassCopyProtection { count += 1 }
if disableViewOnceAutoDelete { count += 1 } if disableViewOnceAutoDelete { count += 1 }
if bypassScreenshotProtection { count += 1 } if bypassScreenshotProtection { count += 1 }
if blockAds { count += 1 } if blockAds { count += 1 }
if alwaysOnline { count += 1 } if alwaysOnline { count += 1 }
return count return count
} }
public func enableAll() { public func enableAll() {
bypassCopyProtection = true bypassCopyProtection = true
disableViewOnceAutoDelete = true disableViewOnceAutoDelete = true
bypassScreenshotProtection = true bypassScreenshotProtection = true
blockAds = true blockAds = true
alwaysOnline = true alwaysOnline = true // setter handles mutual exclusion
} }
public func disableAll() { public func disableAll() {
bypassCopyProtection = false bypassCopyProtection = false
disableViewOnceAutoDelete = false disableViewOnceAutoDelete = false
bypassScreenshotProtection = false bypassScreenshotProtection = false
blockAds = false blockAds = false
alwaysOnline = false alwaysOnline = false
}
// MARK: - Internal mutual exclusion (called by GhostModeManager)
/// Called by GhostModeManager when Ghost Mode is turned on.
/// Disables Always Online without triggering mutual exclusion back.
public func disableAlwaysOnlineForMutualExclusion() {
isApplyingMutualExclusion = true
defaults.set(false, forKey: Keys.alwaysOnline)
notifySettingsChanged()
isApplyingMutualExclusion = false
} }
// MARK: - Notification // MARK: - Notification
@@ -134,7 +155,6 @@ public final class MiscSettingsManager {
// MARK: - Init // MARK: - Init
private init() { private init() {
// Set default values if first launch
if !defaults.bool(forKey: "MiscSettings.initialized") { if !defaults.bool(forKey: "MiscSettings.initialized") {
defaults.set(true, forKey: "MiscSettings.initialized") defaults.set(true, forKey: "MiscSettings.initialized")
defaults.set(false, forKey: Keys.isEnabled) defaults.set(false, forKey: Keys.isEnabled)
@@ -142,6 +162,7 @@ public final class MiscSettingsManager {
defaults.set(true, forKey: Keys.disableViewOnceAutoDelete) defaults.set(true, forKey: Keys.disableViewOnceAutoDelete)
defaults.set(true, forKey: Keys.bypassScreenshotProtection) defaults.set(true, forKey: Keys.bypassScreenshotProtection)
defaults.set(true, forKey: Keys.blockAds) defaults.set(true, forKey: Keys.blockAds)
defaults.set(false, forKey: Keys.alwaysOnline)
} }
} }
} }
@@ -16,8 +16,13 @@ private final class AccountPresenceManagerImpl {
private let currentRequestDisposable = MetaDisposable() private let currentRequestDisposable = MetaDisposable()
private var onlineTimer: SignalKitTimer? private var onlineTimer: SignalKitTimer?
// Tracks the last app-level online value so we can refresh independently
private var wasOnline: Bool = false private var wasOnline: Bool = false
// Observers for settings change notifications
private var ghostModeObserver: NSObjectProtocol?
private var miscSettingsObserver: NSObjectProtocol?
init(queue: Queue, shouldKeepOnlinePresence: Signal<Bool, NoError>, network: Network) { init(queue: Queue, shouldKeepOnlinePresence: Signal<Bool, NoError>, network: Network) {
self.queue = queue self.queue = queue
self.network = network self.network = network
@@ -25,14 +30,37 @@ private final class AccountPresenceManagerImpl {
self.shouldKeepOnlinePresenceDisposable = (shouldKeepOnlinePresence self.shouldKeepOnlinePresenceDisposable = (shouldKeepOnlinePresence
|> distinctUntilChanged |> distinctUntilChanged
|> deliverOn(self.queue)).start(next: { [weak self] value in |> deliverOn(self.queue)).start(next: { [weak self] value in
guard let `self` = self else { guard let self = self else { return }
return self.wasOnline = value
} self.refreshPresence()
if self.wasOnline != value {
self.wasOnline = value
self.updatePresence(value)
}
}) })
// React to Ghost Mode or Always Online settings changes without waiting
// for the next app focus event.
let notificationQueue = DispatchQueue.main
self.ghostModeObserver = NotificationCenter.default.addObserver(
forName: GhostModeManager.settingsChangedNotification,
object: nil,
queue: nil
) { [weak self] _ in
notificationQueue.async {
self?.queue.async {
self?.refreshPresence()
}
}
}
self.miscSettingsObserver = NotificationCenter.default.addObserver(
forName: MiscSettingsManager.settingsChangedNotification,
object: nil,
queue: nil
) { [weak self] _ in
notificationQueue.async {
self?.queue.async {
self?.refreshPresence()
}
}
}
} }
deinit { deinit {
@@ -40,26 +68,43 @@ private final class AccountPresenceManagerImpl {
self.shouldKeepOnlinePresenceDisposable?.dispose() self.shouldKeepOnlinePresenceDisposable?.dispose()
self.currentRequestDisposable.dispose() self.currentRequestDisposable.dispose()
self.onlineTimer?.invalidate() self.onlineTimer?.invalidate()
if let observer = self.ghostModeObserver {
NotificationCenter.default.removeObserver(observer)
}
if let observer = self.miscSettingsObserver {
NotificationCenter.default.removeObserver(observer)
}
} }
private func updatePresence(_ isOnline: Bool) { /// Compute the effective online state and push it to Telegram.
// GHOST MODE: Completely block status updates to freeze "last seen" time /// Priority chain (highest lowest):
if GhostModeManager.shared.shouldHideOnlineStatus { /// 1. Always Online enabled force online = true
/// 2. Ghost Mode hide online status skip update entirely (freeze last-seen)
/// 3. Default app behaviour (wasOnline)
private func refreshPresence() {
let alwaysOnline = MiscSettingsManager.shared.shouldAlwaysBeOnline
let ghostHideOnline = GhostModeManager.shared.shouldHideOnlineStatus
if alwaysOnline {
// Always Online wins push online regardless of Ghost Mode
sendPresenceUpdate(online: true)
} else if ghostHideOnline {
// Ghost Mode active, no Always Online freeze presence (don't send anything)
self.onlineTimer?.invalidate() self.onlineTimer?.invalidate()
self.onlineTimer = nil self.onlineTimer = nil
return } else {
// Normal mode follow the app-level state
sendPresenceUpdate(online: wasOnline)
} }
}
// ALWAYS ONLINE: Force online status when enabled
let effectiveOnline = MiscSettingsManager.shared.shouldAlwaysBeOnline ? true : isOnline private func sendPresenceUpdate(online: Bool) {
let request: Signal<Api.Bool, MTRpcError> let request: Signal<Api.Bool, MTRpcError>
if effectiveOnline { if online {
// Keep pinging every 30 s so the server keeps us online
let timer = SignalKitTimer(timeout: 30.0, repeat: false, completion: { [weak self] in let timer = SignalKitTimer(timeout: 30.0, repeat: false, completion: { [weak self] in
guard let strongSelf = self else { guard let self = self else { return }
return self.refreshPresence()
}
strongSelf.updatePresence(true)
}, queue: self.queue) }, queue: self.queue)
self.onlineTimer = timer self.onlineTimer = timer
timer.start() timer.start()
@@ -69,16 +114,14 @@ private final class AccountPresenceManagerImpl {
self.onlineTimer = nil self.onlineTimer = nil
request = self.network.request(Api.functions.account.updateStatus(offline: .boolTrue)) request = self.network.request(Api.functions.account.updateStatus(offline: .boolTrue))
} }
self.isPerformingUpdate.set(true) self.isPerformingUpdate.set(true)
self.currentRequestDisposable.set((request self.currentRequestDisposable.set((request
|> `catch` { _ -> Signal<Api.Bool, NoError> in |> `catch` { _ -> Signal<Api.Bool, NoError> in
return .single(.boolFalse) return .single(.boolFalse)
} }
|> deliverOn(self.queue)).start(completed: { [weak self] in |> deliverOn(self.queue)).start(completed: { [weak self] in
guard let strongSelf = self else { self?.isPerformingUpdate.set(false)
return
}
strongSelf.isPerformingUpdate.set(false)
})) }))
} }
} }