Files
Leeksov 4647310322 GLEGram 12.5 — Initial public release
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.
2026-04-06 09:48:12 +03:00

440 lines
22 KiB
Swift
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// MARK: Swiftgram Profile cover: photo/video as profile background (visible only to you)
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramPresentationData
import ItemListUI
import PresentationDataUtils
import AccountContext
import SGSimpleSettings
import AVFoundation
import ObjectiveC
import UniformTypeIdentifiers
private var profileCoverImagePickerDelegateKey: UInt8 = 0
private var profileCoverVideoPickerDelegateKey: UInt8 = 0
private var profileCoverDocumentPickerDelegateKey: UInt8 = 0
private let profileCoverSubdirectory = "ProfileCover"
private let profileCoverPhotoName = "cover.jpg"
private let profileCoverVideoName = "cover.mov"
/// Post when profile cover is saved so the profile screen can refresh the cover.
public extension Notification.Name {
static let SGProfileCoverDidChange = Notification.Name("SGProfileCoverDidChange")
}
private func profileCoverDirectoryURL() -> URL {
let support: URL
if #available(iOS 16.0, *) {
support = URL.applicationSupportDirectory
} else {
guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
fatalError("Application Support not available")
}
support = dir
}
return support.appendingPathComponent(profileCoverSubdirectory, isDirectory: true)
}
private func saveProfileCoverPhoto(from image: UIImage) throws -> String {
let fm = FileManager.default
let dir = profileCoverDirectoryURL()
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent(profileCoverPhotoName)
try? fm.removeItem(at: url)
guard let data = image.jpegData(compressionQuality: 0.85) else { throw NSError(domain: "ProfileCover", code: 1, userInfo: nil) }
try data.write(to: url)
return url.path
}
private func saveProfileCoverVideo(from sourceURL: URL) throws -> String {
let fm = FileManager.default
let dir = profileCoverDirectoryURL()
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
let dest = dir.appendingPathComponent(profileCoverVideoName)
try? fm.removeItem(at: dest)
try fm.copyItem(at: sourceURL, to: dest)
return dest.path
}
private func removeProfileCoverMedia() {
let fm = FileManager.default
let dir = profileCoverDirectoryURL()
try? fm.removeItem(at: dir.appendingPathComponent(profileCoverPhotoName))
try? fm.removeItem(at: dir.appendingPathComponent(profileCoverVideoName))
}
// MARK: - Preview row (image/video or placeholder)
private final class ProfileCoverPreviewItem: ListViewItem, ItemListItem {
let presentationData: ItemListPresentationData
let sectionId: ItemListSectionId
let coverPath: String
let isVideo: Bool
init(presentationData: ItemListPresentationData, sectionId: ItemListSectionId, coverPath: String, isVideo: Bool) {
self.presentationData = presentationData
self.sectionId = sectionId
self.coverPath = coverPath
self.isVideo = isVideo
}
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = ProfileCoverPreviewItemNode()
let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, { return (nil, { _ in apply(.None) }) })
}
}
}
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
guard let nodeValue = node() as? ProfileCoverPreviewItemNode else { return completion(ListViewItemNodeLayout(contentSize: .zero, insets: .zero), { _ in }) }
let makeLayout = nodeValue.asyncLayout()
async {
let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
Queue.mainQueue().async {
completion(layout, { _ in apply(animation) })
}
}
}
}
var selectable: Bool { false }
static func == (lhs: ProfileCoverPreviewItem, rhs: ProfileCoverPreviewItem) -> Bool {
lhs.coverPath == rhs.coverPath && lhs.isVideo == rhs.isVideo && lhs.sectionId == rhs.sectionId
}
}
private final class ProfileCoverPreviewItemNode: ListViewItemNode {
private let backgroundNode: ASDisplayNode
private let imageNode: ASImageNode
private let placeholderLabel: ImmediateTextNode
private var item: ProfileCoverPreviewItem?
init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.imageNode = ASImageNode()
self.imageNode.contentMode = .scaleAspectFill
self.imageNode.clipsToBounds = true
self.placeholderLabel = ImmediateTextNode()
super.init(layerBacked: false)
addSubnode(backgroundNode)
addSubnode(imageNode)
addSubnode(placeholderLabel)
}
func asyncLayout() -> (ProfileCoverPreviewItem, ListViewItemLayoutParams, ItemListNeighbors) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) {
return { item, params, neighbors in
let height: CGFloat = 180.0
let contentSize = CGSize(width: params.width, height: height)
let insets = itemListNeighborsGroupedInsets(neighbors, params)
let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets)
return (layout, { [weak self] animation in
guard let self else { return }
self.item = item
self.backgroundNode.backgroundColor = item.presentationData.theme.list.itemBlocksBackgroundColor
self.backgroundNode.frame = CGRect(origin: .zero, size: contentSize)
self.imageNode.frame = CGRect(x: params.leftInset, y: 0, width: params.width - params.leftInset - params.rightInset, height: height)
self.imageNode.isHidden = item.coverPath.isEmpty
if !item.coverPath.isEmpty {
if item.isVideo {
self.loadVideoThumbnail(path: item.coverPath)
} else {
self.imageNode.image = UIImage(contentsOfFile: item.coverPath)
}
} else {
self.imageNode.image = nil
self.placeholderLabel.attributedText = NSAttributedString(string: item.presentationData.strings.baseLanguageCode == "ru" ? "Обложка не выбрана" : "No cover selected", font: Font.regular(15), textColor: item.presentationData.theme.list.itemSecondaryTextColor)
let labelSize = self.placeholderLabel.updateLayout(CGSize(width: params.width - 32, height: 60))
self.placeholderLabel.frame = CGRect(x: (params.width - labelSize.width) / 2, y: (height - labelSize.height) / 2, width: labelSize.width, height: labelSize.height)
self.placeholderLabel.isHidden = false
}
self.placeholderLabel.isHidden = !item.coverPath.isEmpty
})
}
}
private func loadVideoThumbnail(path: String) {
let url = URL(fileURLWithPath: path)
let asset = AVAsset(url: url)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 400, height: 400)
generator.generateCGImagesAsynchronously(forTimes: [NSValue(time: .zero)]) { [weak self] _, cgImage, _, _, _ in
Queue.mainQueue().async {
self?.imageNode.image = cgImage.flatMap { UIImage(cgImage: $0) }
}
}
}
}
// MARK: - Controller
private enum ProfileCoverEntry: ItemListNodeEntry {
case previewHeader(id: Int, text: String)
case preview(id: Int, path: String, isVideo: Bool)
case mediaHeader(id: Int, text: String)
case uploadPhoto(id: Int, text: String)
case setVideo(id: Int, text: String)
case uploadFromFiles(id: Int, text: String)
case deleteMedia(id: Int, text: String)
var id: Int { stableId }
var section: ItemListSectionId {
switch self {
case .previewHeader, .preview: return 0
default: return 1
}
}
var stableId: Int {
switch self {
case .previewHeader(let i, _), .preview(let i, _, _), .mediaHeader(let i, _), .uploadPhoto(let i, _), .setVideo(let i, _), .uploadFromFiles(let i, _), .deleteMedia(let i, _): return i
}
}
static func < (lhs: ProfileCoverEntry, rhs: ProfileCoverEntry) -> Bool { lhs.stableId < rhs.stableId }
static func == (lhs: ProfileCoverEntry, rhs: ProfileCoverEntry) -> Bool {
switch (lhs, rhs) {
case let (.previewHeader(a, t1), .previewHeader(b, t2)): return a == b && t1 == t2
case let (.preview(a, p1, v1), .preview(b, p2, v2)): return a == b && p1 == p2 && v1 == v2
case let (.mediaHeader(a, t1), .mediaHeader(b, t2)): return a == b && t1 == t2
case let (.uploadPhoto(a, t1), .uploadPhoto(b, t2)): return a == b && t1 == t2
case let (.setVideo(a, t1), .setVideo(b, t2)): return a == b && t1 == t2
case let (.uploadFromFiles(a, t1), .uploadFromFiles(b, t2)): return a == b && t1 == t2
case let (.deleteMedia(a, t1), .deleteMedia(b, t2)): return a == b && t1 == t2
default: return false
}
}
func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let args = arguments as! ProfileCoverArguments
switch self {
case .previewHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case .preview(_, let path, let isVideo):
return ProfileCoverPreviewItem(presentationData: presentationData, sectionId: self.section, coverPath: path, isVideo: isVideo)
case .mediaHeader(_, let text):
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
case .uploadPhoto(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.uploadPhoto() })
case .setVideo(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.setVideo() })
case .uploadFromFiles(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.uploadFromFiles() })
case .deleteMedia(_, let text):
return ItemListActionItem(presentationData: presentationData, title: text, kind: .destructive, alignment: .natural, sectionId: self.section, style: .blocks, action: { args.deleteMedia() })
}
}
}
private final class ProfileCoverArguments {
let uploadPhoto: () -> Void
let setVideo: () -> Void
let uploadFromFiles: () -> Void
let deleteMedia: () -> Void
init(uploadPhoto: @escaping () -> Void, setVideo: @escaping () -> Void, uploadFromFiles: @escaping () -> Void, deleteMedia: @escaping () -> Void) {
self.uploadPhoto = uploadPhoto
self.setVideo = setVideo
self.uploadFromFiles = uploadFromFiles
self.deleteMedia = deleteMedia
}
}
private func profileCoverEntries(presentationData: PresentationData, path: String, isVideo: Bool) -> [ProfileCoverEntry] {
let lang = presentationData.strings.baseLanguageCode
var list: [ProfileCoverEntry] = []
var id = 0
list.append(.previewHeader(id: id, text: lang == "ru" ? "ПРЕДПРОСМОТР" : "PREVIEW"))
id += 1
list.append(.preview(id: id, path: path, isVideo: isVideo))
id += 1
list.append(.mediaHeader(id: id, text: lang == "ru" ? "МЕДИА" : "MEDIA"))
id += 1
list.append(.uploadPhoto(id: id, text: lang == "ru" ? "Загрузить фото" : "Upload photo"))
id += 1
list.append(.setVideo(id: id, text: lang == "ru" ? "Установить видео" : "Set video"))
id += 1
list.append(.uploadFromFiles(id: id, text: lang == "ru" ? "Загрузить из файлов" : "Load from files"))
id += 1
list.append(.deleteMedia(id: id, text: lang == "ru" ? "Удалить медиа" : "Delete media"))
return list
}
public func ProfileCoverController(context: AccountContext) -> ViewController {
let reloadPromise = ValuePromise(true, ignoreRepeated: false)
var presentImagePicker: (() -> Void)?
var presentVideoPicker: (() -> Void)?
var presentDocumentPicker: (() -> Void)?
var backAction: (() -> Void)?
let arguments = ProfileCoverArguments(
uploadPhoto: { presentImagePicker?() },
setVideo: { presentVideoPicker?() },
uploadFromFiles: { presentDocumentPicker?() },
deleteMedia: {
removeProfileCoverMedia()
SGSimpleSettings.shared.profileCoverMediaPath = ""
SGSimpleSettings.shared.profileCoverIsVideo = false
SGSimpleSettings.shared.synchronizeShared()
reloadPromise.set(true)
}
)
let signal = combineLatest(reloadPromise.get(), context.sharedContext.presentationData)
|> map { _, presentationData -> (ItemListControllerState, (ItemListNodeState, ProfileCoverArguments)) in
let path = SGSimpleSettings.shared.profileCoverMediaPath
let isVideo = SGSimpleSettings.shared.profileCoverIsVideo
let lang = presentationData.strings.baseLanguageCode
let controllerState = ItemListControllerState(
presentationData: ItemListPresentationData(presentationData),
title: .text(lang == "ru" ? "Обложка профиля" : "Profile cover"),
leftNavigationButton: ItemListNavigationButton(content: .text(presentationData.strings.Common_Back), style: .regular, enabled: true, action: { backAction?() }),
rightNavigationButton: nil,
backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back)
)
let entries = profileCoverEntries(presentationData: presentationData, path: path, isVideo: isVideo)
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)
backAction = { [weak controller] in controller?.dismiss() }
presentImagePicker = { [weak controller] in
guard let controller = controller else { return }
// UIImagePickerController надёжнее PHPicker при выборе из галереи (iOS 16+)
let picker = UIImagePickerController()
picker.sourceType = .photoLibrary
picker.mediaTypes = ["public.image"]
let delegate = ProfileCoverImagePickerDelegate(
onPick: { image in
do {
let savedPath = try saveProfileCoverPhoto(from: image)
SGSimpleSettings.shared.profileCoverMediaPath = savedPath
SGSimpleSettings.shared.profileCoverIsVideo = false
SGSimpleSettings.shared.synchronizeShared()
reloadPromise.set(true)
NotificationCenter.default.post(name: .SGProfileCoverDidChange, object: nil)
} catch {}
}
)
picker.delegate = delegate
objc_setAssociatedObject(picker, &profileCoverImagePickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
}
presentVideoPicker = { [weak controller] in
guard let controller = controller else { return }
let picker = UIImagePickerController()
picker.sourceType = .photoLibrary
picker.mediaTypes = ["public.movie"]
picker.videoMaximumDuration = 30
let delegate = ProfileCoverVideoPickerDelegate(
onPick: { url in
let needsStop = url.startAccessingSecurityScopedResource()
defer { if needsStop { url.stopAccessingSecurityScopedResource() } }
do {
let savedPath = try saveProfileCoverVideo(from: url)
SGSimpleSettings.shared.profileCoverMediaPath = savedPath
SGSimpleSettings.shared.profileCoverIsVideo = true
SGSimpleSettings.shared.synchronizeShared()
reloadPromise.set(true)
NotificationCenter.default.post(name: .SGProfileCoverDidChange, object: nil)
} catch {}
}
)
picker.delegate = delegate
objc_setAssociatedObject(picker, &profileCoverVideoPickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
}
presentDocumentPicker = { [weak controller] in
guard let controller = controller else { return }
let onPick: (URL) -> Void = { url in
// With asCopy: true the file is in app sandbox; only some sources need security-scoped access
let needsStop = url.startAccessingSecurityScopedResource()
defer { if needsStop { url.stopAccessingSecurityScopedResource() } }
let ext = url.pathExtension.lowercased()
let isVideo = ["mov", "mp4", "m4v"].contains(ext)
if isVideo {
do {
let savedPath = try saveProfileCoverVideo(from: url)
SGSimpleSettings.shared.profileCoverMediaPath = savedPath
SGSimpleSettings.shared.profileCoverIsVideo = true
SGSimpleSettings.shared.synchronizeShared()
reloadPromise.set(true)
NotificationCenter.default.post(name: .SGProfileCoverDidChange, object: nil)
} catch {}
} else {
guard let data = try? Data(contentsOf: url), let image = UIImage(data: data) else { return }
do {
let savedPath = try saveProfileCoverPhoto(from: image)
SGSimpleSettings.shared.profileCoverMediaPath = savedPath
SGSimpleSettings.shared.profileCoverIsVideo = false
SGSimpleSettings.shared.synchronizeShared()
reloadPromise.set(true)
NotificationCenter.default.post(name: .SGProfileCoverDidChange, object: nil)
} catch {}
}
}
if #available(iOS 14.0, *) {
let types: [UTType] = [.image, .movie]
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
let delegate = ProfileCoverDocumentPickerDelegate(onPick: onPick)
picker.delegate = delegate
objc_setAssociatedObject(picker, &profileCoverDocumentPickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
} else {
let picker = UIDocumentPickerViewController(documentTypes: ["public.image", "public.movie"], in: .import)
let delegate = ProfileCoverDocumentPickerDelegate(onPick: onPick)
picker.delegate = delegate
objc_setAssociatedObject(picker, &profileCoverDocumentPickerDelegateKey, delegate, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
controller.present(picker, animated: true)
}
}
return controller
}
private final class ProfileCoverImagePickerDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onPick: (UIImage) -> Void
init(onPick: @escaping (UIImage) -> Void) { self.onPick = onPick }
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
picker.dismiss(animated: true)
guard let image = info[.originalImage] as? UIImage else { return }
onPick(image)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
}
private final class ProfileCoverVideoPickerDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let onPick: (URL) -> Void
init(onPick: @escaping (URL) -> Void) { self.onPick = onPick }
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
picker.dismiss(animated: true)
guard let url = info[.mediaURL] as? URL else { return }
onPick(url)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
}
private final class ProfileCoverDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate {
let onPick: (URL) -> Void
init(onPick: @escaping (URL) -> Void) { self.onPick = onPick }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
onPick(url)
}
}