diff --git a/Swiftgram/SGDebugUI/Sources/SGDebugUI.swift b/Swiftgram/SGDebugUI/Sources/SGDebugUI.swift index 33b3592212..0892d813fa 100644 --- a/Swiftgram/SGDebugUI/Sources/SGDebugUI.swift +++ b/Swiftgram/SGDebugUI/Sources/SGDebugUI.swift @@ -20,813 +20,6 @@ import OverlayStatusController #if DEBUG import FLEX #endif -import Security - - -let BACKUP_SERVICE: String = "\(Bundle.main.bundleIdentifier!).sessionsbackup" - -enum KeychainError: Error { - case duplicateEntry - case unknown(OSStatus) - case itemNotFound - case invalidItemFormat -} - -class KeychainBackupManager { - static let shared = KeychainBackupManager() - private let service = "\(Bundle.main.bundleIdentifier!).sessionsbackup" - - private init() {} - - // MARK: - Save Credentials - func saveSession(id: String, _ session: Data) throws { - // Create query dictionary - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: id, - kSecValueData as String: session, - kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked - ] - - // Add to keychain - let status = SecItemAdd(query as CFDictionary, nil) - - if status == errSecDuplicateItem { - // Item already exists, update it - let updateQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: id - ] - - let attributesToUpdate: [String: Any] = [ - kSecValueData as String: session - ] - - let updateStatus = SecItemUpdate(updateQuery as CFDictionary, - attributesToUpdate as CFDictionary) - - if updateStatus != errSecSuccess { - throw KeychainError.unknown(updateStatus) - } - } else if status != errSecSuccess { - throw KeychainError.unknown(status) - } - } - - // MARK: - Retrieve Credentials - func retrieveSession(for id: String) throws -> Data { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: id, - kSecReturnData as String: true - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status == errSecSuccess, let sessionData = result as? Data else { - throw KeychainError.itemNotFound - } - - return sessionData - } - - // MARK: - Delete Credentials - func deleteSession(for id: String) throws { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: id - ] - - let status = SecItemDelete(query as CFDictionary) - - if status != errSecSuccess && status != errSecItemNotFound { - throw KeychainError.unknown(status) - } - } - - // MARK: - Retrieve All Accounts - func getAllSessons() throws -> [Data] { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitAll - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - if status == errSecItemNotFound { - return [] - } - - guard status == errSecSuccess, - let credentialsDataArray = result as? [Data] else { - throw KeychainError.unknown(status) - } - - return credentialsDataArray - } - - // MARK: - Delete All Sessions - func deleteAllSessions() throws { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service - ] - - let status = SecItemDelete(query as CFDictionary) - - // If no items were found, that's fine - just return - if status == errSecItemNotFound { - return - } - - // For any other error, throw - if status != errSecSuccess { - throw KeychainError.unknown(status) - } - } -} - -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 { - 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("Last Backup: \(formattedDate)") - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - - Text(isLoggedIn ? "Logged In" : "Logged Out") - .font(.caption) - .foregroundColor(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(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 { - 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) - - Task { - if let result = try? await combineLatest(signal, signal2).awaitable() { - let (view, accountsWithInfo) = result - 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("OK: \(restoredSessions) Sessions restored", 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 { - print("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) - print("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: "Delete All Backups?", text: "All sessions will be removed from Keychain.\n\nAccounts will not be logged out from Swiftgram.", 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 { - print("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: "Delete 1 Backup?", text: "\(session.name ?? "\(session.userId)") session will be removed from Keychain.\n\nAccount will not be logged out from Swiftgram.", 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 { - print("Error deleting session: \(e)") - } - }), - TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {}) - ]) - - wrapperController?.present(controller, in: .window(.root), with: nil) - } - - - #if DEBUG - private func performRemoveSessionFromApp(session: SessionBackup) { - let presentationData = context.sharedContext.currentPresentationData.with { $0 } - - let controller = textAlertController(context: context, title: "Remove session from App?", text: "\(session.name ?? "\(session.userId)") session will be removed from app? Account WILL BE logged out of Swiftgram.", 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) - } - #endif - - - var body: some View { - List { - Section(header: Text("Actions")) { - Button(action: performBackup) { - HStack { - Image(systemName: "key.fill") - .frame(width: 30) - Text("Backup to Keychain") - Spacer() - } - } - - Button(action: performRestore) { - HStack { - Image(systemName: "arrow.2.circlepath") - .frame(width: 30) - Text("Restore from Keychain") - Spacer() - } - } - - Button(action: performDeleteAll) { - HStack { - Image(systemName: "trash") - .frame(width: 30) - Text("Delete Keychain Backup") - } - } - .foregroundColor(.red) -// Text("Removing sessions from Keychain. This will not affect logged-in accounts.") -// .font(.caption) - } - - Section(header: Text("Backups")) { - ForEach(sessions, id: \.peerIdInternal) { session in - SessionBackupRow( - backup: session, - isLoggedIn: loggedInPeerIDs.contains(session.peerIdInternal) - ) - .contextMenu { - Button(action: { - performDelete(session) - }, label: { - HStack(spacing: 4) { - Text("Delete from Backup") - Image(systemName: "trash") - } - }) - #if DEBUG - Button(action: { - performRemoveSessionFromApp(session: session) - }, label: { - - HStack(spacing: 4) { - Text("Remove from App") - Image(systemName: "trash") - } - }) - #endif - } - } -// .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) - } - } - - print("Will check logged in accounts") - if loggedInPeerIDs != result { - print("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 { - print("IMPORT ERROR: \(e)") - } - } - } catch let e { - print("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 { - print("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 = "Session Backup" //i18n("BackupManager.Title", 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 -} - - -@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 { - @Binding var newKeyword: String - let onAdd: () -> Void - - var body: some View { - HStack { - TextField("Enter keyword", 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? - - @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("Message Filter") - .font(.title) - .bold() - - Text("Remove distraction and reduce visibility of messages containing keywords below.\nKeywords are case-sensitive.") - .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("Keywords")) { - 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 = MessageFilterView(wrapperController: legacyController) - let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true) - legacyController.bind(controller: controller) - - return legacyController -} private enum SGDebugControllerSection: Int32, SGItemListSection { @@ -868,25 +61,13 @@ private func SGDebugControllerEntries(presentationData: PresentationData) -> [SG #if DEBUG entries.append(.action(id: id.count, section: .base, actionType: .flexing, text: "FLEX", kind: .generic)) entries.append(.action(id: id.count, section: .base, actionType: .fileManager, text: "FileManager", kind: .generic)) - entries.append(.disclosure(id: id.count, section: .base, link: .debugIAP, text: "Pro")) - entries.append(.action(id: id.count, section: .base, actionType: .resetIAP, text: "Reset Pro", kind: .destructive)) #endif - - if SGSimpleSettings.shared.b { - entries.append(.disclosure(id: id.count, section: .base, link: .sessionBackupManager, text: "Session Backup")) - entries.append(.disclosure(id: id.count, section: .base, link: .messageFilter, text: "Message Filter")) - if #available(iOS 13.0, *) { - entries.append(.toggle(id: id.count, section: .base, settingName: .inputToolbar, value: SGSimpleSettings.shared.inputToolbar, text: "Message Formatting Toolbar", enabled: true)) - } - } + entries.append(.action(id: id.count, section: .base, actionType: .clearRegDateCache, text: "Clear Regdate cache", kind: .generic)) entries.append(.toggle(id: id.count, section: .base, settingName: .forceImmediateShareSheet, value: SGSimpleSettings.shared.forceSystemSharing, text: "Force System Share Sheet", enabled: true)) + entries.append(.action(id: id.count, section: .base, actionType: .resetIAP, text: "Reset Pro", kind: .destructive)) - entries.append(.header(id: id.count, section: .notifications, text: "NOTIFICATIONS", badge: nil)) - entries.append(.toggle(id: id.count, section: .notifications, settingName: .legacyNotificationsFix, value: SGSimpleSettings.shared.legacyNotificationsFix, text: "[Legacy] Fix empty notifications", enabled: true)) - entries.append(.oneFromManySelector(id: id.count, section: .notifications, settingName: .pinnedMessageNotifications, text: "Pinned Messages", value: SGSimpleSettings.shared.pinnedMessageNotifications, enabled: true)) - entries.append(.oneFromManySelector(id: id.count, section: .notifications, settingName: .mentionsAndRepliesNotifications, text: "@Mentions and Replies", value: SGSimpleSettings.shared.mentionsAndRepliesNotifications, enabled: true)) - + entries.append(.toggle(id: id.count, section: .notifications, settingName: .legacyNotificationsFix, value: SGSimpleSettings.shared.legacyNotificationsFix, text: "[OLD] Fix empty notifications", enabled: true)) return entries } private func okUndoController(_ text: String, _ presentationData: PresentationData) -> UndoOverlayController { @@ -913,44 +94,11 @@ public func sgDebugController(context: AccountContext) -> ViewController { }, setOneFromManyValue: { setting in let presentationData = context.sharedContext.currentPresentationData.with { $0 } let actionSheet = ActionSheetController(presentationData: presentationData) - var items: [ActionSheetItem] = [] + let items: [ActionSheetItem] = [] +// 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: value.rawValue, color: .accent, action: { [weak actionSheet] in - actionSheet?.dismissAnimated() - if SGSimpleSettings.shared.b { - setAction(value.rawValue) - } else { - setAction(SGSimpleSettings.PinnedMessageNotificationsSettings.default.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: value.rawValue, color: .accent, action: { [weak actionSheet] in - actionSheet?.dismissAnimated() - if SGSimpleSettings.shared.b { - setAction(value.rawValue) - } else { - setAction(SGSimpleSettings.MentionsAndRepliesNotificationsSettings.default.rawValue) - } - })) - } - } +// switch (setting) { +// } actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in @@ -958,53 +106,7 @@ public func sgDebugController(context: AccountContext) -> ViewController { }) ])]) presentControllerImpl?(actionSheet, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) - }, openDisclosureLink: { link in - let presentationData = context.sharedContext.currentPresentationData.with { $0 } - switch (link) { - case .sessionBackupManager: - if #available(iOS 13.0, *) { - pushControllerImpl?(sgSessionBackupManagerController(context: context, presentationData: presentationData)) - } else { - presentControllerImpl?(UndoOverlayController( - presentationData: presentationData, - content: .info(title: nil, text: "Update OS to access this feature", timeout: nil, customUndoText: nil), - elevatedLayout: false, - action: { _ in return false } - ), nil) - } - case .messageFilter: - if #available(iOS 13.0, *) { - pushControllerImpl?(sgMessageFilterController(presentationData: presentationData)) - } else { - presentControllerImpl?(UndoOverlayController( - presentationData: presentationData, - content: .info(title: nil, text: "Update OS to access this feature", timeout: nil, customUndoText: nil), - elevatedLayout: false, - action: { _ in return false } - ), nil) - } - case .debugIAP: - #if DEBUG - if #available(iOS 13.0, *) { - if let sgIAPManager = context.sharedContext.SGIAP { - let statusSignal = context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.sgStatus]) - |> map { sharedData -> Int64 in - let sgStatus = sharedData.entries[ApplicationSpecificSharedDataKeys.sgStatus]?.get(SGStatus.self) ?? SGStatus.default - return sgStatus.status - } - presentControllerImpl?(sgPayWallController(statusSignal: statusSignal, replacementController: sgDebugController(context: context), presentationData: presentationData, SGIAPManager: sgIAPManager), ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) - } - } else { - presentControllerImpl?(UndoOverlayController( - presentationData: presentationData, - content: .info(title: nil, text: "Update OS to access this feature", timeout: nil, customUndoText: nil), - elevatedLayout: false, - action: { _ in return false } - ), nil) - } - - #endif - } + }, openDisclosureLink: { _ in }, action: { actionType in let presentationData = context.sharedContext.currentPresentationData.with { $0 } switch actionType { diff --git a/Swiftgram/SGKeychainBackupManager/BUILD b/Swiftgram/SGKeychainBackupManager/BUILD new file mode 100644 index 0000000000..cd1a5d1293 --- /dev/null +++ b/Swiftgram/SGKeychainBackupManager/BUILD @@ -0,0 +1,17 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "SGKeychainBackupManager", + module_name = "SGKeychainBackupManager", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + ], + visibility = [ + "//visibility:public", + ], +) \ No newline at end of file diff --git a/Swiftgram/SGKeychainBackupManager/Sources/SGKeychainBackupManager.swift b/Swiftgram/SGKeychainBackupManager/Sources/SGKeychainBackupManager.swift new file mode 100644 index 0000000000..2de2ebd5ed --- /dev/null +++ b/Swiftgram/SGKeychainBackupManager/Sources/SGKeychainBackupManager.swift @@ -0,0 +1,131 @@ +import Foundation +import Security + +public enum KeychainError: Error { + case duplicateEntry + case unknown(OSStatus) + case itemNotFound + case invalidItemFormat +} + +public class KeychainBackupManager { + public static let shared = KeychainBackupManager() + private let service = "\(Bundle.main.bundleIdentifier!).sessionsbackup" + + private init() {} + + // MARK: - Save Credentials + public func saveSession(id: String, _ session: Data) throws { + // Create query dictionary + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: id, + kSecValueData as String: session, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked + ] + + // Add to keychain + let status = SecItemAdd(query as CFDictionary, nil) + + if status == errSecDuplicateItem { + // Item already exists, update it + let updateQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: id + ] + + let attributesToUpdate: [String: Any] = [ + kSecValueData as String: session + ] + + let updateStatus = SecItemUpdate(updateQuery as CFDictionary, + attributesToUpdate as CFDictionary) + + if updateStatus != errSecSuccess { + throw KeychainError.unknown(updateStatus) + } + } else if status != errSecSuccess { + throw KeychainError.unknown(status) + } + } + + // MARK: - Retrieve Credentials + public func retrieveSession(for id: String) throws -> Data { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: id, + kSecReturnData as String: true + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, let sessionData = result as? Data else { + throw KeychainError.itemNotFound + } + + return sessionData + } + + // MARK: - Delete Credentials + public func deleteSession(for id: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: id + ] + + let status = SecItemDelete(query as CFDictionary) + + if status != errSecSuccess && status != errSecItemNotFound { + throw KeychainError.unknown(status) + } + } + + // MARK: - Retrieve All Accounts + public func getAllSessons() throws -> [Data] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitAll + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { + return [] + } + + guard status == errSecSuccess, + let credentialsDataArray = result as? [Data] else { + throw KeychainError.unknown(status) + } + + return credentialsDataArray + } + + // MARK: - Delete All Sessions + public func deleteAllSessions() throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service + ] + + let status = SecItemDelete(query as CFDictionary) + + // If no items were found, that's fine - just return + if status == errSecItemNotFound { + return + } + + // For any other error, throw + if status != errSecSuccess { + throw KeychainError.unknown(status) + } + } +} diff --git a/Swiftgram/SGPayWall/Sources/SGPayWall.swift b/Swiftgram/SGPayWall/Sources/SGPayWall.swift index cc53923833..2433f73081 100644 --- a/Swiftgram/SGPayWall/Sources/SGPayWall.swift +++ b/Swiftgram/SGPayWall/Sources/SGPayWall.swift @@ -13,7 +13,7 @@ import TelegramUIPreferences @available(iOS 13.0, *) -public func sgPayWallController(statusSignal: Signal, replacementController: ViewController, presentationData: PresentationData? = nil, SGIAPManager: SGIAPManager) -> ViewController { +public func sgPayWallController(statusSignal: Signal, replacementController: ViewController, presentationData: PresentationData? = nil, SGIAPManager: SGIAPManager, openUrl: @escaping (String) -> Void) -> ViewController { // let theme = presentationData?.theme ?? (UITraitCollection.current.userInterfaceStyle == .dark ? defaultDarkColorPresentationTheme : defaultPresentationTheme) let theme = defaultDarkColorPresentationTheme let strings = presentationData?.strings ?? defaultPresentationStrings @@ -30,7 +30,7 @@ public func sgPayWallController(statusSignal: Signal, replacemen let swiftUIView = SGSwiftUIView( legacyController: legacyController, content: { - SGPayWallView(wrapperController: legacyController, replacementController: replacementController, SGIAP: SGIAPManager, statusSignal: statusSignal) + SGPayWallView(wrapperController: legacyController, replacementController: replacementController, SGIAP: SGIAPManager, statusSignal: statusSignal, openUrl: openUrl) } ) let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true) @@ -105,6 +105,7 @@ struct SGPayWallView: View { let replacementController: ViewController let SGIAP: SGIAPManager let statusSignal: Signal + let openUrl: (String) -> Void private enum PayWallState: Equatable { case ready // ready to buy @@ -147,7 +148,7 @@ struct SGPayWallView: View { .font(.largeTitle) .fontWeight(.bold) - Text("Supercharged with Pro features".i18n(lang)) + Text("PayWall.Text".i18n(lang)) .font(.callout) .multilineTextAlignment(.center) .padding(.horizontal) @@ -156,14 +157,18 @@ struct SGPayWallView: View { // Features VStack(spacing: 8) { featuresSection + legalSection restorePurchasesButton } + // Spacer for purchase buttons Color.clear.frame(height: 50) } .padding(.vertical, 50) } + .padding(.leading, max(innerShadowWidth + 8.0, sgLeftSafeAreaInset(containerViewLayout))) + .padding(.trailing, max(innerShadowWidth + 8.0, sgRightSafeAreaInset(containerViewLayout))) // Fixed purchase button at bottom purchaseSection @@ -220,7 +225,7 @@ struct SGPayWallView: View { if let userInfo = notification.userInfo, let error = userInfo["error"] as? String, !error.isEmpty { showErrorAlert(error) } else { - showErrorAlert("Validation Error") + showErrorAlert("PayWall.ValidationError".i18n(lang)) } } } @@ -230,35 +235,33 @@ struct SGPayWallView: View { VStack(spacing: 8) { FeatureRow( icon: FeatureIcon(icon: "lock.fill", backgroundColor: .blue), - title: "Session Backup", - subtitle: "Restore sessions from encrypted local Apple Keychain backup." + title: "PayWall.SessionBackup.Title".i18n(lang), + subtitle: "PayWall.SessionBackup.Notice".i18n(lang) ) FeatureRow( icon: FeatureIcon(icon: "nosign", backgroundColor: .gray, fontWeight: .bold), - title: "Message Filter", - subtitle: "Reduce visibility of spam, promotions and annoying messages." + title: "PayWall.MessageFilter.Title".i18n(lang), + subtitle: "PayWall.MessageFilter.Notice".i18n(lang) ) FeatureRow( icon: FeatureIcon(icon: "bell.badge.slash.fill", backgroundColor: .red), - title: "Disable @mentions and replies", - subtitle: "Hide or silence non-important notifications." + title: "PayWall.Notifications.Title".i18n(lang), + subtitle: "PayWall.MessageFilter.Notice".i18n(lang) ) FeatureRow( icon: FeatureIcon(icon: "bold.underline", backgroundColor: .blue, iconSize: 16), - title: "Quick Formatting panel", - subtitle: "Save time preparing your posts with a panel right above your keyboard." + title: "PayWall.InputToolbar.Title".i18n(lang), + subtitle: "PayWall.InputToolbar.Notice".i18n(lang) ) } - .padding(.leading, max(innerShadowWidth + 8.0, sgLeftSafeAreaInset(containerViewLayout))) - .padding(.trailing, max(innerShadowWidth + 8.0, sgRightSafeAreaInset(containerViewLayout))) } private var restorePurchasesButton: some View { Button(action: handleRestorePurchases) { - Text("Restore Purchases") + Text("PayWall.RestorePurchases".i18n(lang)) .font(.footnote) .fontWeight(.semibold) .foregroundColor(Color(hex: accentColorHex)) @@ -290,6 +293,41 @@ struct SGPayWallView: View { .shadow(radius: 8, y: -4) } + private var legalSection: some View { + Group { + if #available(iOS 15.0, *) { + Text(LocalizedStringKey("PayWall.Notice.Markdown".i18n(lang, args: "PayWall.TermsURL".i18n(lang), "PayWall.PrivacyURL".i18n(lang)))) + .font(.caption) + .tint(Color(hex: accentColorHex)) + .foregroundColor(.secondary) + .environment(\.openURL, OpenURLAction { url in + openUrl(url.absoluteString) + return .handled + }) + } else { + Text("PayWall.Notice.Raw".i18n(lang)) + .font(.caption) + .foregroundColor(.secondary) + HStack(alignment: .top, spacing: 8) { + Button(action: { + openUrl("PayWall.PrivacyURL".i18n(lang)) + }) { + Text("PayWall.Privacy".i18n(lang)) + .font(.caption) + .foregroundColor(Color(hex: accentColorHex)) + } + Button(action: { + openUrl("PayWall.TermsURL".i18n(lang)) + }) { + Text("PayWall.Terms".i18n(lang)) + .font(.caption) + .foregroundColor(Color(hex: accentColorHex)) + } + } + } + } + } + private var closeButtonView: some View { Button(action: { wrapperController?.dismiss(animated: true) @@ -306,22 +344,22 @@ struct SGPayWallView: View { private var buttonTitle: String { if currentStatus > 1 { - return "Use Pro features".i18n(lang) + return "PayWall.Button.OpenPro".i18n(lang) } else { if state == .purchasing { - return "Purchasing...".i18n(lang) + return "PayWall.Button.Purchasing".i18n(lang) } else if state == .restoring { - return "Restoring Purchases...".i18n(lang) + return "PayWall.Button.Restoring".i18n(lang) } else if state == .validating { - return "Validating Purchase...".i18n(lang) + return "PayWall.Button.Validating".i18n(lang) } else if let product = product { if !SGIAP.canMakePayments { - return "Payments unavailable".i18n(lang) + return "PayWall.Button.PaymentsUnavailable".i18n(lang) } else { - return "Subscribe for \(product.price) / month".i18n(lang, args: product.price) + return "PayWall.Button.Subscribe".i18n(lang, args: product.price) } } else { - return "Contacting App Store...".i18n(lang) + return "Paywall.Button.ContactingAppStore".i18n(lang) } } } diff --git a/Swiftgram/SGProUI/BUILD b/Swiftgram/SGProUI/BUILD new file mode 100644 index 0000000000..e748ea0cfe --- /dev/null +++ b/Swiftgram/SGProUI/BUILD @@ -0,0 +1,41 @@ +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/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", + ], +) diff --git a/Swiftgram/SGProUI/Sources/MessageFilterController.swift b/Swiftgram/SGProUI/Sources/MessageFilterController.swift new file mode 100644 index 0000000000..eb36ac2ef5 --- /dev/null +++ b/Swiftgram/SGProUI/Sources/MessageFilterController.swift @@ -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( + legacyController: legacyController, + content: { + MessageFilterView(wrapperController: legacyController) + } + ) + let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true) + legacyController.bind(controller: controller) + + return legacyController +} diff --git a/Swiftgram/SGProUI/Sources/SGProUI.swift b/Swiftgram/SGProUI/Sources/SGProUI.swift new file mode 100644 index 0000000000..033ea3c57c --- /dev/null +++ b/Swiftgram/SGProUI/Sources/SGProUI.swift @@ -0,0 +1,159 @@ +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 + +// Optional +import SGSimpleSettings +import SGLogging + + +private enum SGProControllerSection: Int32, SGItemListSection { + case base + case notifications +} + +private enum SGProDisclosureLink: String { + case sessionBackupManager + case messageFilter +} + +private enum SGProToggles: String { + case inputToolbar +} + +private enum SGProOneFromManySetting: String { + case pinnedMessageNotifications + case mentionsAndRepliesNotifications +} + +private typealias SGProControllerEntry = SGItemListUIEntry + +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)) + + 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(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: + if #available(iOS 13.0, *) { + pushControllerImpl?(sgSessionBackupManagerController(context: context, presentationData: presentationData)) + } else { + presentControllerImpl?(context.sharedContext.makeSGUpdateIOSController(), nil) + } + case .messageFilter: + if #available(iOS 13.0, *) { + pushControllerImpl?(sgMessageFilterController(presentationData: presentationData)) + } else { + presentControllerImpl?(context.sharedContext.makeSGUpdateIOSController(), nil) + } + } + }, action: { _ in + + }) + + 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 +} + + diff --git a/Swiftgram/SGProUI/Sources/SessionBackupController.swift b/Swiftgram/SGProUI/Sources/SessionBackupController.swift new file mode 100644 index 0000000000..8781d7b779 --- /dev/null +++ b/Swiftgram/SGProUI/Sources/SessionBackupController.swift @@ -0,0 +1,520 @@ +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) + + Task { + if let result = try? await combineLatest(signal, signal2).awaitable() { + let (view, accountsWithInfo) = result + 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.DeleteAllBackups".i18n(lang), text: "SessionBackup.DeleteAllBackups.Subtitle".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 +} diff --git a/Swiftgram/SGSettingsUI/Images.xcassets/SwiftgramPro.imageset/Contents.json b/Swiftgram/SGSettingsUI/Images.xcassets/SwiftgramPro.imageset/Contents.json new file mode 100644 index 0000000000..7506e639eb --- /dev/null +++ b/Swiftgram/SGSettingsUI/Images.xcassets/SwiftgramPro.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "SwiftgramPro.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } + } + \ No newline at end of file diff --git a/Swiftgram/SGSettingsUI/Images.xcassets/SwiftgramPro.imageset/SwiftgramPro.pdf b/Swiftgram/SGSettingsUI/Images.xcassets/SwiftgramPro.imageset/SwiftgramPro.pdf new file mode 100644 index 0000000000..fb4264fd56 Binary files /dev/null and b/Swiftgram/SGSettingsUI/Images.xcassets/SwiftgramPro.imageset/SwiftgramPro.pdf differ diff --git a/Swiftgram/SGSimpleSettings/Sources/SimpleSettings.swift b/Swiftgram/SGSimpleSettings/Sources/SimpleSettings.swift index 7b0c1340f6..a3e984d0c8 100644 --- a/Swiftgram/SGSimpleSettings/Sources/SimpleSettings.swift +++ b/Swiftgram/SGSimpleSettings/Sources/SimpleSettings.swift @@ -127,6 +127,7 @@ public class SGSimpleSettings { case pinnedMessageNotifications case mentionsAndRepliesNotifications case primaryUserId + case status } public enum DownloadSpeedBoostValues: String, CaseIterable { @@ -244,7 +245,8 @@ public class SGSimpleSettings { public static let groupDefaultValues: [String: Any] = [ Keys.legacyNotificationsFix.rawValue: false, Keys.pinnedMessageNotifications.rawValue: PinnedMessageNotificationsSettings.default.rawValue, - Keys.mentionsAndRepliesNotifications.rawValue: MentionsAndRepliesNotificationsSettings.default.rawValue + Keys.mentionsAndRepliesNotifications.rawValue: MentionsAndRepliesNotificationsSettings.default.rawValue, + Keys.status.rawValue: 1 ] @UserDefault(key: Keys.hidePhoneInSettings.rawValue) @@ -426,10 +428,10 @@ public class SGSimpleSettings { @UserDefault(key: Keys.legacyNotificationsFix.rawValue, userDefaults: UserDefaults(suiteName: APP_GROUP_IDENTIFIER) ?? .standard) public var legacyNotificationsFix: Bool - @UserDefault(key: Keys.legacyNotificationsFix.rawValue, userDefaults: UserDefaults(suiteName: APP_GROUP_IDENTIFIER) ?? .standard) + @UserDefault(key: Keys.status.rawValue, userDefaults: UserDefaults(suiteName: APP_GROUP_IDENTIFIER) ?? .standard) public var status: Int64 - - public var b: Bool = true + + public var ephemeralStatus: Int64 = 1 @UserDefault(key: Keys.messageFilterKeywords.rawValue) public var messageFilterKeywords: [String] diff --git a/Swiftgram/SGStrings/Strings/en.lproj/SGLocalizable.strings b/Swiftgram/SGStrings/Strings/en.lproj/SGLocalizable.strings index 88a57777fd..f640f2abec 100644 --- a/Swiftgram/SGStrings/Strings/en.lproj/SGLocalizable.strings +++ b/Swiftgram/SGStrings/Strings/en.lproj/SGLocalizable.strings @@ -84,6 +84,7 @@ "Common.RestartNow" = "Restart Now"; "Common.OpenTelegram" = "Open Telegram"; "Common.UseTelegramForPremium" = "Please note that to get Telegram Premium, you must use the official Telegram app. Once you have obtained Telegram Premium, all its features will become available in Swiftgram."; +"Common.UpdateOS" = "iOS update required"; "Message.HoldToShowOrReport" = "Hold to Show or Report."; @@ -151,3 +152,73 @@ "Settings.swipeForVideoPIP" = "Video PIP with Swipe"; "Settings.swipeForVideoPIP.Notice" = "If enabled, swiping video will open it in Picture-in-Picture mode."; + +"SessionBackup.Title" = "Session Backup"; +"SessionBackup.Sessions.Title" = "Sessions"; +"SessionBackup.Actions.Backup" = "Backup to Keychain"; +"SessionBackup.Actions.Restore" = "Restore from Keychain"; +"SessionBackup.Actions.DeleteAll" = "Delete Keychain Backup"; +"SessionBackup.Actions.DeleteOne" = "Delete from Backup"; +"SessionBackup.Actions.RemoveFromApp" = "Remove from App"; +"SessionBackup.LastBackupAt" = "Last Backup: %@"; +"SessionBackup.RestoreOK" = "OK. Sessions restored: %@"; +"SessionBackup.LoggedIn" = "Logged In"; +"SessionBackup.LoggedOut" = "Logged Out"; +"SessionBackup.DeleteAll.Title" = "Delete All Sessions?"; +"SessionBackup.DeleteAll.Text" = "All sessions will be removed from Keychain.\n\nAccounts will not be logged out from Swiftgram."; +"SessionBackup.DeleteSingle.Title" = "Delete 1 (one) Session?"; +"SessionBackup.DeleteSingle.Text" = "%@ session will be removed from Keychain.\n\nAccount will not be logged out from Swiftgram."; +"SessionBackup.RemoveFromApp.Title" = "Remove account from App?"; +"SessionBackup.RemoveFromApp.Text" = "%@ session WILL BE REMOVED from Swiftgram! Session will remain active, so you can restore it later."; +"SessionBackup.Notice" = "Sessions are stored in the Apple Keychain and are encrypted with your device's passcode. Sessions never leave your device."; + +"MessageFilter.Title" = "Message Filter"; +"MessageFilter.SubTitle" = "Remove distractions and reduce visibility of messages containing keywords below.\nKeywords are case-sensitive."; +"MessageFilter.Keywords.Title" = "Keywords"; +"MessageFilter.InputPlaceholder" = "Enter keyword"; + +"InputToolbar.Title" = "Formatting Panel"; + +"Notifications.MentionsAndReplies.Title" = "@Mentions and Replies"; +"Notifications.MentionsAndReplies.value.default" = "Default"; +"Notifications.MentionsAndReplies.value.silenced" = "Muted"; +"Notifications.MentionsAndReplies.value.disabled" = "Disabled"; +"Notifications.PinnedMessages.Title" = "Pinned Messages"; +"Notifications.PinnedMessages.value.default" = "Default"; +"Notifications.PinnedMessages.value.silenced" = "Muted"; +"Notifications.PinnedMessages.value.disabled" = "Disabled"; + + +"PayWall.Text" = "Supercharged with Pro features"; + +"PayWall.SessionBackup.Title" = "Session Backup"; +"PayWall.SessionBackup.Notice" = "Restore sessions from encrypted local Apple Keychain backup."; + +"PayWall.MessageFilter.Title" = "Message Filter"; +"PayWall.MessageFilter.Notice" = "Reduce visibility of SPAM, promotions and annoying messages."; + +"PayWall.Notifications.Title" = "Disable @mentions and replies"; +"PayWall.MessageFilter.Notice" = "Hide or mute non-important notifications."; + +"PayWall.InputToolbar.Title" = "Formatting Panel"; +"PayWall.InputToolbar.Notice" = "Save time preparing your posts with a panel right above your keyboard."; + +"PayWall.RestorePurchases" = "Restore Purchases"; +"PayWall.Terms" = "Terms of Service"; +"PayWall.Privacy" = "Privacy Policy"; +"PayWall.TermsURL" = "https://swiftgram.app/terms"; +"PayWall.PrivacyURL" = "https://swiftgram.app/privacy"; +"PayWall.Notice.Markdown" = "By subscribing to Swiftgram Pro you agree to the [Swiftgram Terms of Service](%1$@) and [Privacy Policy](%2$@)."; +"PayWall.Notice.Raw" = "By subscribing to Swiftgram Pro you agree to the Swiftgram Terms of Service and Privacy Policy."; + +"PayWall.Button.OpenPro" = "Use Pro features"; +"PayWall.Button.Purchasing" = "Purchasing..."; +"PayWall.Button.Restoring" = "Restoring Purchases..."; +"PayWall.Button.Validating" = "Validating Purchase..."; +"PayWall.Button.PaymentsUnavailable" = "Payments unavailable"; +"PayWall.Button.Subscribe" = "Subscribe for %@ / month"; +"PayWall.Button.ContactingAppStore" = "Contacting App Store..."; + +"Paywall.Error.Title" = "Error"; +"PayWall.ValidationError" = "Validation Error"; +"PayWall.ValidationError.TryAgain" = "Something went wrong during purchase validation. No worries! Try to Restore Purchases a bit later."; diff --git a/Telegram/BUILD b/Telegram/BUILD index 04f5a2b4d1..e3cb2e6bca 100644 --- a/Telegram/BUILD +++ b/Telegram/BUILD @@ -339,7 +339,10 @@ alternate_icon_folders = [ "SGNeonBlue", "SGGlass", "SGSparkling", - "SGBeta" + "SGBeta", + "SGPro", + "SGGold", + "SGDucky" ] [ diff --git a/Telegram/NotificationService/Sources/NotificationService.swift b/Telegram/NotificationService/Sources/NotificationService.swift index 5a1736ceb7..20dcafb3ce 100644 --- a/Telegram/NotificationService/Sources/NotificationService.swift +++ b/Telegram/NotificationService/Sources/NotificationService.swift @@ -509,13 +509,15 @@ private struct NotificationContent: CustomStringConvertible { var isMentionOrReply: Bool var isPinned: Bool = false let chatId: Int64? + let sgStatus: SGStatus var senderPerson: INPerson? var senderImage: INImage? var isLockedMessage: String? - init(isLockedMessage: String?, isEmpty: Bool = false, isMentionOrReply: Bool = false, chatId: Int64? = nil) { + init(sgStatus: SGStatus, isLockedMessage: String?, isEmpty: Bool = false, isMentionOrReply: Bool = false, chatId: Int64? = nil) { + self.sgStatus = sgStatus self.isLockedMessage = isLockedMessage self.isEmpty = isEmpty self.isMentionOrReply = isMentionOrReply @@ -541,6 +543,7 @@ private struct NotificationContent: CustomStringConvertible { string += " isPinned: \(self.isPinned),\n" string += " forceIsEmpty: \(self.forceIsEmpty),\n" string += " forceIsSilent: \(self.forceIsSilent),\n" + string += " sgStatus: \(self.sgStatus.status),\n" string += "}" return string } @@ -828,7 +831,8 @@ private final class NotificationServiceHandler { ApplicationSpecificSharedDataKeys.inAppNotificationSettings, ApplicationSpecificSharedDataKeys.voiceCallSettings, ApplicationSpecificSharedDataKeys.automaticMediaDownloadSettings, - SharedDataKeys.loggingSettings + SharedDataKeys.loggingSettings, + ApplicationSpecificSharedDataKeys.sgStatus ]) ) |> take(1) @@ -861,6 +865,7 @@ private final class NotificationServiceHandler { } let inAppNotificationSettings = sharedData.entries[ApplicationSpecificSharedDataKeys.inAppNotificationSettings]?.get(InAppNotificationSettings.self) ?? InAppNotificationSettings.defaultSettings + let sgStatus = sharedData.entries[ApplicationSpecificSharedDataKeys.sgStatus]?.get(SGStatus.self) ?? SGStatus.default let voiceCallSettings: VoiceCallSettings if let value = sharedData.entries[ApplicationSpecificSharedDataKeys.voiceCallSettings]?.get(VoiceCallSettings.self) { @@ -872,7 +877,7 @@ private final class NotificationServiceHandler { guard let strongSelf = self, let recordId = recordId else { Logger.shared.log("NotificationService \(episode)", "Couldn't find a matching decryption key") - let content = NotificationContent(isLockedMessage: nil) + let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil) updateCurrentContent(content) completed() @@ -894,7 +899,7 @@ private final class NotificationServiceHandler { guard let stateManager = stateManager else { Logger.shared.log("NotificationService \(episode)", "Didn't receive stateManager") - let content = NotificationContent(isLockedMessage: nil) + let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil) updateCurrentContent(content) completed() return @@ -912,7 +917,7 @@ private final class NotificationServiceHandler { settings ) |> deliverOn(strongSelf.queue)).start(next: { notificationsKey, notificationSoundList in guard let strongSelf = self else { - let content = NotificationContent(isLockedMessage: nil) + let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil) updateCurrentContent(content) completed() @@ -921,7 +926,7 @@ private final class NotificationServiceHandler { guard let notificationsKey = notificationsKey else { Logger.shared.log("NotificationService \(episode)", "Didn't receive decryption key") - let content = NotificationContent(isLockedMessage: nil) + let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil) updateCurrentContent(content) completed() @@ -930,7 +935,7 @@ private final class NotificationServiceHandler { guard let decryptedPayload = decryptedNotificationPayload(key: notificationsKey, data: payloadData) else { Logger.shared.log("NotificationService \(episode)", "Couldn't decrypt payload") - let content = NotificationContent(isLockedMessage: nil) + let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil) updateCurrentContent(content) completed() @@ -939,7 +944,7 @@ private final class NotificationServiceHandler { guard let payloadJson = try? JSONSerialization.jsonObject(with: decryptedPayload, options: []) as? [String: Any] else { Logger.shared.log("NotificationService \(episode)", "Couldn't process payload as JSON") - let content = NotificationContent(isLockedMessage: nil) + let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil) updateCurrentContent(content) completed() @@ -1047,7 +1052,7 @@ private final class NotificationServiceHandler { action = .logout case "MESSAGE_MUTED": if let peerId = peerId { - action = .poll(peerId: peerId, content: NotificationContent(isLockedMessage: nil, isEmpty: true, isMentionOrReply: isMentionOrReply, chatId: chatId), messageId: nil, reportDelivery: false) + action = .poll(peerId: peerId, content: NotificationContent(sgStatus: sgStatus, isLockedMessage: nil, isEmpty: true, isMentionOrReply: isMentionOrReply, chatId: chatId), messageId: nil, reportDelivery: false) } case "MESSAGE_DELETED": if let peerId = peerId { @@ -1098,7 +1103,7 @@ private final class NotificationServiceHandler { } } else { if let aps = payloadJson["aps"] as? [String: Any], var peerId = peerId { - var content: NotificationContent = NotificationContent(isLockedMessage: isLockedMessage, isMentionOrReply: isMentionOrReply, chatId: chatId) + var content: NotificationContent = NotificationContent(sgStatus: sgStatus, isLockedMessage: isLockedMessage, isMentionOrReply: isMentionOrReply, chatId: chatId) if let alert = aps["alert"] as? [String: Any] { if let topicTitleValue = payloadJson["topic_title"] as? String { topicTitle = topicTitleValue @@ -1249,7 +1254,7 @@ private final class NotificationServiceHandler { switch action { case let .call(callData): if let stateManager = strongSelf.stateManager { - let content = NotificationContent(isLockedMessage: nil) + let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil) updateCurrentContent(content) let _ = (stateManager.postbox.transaction { transaction -> String? in @@ -1272,7 +1277,7 @@ private final class NotificationServiceHandler { if #available(iOS 14.5, *), voiceCallSettings.enableSystemIntegration { Logger.shared.log("NotificationService \(episode)", "Will report voip notification") - let content = NotificationContent(isLockedMessage: nil) + let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil) updateCurrentContent(content) CXProvider.reportNewIncomingVoIPPushPayload(voipPayload, completion: { error in @@ -1281,7 +1286,7 @@ private final class NotificationServiceHandler { completed() }) } else { - var content = NotificationContent(isLockedMessage: nil) + var content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil) if let peer = callData.peer { content.title = peer.debugDisplayTitle content.body = incomingCallMessage @@ -1297,7 +1302,7 @@ private final class NotificationServiceHandler { case .logout: Logger.shared.log("NotificationService \(episode)", "Will logout") - let content = NotificationContent(isLockedMessage: nil, isEmpty: true) + let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil, isEmpty: true) updateCurrentContent(content) completed() case let .poll(peerId, initialContent, messageId, reportDelivery): @@ -1315,7 +1320,7 @@ private final class NotificationServiceHandler { queue.async { guard let strongSelf = self, let stateManager = strongSelf.stateManager else { - let content = NotificationContent(isLockedMessage: isLockedMessage) + let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: isLockedMessage) updateCurrentContent(content) completed() return @@ -1621,7 +1626,7 @@ private final class NotificationServiceHandler { Logger.shared.log("NotificationService \(episode)", "Updating content to \(content)") if wasDisplayed { - content = NotificationContent(isLockedMessage: nil, isMentionOrReply: isMentionOrReply, chatId: chatId) + content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil, isMentionOrReply: isMentionOrReply, chatId: chatId) Logger.shared.log("NotificationService \(episode)", "Was already displayed, skipping content") } else if let messageId { let _ = (stateManager.postbox.transaction { transaction -> Void in @@ -1708,7 +1713,7 @@ private final class NotificationServiceHandler { case let .idBased(maxIncomingReadId, _, _, _, _): if maxIncomingReadId >= messageId.id { Logger.shared.log("NotificationService \(episode)", "maxIncomingReadId: \(maxIncomingReadId), messageId: \(messageId.id), skipping") - content = NotificationContent(isLockedMessage: nil, isMentionOrReply: isMentionOrReply, chatId: chatId) + content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil, isMentionOrReply: isMentionOrReply, chatId: chatId) } else { Logger.shared.log("NotificationService \(episode)", "maxIncomingReadId: \(maxIncomingReadId), messageId: \(messageId.id), not skipping") } @@ -1771,7 +1776,7 @@ private final class NotificationServiceHandler { queue.async { guard let strongSelf = self, let stateManager = strongSelf.stateManager else { - let content = NotificationContent(isLockedMessage: isLockedMessage, isEmpty: true) + let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: isLockedMessage, isEmpty: true) updateCurrentContent(content) completed() return @@ -1971,7 +1976,7 @@ private final class NotificationServiceHandler { var content = content if wasDisplayed { - content = NotificationContent(isLockedMessage: nil) + content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil) } else { let _ = (stateManager.postbox.transaction { transaction -> Void in _internal_setStoryNotificationWasDisplayed(transaction: transaction, id: StoryId(peerId: peerId, id: storyId)) @@ -2059,7 +2064,7 @@ private final class NotificationServiceHandler { postbox: stateManager.postbox ) |> deliverOn(strongSelf.queue)).start(next: { value in - var content = NotificationContent(isLockedMessage: nil, isEmpty: true) + var content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil, isEmpty: true) if isCurrentAccount { content.badge = Int(value.0) } @@ -2101,7 +2106,7 @@ private final class NotificationServiceHandler { } let completeRemoval: () -> Void = { - let content = NotificationContent(isLockedMessage: nil, isEmpty: true) + let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil, isEmpty: true) Logger.shared.log("NotificationService \(episode)", "Updating content to \(content)") updateCurrentContent(content) @@ -2153,7 +2158,7 @@ private final class NotificationServiceHandler { postbox: stateManager.postbox ) |> deliverOn(strongSelf.queue)).start(next: { value in - var content = NotificationContent(isLockedMessage: nil, isEmpty: true) + var content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil, isEmpty: true) if isCurrentAccount { content.badge = Int(value.0) } @@ -2194,7 +2199,7 @@ private final class NotificationServiceHandler { } let completeRemoval: () -> Void = { - let content = NotificationContent(isLockedMessage: nil, isEmpty: true) + let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil, isEmpty: true) updateCurrentContent(content) completed() @@ -2213,7 +2218,7 @@ private final class NotificationServiceHandler { }) } } else { - let content = NotificationContent(isLockedMessage: nil) + let content = NotificationContent(sgStatus: sgStatus, isLockedMessage: nil) updateCurrentContent(content) completed() @@ -2377,7 +2382,7 @@ final class NotificationService: UNNotificationServiceExtension { extension NotificationContent { var forceIsEmpty: Bool { - if !self.isEmpty { + if self.sgStatus.status > 2 && !self.isEmpty { if self.isPinned { var desiredAction = PINNED_MESSAGE_ACTION if let chatId = chatId, let exceptionAction = PINNED_MESSAGE_ACTION_EXCEPTIONS["\(chatId)"] { @@ -2400,7 +2405,7 @@ extension NotificationContent { return false } var forceIsSilent: Bool { - if !self.silent { + if self.sgStatus.status > 2 && !self.silent { if self.isPinned { var desiredAction = PINNED_MESSAGE_ACTION if let chatId = chatId, let exceptionAction = PINNED_MESSAGE_ACTION_EXCEPTIONS["\(chatId)"] { diff --git a/Telegram/Telegram-iOS/SGDucky.alticon/SGDucky@2x.png b/Telegram/Telegram-iOS/SGDucky.alticon/SGDucky@2x.png new file mode 100644 index 0000000000..7a4ee59e65 Binary files /dev/null and b/Telegram/Telegram-iOS/SGDucky.alticon/SGDucky@2x.png differ diff --git a/Telegram/Telegram-iOS/SGDucky.alticon/SGDucky@3x.png b/Telegram/Telegram-iOS/SGDucky.alticon/SGDucky@3x.png new file mode 100644 index 0000000000..1ec818f673 Binary files /dev/null and b/Telegram/Telegram-iOS/SGDucky.alticon/SGDucky@3x.png differ diff --git a/Telegram/Telegram-iOS/SGGold.alticon/SGGold@2x.png b/Telegram/Telegram-iOS/SGGold.alticon/SGGold@2x.png new file mode 100644 index 0000000000..1e929251b5 Binary files /dev/null and b/Telegram/Telegram-iOS/SGGold.alticon/SGGold@2x.png differ diff --git a/Telegram/Telegram-iOS/SGGold.alticon/SGGold@3x.png b/Telegram/Telegram-iOS/SGGold.alticon/SGGold@3x.png new file mode 100644 index 0000000000..38c0118975 Binary files /dev/null and b/Telegram/Telegram-iOS/SGGold.alticon/SGGold@3x.png differ diff --git a/Telegram/Telegram-iOS/SGPro.alticon/SGPro@2x.png b/Telegram/Telegram-iOS/SGPro.alticon/SGPro@2x.png new file mode 100644 index 0000000000..bdeaaac60f Binary files /dev/null and b/Telegram/Telegram-iOS/SGPro.alticon/SGPro@2x.png differ diff --git a/Telegram/Telegram-iOS/SGPro.alticon/SGPro@3x.png b/Telegram/Telegram-iOS/SGPro.alticon/SGPro@3x.png new file mode 100644 index 0000000000..30464e4635 Binary files /dev/null and b/Telegram/Telegram-iOS/SGPro.alticon/SGPro@3x.png differ diff --git a/submodules/AccountContext/Sources/AccountContext.swift b/submodules/AccountContext/Sources/AccountContext.swift index 73f5fc19cb..3d989edbbe 100644 --- a/submodules/AccountContext/Sources/AccountContext.swift +++ b/submodules/AccountContext/Sources/AccountContext.swift @@ -937,6 +937,10 @@ public protocol SharedAccountContext: AnyObject { // MARK: Swiftgram var immediateSGStatus: SGStatus { get } var SGIAP: SGIAPManager? { get } + func makeSGProController(context: AccountContext) -> ViewController + func makeSGPayWallController(context: AccountContext) -> ViewController? + func makeSGUpdateIOSController() -> ViewController + var currentInAppNotificationSettings: Atomic { get } var currentMediaInputSettings: Atomic { get } var currentStickerSettings: Atomic { get } diff --git a/submodules/AccountUtils/Sources/AccountUtils.swift b/submodules/AccountUtils/Sources/AccountUtils.swift index 673ea24151..df544dc075 100644 --- a/submodules/AccountUtils/Sources/AccountUtils.swift +++ b/submodules/AccountUtils/Sources/AccountUtils.swift @@ -18,7 +18,7 @@ public func activeAccountsAndPeers(context: AccountContext, includePrimary: Bool func accountWithPeer(_ context: AccountContext) -> Signal<(AccountContext, EnginePeer, Int32)?, NoError> { return combineLatest(context.account.postbox.peerView(id: context.account.peerId), renderedTotalUnreadCount(accountManager: sharedContext.accountManager, engine: context.engine)) |> map { view, totalUnreadCount -> (EnginePeer?, Int32) in - return (view.peers[view.peerId].flatMap(EnginePeer.init) ?? EnginePeer.init(TelegramUser(id: view.peerId, accessHash: nil, firstName: "IMPORTED", lastName: "\(view.peerId.id._internalGetInt64Value())", username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: UserInfoFlags(), emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil)), totalUnreadCount.0) + return (view.peers[view.peerId].flatMap(EnginePeer.init) ?? EnginePeer.init(TelegramUser(id: view.peerId, accessHash: nil, firstName: "RESTORED", lastName: "\(view.peerId.id._internalGetInt64Value())", username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: UserInfoFlags(), emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil)), totalUnreadCount.0) } |> distinctUntilChanged { lhs, rhs in if lhs.0 != rhs.0 { diff --git a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift index 004161f1a0..dc9cf2e78c 100644 --- a/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift +++ b/submodules/AttachmentTextInputPanelNode/Sources/AttachmentTextInputPanelNode.swift @@ -509,7 +509,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS } // MARK: Swiftgram - self.initToolbarIfNeeded() + self.initToolbarIfNeeded(context: context) } public var sendPressed: ((NSAttributedString?) -> Void)? @@ -636,7 +636,7 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS textInputNode.view.addGestureRecognizer(recognizer) textInputNode.textView.accessibilityHint = self.textPlaceholderNode.attributedText?.string - self.initToolbarIfNeeded() + self.initToolbarIfNeeded(context: self.context) } private func textFieldMaxHeight(_ maxHeight: CGFloat, metrics: LayoutMetrics) -> CGFloat { @@ -1914,10 +1914,10 @@ public class AttachmentTextInputPanelNode: ASDisplayNode, TGCaptionPanelView, AS // MARK: Swiftgram extension AttachmentTextInputPanelNode { - func initToolbarIfNeeded() { + func initToolbarIfNeeded(context: AccountContext) { guard #available(iOS 13.0, *) else { return } guard SGSimpleSettings.shared.inputToolbar else { return } - guard SGSimpleSettings.shared.b else { return } + guard context.sharedContext.immediateSGStatus.status > 1 else { return } guard self.toolbarNode == nil else { return } let toolbarView = ChatToolbarView( onQuote: { [weak self] in diff --git a/submodules/SettingsUI/Sources/Themes/ThemeSettingsAppIconItem.swift b/submodules/SettingsUI/Sources/Themes/ThemeSettingsAppIconItem.swift index 9a94ae2138..6342c1ba04 100644 --- a/submodules/SettingsUI/Sources/Themes/ThemeSettingsAppIconItem.swift +++ b/submodules/SettingsUI/Sources/Themes/ThemeSettingsAppIconItem.swift @@ -394,6 +394,15 @@ class ThemeSettingsAppIconItemNode: ListViewItemNode, ItemListItemNode { case "SGBeta": name = "β Beta" bordered = false + case "SGPro": + name = "Pro" + bordered = false + case "SGGold": + name = "Gold" + bordered = false + case "SGDucky": + name = "Ducky" + bordered = false case "BlueIcon": name = item.strings.Appearance_AppIconDefault case "BlackIcon": @@ -424,7 +433,7 @@ class ThemeSettingsAppIconItemNode: ListViewItemNode, ItemListItemNode { name = icon.name } - imageNode.setup(theme: item.theme, icon: image, title: NSAttributedString(string: name, font: selected ? selectedTextFont : textFont, textColor: selected ? item.theme.list.itemAccentColor : item.theme.list.itemPrimaryTextColor, paragraphAlignment: .center), locked: !item.isPremium && icon.isPremium, color: item.theme.list.itemPrimaryTextColor, bordered: bordered, selected: selected, action: { + imageNode.setup(theme: item.theme, icon: image, title: NSAttributedString(string: name, font: selected ? selectedTextFont : textFont, textColor: selected ? item.theme.list.itemAccentColor : item.theme.list.itemPrimaryTextColor, paragraphAlignment: .center), locked: !item.isPremium && icon.isSGPro, color: item.theme.list.itemPrimaryTextColor, bordered: bordered, selected: selected, action: { item.updated(icon) }) } diff --git a/submodules/SettingsUI/Sources/Themes/ThemeSettingsController.swift b/submodules/SettingsUI/Sources/Themes/ThemeSettingsController.swift index 5a0973a866..b69e1212a1 100644 --- a/submodules/SettingsUI/Sources/Themes/ThemeSettingsController.swift +++ b/submodules/SettingsUI/Sources/Themes/ThemeSettingsController.swift @@ -567,6 +567,13 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The controller?.replace(with: c) } pushControllerImpl?(controller) + // MARK: Swiftgram + } else if icon.isSGPro && context.sharedContext.immediateSGStatus.status < 2 { + if let payWallController = context.sharedContext.makeSGPayWallController(context: context) { + presentControllerImpl?(payWallController, ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + } else { + presentControllerImpl?(context.sharedContext.makeSGUpdateIOSController(), nil) + } } else { currentAppIconName.set(icon.name) context.sharedContext.applicationBindings.requestSetAlternateIconName(icon.name, { _ in @@ -1027,12 +1034,14 @@ public func themeSettingsController(context: AccountContext, focusOnItemTag: The }) }) - let signal = combineLatest(queue: .mainQueue(), context.sharedContext.presentationData, context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.presentationThemeSettings, SharedDataKeys.chatThemes, ApplicationSpecificSharedDataKeys.mediaDisplaySettings]), cloudThemes.get(), availableAppIcons, currentAppIconName.get(), removedThemeIndexesPromise.get(), animatedEmojiStickers, context.account.postbox.peerView(id: context.account.peerId), context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))) + let signal = combineLatest(queue: .mainQueue(), context.sharedContext.presentationData, context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.presentationThemeSettings, SharedDataKeys.chatThemes, ApplicationSpecificSharedDataKeys.mediaDisplaySettings, ApplicationSpecificSharedDataKeys.sgStatus]), cloudThemes.get(), availableAppIcons, currentAppIconName.get(), removedThemeIndexesPromise.get(), animatedEmojiStickers, context.account.postbox.peerView(id: context.account.peerId), context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId))) |> map { presentationData, sharedData, cloudThemes, availableAppIcons, currentAppIconName, removedThemeIndexes, animatedEmojiStickers, peerView, accountPeer -> (ItemListControllerState, (ItemListNodeState, Any)) in let settings = sharedData.entries[ApplicationSpecificSharedDataKeys.presentationThemeSettings]?.get(PresentationThemeSettings.self) ?? PresentationThemeSettings.defaultSettings let mediaSettings = sharedData.entries[ApplicationSpecificSharedDataKeys.mediaDisplaySettings]?.get(MediaDisplaySettings.self) ?? MediaDisplaySettings.defaultSettings - let isPremium = peerView.peers[peerView.peerId]?.isPremium ?? false + // MARK: Swiftgram + let sgStatus = sharedData.entries[ApplicationSpecificSharedDataKeys.sgStatus]?.get(SGStatus.self) ?? SGStatus.default + let isPremium = sgStatus.status > 1 let themeReference: PresentationThemeReference if presentationData.autoNightModeTriggered { diff --git a/submodules/TelegramPresentationData/Sources/PresentationData.swift b/submodules/TelegramPresentationData/Sources/PresentationData.swift index 8ad523ddd1..3923b5a349 100644 --- a/submodules/TelegramPresentationData/Sources/PresentationData.swift +++ b/submodules/TelegramPresentationData/Sources/PresentationData.swift @@ -46,8 +46,10 @@ public struct PresentationAppIcon: Equatable { public let imageName: String public let isDefault: Bool public let isPremium: Bool + public let isSGPro: Bool - public init(name: String, imageName: String, isDefault: Bool = false, isPremium: Bool = false) { + public init(isSGPro: Bool = false, name: String, imageName: String, isDefault: Bool = false, isPremium: Bool = false) { + self.isSGPro = isSGPro self.name = name self.imageName = imageName self.isDefault = isDefault diff --git a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesSettings.swift b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesSettings.swift index 01fcc66234..a414a11cb0 100644 --- a/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesSettings.swift +++ b/submodules/TelegramPresentationData/Sources/Resources/PresentationResourcesSettings.swift @@ -64,6 +64,7 @@ private func renderIcon(name: String, scaleFactor: CGFloat = 1.0, backgroundColo public struct PresentationResourcesSettings { public static let swiftgram = renderIcon(name: "SwiftgramSettings", scaleFactor: 30.0 / 512.0) + public static let swiftgramPro = renderIcon(name: "SwiftgramPro", scaleFactor: 30.0 / 256.0) public static let editProfile = renderIcon(name: "Settings/Menu/EditProfile") public static let proxy = renderIcon(name: "Settings/Menu/Proxy") public static let savedMessages = renderIcon(name: "Settings/Menu/SavedMessages") diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 951c3e9ef8..73678f073d 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -19,7 +19,9 @@ sgdeps = [ "//Swiftgram/SGDebugUI:SGDebugUI", "//Swiftgram/SGInputToolbar:SGInputToolbar", "//Swiftgram/SGIAP:SGIAP", - "//Swiftgram/SGPayWall:SGPayWall" + "//Swiftgram/SGPayWall:SGPayWall", + "//Swiftgram/SGProUI:SGProUI", + "//Swiftgram/SGKeychainBackupManager:SGKeychainBackupManager", # "//Swiftgram/SGContentAnalysis:SGContentAnalysis" ] diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift index 9c9243ce83..7e293f2c68 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift @@ -693,7 +693,7 @@ open class ChatMessageItemView: ListViewItemNode, ChatMessageItemNodeProtocol { open func setupItem(_ item: ChatMessageItem, synchronousLoad: Bool) { self.item = item - if !self.wasFilteredKeywordTested && !SGSimpleSettings.shared.messageFilterKeywords.isEmpty { + if !self.wasFilteredKeywordTested && !SGSimpleSettings.shared.messageFilterKeywords.isEmpty && SGSimpleSettings.shared.ephemeralStatus > 1 { let incomingMessage = item.message.effectivelyIncoming(item.context.account.peerId) if incomingMessage { if let matchedKeyword = SGSimpleSettings.shared.messageFilterKeywords.first(where: { item.message.text.contains($0) }) { diff --git a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift index 944796b9e5..f4fb5b9100 100644 --- a/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift +++ b/submodules/TelegramUI/Components/MessageInputPanelComponent/Sources/MessageInputPanelComponent.swift @@ -528,8 +528,8 @@ public final class MessageInputPanelComponent: Component { public var likeIconView: UIView? { return (self.likeButton.view as? MessageInputActionButtonComponent.View)?.likeIconView } - - override init(frame: CGRect) { + // MARK: Swifgtram + init(context: AccountContext, frame: CGRect) { self.fieldBackgroundView = BlurredBackgroundView(color: UIColor(white: 0.0, alpha: 0.5), enableBlur: true) self.vibrancyEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .dark))) @@ -573,7 +573,7 @@ public final class MessageInputPanelComponent: Component { ) // MARK: Swiftgram - self.initToolbarIfNeeded() + self.initToolbarIfNeeded(context: context) } required init?(coder: NSCoder) { @@ -2277,7 +2277,7 @@ public final class MessageInputPanelComponent: Component { } public func makeView() -> View { - return View(frame: CGRect()) + return View(context: self.context, frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { @@ -2328,10 +2328,10 @@ final class ViewForOverlayContent: UIView { extension MessageInputPanelComponent.View { - func initToolbarIfNeeded() { + func initToolbarIfNeeded(context: AccountContext) { guard #available(iOS 13.0, *) else { return } guard SGSimpleSettings.shared.inputToolbar else { return } - guard SGSimpleSettings.shared.b else { return } + guard context.sharedContext.immediateSGStatus.status > 1 else { return } guard self.toolbarView == nil else { return } let notificationName = Notification.Name("sgToolbarAction") let toolbar = ChatToolbarView( diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index bb75a2df3d..978f22a982 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -504,6 +504,7 @@ private enum PeerInfoContextSubject { private enum PeerInfoSettingsSection { case swiftgram + case swiftgramPro case avatar case edit case proxy @@ -947,7 +948,6 @@ private func settingsItems(showProfileId: Bool, data: PeerInfoScreenData?, conte } } - let sgSectionId = 0 // let locale = presentationData.strings.baseLanguageCode // MARK: Swiftgram let hasNewSGFeatures = { @@ -960,7 +960,10 @@ private func settingsItems(showProfileId: Bool, data: PeerInfoScreenData?, conte swiftgramLabel = .none } - items[.swiftgram]!.append(PeerInfoScreenDisclosureItem(id: sgSectionId, label: swiftgramLabel, text: "Swiftgram", icon: PresentationResourcesSettings.swiftgram, action: { + items[.swiftgram]!.append(PeerInfoScreenDisclosureItem(id: 0, label: .titleBadge(presentationData.strings.Settings_New, presentationData.theme.list.itemAccentColor), text: "Swiftgram Pro", icon: PresentationResourcesSettings.swiftgramPro, action: { + interaction.openSettings(.swiftgramPro) + })) + items[.swiftgram]!.append(PeerInfoScreenDisclosureItem(id: 1, label: swiftgramLabel, text: "Swiftgram", icon: PresentationResourcesSettings.swiftgram, action: { interaction.openSettings(.swiftgram) })) @@ -10247,6 +10250,16 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, PeerInfoScreenNodePro switch section { case .swiftgram: self.controller?.push(sgSettingsController(context: self.context)) + case .swiftgramPro: + if self.context.sharedContext.immediateSGStatus.status > 1 { + self.controller?.push(self.context.sharedContext.makeSGProController(context: self.context)) + } else { + if let payWallController = self.context.sharedContext.makeSGPayWallController(context: self.context) { + self.controller?.present(payWallController, in: .window(.root), with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet)) + } else { + self.controller?.present(self.context.sharedContext.makeSGUpdateIOSController(), animated: true) + } + } case .avatar: self.controller?.openAvatarForEditing() case .edit: diff --git a/submodules/TelegramUI/Sources/AppDelegate.swift b/submodules/TelegramUI/Sources/AppDelegate.swift index 92029fa5a5..bca2666b4a 100644 --- a/submodules/TelegramUI/Sources/AppDelegate.swift +++ b/submodules/TelegramUI/Sources/AppDelegate.swift @@ -850,6 +850,10 @@ private func extractAccountManagerState(records: AccountRecordsView 1 else { return } guard self.toolbarNode == nil else { return } let toolbarView = ChatToolbarView( onQuote: { [weak self] in diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 820309927f..32a1b28fa9 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -1,5 +1,9 @@ // MARK: Swiftgram import SGIAP +import SGPayWall +import SGProUI +import SGSimpleSettings +// import Foundation import UIKit import AsyncDisplayKit @@ -484,6 +488,8 @@ public final class SharedAccountContextImpl: SharedAccountContext { |> deliverOnMainQueue).start(next: { sharedData in if let settings = sharedData.entries[ApplicationSpecificSharedDataKeys.sgStatus]?.get(SGStatus.self) { let _ = immediateSGStatusValue.swap(settings) + SGSimpleSettings.shared.ephemeralStatus = settings.status + SGSimpleSettings.shared.status = settings.status } }) self.initSGIAP(isMainApp: applicationBindings.isMainApp) @@ -3050,4 +3056,39 @@ extension SharedAccountContextImpl { self.SGIAP = nil } } + + public func makeSGProController(context: AccountContext) -> ViewController { + let controller = sgProController(context: context) + return controller + } + + public func makeSGPayWallController(context: AccountContext) -> ViewController? { + guard #available(iOS 13.0, *) else { + return nil + } + guard let sgIAP = self.SGIAP else { + return nil + } + + let statusSignal = self.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.sgStatus]) + |> map { sharedData -> Int64 in + let sgStatus = sharedData.entries[ApplicationSpecificSharedDataKeys.sgStatus]?.get(SGStatus.self) ?? SGStatus.default + return sgStatus.status + } + + let proController = self.makeSGProController(context: context) + let payWallController = sgPayWallController(statusSignal: statusSignal, replacementController: proController, presentationData: self.currentPresentationData.with { $0 }, SGIAPManager: sgIAP, openUrl: self.applicationBindings.openUrl) + return payWallController + } + + public func makeSGUpdateIOSController() -> ViewController { + let presentationData = self.currentPresentationData.with { $0 } + let controller = UndoOverlayController( + presentationData: presentationData, + content: .info(title: nil, text: "Common.UpdateOS".i18n(presentationData.strings.baseLanguageCode), timeout: nil, customUndoText: nil), + elevatedLayout: false, + action: { _ in return false } + ) + return controller + } }