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 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, accountsWithInfo: [AccountWithInfo] = []) { var recordsToBackup: [Int64: AccountRecord] = [:] 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( legacyController: legacyController, manageSafeArea: true, content: { SessionBackupManagerView(wrapperController: legacyController, context: context) } ) let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true) legacyController.bind(controller: controller) return legacyController }