From 0fc3ccb2375ac892a4bf73be7976493af19b751f Mon Sep 17 00:00:00 2001 From: Taksh Date: Fri, 31 Jul 2026 11:42:22 +0300 Subject: [PATCH] feat: add AlternateAppIconSettings for low-profile home icons Persist the chosen catalog icon and apply it via setAlternateIconName, with a panic-wipe reset path that restores the primary glyph. --- .../Services/AlternateAppIconSettings.swift | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 bitchat/Services/AlternateAppIconSettings.swift diff --git a/bitchat/Services/AlternateAppIconSettings.swift b/bitchat/Services/AlternateAppIconSettings.swift new file mode 100644 index 00000000..a3399add --- /dev/null +++ b/bitchat/Services/AlternateAppIconSettings.swift @@ -0,0 +1,121 @@ +// +// AlternateAppIconSettings.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +#if os(iOS) +import UIKit +#endif +import BitLogger + +/// Low-profile alternate home-screen icons (#1447). Neutral / notes / quiet +/// variants live as App Icon sets in the asset catalog and are enabled via +/// `ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS`. +enum AlternateAppIconSettings { + static let storageKey = "appearance.alternateAppIcon" + + /// Catalog names. `nil` means the primary AppIcon. + enum Icon: String, CaseIterable, Identifiable { + case primary + case neutral = "AppIconNeutral" + case notes = "AppIconNotes" + case quiet = "AppIconQuiet" + + var id: String { rawValue } + + /// Name passed to `UIApplication.setAlternateIconName`. Primary is nil. + var systemName: String? { + switch self { + case .primary: return nil + case .neutral, .notes, .quiet: return rawValue + } + } + + var title: String { + switch self { + case .primary: + return String( + localized: "app_info.settings.icon.primary", + defaultValue: "bitchat", + comment: "Label for the default bitchat home-screen icon" + ) + case .neutral: + return String( + localized: "app_info.settings.icon.neutral", + defaultValue: "neutral", + comment: "Label for the grayscale low-profile alternate app icon" + ) + case .notes: + return String( + localized: "app_info.settings.icon.notes", + defaultValue: "notes", + comment: "Label for the blue-gray notes-like alternate app icon" + ) + case .quiet: + return String( + localized: "app_info.settings.icon.quiet", + defaultValue: "quiet", + comment: "Label for the dark monochrome alternate app icon" + ) + } + } + } + + static var selected: Icon { + get { selected(in: .standard) } + set { setSelected(newValue, in: .standard, applySystem: true) } + } + + static func selected(in defaults: UserDefaults) -> Icon { + let raw = defaults.string(forKey: storageKey) ?? Icon.primary.rawValue + return Icon(rawValue: raw) ?? .primary + } + + static func setSelected(_ icon: Icon, in defaults: UserDefaults, applySystem: Bool = true) { + if icon == .primary { + defaults.removeObject(forKey: storageKey) + } else { + defaults.set(icon.rawValue, forKey: storageKey) + } + if applySystem { + apply(icon) + } + } + + /// Apply the stored preference to the system (iOS only). Safe no-op elsewhere. + static func applyStoredPreference() { + apply(selected(in: .standard)) + } + + static func reset(in defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: storageKey) + apply(.primary) + } + + private static func apply(_ icon: Icon) { + #if os(iOS) + // Unit tests and early launch may not have a ready UIApplication. + guard Thread.isMainThread else { + DispatchQueue.main.async { apply(icon) } + return + } + guard UIApplication.shared.supportsAlternateIcons else { return } + let target = icon.systemName + // Avoid a no-op system prompt when already on the desired icon. + let current = UIApplication.shared.alternateIconName + guard current != target else { return } + UIApplication.shared.setAlternateIconName(target) { error in + if let error { + SecureLogger.warning( + "alternate icon change failed: \(error.localizedDescription)", + category: .session + ) + } + } + #endif + } +}