diff --git a/CoreData/ApplicationSettings.swift b/CoreData/ApplicationSettings.swift index 82b2a09..16dc43a 100644 --- a/CoreData/ApplicationSettings.swift +++ b/CoreData/ApplicationSettings.swift @@ -9,33 +9,34 @@ import SwiftData import EnvironmentKit @Model final public class ApplicationSettings { - public var currentAccount: String? - public var theme: Int32 - public var tintColor: Int32 - public var avatarShape: Int32 - public var activeIcon: String - public var lastRefreshTokens: Date + public var currentAccount: String? = nil + public var theme: Int32 = Int32(Theme.system.rawValue) + public var tintColor: Int32 = Int32(TintColor.accentColor2.rawValue) + public var avatarShape: Int32 = Int32(AvatarShape.circle.rawValue) + public var activeIcon: String = "Default" + public var lastRefreshTokens: Date = Date.distantPast - public var hapticTabSelectionEnabled: Bool - public var hapticRefreshEnabled: Bool - public var hapticButtonPressEnabled: Bool - public var hapticAnimationEnabled: Bool - public var hapticNotificationEnabled: Bool + public var hapticTabSelectionEnabled: Bool = true + public var hapticRefreshEnabled: Bool = true + public var hapticButtonPressEnabled: Bool = true + public var hapticAnimationEnabled: Bool = true + public var hapticNotificationEnabled: Bool = true - public var showSensitive: Bool - public var showPhotoDescription: Bool - public var menuPosition: Int32 - public var showAvatarsOnTimeline: Bool - public var showFavouritesOnTimeline: Bool - public var showAltIconOnTimeline: Bool - public var warnAboutMissingAlt: Bool - public var showGridOnUserProfile: Bool - public var showReboostedStatuses: Bool - public var hideStatusesWithoutAlt: Bool + public var showSensitive: Bool = false + public var showApplicationBadge: Bool = false + public var showPhotoDescription: Bool = false + public var menuPosition: Int32 = Int32(MenuPosition.top.rawValue) + public var showAvatarsOnTimeline: Bool = false + public var showFavouritesOnTimeline: Bool = false + public var showAltIconOnTimeline: Bool = false + public var warnAboutMissingAlt: Bool = true + public var showGridOnUserProfile: Bool = false + public var showReboostedStatuses: Bool = false + public var hideStatusesWithoutAlt: Bool = false - public var customNavigationMenuItem1: Int32 - public var customNavigationMenuItem2: Int32 - public var customNavigationMenuItem3: Int32 + public var customNavigationMenuItem1: Int32 = 1 + public var customNavigationMenuItem2: Int32 = 2 + public var customNavigationMenuItem3: Int32 = 5 init( currentAccount: String? = nil, @@ -50,6 +51,7 @@ import EnvironmentKit hapticAnimationEnabled: Bool = true, hapticNotificationEnabled: Bool = true, showSensitive: Bool = false, + showApplicationBadge: Bool = false, showPhotoDescription: Bool = false, menuPosition: Int32 = Int32(MenuPosition.top.rawValue), showAvatarsOnTimeline: Bool = false, @@ -75,6 +77,7 @@ import EnvironmentKit self.hapticAnimationEnabled = hapticAnimationEnabled self.hapticNotificationEnabled = hapticNotificationEnabled self.showSensitive = showSensitive + self.showApplicationBadge = showApplicationBadge self.showPhotoDescription = showPhotoDescription self.menuPosition = menuPosition self.showAvatarsOnTimeline = showAvatarsOnTimeline diff --git a/CoreData/ApplicationSettingsHandler.swift b/CoreData/ApplicationSettingsHandler.swift index 2bf0ac4..ae7efc7 100644 --- a/CoreData/ApplicationSettingsHandler.swift +++ b/CoreData/ApplicationSettingsHandler.swift @@ -67,6 +67,7 @@ class ApplicationSettingsHandler { applicationState.showGridOnUserProfile = defaultSettings.showGridOnUserProfile applicationState.showReboostedStatuses = defaultSettings.showReboostedStatuses applicationState.hideStatusesWithoutAlt = defaultSettings.hideStatusesWithoutAlt + applicationState.showApplicationBadge = defaultSettings.showApplicationBadge if let menuPosition = MenuPosition(rawValue: Int(defaultSettings.menuPosition)) { applicationState.menuPosition = menuPosition @@ -138,6 +139,12 @@ class ApplicationSettingsHandler { defaultSettings.showSensitive = showSensitive try? modelContext.save() } + + func set(showApplicationBadge: Bool, modelContext: ModelContext) { + let defaultSettings = self.get(modelContext: modelContext) + defaultSettings.showApplicationBadge = showApplicationBadge + try? modelContext.save() + } func set(showPhotoDescription: Bool, modelContext: ModelContext) { let defaultSettings = self.get(modelContext: modelContext) diff --git a/EnvironmentKit/Sources/EnvironmentKit/ApplicationState.swift b/EnvironmentKit/Sources/EnvironmentKit/ApplicationState.swift index b1cf5ef..8e38291 100644 --- a/EnvironmentKit/Sources/EnvironmentKit/ApplicationState.swift +++ b/EnvironmentKit/Sources/EnvironmentKit/ApplicationState.swift @@ -38,8 +38,8 @@ import ClientKit /// Last notification seen by the user. public var lastSeenNotificationId: String? - /// Information about new notifications. - public var newNotificationsHasBeenAdded = false + /// Amount of new notifications. + public var amountOfNewNotifications = 0 /// Last status seen by the user. public var lastSeenStatusId: String? @@ -119,12 +119,15 @@ import ClientKit /// Hide statuses without ALT text. public var hideStatusesWithoutAlt = false + /// Should show application badge. + public var showApplicationBadge = false + public func changeApplicationState(accountModel: AccountModel, instance: Instance?, lastSeenStatusId: String?, lastSeenNotificationId: String?) { self.account = accountModel self.lastSeenNotificationId = lastSeenNotificationId self.lastSeenStatusId = lastSeenStatusId self.amountOfNewStatuses = 0 - self.newNotificationsHasBeenAdded = false + self.amountOfNewNotifications = 0 if let statusesConfiguration = instance?.configuration?.statuses { self.statusMaxCharacters = statusesConfiguration.maxCharacters @@ -142,7 +145,7 @@ import ClientKit self.lastSeenStatusId = nil self.lastSeenNotificationId = nil self.amountOfNewStatuses = 0 - self.newNotificationsHasBeenAdded = false + self.amountOfNewNotifications = 0 self.statusMaxCharacters = ApplicationState.defaults.statusMaxCharacters self.statusMaxMediaAttachments = ApplicationState.defaults.statusMaxMediaAttachments diff --git a/EnvironmentKit/Sources/EnvironmentKit/Models/AppConstants.swift b/EnvironmentKit/Sources/EnvironmentKit/Models/AppConstants.swift index 1801189..0e7839a 100644 --- a/EnvironmentKit/Sources/EnvironmentKit/Models/AppConstants.swift +++ b/EnvironmentKit/Sources/EnvironmentKit/Models/AppConstants.swift @@ -22,5 +22,6 @@ public struct AppConstants { public static let accountUri = "\(AppConstants.accountScheme)://\(accountCallbackPart)" public static let imagePipelineCacheName = "dev.mczachurski.Vernissage.DataCache" + public static let backgroundFetcherName = "dev.mczachurski.Vernissage.NotificationFetcher" public static let coreDataPersistantContainerName = "Vernissage" } diff --git a/Localization/Localizable.xcstrings b/Localization/Localizable.xcstrings index 11ad321..92acf55 100644 --- a/Localization/Localizable.xcstrings +++ b/Localization/Localizable.xcstrings @@ -4383,6 +4383,22 @@ } } }, + "settings.error.notificationEnableFailed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error during enabling notifications." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Błąd podczas włączania powiadomień." + } + } + } + }, "settings.navigationBar.title" : { "comment" : "Settings view.", "localizations" : { @@ -5454,6 +5470,56 @@ } } }, + "settings.title.notifications" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifications" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Powiadomienia" + } + } + } + }, + "settings.title.notificationsDescription" : { + "comment" : "Application badge with amount of new notifications will be visible near the app icon.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Application badge with amount of new notifications will be visible new the app icon." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Licznik z ilością nowych powiadomień będzie wyświetlany obok ikony aplikacji." + } + } + } + }, + "settings.title.notificationsTitle" : { + "comment" : "Show application badge", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show application badge" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wyświetl ilość powiadomień" + } + } + } + }, "settings.title.other" : { "localizations" : { "en" : { diff --git a/Vernissage.xcodeproj/project.pbxproj b/Vernissage.xcodeproj/project.pbxproj index 06a69f8..9d62a3a 100644 --- a/Vernissage.xcodeproj/project.pbxproj +++ b/Vernissage.xcodeproj/project.pbxproj @@ -84,6 +84,7 @@ F87AEB922986C44E00434FB6 /* AuthorizationSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87AEB912986C44E00434FB6 /* AuthorizationSession.swift */; }; F87AEB972986D16D00434FB6 /* AuthorisationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87AEB962986D16D00434FB6 /* AuthorisationError.swift */; }; F883402029B62AE900C3E096 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F883401F29B62AE900C3E096 /* SearchView.swift */; }; + F886BBAC2AE7CF510083152B /* NotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F886BBAB2AE7CF510083152B /* NotificationView.swift */; }; F88AB05329B3613900345EDE /* PhotoUrl.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88AB05229B3613900345EDE /* PhotoUrl.swift */; }; F88AB05529B3626300345EDE /* ImageGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88AB05429B3626300345EDE /* ImageGrid.swift */; }; F88AB05829B36B8200345EDE /* AccountsPhotoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F88AB05729B36B8200345EDE /* AccountsPhotoView.swift */; }; @@ -281,6 +282,7 @@ F87AEB912986C44E00434FB6 /* AuthorizationSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationSession.swift; sourceTree = ""; }; F87AEB962986D16D00434FB6 /* AuthorisationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorisationError.swift; sourceTree = ""; }; F883401F29B62AE900C3E096 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; + F886BBAB2AE7CF510083152B /* NotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationView.swift; sourceTree = ""; }; F88AB05229B3613900345EDE /* PhotoUrl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoUrl.swift; sourceTree = ""; }; F88AB05429B3626300345EDE /* ImageGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGrid.swift; sourceTree = ""; }; F88AB05729B36B8200345EDE /* AccountsPhotoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsPhotoView.swift; sourceTree = ""; }; @@ -634,6 +636,7 @@ F8B05ACA29B489B100857221 /* HapticsSectionView.swift */, F8B05ACD29B48E2F00857221 /* MediaSettingsView.swift */, F8DF38E529DDB98A0047F1AA /* SocialsSectionView.swift */, + F886BBAB2AE7CF510083152B /* NotificationView.swift */, ); path = Subviews; sourceTree = ""; @@ -1162,6 +1165,7 @@ F89A46DC296EAACE0062125F /* SettingsView.swift in Sources */, F866F6AE29606367002E8F88 /* ApplicationViewMode.swift in Sources */, F8675DD02A1FA40500A89959 /* WaterfallGrid.swift in Sources */, + F886BBAC2AE7CF510083152B /* NotificationView.swift in Sources */, F85D4DFE29B78C8400345267 /* HashtagModel.swift in Sources */, F866F6AA29605AFA002E8F88 /* SceneDelegate.swift in Sources */, ); diff --git a/Vernissage/AppDelegate.swift b/Vernissage/AppDelegate.swift index 85ae120..d4d9e2b 100644 --- a/Vernissage/AppDelegate.swift +++ b/Vernissage/AppDelegate.swift @@ -15,5 +15,5 @@ class AppDelegate: NSObject, UIApplicationDelegate { let sceneConfig: UISceneConfiguration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) sceneConfig.delegateClass = SceneDelegate.self return sceneConfig - } + } } diff --git a/Vernissage/Info.plist b/Vernissage/Info.plist index 7bf2d1b..e9452e4 100644 --- a/Vernissage/Info.plist +++ b/Vernissage/Info.plist @@ -2,10 +2,10 @@ - ITSAppUsesNonExemptEncryption - - LSMinimumSystemVersion - 14.0 + BGTaskSchedulerPermittedIdentifiers + + dev.mczachurski.Vernissage.NotificationFetcher + CFBundleURLTypes @@ -25,10 +25,19 @@ + ITSAppUsesNonExemptEncryption + + LSMinimumSystemVersion + 14.0 UIApplicationSceneManifest UISceneConfigurations + UIBackgroundModes + + fetch + processing + diff --git a/Vernissage/SceneDelegate.swift b/Vernissage/SceneDelegate.swift index d1b7c55..b7d3c9e 100644 --- a/Vernissage/SceneDelegate.swift +++ b/Vernissage/SceneDelegate.swift @@ -8,6 +8,7 @@ import SwiftUI import PixelfedKit import OAuthSwift import EnvironmentKit +import BackgroundTasks class SceneDelegate: NSObject, UISceneDelegate { func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { diff --git a/Vernissage/Services/NotificationsService.swift b/Vernissage/Services/NotificationsService.swift index 6b78b68..ed8d394 100644 --- a/Vernissage/Services/NotificationsService.swift +++ b/Vernissage/Services/NotificationsService.swift @@ -13,6 +13,7 @@ import Nuke import OSLog import EnvironmentKit import Semaphore +import UserNotifications /// Service responsible for managing notifications. @MainActor @@ -46,6 +47,58 @@ public class NotificationsService { } } + public func amountOfNewNotifications(for account: AccountModel, modelContext: ModelContext) async -> Int { + await semaphore.wait() + defer { semaphore.signal() } + + guard let accessToken = account.accessToken else { + return 0 + } + + // Get maximimum downloaded stauts id. + guard let lastSeenNotificationId = self.getLastSeenNotificationId(accountId: account.id, modelContext: modelContext) else { + return 0 + } + + let client = PixelfedClient(baseURL: account.serverUrl).getAuthenticated(token: accessToken) + var notifications: [PixelfedKit.Notification] = [] + var newestNotificationId = lastSeenNotificationId + + // There can be more then 80 newest notifications, that's why we have to sometimes send more then one request. + while true { + do { + let downloadedNotifications = try await client.notifications(minId: newestNotificationId, limit: 80) + + guard let firstNotification = downloadedNotifications.data.first else { + break + } + + let visibleNotifications = downloadedNotifications.data.filter({ $0.id != lastSeenNotificationId }) + + notifications.append(contentsOf: visibleNotifications) + newestNotificationId = firstNotification.id + } catch { + ErrorService.shared.handle(error, message: "global.error.errorDuringDownloadingNewStatuses") + break + } + } + + // Return number of new notifications not visible yet on the timeline. + return notifications.count + } + + /// Function sets application badge counts when notifications (and badge) are enabled. + public func setBadgeCount(_ count: Int) async throws { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + + guard (settings.authorizationStatus == .authorized) || (settings.authorizationStatus == .provisional) else { return } + + if settings.badgeSetting == .enabled { + try await center.setBadgeCount(count) + } + } + private func getLastSeenNotificationId(accountId: String, modelContext: ModelContext) -> String? { let accountData = AccountDataHandler.shared.getAccountData(accountId: accountId, modelContext: modelContext) return accountData?.lastSeenNotificationId diff --git a/Vernissage/VernissageApp.swift b/Vernissage/VernissageApp.swift index 7208cca..60a199a 100644 --- a/Vernissage/VernissageApp.swift +++ b/Vernissage/VernissageApp.swift @@ -12,10 +12,12 @@ import EnvironmentKit import WidgetKit import SwiftData import TipKit +import BackgroundTasks @main struct VernissageApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @Environment(\.scenePhase) private var phase @State var applicationState = ApplicationState.shared @State var client = Client.shared @@ -102,6 +104,17 @@ struct VernissageApp: App { } } } + .onChange(of: phase) { oldValue, newValue in + switch newValue { + case .background: scheduleAppRefresh() + default: break + } + } + .backgroundTask(.appRefresh(AppConstants.backgroundFetcherName)) { + Task { @MainActor in + await self.setBadgeCount() + } + } } @MainActor @@ -127,7 +140,7 @@ struct VernissageApp: App { self.applicationViewMode = .signIn return } - + // Create model based on core data entity. let accountModel = currentAccount.toAccountModel() @@ -228,7 +241,21 @@ struct VernissageApp: App { let modelContext = self.modelContainer.mainContext if let account = self.applicationState.account { - self.applicationState.newNotificationsHasBeenAdded = await NotificationsService.shared.newNotificationsHasBeenAdded(for: account, modelContext: modelContext) + self.applicationState.amountOfNewStatuses = await NotificationsService.shared.amountOfNewNotifications(for: account, modelContext: modelContext) + try? await NotificationsService.shared.setBadgeCount(self.applicationState.amountOfNewStatuses) + } else { + try? await NotificationsService.shared.setBadgeCount(0) } } + + private func scheduleAppRefresh() { + let request = BGAppRefreshTaskRequest(identifier: AppConstants.backgroundFetcherName) + request.earliestBeginDate = .now.addingTimeInterval(3600) + try? BGTaskScheduler.shared.submit(request) + } + + private func setBadgeCount() async { + await self.calculateNewNotificationsInBackground() + scheduleAppRefresh() + } } diff --git a/Vernissage/Views/MainView.swift b/Vernissage/Views/MainView.swift index 0b17322..578a388 100644 --- a/Vernissage/Views/MainView.swift +++ b/Vernissage/Views/MainView.swift @@ -90,9 +90,9 @@ struct MainView: View { Image(systemName: "person.crop.circle") case .notifications: if applicationState.menuPosition == .top { - applicationState.newNotificationsHasBeenAdded ? Image(systemName: "bell.badge") : Image(systemName: "bell") + applicationState.amountOfNewNotifications > 0 ? Image(systemName: "bell.badge") : Image(systemName: "bell") } else { - applicationState.newNotificationsHasBeenAdded + applicationState.amountOfNewNotifications > 0 ? AnyView( Image(systemName: "bell.badge") .symbolRenderingMode(.palette) @@ -349,7 +349,7 @@ struct MainView: View { private func calculateNewNotificationsInBackground() async { if let account = self.applicationState.account { - self.applicationState.newNotificationsHasBeenAdded = await NotificationsService.shared.newNotificationsHasBeenAdded(for: account, modelContext: modelContext) + self.applicationState.amountOfNewNotifications = await NotificationsService.shared.amountOfNewNotifications(for: account, modelContext: modelContext) } } } diff --git a/Vernissage/Views/NotificationsView/NotificationsView.swift b/Vernissage/Views/NotificationsView/NotificationsView.swift index 56979c0..035dae7 100644 --- a/Vernissage/Views/NotificationsView/NotificationsView.swift +++ b/Vernissage/Views/NotificationsView/NotificationsView.swift @@ -98,7 +98,10 @@ struct NotificationsView: View { } try AccountDataHandler.shared.update(lastSeenNotificationId: linkable.data.first?.id, applicationState: self.applicationState, modelContext: modelContext) - self.applicationState.newNotificationsHasBeenAdded = false + + // Refresh infomation about viewed notifications. + self.applicationState.amountOfNewNotifications = 0 + try? await NotificationsService.shared.setBadgeCount(0) } } catch { if !Task.isCancelled { @@ -135,7 +138,10 @@ struct NotificationsView: View { } try AccountDataHandler.shared.update(lastSeenNotificationId: linkable.data.first?.id, applicationState: self.applicationState, modelContext: modelContext) - self.applicationState.newNotificationsHasBeenAdded = false + + // Refresh infomation about viewed notifications. + self.applicationState.amountOfNewNotifications = 0 + try? await NotificationsService.shared.setBadgeCount(0) self.minId = linkable.link?.minId self.notifications.insert(contentsOf: linkable.data, at: 0) diff --git a/Vernissage/Views/SettingsView/SettingsView.swift b/Vernissage/Views/SettingsView/SettingsView.swift index 7a72d12..5bae7e3 100644 --- a/Vernissage/Views/SettingsView/SettingsView.swift +++ b/Vernissage/Views/SettingsView/SettingsView.swift @@ -39,6 +39,9 @@ struct SettingsView: View { // Avatar shapes. AvatarShapesSectionView() + // Notifications. + NotificationView() + // Media settings view. MediaSettingsView() diff --git a/Vernissage/Views/SettingsView/Subviews/NotificationView.swift b/Vernissage/Views/SettingsView/Subviews/NotificationView.swift new file mode 100644 index 0000000..8058bf9 --- /dev/null +++ b/Vernissage/Views/SettingsView/Subviews/NotificationView.swift @@ -0,0 +1,46 @@ +// +// https://mczachurski.dev +// Copyright © 2023 Marcin Czachurski and the repository contributors. +// Licensed under the Apache License 2.0. +// + +import SwiftUI +import EnvironmentKit +import ServicesKit + +struct NotificationView: View { + @Environment(ApplicationState.self) var applicationState + @Environment(\.colorScheme) var colorScheme + @Environment(\.modelContext) private var modelContext + + var body: some View { + @Bindable var applicationState = applicationState + + Section("settings.title.notifications") { + + Toggle(isOn: $applicationState.showApplicationBadge) { + VStack(alignment: .leading) { + Text("settings.title.notificationsTitle", comment: "Show application badge") + Text("settings.title.notificationsDescription", comment: "Application badge with amount of new notifications will be visible near the app icon.") + .font(.footnote) + .foregroundColor(.customGrayColor) + } + } + .onChange(of: self.applicationState.showApplicationBadge) { oldValue, newValue in + Task { @MainActor in + do { + ApplicationSettingsHandler.shared.set(showApplicationBadge: newValue, modelContext: modelContext) + if newValue { + let center = UNUserNotificationCenter.current() + _ = try await center.requestAuthorization(options: [.alert, .sound, .badge]) + } else { + try await NotificationsService.shared.setBadgeCount(0) + } + } catch { + ErrorService.shared.handle(error, message: "settings.error.notificationEnableFailed", showToastr: false) + } + } + } + } + } +}