From 4e10527f03d2c0e4008e769b955f249423ce11d2 Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Thu, 9 Jan 2025 18:50:44 +0530 Subject: [PATCH] [Widgets]: (complete) Fixed previous pagination issues for ActiveAppsWidget --- AltStore.xcodeproj/project.pbxproj | 8 +- AltWidget/AppsTimelineProvider.swift | 49 ++++----- AltWidget/Extensions/View+AltWidget.swift | 4 +- AltWidget/Intents/PaginationIntent.swift | 27 +++-- AltWidget/Widgets/ActiveAppsWidget.swift | 63 ++++++----- AltWidget/Widgets/PaginationDataHolder.swift | 72 +++++++++++++ AltWidget/Widgets/PaginationViewModel.swift | 105 ------------------- 7 files changed, 154 insertions(+), 174 deletions(-) create mode 100644 AltWidget/Widgets/PaginationDataHolder.swift delete mode 100644 AltWidget/Widgets/PaginationViewModel.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 22da75f8..e8a752b5 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -56,7 +56,7 @@ A809F6A82D04DA1900F0F0F3 /* minimuxer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A809F6A32D04DA1900F0F0F3 /* minimuxer.swift */; }; A809F6A92D04DA1900F0F0F3 /* SwiftBridgeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A809F6A72D04DA1900F0F0F3 /* SwiftBridgeCore.swift */; }; A80D790D2D2F20AF00A40F40 /* PaginationIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D790C2D2F20AF00A40F40 /* PaginationIntent.swift */; }; - A80D790F2D2F217000A40F40 /* PaginationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D790E2D2F217000A40F40 /* PaginationViewModel.swift */; }; + A80D790F2D2F217000A40F40 /* PaginationDataHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D790E2D2F217000A40F40 /* PaginationDataHolder.swift */; }; A82067842D03DC0600645C0D /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; }; A82067C42D03E0DE00645C0D /* SemanticVersion in Frameworks */ = {isa = PBXBuildFile; productRef = A82067C32D03E0DE00645C0D /* SemanticVersion */; }; A859ED5C2D1EE827003DCC58 /* OpenSSL.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */; }; @@ -627,7 +627,7 @@ A809F6A62D04DA1900F0F0F3 /* SwiftBridgeCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SwiftBridgeCore.h; sourceTree = ""; }; A809F6A72D04DA1900F0F0F3 /* SwiftBridgeCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBridgeCore.swift; sourceTree = ""; }; A80D790C2D2F20AF00A40F40 /* PaginationIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIntent.swift; sourceTree = ""; }; - A80D790E2D2F217000A40F40 /* PaginationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationViewModel.swift; sourceTree = ""; }; + A80D790E2D2F217000A40F40 /* PaginationDataHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationDataHolder.swift; sourceTree = ""; }; A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */ = {isa = PBXFileReference; expectedSignature = "AppleDeveloperProgram:67RAULRX93:Marcin Krzyzanowski"; lastKnownFileType = wrapper.xcframework; name = OpenSSL.xcframework; path = SideStore/AltSign/Dependencies/OpenSSL/Frameworks/OpenSSL.xcframework; sourceTree = ""; }; A85ACB8E2D1F31C400AA3DE7 /* AltBackup.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltBackup.xcconfig; sourceTree = ""; }; A85ACB8F2D1F31C400AA3DE7 /* AltStore.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltStore.debug.xcconfig; sourceTree = ""; }; @@ -2080,7 +2080,7 @@ D50C29F22A8ECD71009AB488 /* Widgets */ = { isa = PBXGroup; children = ( - A80D790E2D2F217000A40F40 /* PaginationViewModel.swift */, + A80D790E2D2F217000A40F40 /* PaginationDataHolder.swift */, D577AB7E2A96878A007FE952 /* AppDetailWidget.swift */, D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */, BF98917D250AAC4F002ACF50 /* LockScreenWidget.swift */, @@ -2919,7 +2919,7 @@ A800F7042CE28E3800208744 /* View+AltWidget.swift in Sources */, BF98917F250AAC4F002ACF50 /* LockScreenWidget.swift in Sources */, D5FD4EC52A952EAD0097BEE8 /* AltWidgetBundle.swift in Sources */, - A80D790F2D2F217000A40F40 /* PaginationViewModel.swift in Sources */, + A80D790F2D2F217000A40F40 /* PaginationDataHolder.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/AltWidget/AppsTimelineProvider.swift b/AltWidget/AppsTimelineProvider.swift index 5f8f0a1a..e44ac268 100644 --- a/AltWidget/AppsTimelineProvider.swift +++ b/AltWidget/AppsTimelineProvider.swift @@ -24,29 +24,12 @@ struct AppsTimelineProvider { typealias Entry = AppsEntry - private var viewModel: PaginationViewModel? - private let widgetID: String? + private var dataHolder: PaginationDataHolder? - init(_ viewModel: PaginationViewModel? = nil){ - self.viewModel = viewModel - self.widgetID = viewModel?.widgetID + init(_ dataHolder: PaginationDataHolder? = nil){ + self.dataHolder = dataHolder } - private func reloadEntries(apps: [AppSnapshot]){ - guard let viewModel = self.viewModel else { return } - - let entries = Set(viewModel.backup_entries.map{ (app: AppSnapshot) in app.bundleIdentifier }) - let app_entries = Set(apps.map{ (app: AppSnapshot) in app.bundleIdentifier }) - - // this updates the in-memory entries - if entries.isEmpty || entries != app_entries{ - self.viewModel?.setEntries(apps) - // initialize the view - self.viewModel?.handlePagination(.up) - } - } - - func placeholder(in context: TimelineProviderContext) -> AppsEntry { return AppsEntry(date: Date(), apps: [], isPlaceholder: true) @@ -58,10 +41,9 @@ struct AppsTimelineProvider { try await self.prepare() - let apps = try await self.fetchApps(withBundleIDs: appBundleIDs) + var apps = try await self.fetchApps(withBundleIDs: appBundleIDs) - // send this for pagination - reloadEntries(apps: apps) + apps = getUpdatedData(apps) let entry = AppsEntry(date: Date(), apps: apps) return entry @@ -81,10 +63,9 @@ struct AppsTimelineProvider { try await self.prepare() - let apps = try await self.fetchApps(withBundleIDs: appBundleIDs) + var apps = try await self.fetchApps(withBundleIDs: appBundleIDs) - // send this for pagination - reloadEntries(apps: apps) + apps = getUpdatedData(apps) let entries = self.makeEntries(for: apps) let timeline = Timeline(entries: entries, policy: .atEnd) @@ -103,6 +84,22 @@ struct AppsTimelineProvider private extension AppsTimelineProvider { + + private func getUpdatedData(_ apps: [AppSnapshot]) -> [AppSnapshot]{ + var apps = apps + +// #if DEBUG +// // this dummy data is for simulator (uncomment when testing ActiveAppsWidget pagination) +// apps = apps + apps + apps + apps + apps + apps + apps + apps +// #endif + + if let dataHolder{ + // get paged data if present if available + apps = dataHolder.getUpdatedData(entries: apps) + } + return apps + } + func prepare() async throws { try await DatabaseManager.shared.start() diff --git a/AltWidget/Extensions/View+AltWidget.swift b/AltWidget/Extensions/View+AltWidget.swift index 7bdd0cbd..b9a61a5a 100644 --- a/AltWidget/Extensions/View+AltWidget.swift +++ b/AltWidget/Extensions/View+AltWidget.swift @@ -55,7 +55,7 @@ extension View } @ViewBuilder - func pageUpButton(widgetID: String) -> some View { + func pageUpButton(_ widgetID: String) -> some View { if #available(iOSApplicationExtension 17, *) { Button(intent: PaginationIntent(.up, widgetID)){ self @@ -67,7 +67,7 @@ extension View } @ViewBuilder - func pageDownButton(widgetID: String) -> some View { + func pageDownButton(_ widgetID: String) -> some View { if #available(iOSApplicationExtension 17, *) { Button(intent: PaginationIntent(.down, widgetID)){ self diff --git a/AltWidget/Intents/PaginationIntent.swift b/AltWidget/Intents/PaginationIntent.swift index 733c84ff..bde9b99f 100644 --- a/AltWidget/Intents/PaginationIntent.swift +++ b/AltWidget/Intents/PaginationIntent.swift @@ -9,13 +9,13 @@ import AppIntents import WidgetKit -public enum Direction: String{ +public enum Direction: String, Sendable{ case up = "up" case down = "down" } @available(iOS 17, *) -struct PaginationIntent: AppIntent, @unchecked Sendable { +class PaginationIntent: AppIntent, @unchecked Sendable { static var title: LocalizedStringResource { "Scroll up or down in Active Apps Widget" } static var isDiscoverable: Bool { false } @@ -25,7 +25,11 @@ struct PaginationIntent: AppIntent, @unchecked Sendable { @Parameter(title: "Widget Identifier") var widgetID: String - init(){} + private lazy var widgetHolderQ = { + DispatchQueue(label: widgetID) + }() + + required init(){} init(_ direction: Direction, _ widgetID: String){ self.direction = direction.rawValue @@ -33,13 +37,20 @@ struct PaginationIntent: AppIntent, @unchecked Sendable { } func perform() async throws -> some IntentResult { - let direction = Direction(rawValue: direction)! - guard let viewModel = PaginationViewModel.instance(widgetID) else{ - print("viewModel for widgetID: \(widgetID) not found, ignoring request") + guard let direction = Direction(rawValue: self.direction) else { return .result() } - viewModel.handlePagination(direction) - WidgetCenter.shared.reloadTimelines(ofKind: viewModel.widgetID) + + widgetHolderQ.sync { + // update direction for this widgetID + let dataholder = PaginationDataHolder.holder(for: self.widgetID) + dataholder?.updateDirection(direction) + + // ask widget views to be re-drawn by triggering timeline update + // for the widget uniquely identified by the 'kind: widgetID' + WidgetCenter.shared.reloadTimelines(ofKind: self.widgetID) + } + return .result() } } diff --git a/AltWidget/Widgets/ActiveAppsWidget.swift b/AltWidget/Widgets/ActiveAppsWidget.swift index f41dbf1a..6ccb9c19 100644 --- a/AltWidget/Widgets/ActiveAppsWidget.swift +++ b/AltWidget/Widgets/ActiveAppsWidget.swift @@ -1,5 +1,5 @@ // -// HomeScreenWidget.swift +// ActiveAppsWidget.swift // AltWidgetExtension // // Created by Riley Testut on 8/16/23. @@ -8,10 +8,6 @@ import SwiftUI import WidgetKit -import CoreData - -import AltStoreCore -import AltSign private extension Color { @@ -24,24 +20,40 @@ private extension Color //@available(iOS 17, *) struct ActiveAppsWidget: Widget { - private var viewModel = PaginationViewModel.getNewInstance( - "ActiveApps" + UUID().uuidString - ) + // only constants/singleton what needs to be for the life of all widgets of ActiveAppsWidget type + // should be declared as instance or class fields for ActiveAppWidgets + // NOTE: The computed property (widget)body is recomputed/re-run for every + // 'type' refers to struct/class types and 'kind' refers to the tag which the widget is marked with + // multiple instances of same type or multiple types can be tagged with same 'kind' + // or each instance of same kind too, can be tagged as different(unique) 'kind' + // 1. widget-resizing(of same type) + // 2. new widget addition(of same type) public var body: some WidgetConfiguration { if #available(iOS 17, *) { + let kind = "ActiveApps" + let widgetID = kind + "-" + UUID().uuidString + + let holder = PaginationDataHolder.instance(widgetID) + let timelineProvider = AppsTimelineProvider(holder) // pass the holder + + // each instance of this widget type is identified by unique 'kind' tag + // so that a reloadTimelineFor(kind:) will trigger reload only for that instance let staticConfig = StaticConfiguration( - kind: viewModel.widgetID, - provider: AppsTimelineProvider(viewModel) + kind: widgetID, + provider: timelineProvider ) { entry in - ActiveAppsWidgetView(entry: entry, viewModel: viewModel) + // actual view of the widget + // this gets recreated for each trigger from the scheduled timeline entries provided by the timeline provider + // NOTE: widget views do not adhere to statefulness + // so, Combine constructs such as @State, @StateObject, @ObservedObject etc are simply ignored + ActiveAppsWidgetView(entry: entry, widgetID: widgetID) } .supportedFamilies([.systemMedium]) .configurationDisplayName("Active Apps") .description("View remaining days until your active apps expire. Tap the countdown timers to refresh them in the background.") - // this widgetConfiguration is requested/drawn once per widget per process lifecycle return staticConfig } else @@ -57,17 +69,11 @@ struct ActiveAppsWidget: Widget private struct ActiveAppsWidgetView: View { var entry: AppsEntry - - @ObservedObject private var viewModel: PaginationViewModel + var widgetID: String @Environment(\.colorScheme) private var colorScheme - - init(entry: AppsEntry, viewModel: PaginationViewModel){ - self.entry = entry - self.viewModel = viewModel - } - + var body: some View { Group { if entry.apps.isEmpty @@ -95,17 +101,14 @@ private struct ActiveAppsWidgetView: View private var content: some View { GeometryReader { (geometry) in - let MAX_ROWS_PER_PAGE = PaginationViewModel.MAX_ROWS_PER_PAGE + let MAX_ROWS_PER_PAGE = PaginationDataHolder.MAX_ROWS_PER_PAGE let preferredRowHeight = (geometry.size.height / Double(MAX_ROWS_PER_PAGE)) - 8 let rowHeight = min(preferredRowHeight, geometry.size.height / 2) HStack(alignment: .center) { - -// VStack(spacing: 12) { LazyVStack(spacing: 12) { - ForEach($viewModel.sliding_window, id: \.bundleIdentifier) { app in - let app = app.wrappedValue // remove the binding + ForEach(Array(entry.apps.enumerated()), id: \.offset) { index, app in let icon: UIImage = app.icon ?? UIImage(named: "SideStore")! @@ -160,6 +163,8 @@ private struct ActiveAppsWidgetView: View .padding(.all, -5) } .font(.system(size: 16, weight: .semibold, design: .rounded)) + // this modifier invalidates the view (disables userinteraction and shows a blinking effect) + // until new timeline events occur, unless a observable boolean state is presented as parameter .invalidatableContent() .activatesRefreshAllAppsIntent() } @@ -174,9 +179,9 @@ private struct ActiveAppsWidgetView: View Image(systemName: "arrow.up") .resizable() .frame(width: buttonWidth, height: buttonWidth) - .mask(Capsule()) .opacity(0.3) - .pageUpButton(widgetID: viewModel.widgetID) + // .mask(Capsule()) + .pageUpButton(widgetID) Spacer() @@ -184,8 +189,8 @@ private struct ActiveAppsWidgetView: View .resizable() .frame(width: buttonWidth, height: buttonWidth) .opacity(0.3) - .mask(Capsule()) - .pageDownButton(widgetID: viewModel.widgetID) + // .mask(Capsule()) + .pageDownButton(widgetID) } .padding(.vertical) } diff --git a/AltWidget/Widgets/PaginationDataHolder.swift b/AltWidget/Widgets/PaginationDataHolder.swift new file mode 100644 index 00000000..d934fdbd --- /dev/null +++ b/AltWidget/Widgets/PaginationDataHolder.swift @@ -0,0 +1,72 @@ +// +// PaginationViewModel.swift +// AltStore +// +// Created by Magesh K on 09/01/25. +// Copyright © 2025 SideStore. All rights reserved. +// + +import Foundation + +class PaginationDataHolder: ObservableObject { + + public static let MAX_ROWS_PER_PAGE: UInt = 3 + + private static var instances: [String:PaginationDataHolder] = [:] + + public static func instance(_ widgetID: String) -> PaginationDataHolder { + let instance = PaginationDataHolder(widgetID) + Self.instances[widgetID] = instance + return instance + } + + public static func holder(for widgetID: String) -> PaginationDataHolder? { + return Self.instances[widgetID] + } + + private lazy var serializationQ = { + DispatchQueue(label: widgetID) + }() + + public let widgetID: String + private var currentPageindex: UInt = 0 + + private init(_ widgetID: String){ + self.widgetID = widgetID + } + + deinit { + Self.instances.removeValue(forKey: widgetID) + } + + public func updateDirection(_ direction: Direction) { + switch(direction){ + case .up: + let pageIndex = Int(currentPageindex) + currentPageindex = UInt(max(0, pageIndex-1)) + case .down: + // upper-bounds is checked when computing targetPageIndex in getUpdatedData + currentPageindex+=1 + } + } + + public func getUpdatedData(entries: [AppSnapshot]) -> [AppSnapshot] { + let count = UInt(entries.count) + + if(count == 0) { return entries } + + let availablePages = UInt(ceil(Double(entries.count) / Double(Self.MAX_ROWS_PER_PAGE))) + let targetPageIndex: UInt = currentPageindex < availablePages ? currentPageindex : availablePages-1 + + // do blocking update + serializationQ.sync { + self.currentPageindex = targetPageIndex // preserve it + } + + let startIndex = targetPageIndex * Self.MAX_ROWS_PER_PAGE + let estimatedEndIndex = startIndex + (Self.MAX_ROWS_PER_PAGE-1) + let endIndex: UInt = min(count-1, estimatedEndIndex) + let currentPageEntries = entries[Int(startIndex) ... Int(endIndex)] + return Array(currentPageEntries) + } +} diff --git a/AltWidget/Widgets/PaginationViewModel.swift b/AltWidget/Widgets/PaginationViewModel.swift deleted file mode 100644 index 32f7b0a7..00000000 --- a/AltWidget/Widgets/PaginationViewModel.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// PaginationViewModel.swift -// AltStore -// -// Created by Magesh K on 09/01/25. -// Copyright © 2025 SideStore. All rights reserved. -// - -import Foundation -import Combine - -class PaginationViewModel: ObservableObject { - - private static var instances: [String:PaginationViewModel] = [:] - - public static let MAX_ROWS_PER_PAGE = 3 - - @Published public var sliding_window: [AppSnapshot] = [] - @Published public var refreshed: Bool = false - - private init(){} - - private var _widgetID: String? - public lazy var widgetID: String = { - return _widgetID! - }() - - public static func getNewInstance(_ widgetID: String) -> PaginationViewModel { - let instance = PaginationViewModel() - PaginationViewModel.instances[widgetID] = instance - instance._widgetID = widgetID - return instance - } - - public static func instance(_ widgetID: String) -> PaginationViewModel? { - return PaginationViewModel.instances[widgetID] - } - - public private(set) var backup_entries: [AppSnapshot] = [] - - private var r_queue: [AppSnapshot] = [] - private var l_queue: [AppSnapshot] = [] - - private var lastIndex: Int { r_queue.count - 1 } - - public func setEntries(_ entries: [AppSnapshot]) { - r_queue = entries - backup_entries = entries - } - - public func handlePagination(_ direction: Direction) { - - var sliding_window = Array(sliding_window) - var l_queue = Array(l_queue) - var r_queue = Array(r_queue) - - // If entries is empty, do nothing - guard !backup_entries.isEmpty else { - sliding_window.removeAll() - return - } - - switch direction { - case .up: - // move window contents to left-q since we are moving right side - if !sliding_window.isEmpty { - // take the window as-is and put it to right of l_queue - l_queue.append(contentsOf: sliding_window) - } - - // clear the window - sliding_window.removeAll() - - let size = min(r_queue.count, Self.MAX_ROWS_PER_PAGE) - for _ in 0..