feat: add Ghostgram features

- Anti-Delete: save deleted messages locally
- Ghost Mode: hide online status, read receipts
- Voice Morpher: audio processing effects
- Device Spoof: spoof device info
- Custom GhostIcon app icon
- User Notes: personal notes for contacts
- Misc settings and controllers
- GPLv2 License
This commit is contained in:
ichmagmaus 812
2026-01-19 12:01:00 +01:00
parent b61c8870fd
commit 03f35f40b5
42 changed files with 3659 additions and 0 deletions
@@ -0,0 +1,241 @@
import Foundation
/// Менеджер для сохранения удалённых сообщений
/// Перехватывает сообщения перед удалением и архивирует их локально
public final class AntiDeleteManager {
public static let shared = AntiDeleteManager()
// MARK: - Settings
private let defaults = UserDefaults.standard
private let enabledKey = "antiDelete.enabled"
private let archiveMediaKey = "antiDelete.archiveMedia"
private let archiveKey = "antiDelete.archive"
private let deletedIdsKey = "antiDelete.deletedIds"
/// Включено ли сохранение удалённых сообщений
public var isEnabled: Bool {
get { defaults.bool(forKey: enabledKey) }
set { defaults.set(newValue, forKey: enabledKey) }
}
/// Сохранять ли медиа-контент
public var archiveMedia: Bool {
get { defaults.bool(forKey: archiveMediaKey) }
set { defaults.set(newValue, forKey: archiveMediaKey) }
}
// MARK: - Deleted Message IDs Storage
private var deletedMessageIds: Set<String> = []
private let deletedIdsLock = NSLock()
/// Пометить сообщение как удалённое
public func markAsDeleted(peerId: Int64, messageId: Int32) {
let key = "\(peerId)_\(messageId)"
deletedIdsLock.lock()
deletedMessageIds.insert(key)
deletedIdsLock.unlock()
saveDeletedIds()
}
/// Проверить, является ли сообщение удалённым
public func isMessageDeleted(peerId: Int64, messageId: Int32) -> Bool {
guard isEnabled else { return false }
let key = "\(peerId)_\(messageId)"
deletedIdsLock.lock()
defer { deletedIdsLock.unlock() }
return deletedMessageIds.contains(key)
}
/// Проверить, является ли сообщение удалённым (по тексту - legacy)
public func isMessageDeleted(text: String) -> Bool {
guard isEnabled else { return false }
// Legacy: проверяем наличие дефолтного префикса для обратной совместимости
let defaultPrefix = "🗑️ "
return text.hasPrefix(defaultPrefix)
}
private func saveDeletedIds() {
deletedIdsLock.lock()
let ids = Array(deletedMessageIds)
deletedIdsLock.unlock()
defaults.set(ids, forKey: deletedIdsKey)
}
private func loadDeletedIds() {
if let ids = defaults.stringArray(forKey: deletedIdsKey) {
deletedIdsLock.lock()
deletedMessageIds = Set(ids)
deletedIdsLock.unlock()
}
}
// MARK: - Archived Messages Storage
/// Структура архивированного сообщения
public struct ArchivedMessage: Codable {
public let globalId: Int32
public let peerId: Int64
public let messageId: Int32
public let timestamp: Int32
public let deletedAt: Int32
public let authorId: Int64?
public let text: String
public let forwardAuthorId: Int64?
public let mediaDescription: String?
public init(
globalId: Int32,
peerId: Int64,
messageId: Int32,
timestamp: Int32,
deletedAt: Int32,
authorId: Int64?,
text: String,
forwardAuthorId: Int64?,
mediaDescription: String?
) {
self.globalId = globalId
self.peerId = peerId
self.messageId = messageId
self.timestamp = timestamp
self.deletedAt = deletedAt
self.authorId = authorId
self.text = text
self.forwardAuthorId = forwardAuthorId
self.mediaDescription = mediaDescription
}
}
private var archivedMessages: [ArchivedMessage] = []
private let archiveLock = NSLock()
private init() {
// Set default values
if defaults.object(forKey: enabledKey) == nil {
defaults.set(true, forKey: enabledKey)
}
if defaults.object(forKey: archiveMediaKey) == nil {
defaults.set(true, forKey: archiveMediaKey)
}
loadArchive()
loadDeletedIds()
}
// MARK: - Archive Operations
/// Архивировать сообщение перед удалением
/// - Parameters:
/// - globalId: Глобальный ID сообщения
/// - peerId: ID чата
/// - messageId: Локальный ID сообщения
/// - timestamp: Время отправки
/// - authorId: ID автора
/// - text: Текст сообщения
/// - forwardAuthorId: ID автора пересланного сообщения
/// - mediaDescription: Описание медиа (тип, размер)
public func archiveMessage(
globalId: Int32,
peerId: Int64,
messageId: Int32,
timestamp: Int32,
authorId: Int64?,
text: String,
forwardAuthorId: Int64? = nil,
mediaDescription: String? = nil
) {
guard isEnabled else { return }
let archived = ArchivedMessage(
globalId: globalId,
peerId: peerId,
messageId: messageId,
timestamp: timestamp,
deletedAt: Int32(Date().timeIntervalSince1970),
authorId: authorId,
text: text,
forwardAuthorId: forwardAuthorId,
mediaDescription: mediaDescription
)
archiveLock.lock()
defer { archiveLock.unlock() }
// Avoid duplicates
if !archivedMessages.contains(where: { $0.globalId == globalId }) {
archivedMessages.append(archived)
saveArchive()
}
}
/// Получить все архивированные сообщения
public func getAllArchivedMessages() -> [ArchivedMessage] {
archiveLock.lock()
defer { archiveLock.unlock() }
return archivedMessages.sorted { $0.deletedAt > $1.deletedAt }
}
/// Получить архивированные сообщения для конкретного чата
/// - Parameter peerId: ID чата
public func getArchivedMessages(forPeerId peerId: Int64) -> [ArchivedMessage] {
archiveLock.lock()
defer { archiveLock.unlock() }
return archivedMessages
.filter { $0.peerId == peerId }
.sorted { $0.deletedAt > $1.deletedAt }
}
/// Количество архивированных сообщений
public var archivedCount: Int {
archiveLock.lock()
defer { archiveLock.unlock() }
return archivedMessages.count
}
/// Получить данные архивированных сообщений для удаления из диалогов
/// Возвращает массив (peerId, messageId)
public func getArchivedMessageData() -> [(peerId: Int64, messageId: Int32)] {
archiveLock.lock()
defer { archiveLock.unlock() }
return archivedMessages.map { (peerId: $0.peerId, messageId: $0.messageId) }
}
/// Очистить архив
public func clearArchive() {
archiveLock.lock()
defer { archiveLock.unlock() }
archivedMessages.removeAll()
saveArchive()
}
/// Удалить конкретное сообщение из архива
public func removeFromArchive(globalId: Int32) {
archiveLock.lock()
defer { archiveLock.unlock() }
archivedMessages.removeAll { $0.globalId == globalId }
saveArchive()
}
// MARK: - Persistence
private func saveArchive() {
do {
let data = try JSONEncoder().encode(archivedMessages)
defaults.set(data, forKey: archiveKey)
} catch {
print("[AntiDelete] Failed to save archive: \(error)")
}
}
private func loadArchive() {
guard let data = defaults.data(forKey: archiveKey) else { return }
do {
archivedMessages = try JSONDecoder().decode([ArchivedMessage].self, from: data)
} catch {
print("[AntiDelete] Failed to load archive: \(error)")
archivedMessages = []
}
}
}
@@ -0,0 +1,47 @@
import Foundation
import Postbox
// MARK: - DeletedMessageAttribute
// This attribute marks a message as "deleted but visible" in the chat
// When anti-delete is enabled, messages are not removed but marked with this attribute
public class DeletedMessageAttribute: MessageAttribute, Equatable {
public let deletedAt: Int32
public let deletedByPeerId: Int64?
public var associatedMessageIds: [MessageId] { return [] }
public var associatedPeerIds: [PeerId] { return [] }
public var automaticTimestampBasedAttribute: (UInt32, Int32)? { return nil }
public init(deletedAt: Int32, deletedByPeerId: Int64? = nil) {
self.deletedAt = deletedAt
self.deletedByPeerId = deletedByPeerId
}
public required init(decoder: PostboxDecoder) {
self.deletedAt = decoder.decodeInt32ForKey("d", orElse: 0)
self.deletedByPeerId = decoder.decodeOptionalInt64ForKey("p")
}
public func encode(_ encoder: PostboxEncoder) {
encoder.encodeInt32(self.deletedAt, forKey: "d")
if let peerId = self.deletedByPeerId {
encoder.encodeInt64(peerId, forKey: "p")
}
}
public static func ==(lhs: DeletedMessageAttribute, rhs: DeletedMessageAttribute) -> Bool {
return lhs.deletedAt == rhs.deletedAt && lhs.deletedByPeerId == rhs.deletedByPeerId
}
}
// MARK: - Helper extension for Message
public extension Message {
var isDeletedButVisible: Bool {
return self.attributes.contains(where: { $0 is DeletedMessageAttribute })
}
var deletedMessageAttribute: DeletedMessageAttribute? {
return self.attributes.first(where: { $0 is DeletedMessageAttribute }) as? DeletedMessageAttribute
}
}
@@ -0,0 +1,111 @@
import Foundation
/// Manages edit history for messages
/// GHOSTGRAM: Stores original message text before edits for history viewing
public final class EditHistoryManager {
public static let shared = EditHistoryManager()
private let historyKey = "ghostgram_edit_history"
private var editHistory: [String: [EditRecord]] = [:]
private let lock = NSLock()
public struct EditRecord: Codable, Equatable {
public let text: String
public let editDate: Int32
public init(text: String, editDate: Int32) {
self.text = text
self.editDate = editDate
}
}
private init() {
loadHistory()
}
/// Creates a unique key for message identification
private func messageKey(peerId: Int64, messageId: Int32) -> String {
return "\(peerId)_\(messageId)"
}
/// Saves the original text before an edit
/// Call this BEFORE the message is updated with new text
public func saveOriginalText(peerId: Int64, messageId: Int32, originalText: String, editDate: Int32) {
lock.lock()
defer { lock.unlock() }
let key = messageKey(peerId: peerId, messageId: messageId)
// Don't save empty text
guard !originalText.isEmpty else { return }
// Get existing history or create new
var history = editHistory[key] ?? []
// Check if this exact text already exists (avoid duplicates)
if history.last?.text != originalText {
let record = EditRecord(text: originalText, editDate: editDate)
history.append(record)
editHistory[key] = history
saveHistory()
}
}
/// Gets edit history for a message
public func getEditHistory(peerId: Int64, messageId: Int32) -> [EditRecord] {
lock.lock()
defer { lock.unlock() }
let key = messageKey(peerId: peerId, messageId: messageId)
return editHistory[key] ?? []
}
/// Checks if a message has edit history
public func hasEditHistory(peerId: Int64, messageId: Int32) -> Bool {
lock.lock()
defer { lock.unlock() }
let key = messageKey(peerId: peerId, messageId: messageId)
return !(editHistory[key]?.isEmpty ?? true)
}
/// Clears history for a specific message
public func clearHistory(peerId: Int64, messageId: Int32) {
lock.lock()
defer { lock.unlock() }
let key = messageKey(peerId: peerId, messageId: messageId)
editHistory.removeValue(forKey: key)
saveHistory()
}
/// Clears all edit history
public func clearAllHistory() {
lock.lock()
defer { lock.unlock() }
editHistory.removeAll()
saveHistory()
}
// MARK: - Persistence
private func saveHistory() {
do {
let data = try JSONEncoder().encode(editHistory)
UserDefaults.standard.set(data, forKey: historyKey)
} catch {
// Silent fail - non-critical feature
}
}
private func loadHistory() {
guard let data = UserDefaults.standard.data(forKey: historyKey) else { return }
do {
editHistory = try JSONDecoder().decode([String: [EditRecord]].self, from: data)
} catch {
editHistory = [:]
}
}
}
@@ -0,0 +1,22 @@
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// Objective-C bridge for DeviceSpoofManager (Swift)
/// Provides access to spoofed device information from Objective-C code
@interface DeviceSpoofBridge : NSObject
/// Returns YES if device spoofing is enabled
+ (BOOL)isEnabled;
/// Returns the spoofed device model, or nil if spoofing is disabled or using
/// real device
+ (NSString *_Nullable)spoofedDeviceModel;
/// Returns the spoofed system version, or nil if spoofing is disabled or using
/// real device
+ (NSString *_Nullable)spoofedSystemVersion;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,100 @@
#import "DeviceSpoofBridge.h"
// Access to UserDefaults to get spoofing settings
// This mirrors DeviceSpoofManager logic but in pure Objective-C
// to avoid Swift/ObjC bridging complexities in MtProtoKit
static NSString *const kDeviceSpoofIsEnabled = @"DeviceSpoof.isEnabled";
static NSString *const kDeviceSpoofSelectedProfileId =
@"DeviceSpoof.selectedProfileId";
static NSString *const kDeviceSpoofCustomDeviceModel =
@"DeviceSpoof.customDeviceModel";
static NSString *const kDeviceSpoofCustomSystemVersion =
@"DeviceSpoof.customSystemVersion";
@implementation DeviceSpoofBridge
+ (BOOL)isEnabled {
return
[[NSUserDefaults standardUserDefaults] boolForKey:kDeviceSpoofIsEnabled];
}
+ (NSString *)spoofedDeviceModel {
if (![self isEnabled]) {
return nil;
}
NSInteger profileId = [[NSUserDefaults standardUserDefaults]
integerForKey:kDeviceSpoofSelectedProfileId];
// Profile ID 0 = real device
if (profileId == 0) {
return nil;
}
// Profile ID 100 = custom
if (profileId == 100) {
NSString *custom = [[NSUserDefaults standardUserDefaults]
stringForKey:kDeviceSpoofCustomDeviceModel];
if (custom.length > 0) {
return custom;
}
return nil;
}
// Preset profiles
NSDictionary<NSNumber *, NSString *> *profiles = @{
@1 : @"iPhone 14 Pro",
@2 : @"iPhone 15 Pro Max",
@3 : @"Samsung SM-S918B",
@4 : @"Google Pixel 8 Pro",
@5 : @"PC 64bit",
@6 : @"MacBook Pro",
@7 : @"Web",
@8 : @"HUAWEI MNA-LX9",
@9 : @"Xiaomi 2311DRK48G"
};
return profiles[@(profileId)];
}
+ (NSString *)spoofedSystemVersion {
if (![self isEnabled]) {
return nil;
}
NSInteger profileId = [[NSUserDefaults standardUserDefaults]
integerForKey:kDeviceSpoofSelectedProfileId];
// Profile ID 0 = real device
if (profileId == 0) {
return nil;
}
// Profile ID 100 = custom
if (profileId == 100) {
NSString *custom = [[NSUserDefaults standardUserDefaults]
stringForKey:kDeviceSpoofCustomSystemVersion];
if (custom.length > 0) {
return custom;
}
return nil;
}
// Preset profiles
NSDictionary<NSNumber *, NSString *> *versions = @{
@1 : @"iOS 17.2",
@2 : @"iOS 17.4",
@3 : @"Android 14",
@4 : @"Android 14",
@5 : @"Windows 11",
@6 : @"macOS 14.3",
@7 : @"Chrome 121",
@8 : @"HarmonyOS 4.0",
@9 : @"Android 14"
};
return versions[@(profileId)];
}
@end
@@ -0,0 +1,141 @@
import Foundation
/// DeviceSpoofManager - Manages device identity spoofing
/// Allows changing device model and system version reported to Telegram servers
public final class DeviceSpoofManager {
public static let shared = DeviceSpoofManager()
// MARK: - Device Profile
public struct DeviceProfile: Equatable {
public let id: Int
public let name: String
public let deviceModel: String
public let systemVersion: String
public init(id: Int, name: String, deviceModel: String, systemVersion: String) {
self.id = id
self.name = name
self.deviceModel = deviceModel
self.systemVersion = systemVersion
}
}
// MARK: - Preset Profiles
public static let profiles: [DeviceProfile] = [
DeviceProfile(id: 0, name: "Реальное устройство", deviceModel: "", systemVersion: ""),
DeviceProfile(id: 1, name: "iPhone 14 Pro", deviceModel: "iPhone 14 Pro", systemVersion: "iOS 17.2"),
DeviceProfile(id: 2, name: "iPhone 15 Pro Max", deviceModel: "iPhone 15 Pro Max", systemVersion: "iOS 17.4"),
DeviceProfile(id: 3, name: "Samsung Galaxy S23", deviceModel: "Samsung SM-S918B", systemVersion: "Android 14"),
DeviceProfile(id: 4, name: "Google Pixel 8", deviceModel: "Google Pixel 8 Pro", systemVersion: "Android 14"),
DeviceProfile(id: 5, name: "Desktop Windows", deviceModel: "PC 64bit", systemVersion: "Windows 11"),
DeviceProfile(id: 6, name: "Desktop macOS", deviceModel: "MacBook Pro", systemVersion: "macOS 14.3"),
DeviceProfile(id: 7, name: "Telegram Web", deviceModel: "Web", systemVersion: "Chrome 121"),
DeviceProfile(id: 8, name: "Huawei P60 Pro", deviceModel: "HUAWEI MNA-LX9", systemVersion: "HarmonyOS 4.0"),
DeviceProfile(id: 9, name: "Xiaomi 14", deviceModel: "Xiaomi 2311DRK48G", systemVersion: "Android 14"),
DeviceProfile(id: 100, name: "Своё устройство", deviceModel: "", systemVersion: "")
]
// MARK: - Keys
private enum Keys {
static let isEnabled = "DeviceSpoof.isEnabled"
static let selectedProfileId = "DeviceSpoof.selectedProfileId"
static let customDeviceModel = "DeviceSpoof.customDeviceModel"
static let customSystemVersion = "DeviceSpoof.customSystemVersion"
}
private let defaults = UserDefaults.standard
// MARK: - Properties
/// Whether device spoofing is enabled
public var isEnabled: Bool {
get { defaults.bool(forKey: Keys.isEnabled) }
set {
defaults.set(newValue, forKey: Keys.isEnabled)
notifyChanged()
}
}
/// Selected profile ID (0 = real device, 100 = custom)
public var selectedProfileId: Int {
get { defaults.integer(forKey: Keys.selectedProfileId) }
set {
defaults.set(newValue, forKey: Keys.selectedProfileId)
notifyChanged()
}
}
/// Custom device model (when profile ID = 100)
public var customDeviceModel: String {
get { defaults.string(forKey: Keys.customDeviceModel) ?? "" }
set {
defaults.set(newValue, forKey: Keys.customDeviceModel)
notifyChanged()
}
}
/// Custom system version (when profile ID = 100)
public var customSystemVersion: String {
get { defaults.string(forKey: Keys.customSystemVersion) ?? "" }
set {
defaults.set(newValue, forKey: Keys.customSystemVersion)
notifyChanged()
}
}
// MARK: - Computed
/// Get the currently effective device model
public var effectiveDeviceModel: String? {
guard isEnabled else { return nil }
if selectedProfileId == 100 {
// Custom profile
let custom = customDeviceModel.trimmingCharacters(in: .whitespacesAndNewlines)
return custom.isEmpty ? nil : custom
}
if let profile = Self.profiles.first(where: { $0.id == selectedProfileId }), profile.id != 0 {
return profile.deviceModel.isEmpty ? nil : profile.deviceModel
}
return nil
}
/// Get the currently effective system version
public var effectiveSystemVersion: String? {
guard isEnabled else { return nil }
if selectedProfileId == 100 {
// Custom profile
let custom = customSystemVersion.trimmingCharacters(in: .whitespacesAndNewlines)
return custom.isEmpty ? nil : custom
}
if let profile = Self.profiles.first(where: { $0.id == selectedProfileId }), profile.id != 0 {
return profile.systemVersion.isEmpty ? nil : profile.systemVersion
}
return nil
}
/// Get selected profile
public var selectedProfile: DeviceProfile? {
return Self.profiles.first(where: { $0.id == selectedProfileId })
}
// MARK: - Notification
public static let settingsChangedNotification = Notification.Name("DeviceSpoofSettingsChanged")
private func notifyChanged() {
NotificationCenter.default.post(name: Self.settingsChangedNotification, object: nil)
}
// MARK: - Init
private init() {}
}
@@ -0,0 +1,164 @@
import Foundation
/// GhostModeManager - Central manager for Ghost Mode privacy settings
/// Controls all privacy features: hide read receipts, typing indicator, online status, story views
public final class GhostModeManager {
// MARK: - Singleton
public static let shared = GhostModeManager()
// MARK: - UserDefaults Keys
private enum Keys {
static let isEnabled = "GhostMode.isEnabled"
static let hideReadReceipts = "GhostMode.hideReadReceipts"
static let hideStoryViews = "GhostMode.hideStoryViews"
static let hideOnlineStatus = "GhostMode.hideOnlineStatus"
static let hideTypingIndicator = "GhostMode.hideTypingIndicator"
static let forceOffline = "GhostMode.forceOffline"
}
// MARK: - Settings Storage
private let defaults = UserDefaults.standard
// MARK: - Properties
/// Master toggle for Ghost Mode
public var isEnabled: Bool {
get { defaults.bool(forKey: Keys.isEnabled) }
set {
defaults.set(newValue, forKey: Keys.isEnabled)
notifySettingsChanged()
}
}
/// Don't send read receipts (blue checkmarks)
public var hideReadReceipts: Bool {
get { defaults.bool(forKey: Keys.hideReadReceipts) }
set {
defaults.set(newValue, forKey: Keys.hideReadReceipts)
notifySettingsChanged()
}
}
/// Don't send story view notifications
public var hideStoryViews: Bool {
get { defaults.bool(forKey: Keys.hideStoryViews) }
set {
defaults.set(newValue, forKey: Keys.hideStoryViews)
notifySettingsChanged()
}
}
/// Don't send online status
public var hideOnlineStatus: Bool {
get { defaults.bool(forKey: Keys.hideOnlineStatus) }
set {
defaults.set(newValue, forKey: Keys.hideOnlineStatus)
notifySettingsChanged()
}
}
/// Don't send typing indicator
public var hideTypingIndicator: Bool {
get { defaults.bool(forKey: Keys.hideTypingIndicator) }
set {
defaults.set(newValue, forKey: Keys.hideTypingIndicator)
notifySettingsChanged()
}
}
/// Always appear as offline
public var forceOffline: Bool {
get { defaults.bool(forKey: Keys.forceOffline) }
set {
defaults.set(newValue, forKey: Keys.forceOffline)
notifySettingsChanged()
}
}
// MARK: - Computed Properties
/// Check if read receipts should be hidden (master + individual toggle)
public var shouldHideReadReceipts: Bool {
return isEnabled && hideReadReceipts
}
/// Check if story views should be hidden
public var shouldHideStoryViews: Bool {
return isEnabled && hideStoryViews
}
/// Check if online status should be hidden
public var shouldHideOnlineStatus: Bool {
return isEnabled && hideOnlineStatus
}
/// Check if typing indicator should be hidden
public var shouldHideTypingIndicator: Bool {
return isEnabled && hideTypingIndicator
}
/// Check if should force offline
public var shouldForceOffline: Bool {
return isEnabled && forceOffline
}
/// Count of active features (e.g., "5/5")
public var activeFeatureCount: Int {
var count = 0
if hideReadReceipts { count += 1 }
if hideStoryViews { count += 1 }
if hideOnlineStatus { count += 1 }
if hideTypingIndicator { count += 1 }
if forceOffline { count += 1 }
return count
}
/// Total number of features
public static let totalFeatureCount = 5
// MARK: - Initialization
private init() {
// Set default values if not set
if !defaults.bool(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.hideStoryViews)
defaults.set(true, forKey: Keys.hideOnlineStatus)
defaults.set(true, forKey: Keys.hideTypingIndicator)
defaults.set(true, forKey: Keys.forceOffline)
// Ghost mode itself is off by default
defaults.set(false, forKey: Keys.isEnabled)
}
}
// MARK: - Enable All
/// Enable all ghost mode features
public func enableAll() {
hideReadReceipts = true
hideStoryViews = true
hideOnlineStatus = true
hideTypingIndicator = true
forceOffline = true
isEnabled = true
}
/// Disable all ghost mode features
public func disableAll() {
isEnabled = false
}
// MARK: - Notifications
public static let settingsChangedNotification = Notification.Name("GhostModeSettingsChanged")
private func notifySettingsChanged() {
NotificationCenter.default.post(name: GhostModeManager.settingsChangedNotification, object: nil)
}
}
@@ -0,0 +1,147 @@
import Foundation
/// MiscSettingsManager - Central manager for Misc privacy settings
/// Handles: Forward bypass, View-Once persistence, Screenshot bypass
public final class MiscSettingsManager {
public static let shared = MiscSettingsManager()
private enum Keys {
static let isEnabled = "MiscSettings.isEnabled"
static let bypassCopyProtection = "MiscSettings.bypassCopyProtection"
static let disableViewOnceAutoDelete = "MiscSettings.disableViewOnceAutoDelete"
static let bypassScreenshotProtection = "MiscSettings.bypassScreenshotProtection"
static let blockAds = "MiscSettings.blockAds"
static let alwaysOnline = "MiscSettings.alwaysOnline"
}
private let defaults = UserDefaults.standard
// MARK: - Main Toggle
public var isEnabled: Bool {
get { defaults.bool(forKey: Keys.isEnabled) }
set {
defaults.set(newValue, forKey: Keys.isEnabled)
notifySettingsChanged()
}
}
// MARK: - Individual Features
/// Allow forwarding/copying from protected channels and chats
public var bypassCopyProtection: Bool {
get { defaults.bool(forKey: Keys.bypassCopyProtection) }
set {
defaults.set(newValue, forKey: Keys.bypassCopyProtection)
notifySettingsChanged()
}
}
/// Keep View-Once media visible (don't auto-delete after viewing)
public var disableViewOnceAutoDelete: Bool {
get { defaults.bool(forKey: Keys.disableViewOnceAutoDelete) }
set {
defaults.set(newValue, forKey: Keys.disableViewOnceAutoDelete)
notifySettingsChanged()
}
}
/// Allow screenshots in secret chats and protected content
public var bypassScreenshotProtection: Bool {
get { defaults.bool(forKey: Keys.bypassScreenshotProtection) }
set {
defaults.set(newValue, forKey: Keys.bypassScreenshotProtection)
notifySettingsChanged()
}
}
/// Block all sponsored messages (ads) in channels
public var blockAds: Bool {
get { defaults.bool(forKey: Keys.blockAds) }
set {
defaults.set(newValue, forKey: Keys.blockAds)
notifySettingsChanged()
}
}
/// Keep online status always active
public var alwaysOnline: Bool {
get { defaults.bool(forKey: Keys.alwaysOnline) }
set {
defaults.set(newValue, forKey: Keys.alwaysOnline)
notifySettingsChanged()
}
}
// MARK: - Computed Properties (considers master toggle)
public var shouldBypassCopyProtection: Bool {
return isEnabled && bypassCopyProtection
}
public var shouldDisableViewOnceAutoDelete: Bool {
return isEnabled && disableViewOnceAutoDelete
}
public var shouldBypassScreenshotProtection: Bool {
return isEnabled && bypassScreenshotProtection
}
public var shouldBlockAds: Bool {
return isEnabled && blockAds
}
public var shouldAlwaysBeOnline: Bool {
return isEnabled && alwaysOnline
}
// MARK: - Utility
public var activeFeatureCount: Int {
var count = 0
if bypassCopyProtection { count += 1 }
if disableViewOnceAutoDelete { count += 1 }
if bypassScreenshotProtection { count += 1 }
if blockAds { count += 1 }
if alwaysOnline { count += 1 }
return count
}
public func enableAll() {
bypassCopyProtection = true
disableViewOnceAutoDelete = true
bypassScreenshotProtection = true
blockAds = true
alwaysOnline = true
}
public func disableAll() {
bypassCopyProtection = false
disableViewOnceAutoDelete = false
bypassScreenshotProtection = false
blockAds = false
alwaysOnline = false
}
// MARK: - Notification
public static let settingsChangedNotification = Notification.Name("MiscSettingsChanged")
private func notifySettingsChanged() {
NotificationCenter.default.post(name: MiscSettingsManager.settingsChangedNotification, object: nil)
}
// MARK: - Init
private init() {
// Set default values if first launch
if !defaults.bool(forKey: "MiscSettings.initialized") {
defaults.set(true, forKey: "MiscSettings.initialized")
defaults.set(false, forKey: Keys.isEnabled)
defaults.set(true, forKey: Keys.bypassCopyProtection)
defaults.set(true, forKey: Keys.disableViewOnceAutoDelete)
defaults.set(true, forKey: Keys.bypassScreenshotProtection)
defaults.set(true, forKey: Keys.blockAds)
}
}
}
@@ -0,0 +1,90 @@
import Foundation
/// UserNotesManager - Local storage for personal notes about users
/// Notes are stored ONLY on device, never synced to Telegram servers
public final class UserNotesManager {
public static let shared = UserNotesManager()
private enum Keys {
static let notesPrefix = "UserNotes.note."
static let updatedAtPrefix = "UserNotes.updatedAt."
}
private let defaults = UserDefaults.standard
// MARK: - Public API
/// Get note for a specific user by their peerId
public func getNote(for peerId: Int64) -> String? {
return defaults.string(forKey: Keys.notesPrefix + String(peerId))
}
/// Set note for a specific user (pass nil or empty string to delete)
public func setNote(_ note: String?, for peerId: Int64) {
let key = Keys.notesPrefix + String(peerId)
let dateKey = Keys.updatedAtPrefix + String(peerId)
if let note = note, !note.isEmpty {
defaults.set(note, forKey: key)
defaults.set(Date(), forKey: dateKey)
} else {
defaults.removeObject(forKey: key)
defaults.removeObject(forKey: dateKey)
}
notifyNoteChanged(peerId: peerId)
}
/// Check if user has a note
public func hasNote(for peerId: Int64) -> Bool {
guard let note = getNote(for: peerId) else { return false }
return !note.isEmpty
}
/// Get last update date for a note
public func getUpdatedAt(for peerId: Int64) -> Date? {
return defaults.object(forKey: Keys.updatedAtPrefix + String(peerId)) as? Date
}
/// Get all peerIds that have notes
public func getAllNotedPeerIds() -> [Int64] {
let allKeys = defaults.dictionaryRepresentation().keys
return allKeys
.filter { $0.hasPrefix(Keys.notesPrefix) }
.compactMap { key -> Int64? in
let peerIdString = String(key.dropFirst(Keys.notesPrefix.count))
return Int64(peerIdString)
}
}
/// Delete all notes
public func deleteAllNotes() {
let peerIds = getAllNotedPeerIds()
for peerId in peerIds {
defaults.removeObject(forKey: Keys.notesPrefix + String(peerId))
defaults.removeObject(forKey: Keys.updatedAtPrefix + String(peerId))
}
NotificationCenter.default.post(name: UserNotesManager.notesChangedNotification, object: nil)
}
/// Get notes count
public var notesCount: Int {
return getAllNotedPeerIds().count
}
// MARK: - Notification
public static let notesChangedNotification = Notification.Name("UserNotesChanged")
private func notifyNoteChanged(peerId: Int64) {
NotificationCenter.default.post(
name: UserNotesManager.notesChangedNotification,
object: nil,
userInfo: ["peerId": peerId]
)
}
// MARK: - Init
private init() {}
}
@@ -0,0 +1,130 @@
import Foundation
import AVFoundation
/// VoiceMorpherManager - Manages voice morphing presets and settings
public final class VoiceMorpherManager {
public static let shared = VoiceMorpherManager()
// MARK: - Voice Preset
public enum VoicePreset: Int, CaseIterable {
case disabled = 0
case anonymous = 1
case female = 2
case male = 3
case child = 4
case robot = 5
public var name: String {
switch self {
case .disabled: return "Выключено"
case .anonymous: return "Аноним"
case .female: return "Женский"
case .male: return "Мужской"
case .child: return "Ребёнок"
case .robot: return "Робот"
}
}
public var description: String {
switch self {
case .disabled: return "Голос без изменений"
case .anonymous: return "Искаженный голос (как в новостях)"
case .female: return "Повышенный питч + форманты"
case .male: return "Пониженный питч + форманты"
case .child: return "Высокий детский голос"
case .robot: return "Металлический эффект"
}
}
/// Pitch multiplier (1.0 = normal, >1 = higher, <1 = lower)
public var pitchShift: Float {
switch self {
case .disabled: return 0
case .anonymous: return -200 // semitones down slightly
case .female: return 600 // More feminine - higher pitch
case .male: return -300 // semitones down
case .child: return 600 // high pitch
case .robot: return 0 // no pitch change for robot
}
}
/// Rate adjustment
public var rate: Float {
switch self {
case .disabled: return 1.0
case .anonymous: return 0.95
case .female: return 1.08 // Slightly faster for feminine effect
case .male: return 0.95
case .child: return 1.1
case .robot: return 1.0
}
}
/// Distortion preset for robot effect
public var useDistortion: Bool {
return self == .robot || self == .anonymous
}
/// Reverb amount (0-100)
public var reverbAmount: Float {
switch self {
case .anonymous: return 20
case .robot: return 30
default: return 0
}
}
}
// MARK: - Keys
private enum Keys {
static let isEnabled = "VoiceMorpher.isEnabled"
static let selectedPreset = "VoiceMorpher.selectedPreset"
}
private let defaults = UserDefaults.standard
// MARK: - Properties
/// Whether voice morphing is enabled
public var isEnabled: Bool {
get { defaults.bool(forKey: Keys.isEnabled) }
set {
defaults.set(newValue, forKey: Keys.isEnabled)
notifyChanged()
}
}
/// Selected preset ID
public var selectedPresetId: Int {
get { defaults.integer(forKey: Keys.selectedPreset) }
set {
defaults.set(newValue, forKey: Keys.selectedPreset)
notifyChanged()
}
}
/// Get selected preset
public var selectedPreset: VoicePreset {
return VoicePreset(rawValue: selectedPresetId) ?? .disabled
}
/// Get effective preset (returns disabled if not enabled)
public var effectivePreset: VoicePreset {
guard isEnabled else { return .disabled }
return selectedPreset
}
// MARK: - Notification
public static let settingsChangedNotification = Notification.Name("VoiceMorpherSettingsChanged")
private func notifyChanged() {
NotificationCenter.default.post(name: Self.settingsChangedNotification, object: nil)
}
// MARK: - Init
private init() {}
}