mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-07-25 08:50:51 +02:00
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:
@@ -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() {}
|
||||
}
|
||||
Reference in New Issue
Block a user