mirror of
https://github.com/GLEGram/GLEGram-iOS.git
synced 2026-04-23 19:36:26 +02:00
4647310322
Based on Swiftgram 12.5 (Telegram iOS 12.5). All GLEGram features ported and organized in GLEGram/ folder. Features: Ghost Mode, Saved Deleted Messages, Content Protection Bypass, Font Replacement, Fake Profile, Chat Export, Plugin System, and more. See CHANGELOG_12.5.md for full details.
2092 lines
89 KiB
Markdown
2092 lines
89 KiB
Markdown
# GLEGram: описание функций и полный исходный код
|
||
|
||
Документ описывает реализацию в репозитории. Раздел про текст «Чаты» в шапке **не включён** (по запросу). Во всех примерах ниже приведён **полный** фрагмент или файл **без сокращений** (`...` не используется).
|
||
|
||
---
|
||
|
||
## 1. Двойное дно
|
||
|
||
**Смысл:** скрытые аккаунты, отдельные пароли в Keychain, при разблокировке приложения разные коды ведут к разным сценариям (основной пароль Telegram, «секретный» пароль, переключение на скрытый аккаунт по совпадению пароля). Флаги `isDoubleBottomOn` / `inDoubleBottom` хранятся в UserDefaults (`VarSystemNGSettings`). Экран настроек в Swiftgram — `doubleBottomSettingsController`; проверки при вводе пароля приложения — в `AppDelegate` (`additionalPasscodeCheck`, `onUnlockWithPasscode`).
|
||
|
||
### `Nicegram/NGData/Sources/SystemNGSettings.swift` (полностью)
|
||
|
||
```swift
|
||
// From Nicegram NGData/Sources/NGSettings.swift – only SystemNGSettings for Double Bottom
|
||
import Foundation
|
||
|
||
public class SystemNGSettings {
|
||
let UD = UserDefaults.standard
|
||
|
||
public init() {}
|
||
|
||
public var dbReset: Bool {
|
||
get {
|
||
return UD.bool(forKey: "ng_db_reset")
|
||
}
|
||
set {
|
||
UD.set(newValue, forKey: "ng_db_reset")
|
||
}
|
||
}
|
||
|
||
public var isDoubleBottomOn: Bool {
|
||
get {
|
||
return UD.bool(forKey: "isDoubleBottomOn")
|
||
}
|
||
set {
|
||
UD.set(newValue, forKey: "isDoubleBottomOn")
|
||
}
|
||
}
|
||
|
||
public var inDoubleBottom: Bool {
|
||
get {
|
||
return UD.bool(forKey: "inDoubleBottom")
|
||
}
|
||
set {
|
||
UD.set(newValue, forKey: "inDoubleBottom")
|
||
}
|
||
}
|
||
}
|
||
|
||
public var VarSystemNGSettings = SystemNGSettings()
|
||
```
|
||
|
||
### `Swiftgram/SGSettingsUI/Sources/DoubleBottomPasscodeStore.swift` (полностью)
|
||
|
||
```swift
|
||
// MARK: Swiftgram – Keychain storage for hidden-account passcodes (Double Bottom)
|
||
import Foundation
|
||
import Security
|
||
|
||
private let serviceName = "SwiftgramDoubleBottom"
|
||
|
||
/// Key for the single "secret" passcode (second password). When user unlocks with this, only one account is shown.
|
||
private let secretPasscodeAccountKey = "secret"
|
||
|
||
public enum DoubleBottomPasscodeStore {
|
||
// MARK: - Secret passcode (second password → show only 1 account)
|
||
|
||
public static func setSecretPasscode(_ passcode: String) {
|
||
let data = passcode.data(using: .utf8)!
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: serviceName,
|
||
kSecAttrAccount as String: secretPasscodeAccountKey
|
||
]
|
||
var addQuery = query
|
||
addQuery[kSecValueData as String] = data
|
||
var status = SecItemAdd(addQuery as CFDictionary, nil)
|
||
if status == errSecDuplicateItem {
|
||
SecItemDelete(query as CFDictionary)
|
||
status = SecItemAdd(addQuery as CFDictionary, nil)
|
||
}
|
||
}
|
||
|
||
public static func secretPasscodeMatches(_ passcode: String) -> Bool {
|
||
guard let stored = secretPasscode() else { return false }
|
||
return stored == passcode
|
||
}
|
||
|
||
public static func hasSecretPasscode() -> Bool {
|
||
return secretPasscode() != nil
|
||
}
|
||
|
||
public static func removeSecretPasscode() {
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: serviceName,
|
||
kSecAttrAccount as String: secretPasscodeAccountKey
|
||
]
|
||
SecItemDelete(query as CFDictionary)
|
||
}
|
||
|
||
private static func secretPasscode() -> String? {
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: serviceName,
|
||
kSecAttrAccount as String: secretPasscodeAccountKey,
|
||
kSecReturnData as String: true,
|
||
kSecMatchLimit as String: kSecMatchLimitOne
|
||
]
|
||
var result: AnyObject?
|
||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||
guard status == errSecSuccess, let data = result as? Data, let string = String(data: data, encoding: .utf8) else {
|
||
return nil
|
||
}
|
||
return string
|
||
}
|
||
|
||
// MARK: - Per-account passcodes (hidden accounts)
|
||
|
||
public static func setPasscode(_ passcode: String, forAccountId accountId: Int64) {
|
||
let account = "\(accountId)"
|
||
let data = passcode.data(using: .utf8)!
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: serviceName,
|
||
kSecAttrAccount as String: account
|
||
]
|
||
var addQuery = query
|
||
addQuery[kSecValueData as String] = data
|
||
var status = SecItemAdd(addQuery as CFDictionary, nil)
|
||
if status == errSecDuplicateItem {
|
||
SecItemDelete(query as CFDictionary)
|
||
status = SecItemAdd(addQuery as CFDictionary, nil)
|
||
}
|
||
}
|
||
|
||
public static func passcode(forAccountId accountId: Int64) -> String? {
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: serviceName,
|
||
kSecAttrAccount as String: "\(accountId)",
|
||
kSecReturnData as String: true,
|
||
kSecMatchLimit as String: kSecMatchLimitOne
|
||
]
|
||
var result: AnyObject?
|
||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||
guard status == errSecSuccess, let data = result as? Data, let string = String(data: data, encoding: .utf8) else {
|
||
return nil
|
||
}
|
||
return string
|
||
}
|
||
|
||
public static func removePasscode(forAccountId accountId: Int64) {
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: serviceName,
|
||
kSecAttrAccount as String: "\(accountId)"
|
||
]
|
||
SecItemDelete(query as CFDictionary)
|
||
}
|
||
|
||
/// Returns the account id whose passcode matches the given value, or nil.
|
||
public static func accountId(matchingPasscode passcode: String, candidateIds: [Int64]) -> Int64? {
|
||
for id in candidateIds {
|
||
if Self.passcode(forAccountId: id) == passcode {
|
||
return id
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
```
|
||
|
||
### `Swiftgram/SGSettingsUI/Sources/DoubleBottomSettingsController.swift` (полностью)
|
||
|
||
```swift
|
||
// MARK: Swiftgram – Double Bottom (full logic from Nicegram NGDoubleBottom/DoubleBottomListController)
|
||
// Ref: https://github.com/nicegram/Nicegram-iOS/blob/master/Nicegram/NGDoubleBottom/Sources/DoubleBottomListController.swift
|
||
import Foundation
|
||
import UIKit
|
||
import Display
|
||
import SwiftSignalKit
|
||
import Postbox
|
||
import TelegramCore
|
||
import TelegramPresentationData
|
||
import ItemListUI
|
||
import PresentationDataUtils
|
||
import AccountContext
|
||
import PasscodeUI
|
||
import SGSimpleSettings
|
||
import TelegramStringFormatting
|
||
|
||
// MARK: - Section (Nicegram: DoubleBottomControllerSection)
|
||
|
||
private enum DoubleBottomControllerSection: Int32 {
|
||
case isOn = 0
|
||
}
|
||
|
||
// MARK: - Entry (Nicegram: isOn + info)
|
||
|
||
private enum DoubleBottomEntry: ItemListNodeEntry {
|
||
case isOn(String, Bool, Bool) // title, value, enabled
|
||
case info(String)
|
||
|
||
var section: ItemListSectionId { DoubleBottomControllerSection.isOn.rawValue }
|
||
|
||
var stableId: Int32 {
|
||
switch self {
|
||
case .isOn: return 1000
|
||
case .info: return 1100
|
||
}
|
||
}
|
||
|
||
static func < (lhs: DoubleBottomEntry, rhs: DoubleBottomEntry) -> Bool {
|
||
lhs.stableId < rhs.stableId
|
||
}
|
||
|
||
static func == (lhs: DoubleBottomEntry, rhs: DoubleBottomEntry) -> Bool {
|
||
switch (lhs, rhs) {
|
||
case let (.isOn(lhsText, lhsBool, _), .isOn(rhsText, rhsBool, _)):
|
||
return lhsText == rhsText && lhsBool == rhsBool
|
||
case let (.info(lhsText), .info(rhsText)):
|
||
return lhsText == rhsText
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
|
||
let args = arguments as! DoubleBottomArguments
|
||
switch self {
|
||
case let .isOn(text, value, enabled):
|
||
return ItemListSwitchItem(
|
||
presentationData: presentationData,
|
||
title: text,
|
||
value: value,
|
||
enabled: enabled,
|
||
sectionId: section,
|
||
style: .blocks,
|
||
updated: { value in
|
||
args.updated(value)
|
||
}
|
||
)
|
||
case let .info(text):
|
||
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: section)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Arguments (Nicegram: DoubleBottomControllerArguments)
|
||
|
||
private final class DoubleBottomArguments {
|
||
let context: AccountContext
|
||
let updated: (Bool) -> Void
|
||
init(context: AccountContext, updated: @escaping (Bool) -> Void) {
|
||
self.context = context
|
||
self.updated = updated
|
||
}
|
||
}
|
||
|
||
// MARK: - Controller (logic from Nicegram DoubleBottomListController)
|
||
|
||
public func doubleBottomSettingsController(context: AccountContext) -> ViewController {
|
||
let lang = context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode
|
||
let title = lang == "ru" ? "Двойное дно" : "Double Bottom"
|
||
let toggleTitle = lang == "ru" ? "Двойное дно" : "Double Bottom"
|
||
let noticeText = lang == "ru"
|
||
? "Скрытые аккаунты и вход по паролю. Разные пароли открывают разные профили."
|
||
: "Hidden accounts and passcode access. Different passwords open different profiles."
|
||
|
||
let arguments = DoubleBottomArguments(context: context, updated: { value in
|
||
if value {
|
||
SGSimpleSettings.shared.doubleBottomEnabled = true
|
||
let setupController = PasscodeSetupController(context: context, mode: .setup(change: false, .digits6))
|
||
setupController.complete = { passcode, _ in
|
||
DoubleBottomPasscodeStore.setSecretPasscode(passcode)
|
||
setupController.dismiss()
|
||
}
|
||
context.sharedContext.presentGlobalController(setupController, nil)
|
||
} else {
|
||
SGSimpleSettings.shared.doubleBottomEnabled = false
|
||
DoubleBottomPasscodeStore.removeSecretPasscode()
|
||
DoubleBottomViewingSecretStore.setViewingWithSecretPasscode(false)
|
||
let accountManager = context.sharedContext.accountManager
|
||
// Remove secret passcodes from Keychain for previously hidden accounts
|
||
let _ = (accountManager.accountRecords()
|
||
|> take(1)
|
||
|> deliverOnMainQueue).start(next: { view in
|
||
for record in view.records where record.attributes.contains(where: { $0.isHiddenAccountAttribute }) {
|
||
DoubleBottomPasscodeStore.removePasscode(forAccountId: record.id.int64)
|
||
}
|
||
})
|
||
// Nicegram: single transaction – keep device passcode, remove HiddenAccount from all records
|
||
let _ = accountManager.transaction { transaction in
|
||
let challengeData = transaction.getAccessChallengeData()
|
||
let challenge: PostboxAccessChallengeData
|
||
switch challengeData {
|
||
case .numericalPassword(let value):
|
||
challenge = .numericalPassword(value: value)
|
||
case .plaintextPassword(let value):
|
||
challenge = .plaintextPassword(value: value)
|
||
case .none:
|
||
challenge = .none
|
||
}
|
||
transaction.setAccessChallengeData(challenge)
|
||
for record in transaction.getRecords() {
|
||
transaction.updateRecord(record.id) { current in
|
||
guard let current = current else { return nil }
|
||
var attributes = current.attributes
|
||
attributes.removeAll { $0.isHiddenAccountAttribute }
|
||
return AccountRecord(id: current.id, attributes: attributes, temporarySessionId: current.temporarySessionId)
|
||
}
|
||
}
|
||
}.start()
|
||
}
|
||
})
|
||
|
||
let transactionStatus = context.sharedContext.accountManager.transaction { transaction -> (Bool, Bool) in
|
||
let records = transaction.getRecords()
|
||
let publicCount = records.filter { record in
|
||
let attrs = record.attributes
|
||
let hiddenOrLoggedOut = attrs.contains(where: { $0.isHiddenAccountAttribute || $0.isLoggedOutAccountAttribute })
|
||
return !hiddenOrLoggedOut
|
||
}.count
|
||
let hasMoreThanOnePublic = publicCount > 1
|
||
let hasMainPasscode = transaction.getAccessChallengeData() != .none
|
||
return (hasMoreThanOnePublic, hasMainPasscode)
|
||
}
|
||
|
||
let signal: Signal<(ItemListControllerState, (ItemListNodeState, DoubleBottomArguments)), NoError> = combineLatest(context.sharedContext.presentationData, transactionStatus)
|
||
|> map { presentationData, contextStatus -> (ItemListControllerState, (ItemListNodeState, DoubleBottomArguments)) in
|
||
let isOn = SGSimpleSettings.shared.doubleBottomEnabled
|
||
let enabled = isOn || (contextStatus.0 && contextStatus.1)
|
||
let entries: [DoubleBottomEntry] = [
|
||
.isOn(toggleTitle, isOn, enabled),
|
||
.info(noticeText)
|
||
]
|
||
let controllerState = ItemListControllerState(
|
||
presentationData: ItemListPresentationData(presentationData),
|
||
title: .text(title),
|
||
leftNavigationButton: nil,
|
||
rightNavigationButton: nil,
|
||
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
|
||
)
|
||
let listState = ItemListNodeState(
|
||
presentationData: ItemListPresentationData(presentationData),
|
||
entries: entries,
|
||
style: .blocks,
|
||
ensureVisibleItemTag: nil,
|
||
footerItem: nil,
|
||
initialScrollToItem: nil
|
||
)
|
||
return (controllerState, (listState, arguments))
|
||
}
|
||
|
||
return ItemListController(context: context, state: signal)
|
||
}
|
||
|
||
// MARK: - Passcode check (Nicegram: check(passcode:challengeData:) for device passcode validation)
|
||
|
||
public func doubleBottomCheckPasscode(_ passcode: String, challengeData: PostboxAccessChallengeData) -> Bool {
|
||
let passcodeType: PasscodeEntryFieldType
|
||
switch challengeData {
|
||
case let .numericalPassword(value):
|
||
passcodeType = value.count == 6 ? .digits6 : .digits4
|
||
default:
|
||
passcodeType = .alphanumeric
|
||
}
|
||
switch challengeData {
|
||
case .none:
|
||
return true
|
||
case let .numericalPassword(code):
|
||
if passcodeType == .alphanumeric {
|
||
return false
|
||
}
|
||
return passcode == normalizeArabicNumeralString(code, type: .western)
|
||
case let .plaintextPassword(code):
|
||
if passcodeType != .alphanumeric {
|
||
return false
|
||
}
|
||
return passcode == code
|
||
}
|
||
}
|
||
```
|
||
|
||
### `submodules/TelegramUI/Sources/AppDelegate.swift` — класс кэша и хуки разблокировки (полные строки файла)
|
||
|
||
```swift
|
||
#if canImport(SGSettingsUI)
|
||
private final class DoubleBottomHiddenIdsCache {
|
||
var hiddenIds: [Int64] = []
|
||
var disposable: Disposable?
|
||
|
||
init(accountManager: AccountManager<TelegramAccountManagerTypes>) {
|
||
self.disposable = (accountManager.accountRecords()
|
||
|> deliverOnMainQueue).start(next: { [weak self] view in
|
||
self?.hiddenIds = view.records
|
||
.filter { $0.attributes.contains(where: { $0.isHiddenAccountAttribute }) }
|
||
.map { $0.id.int64 }
|
||
})
|
||
}
|
||
}
|
||
#endif
|
||
```
|
||
|
||
```swift
|
||
#if canImport(SGSettingsUI)
|
||
let doubleBottomHiddenIdsCache = DoubleBottomHiddenIdsCache(accountManager: accountManager)
|
||
appLockContext.additionalPasscodeCheck = { passcode in
|
||
guard VarSystemNGSettings.isDoubleBottomOn else { return false }
|
||
if DoubleBottomPasscodeStore.secretPasscodeMatches(passcode) { return true }
|
||
let ids = doubleBottomHiddenIdsCache.hiddenIds
|
||
if !ids.isEmpty, DoubleBottomPasscodeStore.accountId(matchingPasscode: passcode, candidateIds: ids) != nil { return true }
|
||
return false
|
||
}
|
||
appLockContext.onUnlockWithPasscode = { [weak sharedContext] passcode in
|
||
guard let sharedContext = sharedContext, VarSystemNGSettings.isDoubleBottomOn else { return }
|
||
let _ = (accountManager.accessChallengeData()
|
||
|> take(1)
|
||
|> deliverOnMainQueue).start(next: { [weak sharedContext] challengeView in
|
||
guard let sharedContext = sharedContext else { return }
|
||
let challengeData = challengeView.data
|
||
if doubleBottomCheckPasscode(passcode, challengeData: challengeData) {
|
||
DoubleBottomViewingSecretStore.setViewingWithSecretPasscode(false)
|
||
} else if DoubleBottomPasscodeStore.secretPasscodeMatches(passcode) {
|
||
DoubleBottomViewingSecretStore.setViewingWithSecretPasscode(true)
|
||
} else {
|
||
let _ = (accountManager.accountRecords()
|
||
|> take(1)
|
||
|> deliverOnMainQueue).start(next: { view in
|
||
let hiddenIds = view.records
|
||
.filter { $0.attributes.contains(where: { $0.isHiddenAccountAttribute }) }
|
||
.map { $0.id.int64 }
|
||
guard !hiddenIds.isEmpty,
|
||
let matchedId = DoubleBottomPasscodeStore.accountId(matchingPasscode: passcode, candidateIds: hiddenIds) else { return }
|
||
sharedContext.switchToAccount(id: AccountRecordId(rawValue: matchedId))
|
||
})
|
||
}
|
||
})
|
||
}
|
||
#endif
|
||
```
|
||
|
||
Точка входа в настройках GLEGram (пункты меню, не вся функция `gleGramAppearanceEntries`):
|
||
|
||
```swift
|
||
entries.append(.header(id: id.count, section: .doubleBottom, text: (lang == "ru" ? "ДВОЙНОЕ ДНО" : "DOUBLE BOTTOM"), badge: nil))
|
||
entries.append(.disclosure(id: id.count, section: .doubleBottom, link: .doubleBottomSettings, text: (lang == "ru" ? "Двойное дно" : "Double Bottom")))
|
||
entries.append(.notice(id: id.count, section: .doubleBottom, text: (lang == "ru" ? "Скрытые аккаунты и вход по паролю. Разные пароли открывают разные профили." : "Hidden accounts and passcode access. Different passwords open different profiles.")))
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Пароль при заходе в чат
|
||
|
||
**Смысл:** список защищённых peer id в UserDefaults; при открытии чата показывается `UIAlertController` с полем пароля; проверка либо через `doubleBottomCheckPasscode` (код Telegram на устройстве), либо через отдельный пароль в Keychain (`ProtectedChatsStore`).
|
||
|
||
### `Swiftgram/SGSettingsUI/Sources/ProtectedChatsStore.swift` (полностью)
|
||
|
||
См. файл в репозитории — ниже идентичное содержимое.
|
||
|
||
```swift
|
||
// MARK: Swiftgram – Password for selected chats/folders
|
||
import Foundation
|
||
import Security
|
||
|
||
private let enabledKey = "sg_protected_chats_enabled"
|
||
private let peerIdsKey = "sg_protected_chat_peer_ids"
|
||
private let folderIdsKey = "sg_protected_folder_ids"
|
||
private let useDevicePasscodeKey = "sg_protected_chats_use_device_passcode"
|
||
private let serviceName = "SwiftgramProtectedChats"
|
||
private let customPasscodeAccount = "chats"
|
||
|
||
public enum ProtectedChatsStore {
|
||
public static var isEnabled: Bool {
|
||
get { UserDefaults.standard.bool(forKey: enabledKey) }
|
||
set { UserDefaults.standard.set(newValue, forKey: enabledKey) }
|
||
}
|
||
|
||
public static var useDevicePasscode: Bool {
|
||
get { UserDefaults.standard.object(forKey: useDevicePasscodeKey) as? Bool ?? true }
|
||
set { UserDefaults.standard.set(newValue, forKey: useDevicePasscodeKey) }
|
||
}
|
||
|
||
public static var protectedPeerIds: Set<Int64> {
|
||
get {
|
||
let list = UserDefaults.standard.array(forKey: peerIdsKey) as? [Int64] ?? []
|
||
return Set(list)
|
||
}
|
||
set {
|
||
UserDefaults.standard.set(Array(newValue), forKey: peerIdsKey)
|
||
}
|
||
}
|
||
|
||
public static var protectedFolderIds: Set<Int32> {
|
||
get {
|
||
let list = UserDefaults.standard.array(forKey: folderIdsKey) as? [Int32] ?? []
|
||
return Set(list)
|
||
}
|
||
set {
|
||
UserDefaults.standard.set(Array(newValue), forKey: folderIdsKey)
|
||
}
|
||
}
|
||
|
||
public static func addProtectedPeer(_ peerId: Int64) {
|
||
var set = protectedPeerIds
|
||
set.insert(peerId)
|
||
protectedPeerIds = set
|
||
}
|
||
|
||
public static func removeProtectedPeer(_ peerId: Int64) {
|
||
var set = protectedPeerIds
|
||
set.remove(peerId)
|
||
protectedPeerIds = set
|
||
}
|
||
|
||
public static func addProtectedFolder(_ folderId: Int32) {
|
||
var set = protectedFolderIds
|
||
set.insert(folderId)
|
||
protectedFolderIds = set
|
||
}
|
||
|
||
public static func removeProtectedFolder(_ folderId: Int32) {
|
||
var set = protectedFolderIds
|
||
set.remove(folderId)
|
||
protectedFolderIds = set
|
||
}
|
||
|
||
public static func isProtected(peerId: Int64) -> Bool {
|
||
isEnabled && protectedPeerIds.contains(peerId)
|
||
}
|
||
|
||
public static func isProtected(folderId: Int32) -> Bool {
|
||
isEnabled && protectedFolderIds.contains(folderId)
|
||
}
|
||
|
||
// MARK: - Custom passcode (when not using device passcode)
|
||
|
||
public static func setCustomPasscode(_ passcode: String) {
|
||
let data = passcode.data(using: .utf8)!
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: serviceName,
|
||
kSecAttrAccount as String: customPasscodeAccount
|
||
]
|
||
var addQuery = query
|
||
addQuery[kSecValueData as String] = data
|
||
var status = SecItemAdd(addQuery as CFDictionary, nil)
|
||
if status == errSecDuplicateItem {
|
||
SecItemDelete(query as CFDictionary)
|
||
status = SecItemAdd(addQuery as CFDictionary, nil)
|
||
}
|
||
}
|
||
|
||
public static func customPasscodeMatches(_ passcode: String) -> Bool {
|
||
guard let stored = getCustomPasscode() else { return false }
|
||
return stored == passcode
|
||
}
|
||
|
||
public static func hasCustomPasscode() -> Bool {
|
||
getCustomPasscode() != nil
|
||
}
|
||
|
||
public static func removeCustomPasscode() {
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: serviceName,
|
||
kSecAttrAccount as String: customPasscodeAccount
|
||
]
|
||
SecItemDelete(query as CFDictionary)
|
||
}
|
||
|
||
private static func getCustomPasscode() -> String? {
|
||
let query: [String: Any] = [
|
||
kSecClass as String: kSecClassGenericPassword,
|
||
kSecAttrService as String: serviceName,
|
||
kSecAttrAccount as String: customPasscodeAccount,
|
||
kSecReturnData as String: true,
|
||
kSecMatchLimit as String: kSecMatchLimitOne
|
||
]
|
||
var result: AnyObject?
|
||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||
guard status == errSecSuccess, let data = result as? Data, let string = String(data: data, encoding: .utf8) else {
|
||
return nil
|
||
}
|
||
return string
|
||
}
|
||
}
|
||
```
|
||
|
||
### `Swiftgram/SGSettingsUI/Sources/ProtectedChatsSettingsController.swift` (полностью)
|
||
|
||
```swift
|
||
// MARK: Swiftgram – Password for selected chats/folders settings
|
||
import Foundation
|
||
import UIKit
|
||
import Display
|
||
import SwiftSignalKit
|
||
import TelegramCore
|
||
import TelegramPresentationData
|
||
import ItemListUI
|
||
import PresentationDataUtils
|
||
import AccountContext
|
||
import PasscodeUI
|
||
|
||
private enum ProtectedChatsEntry: ItemListNodeEntry {
|
||
case enabled(String, Bool)
|
||
case useDevicePasscode(String, Bool)
|
||
case setCustomPasscode(String)
|
||
case addChat(String)
|
||
case protectedPeer(id: Int64, title: String)
|
||
case notice(String)
|
||
|
||
var section: ItemListSectionId {
|
||
switch self {
|
||
case .enabled, .useDevicePasscode, .setCustomPasscode, .notice: return 0
|
||
case .addChat, .protectedPeer: return 1
|
||
}
|
||
}
|
||
|
||
var stableId: Int {
|
||
switch self {
|
||
case .enabled: return 0
|
||
case .useDevicePasscode: return 1
|
||
case .setCustomPasscode: return 2
|
||
case .addChat: return 3
|
||
case .protectedPeer(let id, _): return 100 + Int(id % 100000)
|
||
case .notice: return 200
|
||
}
|
||
}
|
||
|
||
static func < (lhs: ProtectedChatsEntry, rhs: ProtectedChatsEntry) -> Bool {
|
||
lhs.stableId < rhs.stableId
|
||
}
|
||
|
||
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
|
||
let args = arguments as! ProtectedChatsArguments
|
||
let lang = presentationData.strings.baseLanguageCode
|
||
switch self {
|
||
case let .enabled(title, value):
|
||
return ItemListSwitchItem(presentationData: presentationData, title: title, value: value, sectionId: section, style: .blocks, updated: { args.toggleEnabled($0) })
|
||
case let .useDevicePasscode(title, value):
|
||
return ItemListSwitchItem(presentationData: presentationData, title: title, value: value, sectionId: section, style: .blocks, updated: { args.toggleUseDevicePasscode($0) })
|
||
case let .setCustomPasscode(title):
|
||
return ItemListDisclosureItem(presentationData: presentationData, title: title, label: "", sectionId: section, style: .blocks, action: { args.setCustomPasscode() })
|
||
case let .addChat(title):
|
||
return ItemListDisclosureItem(presentationData: presentationData, title: title, label: "", sectionId: section, style: .blocks, action: { args.addChat() })
|
||
case let .protectedPeer(_, title):
|
||
return ItemListDisclosureItem(presentationData: presentationData, title: title, label: lang == "ru" ? "Удалить" : "Remove", sectionId: section, style: .blocks, action: { [self] in
|
||
if case let .protectedPeer(peerId, _) = self { args.removePeer(peerId) }
|
||
})
|
||
case let .notice(text):
|
||
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: section)
|
||
}
|
||
}
|
||
}
|
||
|
||
private final class ProtectedChatsArguments {
|
||
let context: AccountContext
|
||
let toggleEnabled: (Bool) -> Void
|
||
let toggleUseDevicePasscode: (Bool) -> Void
|
||
let setCustomPasscode: () -> Void
|
||
let addChat: () -> Void
|
||
let removePeer: (Int64) -> Void
|
||
|
||
init(context: AccountContext, toggleEnabled: @escaping (Bool) -> Void, toggleUseDevicePasscode: @escaping (Bool) -> Void, setCustomPasscode: @escaping () -> Void, addChat: @escaping () -> Void, removePeer: @escaping (Int64) -> Void) {
|
||
self.context = context
|
||
self.toggleEnabled = toggleEnabled
|
||
self.toggleUseDevicePasscode = toggleUseDevicePasscode
|
||
self.setCustomPasscode = setCustomPasscode
|
||
self.addChat = addChat
|
||
self.removePeer = removePeer
|
||
}
|
||
}
|
||
|
||
public func protectedChatsSettingsController(context: AccountContext) -> ViewController {
|
||
let lang = context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode
|
||
let title = lang == "ru" ? "Пароль для чатов" : "Password for chats"
|
||
|
||
let statePromise = Promise<[(Int64, String)]>()
|
||
let peerTitles: [(Int64, String)] = ProtectedChatsStore.protectedPeerIds.map { ($0, "Chat \($0)") }
|
||
statePromise.set(.single(peerTitles))
|
||
|
||
var pushControllerImpl: ((ViewController) -> Void)?
|
||
|
||
let arguments = ProtectedChatsArguments(
|
||
context: context,
|
||
toggleEnabled: { value in
|
||
ProtectedChatsStore.isEnabled = value
|
||
},
|
||
toggleUseDevicePasscode: { value in
|
||
ProtectedChatsStore.useDevicePasscode = value
|
||
},
|
||
setCustomPasscode: {
|
||
let setup = PasscodeSetupController(context: context, mode: .setup(change: false, .digits6))
|
||
setup.complete = { passcode, _ in
|
||
ProtectedChatsStore.setCustomPasscode(passcode)
|
||
ProtectedChatsStore.useDevicePasscode = false
|
||
_ = (setup.navigationController as? NavigationController)?.popViewController(animated: true)
|
||
}
|
||
pushControllerImpl?(setup)
|
||
},
|
||
addChat: {
|
||
let filter: ChatListNodePeersFilter = [.onlyWriteable, .excludeDisabled, .doNotSearchMessages]
|
||
let controller = context.sharedContext.makePeerSelectionController(PeerSelectionControllerParams(
|
||
context: context,
|
||
filter: filter,
|
||
hasContactSelector: false,
|
||
hasGlobalSearch: true,
|
||
title: lang == "ru" ? "Выберите чат" : "Select chat"
|
||
))
|
||
controller.peerSelected = { [weak controller] peer, _ in
|
||
let peerId = peer.id.toInt64()
|
||
ProtectedChatsStore.addProtectedPeer(peerId)
|
||
statePromise.set(.single(ProtectedChatsStore.protectedPeerIds.map { ($0, "Chat \($0)") }))
|
||
_ = (controller?.navigationController as? NavigationController)?.popViewController(animated: true)
|
||
}
|
||
pushControllerImpl?(controller)
|
||
},
|
||
removePeer: { peerId in
|
||
ProtectedChatsStore.removeProtectedPeer(peerId)
|
||
statePromise.set(.single(ProtectedChatsStore.protectedPeerIds.map { ($0, "Chat \($0)") }))
|
||
}
|
||
)
|
||
|
||
let signal = combineLatest(
|
||
context.sharedContext.presentationData,
|
||
statePromise.get()
|
||
)
|
||
|> map { presentationData, peerTitles -> (ItemListControllerState, (ItemListNodeState, ProtectedChatsArguments)) in
|
||
let enabled = ProtectedChatsStore.isEnabled
|
||
let useDevice = ProtectedChatsStore.useDevicePasscode
|
||
let lang = presentationData.strings.baseLanguageCode
|
||
|
||
var entries: [ProtectedChatsEntry] = []
|
||
entries.append(.enabled(lang == "ru" ? "Пароль для чатов" : "Password for chats", enabled))
|
||
if enabled {
|
||
entries.append(.useDevicePasscode(lang == "ru" ? "Использовать пароль Telegram" : "Use Telegram passcode", useDevice))
|
||
if !useDevice {
|
||
entries.append(.setCustomPasscode(lang == "ru" ? "Установить отдельный пароль" : "Set separate passcode"))
|
||
}
|
||
entries.append(.notice(lang == "ru" ? "При открытии выбранных чатов будет запрашиваться пароль." : "Opening selected chats will require passcode."))
|
||
}
|
||
|
||
entries.append(.addChat(lang == "ru" ? "Добавить чат" : "Add chat"))
|
||
for (id, t) in peerTitles.sorted(by: { $0.0 < $1.0 }) {
|
||
entries.append(.protectedPeer(id: id, title: t))
|
||
}
|
||
|
||
let controllerState = ItemListControllerState(
|
||
presentationData: ItemListPresentationData(presentationData),
|
||
title: .text(title),
|
||
leftNavigationButton: nil,
|
||
rightNavigationButton: nil,
|
||
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
|
||
)
|
||
let listState = ItemListNodeState(
|
||
presentationData: ItemListPresentationData(presentationData),
|
||
entries: entries,
|
||
style: .blocks,
|
||
ensureVisibleItemTag: nil,
|
||
footerItem: nil,
|
||
initialScrollToItem: nil
|
||
)
|
||
return (controllerState, (listState, arguments))
|
||
}
|
||
|
||
let signalTyped: Signal<(ItemListControllerState, (ItemListNodeState, ProtectedChatsArguments)), NoError> = signal
|
||
let controller = ItemListController(context: context, state: signalTyped)
|
||
pushControllerImpl = { [weak controller] (vc: ViewController) in
|
||
(controller?.navigationController as? NavigationController)?.pushViewController(vc)
|
||
}
|
||
return controller
|
||
}
|
||
```
|
||
|
||
### `submodules/TelegramUI/Sources/SharedAccountContext.swift` — проверка при открытии чата (полный фрагмент)
|
||
|
||
```swift
|
||
public func isChatProtected(peerId: PeerId) -> Bool {
|
||
#if canImport(SGSettingsUI)
|
||
return ProtectedChatsStore.isEnabled && ProtectedChatsStore.isProtected(peerId: peerId.toInt64())
|
||
#else
|
||
return false
|
||
#endif
|
||
}
|
||
```
|
||
|
||
```swift
|
||
public func navigateToChatController(_ params: NavigateToChatControllerParams) {
|
||
if case let .peer(peer) = params.chatLocation {
|
||
let accountId = params.context.account.peerId.toInt64()
|
||
let peerId = peer.id.toInt64()
|
||
SGPluginHooks.willOpenChatRunner?(accountId, peerId)
|
||
if let eventResult = SGPluginHooks.emitEvent("chat.willOpen", ["accountId": accountId, "peerId": peerId, "subject": params.subject.map { String(describing: $0) } ?? ""]), eventResult["cancel"] as? Bool == true {
|
||
return
|
||
}
|
||
}
|
||
#if canImport(SGSettingsUI)
|
||
/// Saved Messages «Chats» tab opens dialogs as `.replyThread` with `peerId == account` and real peer id in `threadId`.
|
||
let peerIdValue: Int64 = {
|
||
switch params.chatLocation {
|
||
case let .peer(peer):
|
||
return peer.id.toInt64()
|
||
case let .replyThread(message):
|
||
if message.peerId == params.context.account.peerId,
|
||
!message.isForumPost, !message.isChannelPost, !message.isMonoforumPost {
|
||
return message.threadId
|
||
}
|
||
return message.peerId.toInt64()
|
||
}
|
||
}()
|
||
if ProtectedChatsStore.isEnabled && ProtectedChatsStore.isProtected(peerId: peerIdValue) {
|
||
let presentationData = params.context.sharedContext.currentPresentationData.with { $0 }
|
||
let strings = presentationData.strings
|
||
let useDevice = ProtectedChatsStore.useDevicePasscode
|
||
let title = presentationData.strings.baseLanguageCode == "ru" ? "Введите пароль" : "Enter passcode"
|
||
let message = presentationData.strings.baseLanguageCode == "ru" ? "Этот чат защищён паролем." : "This chat is protected with a passcode."
|
||
var textField: UITextField?
|
||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||
alert.addTextField { field in
|
||
field.isSecureTextEntry = true
|
||
field.placeholder = presentationData.strings.baseLanguageCode == "ru" ? "Пароль" : "Passcode"
|
||
textField = field
|
||
}
|
||
let cancelTitle = strings.Common_Cancel
|
||
let okTitle = strings.Common_OK
|
||
alert.addAction(UIAlertAction(title: cancelTitle, style: .cancel) { _ in })
|
||
alert.addAction(UIAlertAction(title: okTitle, style: .default) { [weak self] _ in
|
||
guard let self = self, let entered = textField?.text, !entered.isEmpty else { return }
|
||
let accountManager = self.accountManager
|
||
let proceed: () -> Void = {
|
||
DispatchQueue.main.async {
|
||
navigateToChatControllerImpl(params)
|
||
}
|
||
}
|
||
if useDevice {
|
||
let _ = (accountManager.accessChallengeData()
|
||
|> take(1)
|
||
|> deliverOnMainQueue).start(next: { view in
|
||
if doubleBottomCheckPasscode(entered, challengeData: view.data) {
|
||
alert.dismiss(animated: true, completion: proceed)
|
||
} else {
|
||
let err = UIAlertController(title: nil, message: presentationData.strings.baseLanguageCode == "ru" ? "Неверный пароль" : "Wrong passcode", preferredStyle: .alert)
|
||
err.addAction(UIAlertAction(title: strings.Common_OK, style: .default) { _ in })
|
||
alert.present(err, animated: true)
|
||
}
|
||
})
|
||
} else {
|
||
if ProtectedChatsStore.customPasscodeMatches(entered) {
|
||
alert.dismiss(animated: true, completion: proceed)
|
||
} else {
|
||
let err = UIAlertController(title: nil, message: presentationData.strings.baseLanguageCode == "ru" ? "Неверный пароль" : "Wrong passcode", preferredStyle: .alert)
|
||
err.addAction(UIAlertAction(title: strings.Common_OK, style: .default) { _ in })
|
||
alert.present(err, animated: true)
|
||
}
|
||
}
|
||
})
|
||
if let top = params.navigationController.viewControllers.last {
|
||
top.present(alert, animated: true)
|
||
} else {
|
||
navigateToChatControllerImpl(params)
|
||
}
|
||
return
|
||
}
|
||
#endif
|
||
navigateToChatControllerImpl(params)
|
||
}
|
||
```
|
||
|
||
Пункты в GLEGram (фрагмент `GLEGramSettingsController.swift`):
|
||
|
||
```swift
|
||
entries.append(.header(id: id.count, section: .protectedChats, text: (lang == "ru" ? "ПАРОЛЬ ДЛЯ ЧАТОВ" : "PASSWORD FOR CHATS"), badge: nil))
|
||
entries.append(.disclosure(id: id.count, section: .protectedChats, link: .protectedChatsSettings, text: (lang == "ru" ? "Пароль при заходе в чат" : "Password when entering chat")))
|
||
entries.append(.notice(id: id.count, section: .protectedChats, text: (lang == "ru" ? "Выберите чаты и/или папки, при открытии которых нужно вводить пароль (пароль Telegram или отдельный)." : "Select chats and/or folders that require a passcode to open (device passcode or a separate one).")))
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Смена голоса (Voice Morpher)
|
||
|
||
**Смысл:** локальная обработка OGG голосовых сообщений: декодирование Opus → `AVAudioEngine` (тон, скорость, искажение) → снова OGG. Настройки и пресеты — `VoiceMorpherManager`; вызов нативного процессора — `VoiceMorpherEngine` → `VoiceMorpherProcessor`.
|
||
|
||
### Пункты в GLEGram (`Swiftgram/SGSettingsUI/Sources/GLEGramSettingsController.swift`)
|
||
|
||
```swift
|
||
// MARK: Voice Morpher (Privacy tab) — ghostgram-style local processing
|
||
entries.append(.header(id: id.count, section: .voiceChanger, text: (lang == "ru" ? "СМЕНА ГОЛОСА" : "VOICE MORPHER"), badge: nil))
|
||
let vm = VoiceMorpherManager.shared
|
||
entries.append(.toggle(id: id.count, section: .voiceChanger, settingName: .voiceChangerEnabled, value: vm.isEnabled, text: (lang == "ru" ? "Изменять голос при записи" : "Change voice when recording"), enabled: true))
|
||
let ru = lang == "ru"
|
||
let displayedPreset: VoiceMorpherManager.VoicePreset = {
|
||
if !vm.isEnabled { return .disabled }
|
||
if vm.selectedPresetId == 0 { return .anonymous }
|
||
return vm.selectedPreset
|
||
}()
|
||
let presetTitle = displayedPreset.title(langIsRu: ru)
|
||
entries.append(.disclosure(id: id.count, section: .voiceChanger, link: .voiceChangerVoicePicker, text: (ru ? "Эффект: \(presetTitle)" : "Effect: \(presetTitle)")))
|
||
entries.append(.notice(id: id.count, section: .voiceChanger, text: (ru
|
||
? "Локально: OGG → эффекты iOS (тон, искажение) → снова OGG. Без серверов. Как в ghostgram iOS."
|
||
: "On-device: OGG → iOS audio effects (pitch, distortion) → OGG. No servers. Same approach as ghostgram iOS.")))
|
||
```
|
||
|
||
### `submodules/TelegramCore/Sources/VoiceMorpher/VoiceMorpherManager.swift` (полностью)
|
||
|
||
```swift
|
||
import Foundation
|
||
|
||
/// GLEGram / ghostgram-style: local voice morphing for outgoing voice messages (UserDefaults).
|
||
public final class VoiceMorpherManager {
|
||
public static let shared = VoiceMorpherManager()
|
||
|
||
public enum VoicePreset: Int, CaseIterable {
|
||
case disabled = 0
|
||
case anonymous = 1
|
||
case female = 2
|
||
case male = 3
|
||
case child = 4
|
||
case robot = 5
|
||
|
||
public func title(langIsRu: Bool) -> String {
|
||
switch self {
|
||
case .disabled:
|
||
return langIsRu ? "Выключено" : "Off"
|
||
case .anonymous:
|
||
return langIsRu ? "Аноним" : "Anonymous"
|
||
case .female:
|
||
return langIsRu ? "Женский" : "Female"
|
||
case .male:
|
||
return langIsRu ? "Мужской" : "Male"
|
||
case .child:
|
||
return langIsRu ? "Ребёнок" : "Child"
|
||
case .robot:
|
||
return langIsRu ? "Робот" : "Robot"
|
||
}
|
||
}
|
||
|
||
public func subtitle(langIsRu: Bool) -> String {
|
||
switch self {
|
||
case .disabled:
|
||
return langIsRu ? "Без изменений" : "Unchanged"
|
||
case .anonymous:
|
||
return langIsRu ? "Искажённый голос" : "Distorted voice"
|
||
case .female:
|
||
return langIsRu ? "Выше тон" : "Higher pitch"
|
||
case .male:
|
||
return langIsRu ? "Ниже тон" : "Lower pitch"
|
||
case .child:
|
||
return langIsRu ? "Детский тон" : "Child-like"
|
||
case .robot:
|
||
return langIsRu ? "Металлический эффект" : "Metallic effect"
|
||
}
|
||
}
|
||
}
|
||
|
||
private enum Keys {
|
||
static let isEnabled = "VoiceMorpher.isEnabled"
|
||
static let selectedPreset = "VoiceMorpher.selectedPreset"
|
||
}
|
||
|
||
private let defaults = UserDefaults.standard
|
||
|
||
public var isEnabled: Bool {
|
||
get { defaults.bool(forKey: Keys.isEnabled) }
|
||
set {
|
||
defaults.set(newValue, forKey: Keys.isEnabled)
|
||
notifyChanged()
|
||
}
|
||
}
|
||
|
||
public var selectedPresetId: Int {
|
||
get { defaults.integer(forKey: Keys.selectedPreset) }
|
||
set {
|
||
defaults.set(newValue, forKey: Keys.selectedPreset)
|
||
notifyChanged()
|
||
}
|
||
}
|
||
|
||
public var selectedPreset: VoicePreset {
|
||
VoicePreset(rawValue: selectedPresetId) ?? .disabled
|
||
}
|
||
|
||
public var effectivePreset: VoicePreset {
|
||
guard isEnabled else { return .disabled }
|
||
return selectedPreset
|
||
}
|
||
|
||
public static let settingsChangedNotification = Notification.Name("VoiceMorpherSettingsChanged")
|
||
|
||
private func notifyChanged() {
|
||
NotificationCenter.default.post(name: Self.settingsChangedNotification, object: nil)
|
||
}
|
||
|
||
private init() {}
|
||
}
|
||
```
|
||
|
||
### `submodules/TelegramUI/Sources/VoiceMorpher/VoiceMorpherEngine.swift` (полностью)
|
||
|
||
```swift
|
||
import Foundation
|
||
import OpusBinding
|
||
import TelegramCore
|
||
|
||
/// Local OGG voice morphing (ghostgram-style): decode → AVAudioEngine effects → encode.
|
||
public final class VoiceMorpherEngine {
|
||
public static let shared = VoiceMorpherEngine()
|
||
|
||
private init() {}
|
||
|
||
public func processOggData(
|
||
_ inputData: Data,
|
||
completion: @escaping (Swift.Result<Data, Error>) -> Void
|
||
) {
|
||
let preset = VoiceMorpherManager.shared.effectivePreset
|
||
|
||
guard preset != .disabled else {
|
||
completion(.success(inputData))
|
||
return
|
||
}
|
||
|
||
let objcPreset: VoiceMorpherPreset
|
||
switch preset {
|
||
case .disabled:
|
||
objcPreset = .disabled
|
||
case .anonymous:
|
||
objcPreset = .anonymous
|
||
case .female:
|
||
objcPreset = .female
|
||
case .male:
|
||
objcPreset = .male
|
||
case .child:
|
||
objcPreset = .child
|
||
case .robot:
|
||
objcPreset = .robot
|
||
}
|
||
|
||
VoiceMorpherProcessor.processOggData(inputData, preset: objcPreset) { outputData, error in
|
||
if let error {
|
||
completion(.failure(error))
|
||
} else if let outputData {
|
||
completion(.success(outputData))
|
||
} else {
|
||
completion(.failure(VoiceMorpherError.processingFailed))
|
||
}
|
||
}
|
||
}
|
||
|
||
public enum VoiceMorpherError: Error, LocalizedError {
|
||
case processingFailed
|
||
|
||
public var errorDescription: String? {
|
||
switch self {
|
||
case .processingFailed:
|
||
return "Voice morphing processing failed"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### `submodules/OpusBinding/PublicHeaders/OpusBinding/VoiceMorpherProcessor.h` (полностью)
|
||
|
||
```objc
|
||
#import <AVFoundation/AVFoundation.h>
|
||
#import <Foundation/Foundation.h>
|
||
|
||
NS_ASSUME_NONNULL_BEGIN
|
||
|
||
/// VoiceMorpherProcessor - Processes OGG/Opus audio with voice effects
|
||
/// Decodes OGG -> applies effects -> re-encodes to OGG
|
||
@interface VoiceMorpherProcessor : NSObject
|
||
|
||
typedef NS_ENUM(NSInteger, VoiceMorpherPreset) {
|
||
VoiceMorpherPresetDisabled = 0,
|
||
VoiceMorpherPresetAnonymous = 1,
|
||
VoiceMorpherPresetFemale = 2,
|
||
VoiceMorpherPresetMale = 3,
|
||
VoiceMorpherPresetChild = 4,
|
||
VoiceMorpherPresetRobot = 5
|
||
};
|
||
|
||
/// Process OGG audio data with voice morphing effect
|
||
+ (void)processOggData:(NSData *)inputData
|
||
preset:(VoiceMorpherPreset)preset
|
||
completion:(void (^)(NSData *_Nullable outputData,
|
||
NSError *_Nullable error))completion;
|
||
|
||
+ (float)pitchShiftForPreset:(VoiceMorpherPreset)preset;
|
||
+ (float)rateForPreset:(VoiceMorpherPreset)preset;
|
||
|
||
@end
|
||
|
||
NS_ASSUME_NONNULL_END
|
||
```
|
||
|
||
### `submodules/OpusBinding/Sources/VoiceMorpherProcessor.m` (полностью)
|
||
|
||
```objc
|
||
#import "VoiceMorpherProcessor.h"
|
||
#import "OggOpusReader.h"
|
||
#import "TGDataItem.h"
|
||
#import "TGOggOpusWriter.h"
|
||
|
||
@implementation VoiceMorpherProcessor
|
||
|
||
+ (float)pitchShiftForPreset:(VoiceMorpherPreset)preset {
|
||
switch (preset) {
|
||
case VoiceMorpherPresetDisabled:
|
||
return 0;
|
||
case VoiceMorpherPresetAnonymous:
|
||
return -200;
|
||
case VoiceMorpherPresetFemale:
|
||
return 600; // More feminine - higher pitch
|
||
case VoiceMorpherPresetMale:
|
||
return -300;
|
||
case VoiceMorpherPresetChild:
|
||
return 600;
|
||
case VoiceMorpherPresetRobot:
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
+ (float)rateForPreset:(VoiceMorpherPreset)preset {
|
||
switch (preset) {
|
||
case VoiceMorpherPresetDisabled:
|
||
return 1.0;
|
||
case VoiceMorpherPresetAnonymous:
|
||
return 0.95;
|
||
case VoiceMorpherPresetFemale:
|
||
return 1.08; // Slightly faster for feminine effect
|
||
case VoiceMorpherPresetMale:
|
||
return 0.95;
|
||
case VoiceMorpherPresetChild:
|
||
return 1.1;
|
||
case VoiceMorpherPresetRobot:
|
||
return 1.0;
|
||
}
|
||
}
|
||
|
||
+ (void)processOggData:(NSData *)inputData
|
||
preset:(VoiceMorpherPreset)preset
|
||
completion:
|
||
(void (^)(NSData *_Nullable, NSError *_Nullable))completion {
|
||
|
||
if (preset == VoiceMorpherPresetDisabled) {
|
||
completion(inputData, nil);
|
||
return;
|
||
}
|
||
|
||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0),
|
||
^{
|
||
NSError *error = nil;
|
||
NSData *result = [self processOggDataSync:inputData
|
||
preset:preset
|
||
error:&error];
|
||
|
||
// Call completion on background thread to avoid deadlock
|
||
// when caller uses semaphore on main thread
|
||
completion(result, error);
|
||
});
|
||
}
|
||
|
||
+ (NSData *_Nullable)processOggDataSync:(NSData *)inputData
|
||
preset:(VoiceMorpherPreset)preset
|
||
error:(NSError **)error {
|
||
// Save input OGG to temp file for decoding
|
||
NSString *tempInputPath = [NSTemporaryDirectory()
|
||
stringByAppendingPathComponent:
|
||
[NSString
|
||
stringWithFormat:@"vm_in_%lld.ogg", (long long)[[NSDate date]
|
||
timeIntervalSince1970] *
|
||
1000]];
|
||
|
||
[inputData writeToFile:tempInputPath atomically:YES];
|
||
|
||
// Decode OGG to PCM
|
||
OggOpusReader *reader = [[OggOpusReader alloc] initWithPath:tempInputPath];
|
||
if (!reader) {
|
||
if (error) {
|
||
*error = [NSError
|
||
errorWithDomain:@"VoiceMorpher"
|
||
code:1
|
||
userInfo:@{
|
||
NSLocalizedDescriptionKey : @"Failed to open OGG file"
|
||
}];
|
||
}
|
||
[[NSFileManager defaultManager] removeItemAtPath:tempInputPath error:nil];
|
||
return nil;
|
||
}
|
||
|
||
// Opus outputs 16-bit stereo at 48kHz
|
||
NSMutableData *pcmData = [[NSMutableData alloc] init];
|
||
int16_t buffer[5760 * 2]; // Max frame size * channels
|
||
int32_t samplesRead;
|
||
|
||
while ((samplesRead = [reader read:buffer
|
||
bufSize:sizeof(buffer) / sizeof(buffer[0])]) > 0) {
|
||
[pcmData appendBytes:buffer length:samplesRead * sizeof(int16_t)];
|
||
}
|
||
|
||
[[NSFileManager defaultManager] removeItemAtPath:tempInputPath error:nil];
|
||
|
||
if (pcmData.length == 0) {
|
||
if (error) {
|
||
*error =
|
||
[NSError errorWithDomain:@"VoiceMorpher"
|
||
code:2
|
||
userInfo:@{
|
||
NSLocalizedDescriptionKey : @"No PCM data decoded"
|
||
}];
|
||
}
|
||
return nil;
|
||
}
|
||
|
||
// Apply voice effects using AVAudioEngine
|
||
NSData *processedPcm = [self applyEffectsToPcmData:pcmData
|
||
preset:preset
|
||
error:error];
|
||
if (!processedPcm) {
|
||
return nil;
|
||
}
|
||
|
||
// Encode processed PCM back to OGG
|
||
TGDataItem *dataItem = [[TGDataItem alloc] init];
|
||
TGOggOpusWriter *writer = [[TGOggOpusWriter alloc] init];
|
||
|
||
if (![writer beginWithDataItem:dataItem]) {
|
||
if (error) {
|
||
*error = [NSError
|
||
errorWithDomain:@"VoiceMorpher"
|
||
code:4
|
||
userInfo:@{
|
||
NSLocalizedDescriptionKey : @"Failed to begin OGG encoding"
|
||
}];
|
||
}
|
||
return nil;
|
||
}
|
||
|
||
// Write PCM data in frames (960 samples = 20ms at 48kHz)
|
||
const int frameSize = 960 * sizeof(int16_t);
|
||
const uint8_t *bytes = processedPcm.bytes;
|
||
NSUInteger remaining = processedPcm.length;
|
||
NSUInteger offset = 0;
|
||
|
||
while (remaining >= frameSize) {
|
||
[writer writeFrame:(uint8_t *)(bytes + offset) frameByteCount:frameSize];
|
||
offset += frameSize;
|
||
remaining -= frameSize;
|
||
}
|
||
|
||
if (remaining > 0) {
|
||
uint8_t lastFrame[frameSize];
|
||
memset(lastFrame, 0, frameSize);
|
||
memcpy(lastFrame, bytes + offset, remaining);
|
||
[writer writeFrame:lastFrame frameByteCount:frameSize];
|
||
}
|
||
|
||
return [dataItem data];
|
||
}
|
||
|
||
+ (NSData *_Nullable)applyEffectsToPcmData:(NSData *)pcmData
|
||
preset:(VoiceMorpherPreset)preset
|
||
error:(NSError **)error {
|
||
NSUInteger sampleCount = pcmData.length / sizeof(int16_t);
|
||
const int16_t *int16Samples = (const int16_t *)pcmData.bytes;
|
||
|
||
float *floatSamples = (float *)malloc(sampleCount * sizeof(float));
|
||
if (!floatSamples) {
|
||
if (error) {
|
||
*error = [NSError
|
||
errorWithDomain:@"VoiceMorpher"
|
||
code:5
|
||
userInfo:@{
|
||
NSLocalizedDescriptionKey : @"Memory allocation failed"
|
||
}];
|
||
}
|
||
return nil;
|
||
}
|
||
|
||
// Convert int16 to float (-1.0 to 1.0 range)
|
||
for (NSUInteger i = 0; i < sampleCount; i++) {
|
||
floatSamples[i] = (float)int16Samples[i] / 32768.0f;
|
||
}
|
||
|
||
// Create audio format (mono, 48kHz, float)
|
||
AVAudioFormat *format =
|
||
[[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32
|
||
sampleRate:48000
|
||
channels:1
|
||
interleaved:NO];
|
||
|
||
AVAudioFrameCount frameCount = (AVAudioFrameCount)sampleCount;
|
||
AVAudioPCMBuffer *inputBuffer =
|
||
[[AVAudioPCMBuffer alloc] initWithPCMFormat:format
|
||
frameCapacity:frameCount];
|
||
inputBuffer.frameLength = frameCount;
|
||
|
||
memcpy(inputBuffer.floatChannelData[0], floatSamples,
|
||
sampleCount * sizeof(float));
|
||
free(floatSamples);
|
||
|
||
// Create engine and nodes
|
||
AVAudioEngine *engine = [[AVAudioEngine alloc] init];
|
||
AVAudioPlayerNode *playerNode = [[AVAudioPlayerNode alloc] init];
|
||
AVAudioUnitTimePitch *pitchNode = [[AVAudioUnitTimePitch alloc] init];
|
||
|
||
pitchNode.pitch = [self pitchShiftForPreset:preset];
|
||
pitchNode.rate = [self rateForPreset:preset];
|
||
|
||
[engine attachNode:playerNode];
|
||
[engine attachNode:pitchNode];
|
||
[engine connect:playerNode to:pitchNode format:format];
|
||
|
||
AVAudioNode *lastNode = pitchNode;
|
||
|
||
if (preset == VoiceMorpherPresetRobot) {
|
||
AVAudioUnitDistortion *distortion = [[AVAudioUnitDistortion alloc] init];
|
||
[distortion loadFactoryPreset:AVAudioUnitDistortionPresetSpeechRadioTower];
|
||
distortion.wetDryMix = 40;
|
||
[engine attachNode:distortion];
|
||
[engine connect:pitchNode to:distortion format:format];
|
||
lastNode = distortion;
|
||
} else if (preset == VoiceMorpherPresetAnonymous) {
|
||
AVAudioUnitDistortion *distortion = [[AVAudioUnitDistortion alloc] init];
|
||
[distortion
|
||
loadFactoryPreset:AVAudioUnitDistortionPresetSpeechCosmicInterference];
|
||
distortion.wetDryMix = 30;
|
||
[engine attachNode:distortion];
|
||
[engine connect:pitchNode to:distortion format:format];
|
||
lastNode = distortion;
|
||
}
|
||
|
||
[engine connect:lastNode to:engine.mainMixerNode format:format];
|
||
|
||
__block NSMutableData *outputData = [[NSMutableData alloc] init];
|
||
|
||
[engine.mainMixerNode
|
||
installTapOnBus:0
|
||
bufferSize:4096
|
||
format:format
|
||
block:^(AVAudioPCMBuffer *buffer, AVAudioTime *when) {
|
||
float *samples = buffer.floatChannelData[0];
|
||
AVAudioFrameCount count = buffer.frameLength;
|
||
|
||
int16_t *int16Buffer =
|
||
(int16_t *)malloc(count * sizeof(int16_t));
|
||
for (AVAudioFrameCount i = 0; i < count; i++) {
|
||
float sample = samples[i];
|
||
if (sample > 1.0f)
|
||
sample = 1.0f;
|
||
if (sample < -1.0f)
|
||
sample = -1.0f;
|
||
int16Buffer[i] = (int16_t)(sample * 32767.0f);
|
||
}
|
||
|
||
[outputData appendBytes:int16Buffer
|
||
length:count * sizeof(int16_t)];
|
||
free(int16Buffer);
|
||
}];
|
||
|
||
NSError *startError = nil;
|
||
[engine startAndReturnError:&startError];
|
||
if (startError) {
|
||
if (error) {
|
||
*error = startError;
|
||
}
|
||
return nil;
|
||
}
|
||
|
||
[playerNode scheduleBuffer:inputBuffer
|
||
atTime:nil
|
||
options:0
|
||
completionHandler:nil];
|
||
[playerNode play];
|
||
|
||
float rate = [self rateForPreset:preset];
|
||
NSTimeInterval duration = (double)sampleCount / 48000.0 / rate + 0.5;
|
||
[NSThread sleepForTimeInterval:duration];
|
||
|
||
[playerNode stop];
|
||
[engine.mainMixerNode removeTapOnBus:0];
|
||
[engine stop];
|
||
|
||
return outputData;
|
||
}
|
||
|
||
@end
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Поиск во вкладке сохранённых удалённых сообщений
|
||
|
||
**Смысл:** экран `savedDeletedMessagesListController` строит записи через `savedDeletedListEntries`, поле поиска — первая строка; `filterSavedDeletedListEntries` отфильтровывает секции без совпадений по запросу (имя чата, текст сообщения, дата, подписи кнопок).
|
||
|
||
### `Swiftgram/SGSettingsUI/Sources/SavedDeletedMessagesListController.swift` (полностью)
|
||
|
||
Файл приведён целиком в репозитории (292 строки). Ниже — полная копия без изменений.
|
||
|
||
```swift
|
||
// MARK: Swiftgram – Saved Deleted Messages List
|
||
import Foundation
|
||
import UIKit
|
||
import Display
|
||
import SwiftSignalKit
|
||
import Postbox
|
||
import TelegramCore
|
||
import TelegramPresentationData
|
||
import ItemListUI
|
||
import PresentationDataUtils
|
||
import AccountContext
|
||
#if canImport(SGDeletedMessages)
|
||
import SGDeletedMessages
|
||
#endif
|
||
|
||
// MARK: - Entry
|
||
|
||
private enum SavedDeletedListEntry: ItemListNodeEntry {
|
||
case search(id: Int, query: String)
|
||
case empty(id: Int, text: String)
|
||
case peerHeader(id: Int, sectionIndex: Int32, text: String)
|
||
case messageRow(id: Int, sectionIndex: Int32, text: String, dateText: String, peerId: PeerId, messageId: MessageId, searchableText: String)
|
||
case deleteAction(id: Int, sectionIndex: Int32, text: String, peerId: PeerId)
|
||
|
||
var stableId: Int {
|
||
switch self {
|
||
case .search(let id, _): return id
|
||
case .empty(let id, _): return id
|
||
case .peerHeader(let id, _, _): return id
|
||
case .messageRow(let id, _, _, _, _, _, _): return id
|
||
case .deleteAction(let id, _, _, _): return id
|
||
}
|
||
}
|
||
|
||
var section: ItemListSectionId {
|
||
switch self {
|
||
case .search(_, _): return 0
|
||
case .empty: return 0
|
||
case .peerHeader(_, let s, _): return s
|
||
case .messageRow(_, let s, _, _, _, _, _): return s
|
||
case .deleteAction(_, let s, _, _): return s
|
||
}
|
||
}
|
||
|
||
static func < (lhs: SavedDeletedListEntry, rhs: SavedDeletedListEntry) -> Bool {
|
||
lhs.stableId < rhs.stableId
|
||
}
|
||
|
||
static func == (lhs: SavedDeletedListEntry, rhs: SavedDeletedListEntry) -> Bool {
|
||
switch (lhs, rhs) {
|
||
case let (.search(a, q1), .search(b, q2)): return a == b && q1 == q2
|
||
case let (.empty(a, t1), .empty(b, t2)): return a == b && t1 == t2
|
||
case let (.peerHeader(a, s1, t1), .peerHeader(b, s2, t2)): return a == b && s1 == s2 && t1 == t2
|
||
case let (.messageRow(a, s1, t1, d1, p1, m1, _), .messageRow(b, s2, t2, d2, p2, m2, _)): return a == b && s1 == s2 && t1 == t2 && d1 == d2 && p1 == p2 && m1 == m2
|
||
case let (.deleteAction(a, s1, t1, p1), .deleteAction(b, s2, t2, p2)): return a == b && s1 == s2 && t1 == t2 && p1 == p2
|
||
default: return false
|
||
}
|
||
}
|
||
|
||
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
|
||
let args = arguments as! SavedDeletedListArguments
|
||
switch self {
|
||
case .search(_, let query):
|
||
return ItemListSingleLineInputItem(presentationData: presentationData, systemStyle: .glass, title: NSAttributedString(string: "🔍"), text: query, placeholder: presentationData.strings.Common_Search, type: .regular(capitalization: false, autocorrection: false), spacing: 0.0, clearType: .always, tag: nil, sectionId: section, textUpdated: { args.searchUpdated($0) }, action: {})
|
||
case .empty(_, let text):
|
||
return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: section)
|
||
case .peerHeader(_, _, let text):
|
||
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: section)
|
||
case .messageRow(_, _, let text, let dateText, let peerId, let messageId, _):
|
||
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: dateText, sectionId: section, style: .blocks, action: {
|
||
args.openMessage(peerId, messageId)
|
||
})
|
||
case .deleteAction(_, _, let text, let peerId):
|
||
return ItemListActionItem(presentationData: presentationData, title: text, kind: .destructive, alignment: .natural, sectionId: section, style: .blocks, action: {
|
||
args.deleteMessagesForPeer(peerId)
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Arguments
|
||
|
||
private final class SearchQueryRef {
|
||
var value: String = ""
|
||
}
|
||
|
||
private final class SavedDeletedListArguments {
|
||
let searchQueryRef: SearchQueryRef
|
||
var searchQuery: String { searchQueryRef.value }
|
||
let searchUpdated: (String) -> Void
|
||
let deleteMessagesForPeer: (PeerId) -> Void
|
||
let openMessage: (PeerId, MessageId) -> Void
|
||
init(searchQueryRef: SearchQueryRef, searchUpdated: @escaping (String) -> Void, deleteMessagesForPeer: @escaping (PeerId) -> Void, openMessage: @escaping (PeerId, MessageId) -> Void) {
|
||
self.searchQueryRef = searchQueryRef
|
||
self.searchUpdated = searchUpdated
|
||
self.deleteMessagesForPeer = deleteMessagesForPeer
|
||
self.openMessage = openMessage
|
||
}
|
||
}
|
||
|
||
// MARK: - Date formatting
|
||
|
||
private let dateFormatter: DateFormatter = {
|
||
let f = DateFormatter()
|
||
f.dateStyle = .medium
|
||
f.timeStyle = .short
|
||
return f
|
||
}()
|
||
|
||
// MARK: - Entries builder (full list, no filter — like GLEGram settings)
|
||
|
||
#if canImport(SGDeletedMessages)
|
||
private func savedDeletedListEntries(
|
||
data: [(peer: Peer?, peerId: PeerId, messages: [Message])],
|
||
lang: String
|
||
) -> [SavedDeletedListEntry] {
|
||
var entries: [SavedDeletedListEntry] = []
|
||
var id = 0
|
||
|
||
entries.append(.search(id: id, query: ""))
|
||
id += 1
|
||
|
||
if data.isEmpty {
|
||
let text = (lang == "ru" ? "Нет сохранённых удалённых сообщений." : "No saved deleted messages.")
|
||
entries.append(.empty(id: id, text: text))
|
||
return entries
|
||
}
|
||
|
||
var sectionIndex: Int32 = 0
|
||
for group in data {
|
||
let peerName: String
|
||
if let peer = group.peer {
|
||
peerName = peer.debugDisplayTitle
|
||
} else {
|
||
peerName = "Peer \(group.peerId.id._internalGetInt64Value())"
|
||
}
|
||
sectionIndex += 1
|
||
let countStr = lang == "ru" ? "\(group.messages.count) сообщ." : "\(group.messages.count) msg"
|
||
entries.append(.peerHeader(id: id, sectionIndex: sectionIndex, text: "\(peerName.uppercased()) (\(countStr))"))
|
||
id += 1
|
||
|
||
for message in group.messages {
|
||
let text = message.text.isEmpty
|
||
? (lang == "ru" ? "[медиа]" : "[media]")
|
||
: String(message.text.prefix(120)).replacingOccurrences(of: "\n", with: " ")
|
||
let searchableText = (message.text + " " + (message.sgDeletedAttribute.originalText ?? "")).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let date = dateFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(message.timestamp)))
|
||
entries.append(.messageRow(id: id, sectionIndex: sectionIndex, text: text, dateText: date, peerId: group.peerId, messageId: message.id, searchableText: searchableText))
|
||
id += 1
|
||
}
|
||
|
||
let deleteText = lang == "ru" ? "Удалить все для этого чата" : "Delete all for this chat"
|
||
entries.append(.deleteAction(id: id, sectionIndex: sectionIndex, text: deleteText, peerId: group.peerId))
|
||
id += 1
|
||
}
|
||
|
||
return entries
|
||
}
|
||
|
||
/// Filter by search query — same logic as filterSGItemListUIEntrires in GLEGram settings: two-pass, keep search, keep sections that have matches.
|
||
private func filterSavedDeletedListEntries(_ entries: [SavedDeletedListEntry], by searchQuery: String?, lang: String) -> [SavedDeletedListEntry] {
|
||
guard let query = searchQuery?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !query.isEmpty else {
|
||
return entries
|
||
}
|
||
var sectionIdsWithMatches: Set<Int32> = []
|
||
for entry in entries {
|
||
switch entry {
|
||
case .search(_, _), .empty:
|
||
break
|
||
case .peerHeader(_, let s, let text):
|
||
if text.lowercased().contains(query) { sectionIdsWithMatches.insert(s) }
|
||
case .messageRow(_, let s, _, let dateText, _, _, let searchableText):
|
||
if searchableText.lowercased().contains(query) || dateText.lowercased().contains(query) { sectionIdsWithMatches.insert(s) }
|
||
case .deleteAction(_, let s, let text, _):
|
||
if text.lowercased().contains(query) { sectionIdsWithMatches.insert(s) }
|
||
}
|
||
}
|
||
var filtered: [SavedDeletedListEntry] = []
|
||
for entry in entries {
|
||
switch entry {
|
||
case .search(_, _):
|
||
filtered.append(entry)
|
||
case .empty:
|
||
continue
|
||
case .peerHeader(_, let s, _), .messageRow(_, let s, _, _, _, _, _), .deleteAction(_, let s, _, _):
|
||
if sectionIdsWithMatches.contains(s) {
|
||
filtered.append(entry)
|
||
}
|
||
}
|
||
}
|
||
if filtered.count == 1, case .search(_, _) = filtered[0] {
|
||
filtered.append(.empty(id: Int.max, text: lang == "ru" ? "Ничего не найдено." : "No results."))
|
||
}
|
||
return filtered
|
||
}
|
||
#endif
|
||
|
||
// MARK: - Controller
|
||
|
||
public func savedDeletedMessagesListController(context: AccountContext) -> ViewController {
|
||
#if canImport(SGDeletedMessages)
|
||
var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)?
|
||
var pushControllerImpl: ((ViewController) -> Void)?
|
||
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
|
||
let searchQueryPromise = ValuePromise("", ignoreRepeated: false)
|
||
let searchQueryRef = SearchQueryRef()
|
||
|
||
let arguments = SavedDeletedListArguments(
|
||
searchQueryRef: searchQueryRef,
|
||
searchUpdated: { value in
|
||
searchQueryRef.value = value
|
||
searchQueryPromise.set(value)
|
||
},
|
||
deleteMessagesForPeer: { peerId in
|
||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||
let lang = presentationData.strings.baseLanguageCode
|
||
let title = lang == "ru" ? "Удалить" : "Delete"
|
||
let text = lang == "ru" ? "Удалить все сохранённые удалённые сообщения для этого чата?" : "Delete all saved deleted messages for this chat?"
|
||
let alert = textAlertController(
|
||
context: context,
|
||
title: title,
|
||
text: text,
|
||
actions: [
|
||
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
|
||
let _ = (SGDeletedMessages.getAllSavedDeletedMessages(postbox: context.account.postbox)
|
||
|> mapToSignal { groups -> Signal<Void, NoError> in
|
||
var idsToDelete: [MessageId] = []
|
||
for group in groups where group.peerId == peerId {
|
||
idsToDelete.append(contentsOf: group.messages.map { $0.id })
|
||
}
|
||
return SGDeletedMessages.deleteSavedDeletedMessages(ids: idsToDelete, postbox: context.account.postbox)
|
||
}
|
||
|> deliverOnMainQueue).start(completed: {
|
||
reloadPromise.set(true)
|
||
})
|
||
}),
|
||
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
|
||
]
|
||
)
|
||
presentControllerImpl?(alert, nil)
|
||
},
|
||
openMessage: { peerId, messageId in
|
||
let chatController = context.sharedContext.makeChatController(context: context, chatLocation: .peer(id: peerId), subject: .message(id: .id(messageId), highlight: nil, timecode: nil, setupReply: false), botStart: nil, mode: .standard(.default), params: nil)
|
||
pushControllerImpl?(chatController)
|
||
}
|
||
)
|
||
|
||
let dataSignal = reloadPromise.get()
|
||
|> mapToSignal { _ -> Signal<[(peer: Peer?, peerId: PeerId, messages: [Message])], NoError> in
|
||
return SGDeletedMessages.getAllSavedDeletedMessages(postbox: context.account.postbox)
|
||
}
|
||
|
||
let signal = combineLatest(dataSignal, searchQueryPromise.get(), context.sharedContext.presentationData)
|
||
|> map { data, searchQuery, presentationData -> (ItemListControllerState, (ItemListNodeState, SavedDeletedListArguments)) in
|
||
let lang = presentationData.strings.baseLanguageCode
|
||
let title = lang == "ru" ? "Сохранённые удалённые" : "Saved Deleted"
|
||
let controllerState = ItemListControllerState(
|
||
presentationData: ItemListPresentationData(presentationData),
|
||
title: .text(title),
|
||
leftNavigationButton: nil,
|
||
rightNavigationButton: nil,
|
||
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
|
||
)
|
||
let allEntries = savedDeletedListEntries(data: data, lang: lang)
|
||
let entriesWithQuery = allEntries.map { entry -> SavedDeletedListEntry in
|
||
if case .search(let id, _) = entry { return .search(id: id, query: searchQuery) }
|
||
return entry
|
||
}
|
||
let entries = filterSavedDeletedListEntries(entriesWithQuery, by: searchQuery, lang: lang)
|
||
let listState = ItemListNodeState(
|
||
presentationData: ItemListPresentationData(presentationData),
|
||
entries: entries,
|
||
style: .blocks,
|
||
ensureVisibleItemTag: nil,
|
||
initialScrollToItem: nil
|
||
)
|
||
return (controllerState, (listState, arguments))
|
||
}
|
||
|
||
let controller = ItemListController(context: context, state: signal)
|
||
presentControllerImpl = { [weak controller] c, a in
|
||
controller?.present(c, in: PresentationContextType.window(PresentationSurfaceLevel.root), with: a)
|
||
}
|
||
pushControllerImpl = { [weak controller] c in
|
||
controller?.navigationController?.pushViewController(c, animated: true)
|
||
}
|
||
return controller
|
||
#else
|
||
return ViewController(navigationBarPresentationData: nil)
|
||
#endif
|
||
}
|
||
```
|
||
|
||
### `Swiftgram/SGDeletedMessages/Sources/SGDeletedMessages.swift` (полностью)
|
||
|
||
```swift
|
||
import Foundation
|
||
import Postbox
|
||
import SwiftSignalKit
|
||
import SGSimpleSettings
|
||
#if canImport(SGLogging)
|
||
import SGLogging
|
||
#endif
|
||
|
||
// Local constants to avoid circular dependency with TelegramCore (SyncCore_Namespaces).
|
||
// Namespaces.Message.Cloud = 0
|
||
private let messageNamespaceCloud: Int32 = 0
|
||
// Namespaces.Message.SavedDeleted = 1338
|
||
private let messageNamespaceSavedDeleted: Int32 = 1338
|
||
|
||
public struct SGDeletedMessages {
|
||
public static var showDeletedMessages: Bool {
|
||
get {
|
||
return SGSimpleSettings.shared.showDeletedMessages
|
||
}
|
||
set {
|
||
SGSimpleSettings.shared.showDeletedMessages = newValue
|
||
}
|
||
}
|
||
|
||
private static func savedDeletedId(for originalId: MessageId) -> MessageId {
|
||
return MessageId(peerId: originalId.peerId, namespace: messageNamespaceSavedDeleted, id: originalId.id)
|
||
}
|
||
|
||
/// AyuGram-style: create a local SavedDeleted snapshot (separate namespace) and return `true` if saved.
|
||
private static func saveSnapshotIfPossible(
|
||
originalId: MessageId,
|
||
transaction: Transaction,
|
||
shouldSave: ((MessageId, Message) -> Bool)?,
|
||
transformAttributes: ((Message, inout [MessageAttribute]) -> Void)?,
|
||
transformMedia: ((Message, [Media]) -> [Media])?
|
||
) -> Bool {
|
||
// If we're deleting an already-saved snapshot, don't re-save it.
|
||
if originalId.namespace == messageNamespaceSavedDeleted {
|
||
return false
|
||
}
|
||
|
||
guard let message = transaction.getMessage(originalId) else {
|
||
// No local copy -> can't save (AyuGram behavior).
|
||
return false
|
||
}
|
||
|
||
if let shouldSave, !shouldSave(originalId, message) {
|
||
return false
|
||
}
|
||
|
||
let snapshotId = savedDeletedId(for: originalId)
|
||
if transaction.messageExists(id: snapshotId) {
|
||
return true
|
||
}
|
||
|
||
let storeForwardInfo = message.forwardInfo.flatMap(StoreMessageForwardInfo.init)
|
||
var attributes = message.attributes
|
||
var hasDeletedAttribute = false
|
||
for attribute in attributes {
|
||
if let deletedAttribute = attribute as? SGDeletedMessageAttribute {
|
||
deletedAttribute.isDeleted = true
|
||
if deletedAttribute.originalText == nil {
|
||
deletedAttribute.originalText = message.text
|
||
}
|
||
deletedAttribute.originalNamespace = originalId.namespace
|
||
deletedAttribute.originalId = originalId.id
|
||
hasDeletedAttribute = true
|
||
break
|
||
}
|
||
}
|
||
if !hasDeletedAttribute {
|
||
attributes.append(SGDeletedMessageAttribute(isDeleted: true, originalText: message.text, originalNamespace: originalId.namespace, originalId: originalId.id))
|
||
}
|
||
|
||
transformAttributes?(message, &attributes)
|
||
|
||
let media: [Media]
|
||
if let transformMedia {
|
||
media = transformMedia(message, message.media)
|
||
} else {
|
||
media = message.media
|
||
}
|
||
|
||
// Important: this is a local-only snapshot, so we don't keep a globallyUniqueId
|
||
// (to avoid collisions with the original message).
|
||
let storeMessage = StoreMessage(
|
||
id: snapshotId,
|
||
customStableId: nil,
|
||
globallyUniqueId: nil,
|
||
groupingKey: message.groupingKey,
|
||
threadId: message.threadId,
|
||
timestamp: message.timestamp,
|
||
flags: StoreMessageFlags(message.flags),
|
||
tags: message.tags,
|
||
globalTags: message.globalTags,
|
||
localTags: message.localTags,
|
||
forwardInfo: storeForwardInfo,
|
||
authorId: message.author?.id,
|
||
text: message.text,
|
||
attributes: attributes,
|
||
media: media
|
||
)
|
||
let _ = transaction.addMessages([storeMessage], location: .UpperHistoryBlock)
|
||
#if canImport(SGLogging)
|
||
SGLogger.shared.log("SGDeletedMessages", "saveSnapshotIfPossible: saved snapshot \(snapshotId) for original \(originalId)")
|
||
#endif
|
||
return true
|
||
}
|
||
|
||
/// AyuGram-style: save snapshots (when possible).
|
||
/// Returns the set of message ids for which a snapshot exists (created or already present).
|
||
public static func saveSnapshots(
|
||
ids: [MessageId],
|
||
transaction: Transaction,
|
||
shouldSave: ((MessageId, Message) -> Bool)? = nil,
|
||
transformAttributes: ((Message, inout [MessageAttribute]) -> Void)? = nil,
|
||
transformMedia: ((Message, [Media]) -> [Media])? = nil
|
||
) -> Set<MessageId> {
|
||
guard showDeletedMessages, !ids.isEmpty else { return Set() }
|
||
|
||
var result = Set<MessageId>()
|
||
result.reserveCapacity(ids.count)
|
||
|
||
for id in ids {
|
||
if saveSnapshotIfPossible(originalId: id, transaction: transaction, shouldSave: shouldSave, transformAttributes: transformAttributes, transformMedia: transformMedia) {
|
||
result.insert(id)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
/// AyuGram-style: for delete-by-global-id pipelines, save snapshots for locally-present messages.
|
||
public static func saveSnapshotsForGlobalIds(
|
||
_ globalIds: [Int32],
|
||
transaction: Transaction,
|
||
shouldSave: ((MessageId, Message) -> Bool)? = nil,
|
||
transformAttributes: ((Message, inout [MessageAttribute]) -> Void)? = nil,
|
||
transformMedia: ((Message, [Media]) -> [Media])? = nil
|
||
) {
|
||
guard showDeletedMessages else { return }
|
||
for globalId in globalIds {
|
||
if let id = transaction.messageIdsForGlobalIds([globalId]).first {
|
||
_ = saveSnapshotIfPossible(originalId: id, transaction: transaction, shouldSave: shouldSave, transformAttributes: transformAttributes, transformMedia: transformMedia)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// AyuGram-style: save snapshots (when possible) and return ids to physically delete.
|
||
/// If the id itself is already a SavedDeleted snapshot, it will be deleted (no resave).
|
||
public static func saveSnapshotsAndReturnIdsToDelete(ids: [MessageId], transaction: Transaction) -> [MessageId] {
|
||
_ = saveSnapshots(ids: ids, transaction: transaction, shouldSave: nil, transformAttributes: nil, transformMedia: nil)
|
||
return ids
|
||
}
|
||
|
||
/// Check if message is marked as deleted (using extension like Nicegram)
|
||
public static func isMessageDeleted(_ message: Message) -> Bool {
|
||
return message.sgDeletedAttribute.isDeleted
|
||
}
|
||
|
||
/// Get original text from message attribute (for edit history, using extension like Nicegram)
|
||
public static func getOriginalText(_ message: Message) -> String? {
|
||
return message.sgDeletedAttribute.originalText
|
||
}
|
||
|
||
/// Returns the combined on-disk size (in bytes) of the saved-deleted-attachments folder.
|
||
public static func storageSizeBytes(mediaBoxBasePath: String) -> Int64 {
|
||
let attachmentsPath = mediaBoxBasePath + "/saved-deleted-attachments"
|
||
guard let enumerator = FileManager.default.enumerator(
|
||
at: URL(fileURLWithPath: attachmentsPath),
|
||
includingPropertiesForKeys: [.fileSizeKey],
|
||
options: [.skipsHiddenFiles]
|
||
) else { return 0 }
|
||
var total: Int64 = 0
|
||
for case let url as URL in enumerator {
|
||
total += Int64((try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0)
|
||
}
|
||
return total
|
||
}
|
||
|
||
/// Fetch all saved deleted messages grouped by peer.
|
||
public static func getAllSavedDeletedMessages(
|
||
postbox: Postbox
|
||
) -> Signal<[(peer: Peer?, peerId: PeerId, messages: [Message])], NoError> {
|
||
return postbox.transaction { transaction -> [(peer: Peer?, peerId: PeerId, messages: [Message])] in
|
||
var result: [(peer: Peer?, peerId: PeerId, messages: [Message])] = []
|
||
let allPeerIds = transaction.chatListGetAllPeerIds()
|
||
for peerId in allPeerIds {
|
||
var messages: [Message] = []
|
||
transaction.scanMessageAttributes(peerId: peerId, namespace: messageNamespaceSavedDeleted, limit: Int.max) { messageId, _ in
|
||
if let message = transaction.getMessage(messageId) {
|
||
messages.append(message)
|
||
}
|
||
return true
|
||
}
|
||
if !messages.isEmpty {
|
||
messages.sort { $0.timestamp > $1.timestamp }
|
||
let peer = transaction.getPeer(peerId)
|
||
result.append((peer: peer, peerId: peerId, messages: messages))
|
||
}
|
||
}
|
||
result.sort { ($0.messages.first?.timestamp ?? 0) > ($1.messages.first?.timestamp ?? 0) }
|
||
return result
|
||
}
|
||
}
|
||
|
||
/// Delete specific saved deleted messages by their IDs.
|
||
public static func deleteSavedDeletedMessages(
|
||
ids: [MessageId],
|
||
postbox: Postbox
|
||
) -> Signal<Void, NoError> {
|
||
return postbox.transaction { transaction -> Void in
|
||
if !ids.isEmpty {
|
||
transaction.deleteMessages(ids, forEachMedia: { _ in })
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Clear all saved deleted messages (actually delete them). Returns the number of deleted messages.
|
||
public static func clearAllDeletedMessages(
|
||
postbox: Postbox
|
||
) -> Signal<Int, NoError> {
|
||
return postbox.transaction { transaction -> Int in
|
||
// Remove saved attachment copies (AyuGram-style "Saved Attachments").
|
||
let attachmentsPath = postbox.mediaBox.basePath + "/saved-deleted-attachments"
|
||
let _ = try? FileManager.default.removeItem(atPath: attachmentsPath)
|
||
let _ = try? FileManager.default.createDirectory(atPath: attachmentsPath, withIntermediateDirectories: true, attributes: nil)
|
||
|
||
// All messages in the SavedDeleted namespace (1338) are snapshots — no attribute check needed.
|
||
var messageIdsToDelete: [MessageId] = []
|
||
let allPeerIds = transaction.chatListGetAllPeerIds()
|
||
for peerId in allPeerIds {
|
||
transaction.scanMessageAttributes(peerId: peerId, namespace: messageNamespaceSavedDeleted, limit: Int.max) { messageId, _ in
|
||
messageIdsToDelete.append(messageId)
|
||
return true
|
||
}
|
||
}
|
||
|
||
let count = messageIdsToDelete.count
|
||
if !messageIdsToDelete.isEmpty {
|
||
transaction.deleteMessages(messageIdsToDelete, forEachMedia: { _ in })
|
||
}
|
||
|
||
return count
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Ответ на удалённое сообщение: цитата оформляется сущностью `.Pre`
|
||
|
||
**Смысл:** если ответ идёт на сообщение с признаком «удалённое» (`sgDeletedAttribute.isDeleted`), вместо обычного `ReplyMessageAttribute` текст исходного сообщения вставляется **перед** вашим текстом с переводом строки, а диапазон цитаты помечается `MessageTextEntity` с типом `.Pre(language: nil)` (в клиентах Telegram это отображается как моноширинный / «блок кода» блок — визуальное выделение цитаты).
|
||
|
||
### `submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift` — полный фрагмент `if let replyToMessageId = replyToMessageId { ... }`
|
||
|
||
```swift
|
||
if let replyToMessageId = replyToMessageId {
|
||
#if canImport(SGDeletedMessages)
|
||
let useDeletedCitation: Bool = {
|
||
if let replyMessage = transaction.getMessage(replyToMessageId.messageId) {
|
||
return replyMessage.sgDeletedAttribute.isDeleted
|
||
}
|
||
return false
|
||
}()
|
||
#else
|
||
let useDeletedCitation = false
|
||
#endif
|
||
if useDeletedCitation {
|
||
#if canImport(SGDeletedMessages)
|
||
if let replyMessage = transaction.getMessage(replyToMessageId.messageId) {
|
||
let quoteText = replyMessage.sgDeletedAttribute.originalText ?? replyMessage.text
|
||
let citationPrefix = quoteText + "\n"
|
||
effectiveText = citationPrefix + text
|
||
let offset = citationPrefix.count
|
||
let citationEntities = [MessageTextEntity(range: 0..<offset, type: .Pre(language: nil))]
|
||
var foundEntities = false
|
||
for i in attributes.indices {
|
||
if let entityAttr = attributes[i] as? TextEntitiesMessageAttribute {
|
||
let shifted = entityAttr.entities.map { MessageTextEntity(range: $0.range.lowerBound + offset ..< $0.range.upperBound + offset, type: $0.type) }
|
||
attributes[i] = TextEntitiesMessageAttribute(entities: citationEntities + shifted)
|
||
foundEntities = true
|
||
break
|
||
}
|
||
}
|
||
if !foundEntities {
|
||
attributes.append(TextEntitiesMessageAttribute(entities: citationEntities))
|
||
}
|
||
}
|
||
#endif
|
||
} else {
|
||
var threadMessageId: MessageId?
|
||
var quote = replyToMessageId.quote
|
||
let isQuote = quote != nil
|
||
if let replyMessage = transaction.getMessage(replyToMessageId.messageId) {
|
||
if replyMessage.id.namespace == Namespaces.Message.Cloud, let threadId = replyMessage.threadId {
|
||
threadMessageId = MessageId(peerId: replyMessage.id.peerId, namespace: Namespaces.Message.Cloud, id: Int32(clamping: threadId))
|
||
}
|
||
if quote == nil, replyToMessageId.messageId.peerId != peerId {
|
||
let nsText = replyMessage.text as NSString
|
||
var replyMedia: Media?
|
||
for m in replyMessage.media {
|
||
switch m {
|
||
case _ as TelegramMediaImage, _ as TelegramMediaFile:
|
||
replyMedia = m
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
quote = EngineMessageReplyQuote(text: replyMessage.text, offset: nil, entities: messageTextEntitiesInRange(entities: replyMessage.textEntitiesAttribute?.entities ?? [], range: NSRange(location: 0, length: nsText.length), onlyQuoteable: true), media: replyMedia)
|
||
}
|
||
}
|
||
attributes.append(ReplyMessageAttribute(messageId: replyToMessageId.messageId, threadMessageId: threadMessageId, quote: quote, isQuote: isQuote, todoItemId: replyToMessageId.todoItemId))
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Добавление «чужих» подарков в свой профиль (локально, только у вас)
|
||
|
||
**Смысл:** в профиле **другого** пользователя по долгому нажатию на уникальный подарок появляется пункт «Добавить в свой профиль (только вы увидите)». Slug подарка сохраняется в `SGSimpleSettings.customProfileGiftSlugs` и `customProfileGiftShownSlugs`; в своём профиле можно удалить запись из списка.
|
||
|
||
### Фрагмент `submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoGiftsPaneNode.swift`
|
||
|
||
```swift
|
||
#if canImport(SGSimpleSettings)
|
||
let isMyProfile = self.peerId == self.context.account.peerId
|
||
if !isMyProfile, case let .unique(uniqueGift) = gift.gift {
|
||
let slug = uniqueGift.slug
|
||
let alreadyAdded = SGSimpleSettings.shared.customProfileGiftSlugs.contains(slug)
|
||
if !alreadyAdded {
|
||
let addTitle = presentationData.strings.baseLanguageCode == "ru" ? "Добавить в свой профиль (только вы увидите)" : "Add to my profile (only you will see)"
|
||
items.append(.action(ContextMenuActionItem(text: addTitle, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Add"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in
|
||
var slugs = SGSimpleSettings.shared.customProfileGiftSlugs
|
||
if !slugs.contains(slug) {
|
||
slugs.append(slug)
|
||
SGSimpleSettings.shared.customProfileGiftSlugs = slugs
|
||
}
|
||
var shown = SGSimpleSettings.shared.customProfileGiftShownSlugs
|
||
if !shown.contains(slug) {
|
||
shown.append(slug)
|
||
SGSimpleSettings.shared.customProfileGiftShownSlugs = shown
|
||
}
|
||
self?.giftsListView.triggerCustomShownRefresh()
|
||
c?.dismiss(completion: nil)
|
||
})))
|
||
items.append(.separator)
|
||
}
|
||
} else if isMyProfile, case let .slug(slug) = gift.reference {
|
||
let removeTitle = presentationData.strings.baseLanguageCode == "ru" ? "Удалить из профиля" : "Remove from profile"
|
||
items.append(.action(ContextMenuActionItem(text: removeTitle, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { [weak self] _, _ in
|
||
var slugs = SGSimpleSettings.shared.customProfileGiftSlugs
|
||
slugs.removeAll { $0 == slug }
|
||
SGSimpleSettings.shared.customProfileGiftSlugs = slugs
|
||
var shown = SGSimpleSettings.shared.customProfileGiftShownSlugs
|
||
shown.removeAll { $0 == slug }
|
||
SGSimpleSettings.shared.customProfileGiftShownSlugs = shown
|
||
self?.giftsListView.triggerCustomShownRefresh()
|
||
})))
|
||
items.append(.separator)
|
||
}
|
||
#endif
|
||
```
|
||
|
||
### Ключи и свойства в `Swiftgram/SGSimpleSettings/Sources/SimpleSettings.swift`
|
||
|
||
```swift
|
||
case customProfileGiftSlugs
|
||
case customProfileGiftShownSlugs
|
||
case pinnedCustomProfileGiftSlugs
|
||
case localProfileGiftStatusFileId
|
||
```
|
||
|
||
```swift
|
||
@UserDefault(key: Keys.customProfileGiftSlugs.rawValue)
|
||
public var customProfileGiftSlugs: [String]
|
||
|
||
/// Slugs of custom gifts that are shown on profile (worn). Persisted locally so "Show/Hide" state doesn't reset.
|
||
@UserDefault(key: Keys.customProfileGiftShownSlugs.rawValue)
|
||
public var customProfileGiftShownSlugs: [String]
|
||
|
||
@UserDefault(key: Keys.pinnedCustomProfileGiftSlugs.rawValue)
|
||
public var pinnedCustomProfileGiftSlugs: [String]
|
||
|
||
/// When set, show this fileId as emoji status on my profile (so gift status doesn't disappear).
|
||
@UserDefault(key: Keys.localProfileGiftStatusFileId.rawValue)
|
||
public var localProfileGiftStatusFileId: String
|
||
```
|
||
|
||
---
|
||
|
||
*Файл: `GLEGram-features.md` в корне репозитория.*
|