Update Ghostgram features

This commit is contained in:
ichmagmaus 812
2026-03-07 18:15:32 +01:00
parent 1a3303b059
commit 24a7ec39d9
902 changed files with 148302 additions and 62355 deletions
+43
View File
@@ -0,0 +1,43 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGProUI",
module_name = "SGProUI",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//Swiftgram/SGKeychainBackupManager:SGKeychainBackupManager",
"//Swiftgram/SGItemListUI:SGItemListUI",
"//Swiftgram/SGLogging:SGLogging",
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//Swiftgram/SGStrings:SGStrings",
"//Swiftgram/SGAPI:SGAPI",
"//Swiftgram/SGAPIToken:SGAPIToken",
"//Swiftgram/SGSwiftUI:SGSwiftUI",
#
"//submodules/SettingsUI:SettingsUI",
#
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/Display:Display",
"//submodules/Postbox:Postbox",
"//submodules/TelegramCore:TelegramCore",
"//submodules/MtProtoKit:MtProtoKit",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/TelegramUIPreferences:TelegramUIPreferences",
"//submodules/ItemListUI:ItemListUI",
"//submodules/PresentationDataUtils:PresentationDataUtils",
"//submodules/OverlayStatusController:OverlayStatusController",
"//submodules/AccountContext:AccountContext",
"//submodules/AppBundle:AppBundle",
"//submodules/TelegramUI/Components/Settings/PeerNameColorScreen",
"//submodules/UndoUI:UndoUI",
],
visibility = [
"//visibility:public",
],
)
@@ -0,0 +1,147 @@
import Foundation
import SwiftUI
import SGSwiftUI
import SGStrings
import SGSimpleSettings
import LegacyUI
import Display
import TelegramPresentationData
import AccountContext
struct AppBadge: Identifiable, Hashable {
let id: UUID = .init()
let displayName: String
let assetName: String
}
func getAvailableAppBadges() -> [AppBadge] {
var appBadges: [AppBadge] = [
.init(displayName: "Default", assetName: "Components/AppBadge"),
.init(displayName: "Sky", assetName: "SkyAppBadge"),
.init(displayName: "Night", assetName: "NightAppBadge"),
.init(displayName: "Titanium", assetName: "TitaniumAppBadge"),
.init(displayName: "Pro", assetName: "ProAppBadge"),
.init(displayName: "Day", assetName: "DayAppBadge"),
]
if SGSimpleSettings.shared.duckyAppIconAvailable {
appBadges.append(.init(displayName: "Ducky", assetName: "DuckyAppBadge"))
}
appBadges += [
.init(displayName: "Sparkling", assetName: "SparklingAppBadge"),
]
return appBadges
}
@available(iOS 14.0, *)
struct AppBadgeSettingsView: View {
weak var wrapperController: LegacyController?
let context: AccountContext
@Environment(\.colorScheme) var colorScheme
@Environment(\.lang) var lang: String
@State var selectedBadge: AppBadge
let availableAppBadges: [AppBadge] = getAvailableAppBadges()
private enum Layout {
static let cardCorner: CGFloat = 12
static let imageHeight: CGFloat = 56
static let columnSpacing: CGFloat = 16
static let horizontalPadding: CGFloat = 20
}
private var columns: [SwiftUI.GridItem] {
Array(repeating: GridItem(.flexible(), spacing: Layout.columnSpacing), count: 2)
}
init(wrapperController: LegacyController?, context: AccountContext) {
self.wrapperController = wrapperController
self.context = context
for badge in self.availableAppBadges {
if badge.assetName == SGSimpleSettings.shared.customAppBadge {
self._selectedBadge = State(initialValue: badge)
return
}
}
self._selectedBadge = State(initialValue: self.availableAppBadges.first!)
}
private func onSelectBadge(_ badge: AppBadge) {
self.selectedBadge = badge
let image = UIImage(bundleImageName: selectedBadge.assetName) ?? UIImage(bundleImageName: "Components/AppBadge")
DispatchQueue.main.async {
SGSimpleSettings.shared.customAppBadge = selectedBadge.assetName
self.context.sharedContext.mainWindow?.badgeView.image = image
}
}
var body: some View {
ScrollView {
LazyVGrid(columns: columns, alignment: .center, spacing: Layout.columnSpacing) {
ForEach(availableAppBadges) { badge in
Button {
onSelectBadge(badge)
} label: {
VStack(spacing: 8) {
Image(badge.assetName)
.resizable()
.scaledToFit()
.frame(height: Layout.imageHeight)
.accessibilityHidden(true)
Text(badge.displayName)
.font(.footnote)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color(colorScheme == .dark ? .secondarySystemBackground : .systemBackground))
.cornerRadius(Layout.cardCorner)
.overlay(
RoundedRectangle(cornerRadius: Layout.cardCorner)
.stroke(selectedBadge == badge ? Color.accentColor : Color.clear, lineWidth: 2)
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, Layout.horizontalPadding)
.padding(.vertical, 24)
}
.background(Color(colorScheme == .light ? .secondarySystemBackground : .systemBackground).ignoresSafeArea())
}
}
@available(iOS 14.0, *)
public func sgAppBadgeSettingsController(context: AccountContext, presentationData: PresentationData? = nil) -> ViewController {
let theme = presentationData?.theme ?? (UITraitCollection.current.userInterfaceStyle == .dark ? defaultDarkColorPresentationTheme : defaultPresentationTheme)
let strings = presentationData?.strings ?? defaultPresentationStrings
let legacyController = LegacySwiftUIController(
presentation: .navigation,
theme: theme,
strings: strings
)
legacyController.statusBar.statusBarStyle = theme.rootController
.statusBarStyle.style
legacyController.title = "AppBadge.Title".i18n(strings.baseLanguageCode)
let swiftUIView = SGSwiftUIView<AppBadgeSettingsView>(
legacyController: legacyController,
manageSafeArea: true,
content: {
AppBadgeSettingsView(wrapperController: legacyController, context: context)
}
)
let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true)
legacyController.bind(controller: controller)
return legacyController
}
@@ -0,0 +1,181 @@
import Foundation
import SwiftUI
import SGSwiftUI
import SGStrings
import SGSimpleSettings
import LegacyUI
import Display
import TelegramPresentationData
@available(iOS 13.0, *)
struct MessageFilterKeywordInputFieldModifier: ViewModifier {
@Binding var newKeyword: String
let onAdd: () -> Void
func body(content: Content) -> some View {
if #available(iOS 15.0, *) {
content
.submitLabel(.return)
.submitScope(false) // TODO(swiftgram): Keyboard still closing
.interactiveDismissDisabled()
.onSubmit {
onAdd()
}
} else {
content
}
}
}
@available(iOS 13.0, *)
struct MessageFilterKeywordInputView: View {
@Environment(\.lang) var lang: String
@Binding var newKeyword: String
let onAdd: () -> Void
var body: some View {
HStack {
TextField("MessageFilter.InputPlaceholder".i18n(lang), text: $newKeyword)
.autocorrectionDisabled(true)
.autocapitalization(.none)
.keyboardType(.default)
.modifier(MessageFilterKeywordInputFieldModifier(newKeyword: $newKeyword, onAdd: onAdd))
Button(action: onAdd) {
Image(systemName: "plus.circle.fill")
.foregroundColor(newKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .secondary : .accentColor)
.imageScale(.large)
}
.disabled(newKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.buttonStyle(PlainButtonStyle())
}
}
}
@available(iOS 13.0, *)
struct MessageFilterView: View {
weak var wrapperController: LegacyController?
@Environment(\.lang) var lang: String
@State private var newKeyword: String = ""
@State private var keywords: [String] {
didSet {
SGSimpleSettings.shared.messageFilterKeywords = keywords
}
}
init(wrapperController: LegacyController?) {
self.wrapperController = wrapperController
_keywords = State(initialValue: SGSimpleSettings.shared.messageFilterKeywords)
}
var bodyContent: some View {
List {
Section {
// Icon and title
VStack(spacing: 8) {
Image(systemName: "nosign.app.fill")
.font(.system(size: 50))
.foregroundColor(.secondary)
Text("MessageFilter.Title".i18n(lang))
.font(.title)
.bold()
Text("MessageFilter.SubTitle".i18n(lang))
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.listRowInsets(EdgeInsets())
}
Section {
MessageFilterKeywordInputView(newKeyword: $newKeyword, onAdd: addKeyword)
}
Section(header: Text("MessageFilter.Keywords.Title".i18n(lang))) {
ForEach(keywords.reversed(), id: \.self) { keyword in
Text(keyword)
}
.onDelete { indexSet in
let originalIndices = IndexSet(
indexSet.map { keywords.count - 1 - $0 }
)
deleteKeywords(at: originalIndices)
}
}
}
.tgNavigationBackButton(wrapperController: wrapperController)
}
var body: some View {
NavigationView {
if #available(iOS 14.0, *) {
bodyContent
.toolbar {
EditButton()
}
} else {
bodyContent
}
}
}
private func addKeyword() {
let trimmedKeyword = newKeyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedKeyword.isEmpty else { return }
let keywordExists = keywords.contains {
$0 == trimmedKeyword
}
guard !keywordExists else {
return
}
withAnimation {
keywords.append(trimmedKeyword)
}
newKeyword = ""
}
private func deleteKeywords(at offsets: IndexSet) {
withAnimation {
keywords.remove(atOffsets: offsets)
}
}
}
@available(iOS 13.0, *)
public func sgMessageFilterController(presentationData: PresentationData? = nil) -> ViewController {
let theme = presentationData?.theme ?? (UITraitCollection.current.userInterfaceStyle == .dark ? defaultDarkColorPresentationTheme : defaultPresentationTheme)
let strings = presentationData?.strings ?? defaultPresentationStrings
let legacyController = LegacySwiftUIController(
presentation: .navigation,
theme: theme,
strings: strings
)
// Status bar color will break if theme changed
legacyController.statusBar.statusBarStyle = theme.rootController
.statusBarStyle.style
legacyController.displayNavigationBar = false
let swiftUIView = SGSwiftUIView<MessageFilterView>(
legacyController: legacyController,
content: {
MessageFilterView(wrapperController: legacyController)
}
)
let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true)
legacyController.bind(controller: controller)
return legacyController
}
+189
View File
@@ -0,0 +1,189 @@
import Foundation
import UniformTypeIdentifiers
import SGItemListUI
import UndoUI
import AccountContext
import Display
import TelegramCore
import Postbox
import ItemListUI
import SwiftSignalKit
import TelegramPresentationData
import PresentationDataUtils
import TelegramUIPreferences
import SettingsUI
// Optional
import SGSimpleSettings
import SGLogging
private enum SGProControllerSection: Int32, SGItemListSection {
case base
case appearance
case notifications
case footer
}
private enum SGProDisclosureLink: String {
case sessionBackupManager
case messageFilter
case appIcons
case appBages
}
private enum SGProToggles: String {
case inputToolbar
}
private enum SGProOneFromManySetting: String {
case pinnedMessageNotifications
case mentionsAndRepliesNotifications
}
private enum SGProAction {
case resetIAP
}
private typealias SGProControllerEntry = SGItemListUIEntry<SGProControllerSection, SGProToggles, AnyHashable, SGProOneFromManySetting, SGProDisclosureLink, SGProAction>
private func SGProControllerEntries(presentationData: PresentationData) -> [SGProControllerEntry] {
var entries: [SGProControllerEntry] = []
let lang = presentationData.strings.baseLanguageCode
let id = SGItemListCounter()
entries.append(.disclosure(id: id.count, section: .base, link: .sessionBackupManager, text: "SessionBackup.Title".i18n(lang)))
entries.append(.disclosure(id: id.count, section: .base, link: .messageFilter, text: "MessageFilter.Title".i18n(lang)))
entries.append(.toggle(id: id.count, section: .base, settingName: .inputToolbar, value: SGSimpleSettings.shared.inputToolbar, text: "InputToolbar.Title".i18n(lang), enabled: true))
entries.append(.header(id: id.count, section: .notifications, text: presentationData.strings.Notifications_Title.uppercased(), badge: nil))
entries.append(.oneFromManySelector(id: id.count, section: .notifications, settingName: .pinnedMessageNotifications, text: "Notifications.PinnedMessages.Title".i18n(lang), value: "Notifications.PinnedMessages.value.\(SGSimpleSettings.shared.pinnedMessageNotifications)".i18n(lang), enabled: true))
entries.append(.oneFromManySelector(id: id.count, section: .notifications, settingName: .mentionsAndRepliesNotifications, text: "Notifications.MentionsAndReplies.Title".i18n(lang), value: "Notifications.MentionsAndReplies.value.\(SGSimpleSettings.shared.mentionsAndRepliesNotifications)".i18n(lang), enabled: true))
entries.append(.header(id: id.count, section: .appearance, text: presentationData.strings.Appearance_Title.uppercased(), badge: nil))
entries.append(.disclosure(id: id.count, section: .appearance, link: .appIcons, text: presentationData.strings.Appearance_AppIcon))
entries.append(.disclosure(id: id.count, section: .appearance, link: .appBages, text: "AppBadge.Title".i18n(lang)))
entries.append(.notice(id: id.count, section: .appearance, text: "AppBadge.Notice".i18n(lang)))
#if DEBUG
entries.append(.action(id: id.count, section: .footer, actionType: .resetIAP, text: "Reset Pro", kind: .destructive))
#endif
return entries
}
public func okUndoController(_ text: String, _ presentationData: PresentationData) -> UndoOverlayController {
return UndoOverlayController(presentationData: presentationData, content: .succeed(text: text, timeout: nil, customUndoText: nil), elevatedLayout: false, action: { _ in return false })
}
public func sgProController(context: AccountContext) -> ViewController {
var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)?
var pushControllerImpl: ((ViewController) -> Void)?
let simplePromise = ValuePromise(true, ignoreRepeated: false)
let arguments = SGItemListArguments<SGProToggles, AnyHashable, SGProOneFromManySetting, SGProDisclosureLink, SGProAction>(context: context, setBoolValue: { toggleName, value in
switch toggleName {
case .inputToolbar:
SGSimpleSettings.shared.inputToolbar = value
}
}, setOneFromManyValue: { setting in
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let lang = presentationData.strings.baseLanguageCode
let actionSheet = ActionSheetController(presentationData: presentationData)
var items: [ActionSheetItem] = []
switch (setting) {
case .pinnedMessageNotifications:
let setAction: (String) -> Void = { value in
SGSimpleSettings.shared.pinnedMessageNotifications = value
SGSimpleSettings.shared.synchronizeShared()
simplePromise.set(true)
}
for value in SGSimpleSettings.PinnedMessageNotificationsSettings.allCases {
items.append(ActionSheetButtonItem(title: "Notifications.PinnedMessages.value.\(value.rawValue)".i18n(lang), color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
setAction(value.rawValue)
}))
}
case .mentionsAndRepliesNotifications:
let setAction: (String) -> Void = { value in
SGSimpleSettings.shared.mentionsAndRepliesNotifications = value
SGSimpleSettings.shared.synchronizeShared()
simplePromise.set(true)
}
for value in SGSimpleSettings.MentionsAndRepliesNotificationsSettings.allCases {
items.append(ActionSheetButtonItem(title: "Notifications.MentionsAndReplies.value.\(value.rawValue)".i18n(lang), color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
setAction(value.rawValue)
}))
}
}
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
presentControllerImpl?(actionSheet, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
}, openDisclosureLink: { link in
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
switch (link) {
case .sessionBackupManager:
pushControllerImpl?(sgSessionBackupManagerController(context: context, presentationData: presentationData))
case .messageFilter:
pushControllerImpl?(sgMessageFilterController(presentationData: presentationData))
case .appIcons:
pushControllerImpl?(themeSettingsController(context: context, focusOnItemTag: .icon))
case .appBages:
if #available(iOS 14.0, *) {
pushControllerImpl?(sgAppBadgeSettingsController(context: context, presentationData: presentationData))
} else {
presentControllerImpl?(UndoOverlayController(
presentationData: presentationData,
content: .info(title: nil, text: "iOS 14 or newer is required for this feature.", timeout: nil, customUndoText: nil),
elevatedLayout: false,
action: { _ in return false }
), nil)
}
}
}, action: { action in
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
switch action {
case .resetIAP:
presentControllerImpl?(UndoOverlayController(
presentationData: presentationData,
content: .info(title: nil, text: "Reset is unavailable in this build.", timeout: nil, customUndoText: nil),
elevatedLayout: false,
action: { _ in return false }
), nil)
}
})
let signal = combineLatest(context.sharedContext.presentationData, simplePromise.get())
|> map { presentationData, _ -> (ItemListControllerState, (ItemListNodeState, Any)) in
let entries = SGProControllerEntries(presentationData: presentationData)
let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text("Swiftgram Pro"), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back))
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: /*focusOnItemTag*/ nil, initialScrollToItem: nil /* scrollToItem*/ )
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
presentControllerImpl = { [weak controller] c, a in
controller?.present(c, in: .window(.root), with: a)
}
pushControllerImpl = { [weak controller] c in
(controller?.navigationController as? NavigationController)?.pushViewController(c)
}
// Workaround
let _ = pushControllerImpl
return controller
}
@@ -0,0 +1,519 @@
import Foundation
import UndoUI
import AccountContext
import TelegramCore
import Postbox
import Display
import SwiftSignalKit
import TelegramPresentationData
import PresentationDataUtils
import SGSimpleSettings
import SGLogging
import SGKeychainBackupManager
struct SessionBackup: Codable {
var name: String? = nil
var date: Date = Date()
let accountRecord: AccountRecord<TelegramAccountManagerTypes.Attribute>
var peerIdInternal: Int64 {
var userId: Int64 = 0
for attribute in accountRecord.attributes {
if case let .backupData(backupData) = attribute, let backupPeerID = backupData.data?.peerId {
userId = backupPeerID
break
}
}
return userId
}
var userId: Int64 {
return PeerId(peerIdInternal).id._internalGetInt64Value()
}
}
import SwiftUI
import SGSwiftUI
import LegacyUI
import SGStrings
@available(iOS 13.0, *)
struct SessionBackupRow: View {
@Environment(\.lang) var lang: String
let backup: SessionBackup
let isLoggedIn: Bool
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter
}()
var formattedDate: String {
if #available(iOS 15.0, *) {
return backup.date.formatted(date: .abbreviated, time: .shortened)
} else {
return dateFormatter.string(from: backup.date)
}
}
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(backup.name ?? String(backup.userId))
.font(.body)
Text("ID: \(backup.userId)")
.font(.subheadline)
.foregroundColor(.secondary)
Text("SessionBackup.LastBackupAt".i18n(lang, args: formattedDate))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Text((isLoggedIn ? "SessionBackup.LoggedIn" : "SessionBackup.LoggedOut").i18n(lang))
.font(.caption)
.foregroundColor(isLoggedIn ? .white : .secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(isLoggedIn ? Color.accentColor : Color.secondary.opacity(0.1))
.cornerRadius(4)
}
.padding(.vertical, 4)
}
}
@available(iOS 13.0, *)
struct BorderedButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.accentColor, lineWidth: 1)
)
.opacity(configuration.isPressed ? 0.7 : 1.0)
}
}
@available(iOS 13.0, *)
struct SessionBackupManagerView: View {
@Environment(\.lang) var lang: String
weak var wrapperController: LegacyController?
let context: AccountContext
@State private var sessions: [SessionBackup] = []
@State private var loggedInPeerIDs: [Int64] = []
@State private var loggedInAccountsDisposable: Disposable? = nil
private func performBackup() {
let controller = OverlayStatusController(theme: context.sharedContext.currentPresentationData.with { $0 }.theme, type: .loading(cancelled: nil))
let signal = context.sharedContext.accountManager.accountRecords()
|> take(1)
|> deliverOnMainQueue
let signal2 = context.sharedContext.activeAccountsWithInfo
|> take(1)
|> deliverOnMainQueue
wrapperController?.present(controller, in: .window(.root), with: nil)
let _ = (combineLatest(signal, signal2)
|> take(1)
|> deliverOnMainQueue).start(next: { view, accountsWithInfo in
backupSessionsFromView(view, accountsWithInfo: accountsWithInfo.1)
withAnimation {
sessions = getBackedSessions()
}
controller.dismiss()
})
}
private func performRestore() {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
let _ = (context.sharedContext.accountManager.accountRecords()
|> take(1)
|> deliverOnMainQueue).start(next: { [weak controller] view in
let backupSessions = getBackedSessions()
var restoredSessions: Int64 = 0
func importNextBackup(index: Int) {
// Check if we're done
if index >= backupSessions.count {
// All done, update UI
withAnimation {
sessions = getBackedSessions()
}
controller?.dismiss()
wrapperController?.present(
okUndoController("SessionBackup.RestoreOK".i18n(lang, args: "\(restoredSessions)"), presentationData),
in: .current
)
return
}
let backup = backupSessions[index]
// Check for existing record
let existingRecord = view.records.first { record in
var userId: Int64 = 0
for attribute in record.attributes {
if case let .backupData(backupData) = attribute {
userId = backupData.data?.peerId ?? 0
}
}
return userId == backup.peerIdInternal
}
if existingRecord != nil {
SGLogger.shared.log("SessionBackup", "Record \(backup.userId) already exists, skipping")
importNextBackup(index: index + 1)
return
}
var importAttributes = backup.accountRecord.attributes
importAttributes.removeAll { attribute in
if case .sortOrder = attribute {
return true
}
return false
}
let importBackupSignal = context.sharedContext.accountManager.transaction { transaction -> Void in
let nextSortOrder = (transaction.getRecords().map({ record -> Int32 in
for attribute in record.attributes {
if case let .sortOrder(sortOrder) = attribute {
return sortOrder.order
}
}
return 0
}).max() ?? 0) + 1
importAttributes.append(.sortOrder(AccountSortOrderAttribute(order: nextSortOrder)))
let accountRecordId = transaction.createRecord(importAttributes)
SGLogger.shared.log("SessionBackup", "Imported record \(accountRecordId) for \(backup.userId)")
restoredSessions += 1
}
|> deliverOnMainQueue
let _ = importBackupSignal.start(completed: {
importNextBackup(index: index + 1)
})
}
// Start the import chain
importNextBackup(index: 0)
})
wrapperController?.present(controller, in: .window(.root), with: nil)
}
private func performDeleteAll() {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let controller = textAlertController(context: context, title: "SessionBackup.DeleteAll.Title".i18n(lang), text: "SessionBackup.DeleteAll.Text".i18n(lang), actions: [
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
wrapperController?.present(controller, in: .window(.root), with: nil)
do {
try KeychainBackupManager.shared.deleteAllSessions()
withAnimation {
sessions = getBackedSessions()
}
controller.dismiss()
} catch let e {
SGLogger.shared.log("SessionBackup", "Error deleting all sessions: \(e)")
}
}),
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
])
wrapperController?.present(controller, in: .window(.root), with: nil)
}
private func performDelete(_ session: SessionBackup) {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let controller = textAlertController(context: context, title: "SessionBackup.DeleteSingle.Title".i18n(lang), text: "SessionBackup.DeleteSingle.Text".i18n(lang, args: "\(session.name ?? "\(session.userId)")"), actions: [
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
wrapperController?.present(controller, in: .window(.root), with: nil)
do {
try KeychainBackupManager.shared.deleteSession(for: "\(session.peerIdInternal)")
withAnimation {
sessions = getBackedSessions()
}
controller.dismiss()
} catch let e {
SGLogger.shared.log("SessionBackup", "Error deleting session: \(e)")
}
}),
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
])
wrapperController?.present(controller, in: .window(.root), with: nil)
}
private func performRemoveSessionFromApp(session: SessionBackup) {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let controller = textAlertController(context: context, title: "SessionBackup.RemoveFromApp.Title".i18n(lang), text: "SessionBackup.RemoveFromApp.Text".i18n(lang, args: "\(session.name ?? "\(session.userId)")"), actions: [
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
wrapperController?.present(controller, in: .window(.root), with: nil)
let signal = context.sharedContext.accountManager.accountRecords()
|> take(1)
|> deliverOnMainQueue
let _ = signal.start(next: { [weak controller] view in
// Find record to delete
let accountRecord = view.records.first { record in
var userId: Int64 = 0
for attribute in record.attributes {
if case let .backupData(backupData) = attribute {
userId = backupData.data?.peerId ?? 0
}
}
return userId == session.peerIdInternal
}
if let record = accountRecord {
let deleteSignal = context.sharedContext.accountManager.transaction { transaction -> Void in
transaction.updateRecord(record.id, { _ in return nil})
}
|> deliverOnMainQueue
let _ = deleteSignal.start(next: {
withAnimation {
sessions = getBackedSessions()
}
controller?.dismiss()
})
} else {
controller?.dismiss()
}
})
}),
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
])
wrapperController?.present(controller, in: .window(.root), with: nil)
}
var body: some View {
List {
Section() {
Button(action: performBackup) {
HStack {
Image(systemName: "key.fill")
.frame(width: 30)
Text("SessionBackup.Actions.Backup".i18n(lang))
Spacer()
}
}
Button(action: performRestore) {
HStack {
Image(systemName: "arrow.2.circlepath")
.frame(width: 30)
Text("SessionBackup.Actions.Restore".i18n(lang))
Spacer()
}
}
Button(action: performDeleteAll) {
HStack {
Image(systemName: "trash")
.frame(width: 30)
Text("SessionBackup.Actions.DeleteAll".i18n(lang))
}
}
.foregroundColor(.red)
}
Text("SessionBackup.Notice".i18n(lang))
.font(.caption)
.foregroundColor(.secondary)
Section(header: Text("SessionBackup.Sessions.Title".i18n(lang))) {
ForEach(sessions, id: \.peerIdInternal) { session in
SessionBackupRow(
backup: session,
isLoggedIn: loggedInPeerIDs.contains(session.peerIdInternal)
)
.contextMenu {
Button(action: {
performDelete(session)
}, label: {
HStack(spacing: 4) {
Text("SessionBackup.Actions.DeleteOne".i18n(lang))
Image(systemName: "trash")
}
})
Button(action: {
performRemoveSessionFromApp(session: session)
}, label: {
HStack(spacing: 4) {
Text("SessionBackup.Actions.RemoveFromApp".i18n(lang))
Image(systemName: "trash")
}
})
}
}
// .onDelete { indexSet in
// performDelete(indexSet)
// }
}
}
.onAppear {
withAnimation {
sessions = getBackedSessions()
}
let accountsSignal = context.sharedContext.accountManager.accountRecords()
|> deliverOnMainQueue
loggedInAccountsDisposable = accountsSignal.start(next: { view in
var result: [Int64] = []
for record in view.records {
var isLoggedOut: Bool = false
var userId: Int64 = 0
for attribute in record.attributes {
if case .loggedOut = attribute {
isLoggedOut = true
} else if case let .backupData(backupData) = attribute {
userId = backupData.data?.peerId ?? 0
}
}
if !isLoggedOut && userId != 0 {
result.append(userId)
}
}
SGLogger.shared.log("SessionBackup", "Logged in accounts: \(result)")
if loggedInPeerIDs != result {
SGLogger.shared.log("SessionBackup", "Updating logged in accounts: \(result)")
loggedInPeerIDs = result
}
})
}
.onDisappear {
loggedInAccountsDisposable?.dispose()
}
}
}
func getBackedSessions() -> [SessionBackup] {
var sessions: [SessionBackup] = []
do {
let backupSessionsData = try KeychainBackupManager.shared.getAllSessons()
for sessionBackupData in backupSessionsData {
do {
let backup = try JSONDecoder().decode(SessionBackup.self, from: sessionBackupData)
sessions.append(backup)
} catch let e {
SGLogger.shared.log("SessionBackup", "IMPORT ERROR: \(e)")
}
}
} catch let e {
SGLogger.shared.log("SessionBackup", "Error getting all sessions: \(e)")
}
return sessions
}
func backupSessionsFromView(_ view: AccountRecordsView<TelegramAccountManagerTypes>, accountsWithInfo: [AccountWithInfo] = []) {
var recordsToBackup: [Int64: AccountRecord<TelegramAccountManagerTypes.Attribute>] = [:]
for record in view.records {
var sortOrder: Int32 = 0
var isLoggedOut: Bool = false
var isTestingEnvironment: Bool = false
var peerId: Int64 = 0
for attribute in record.attributes {
if case let .sortOrder(value) = attribute {
sortOrder = value.order
} else if case .loggedOut = attribute {
isLoggedOut = true
} else if case let .environment(environment) = attribute, case .test = environment.environment {
isTestingEnvironment = true
} else if case let .backupData(backupData) = attribute {
peerId = backupData.data?.peerId ?? 0
}
}
let _ = sortOrder
let _ = isTestingEnvironment
if !isLoggedOut && peerId != 0 {
recordsToBackup[peerId] = record
}
}
for (peerId, record) in recordsToBackup {
var backupName: String? = nil
if let accountWithInfo = accountsWithInfo.first(where: { $0.peer.id == PeerId(peerId) }) {
if let user = accountWithInfo.peer as? TelegramUser {
if let username = user.username {
backupName = "@\(username)"
} else {
backupName = user.nameOrPhone
}
}
}
let backup = SessionBackup(name: backupName, accountRecord: record)
do {
let data = try JSONEncoder().encode(backup)
try KeychainBackupManager.shared.saveSession(id: "\(backup.peerIdInternal)", data)
} catch let e {
SGLogger.shared.log("SessionBackup", "BACKUP ERROR: \(e)")
}
}
}
@available(iOS 13.0, *)
public func sgSessionBackupManagerController(context: AccountContext, presentationData: PresentationData? = nil) -> ViewController {
let theme = presentationData?.theme ?? (UITraitCollection.current.userInterfaceStyle == .dark ? defaultDarkColorPresentationTheme : defaultPresentationTheme)
let strings = presentationData?.strings ?? defaultPresentationStrings
let legacyController = LegacySwiftUIController(
presentation: .navigation,
theme: theme,
strings: strings
)
legacyController.statusBar.statusBarStyle = theme.rootController
.statusBarStyle.style
legacyController.title = "SessionBackup.Title".i18n(strings.baseLanguageCode)
let swiftUIView = SGSwiftUIView<SessionBackupManagerView>(
legacyController: legacyController,
manageSafeArea: true,
content: {
SessionBackupManagerView(wrapperController: legacyController, context: context)
}
)
let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true)
legacyController.bind(controller: controller)
return legacyController
}