diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 58f658e8f1d7..a97d39245261 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,5 +1,6 @@ 23.3 ----- +* [***] [Jetpack-only] Lock Screen Widgets are now available on Jetpack. Quickly view your site stats without unlocking your phone. [#21535] * [*] Block editor: Fix the obscurred "Insert from URL" input for media blocks when using a device in landscape orientation. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/6143] * [**] Block editor: Updated placeholder text colors for block-based themes [https://github.com/wordpress-mobile/gutenberg-mobile/pull/6182] * [**] Update the main site navigation on the My Site screen, making it easier to access the common site sections. The new shortcuts can be modified using the Personalize Home Tab screen. [21502](https://github.com/wordpress-mobile/WordPress-iOS/pull/21502). diff --git a/WordPress/Classes/Stores/StatsWidgetsStore.swift b/WordPress/Classes/Stores/StatsWidgetsStore.swift index cfe9b2bbac47..0e5279dcd4e8 100644 --- a/WordPress/Classes/Stores/StatsWidgetsStore.swift +++ b/WordPress/Classes/Stores/StatsWidgetsStore.swift @@ -35,24 +35,32 @@ class StatsWidgetsStore { /// Initialize the local cache for widgets, if it does not exist @objc func initializeStatsWidgetsIfNeeded() { + UserDefaults(suiteName: WPAppGroupName)?.setValue(AccountHelper.isLoggedIn, forKey: AppConfiguration.Widget.Stats.userDefaultsLoggedInKey) UserDefaults(suiteName: WPAppGroupName)?.setValue(AccountHelper.defaultSiteId, forKey: AppConfiguration.Widget.Stats.userDefaultsSiteIdKey) + storeCredentials() + + var isReloadRequired = false if !HomeWidgetTodayData.cacheDataExists() { DDLogInfo("StatsWidgets: Writing initialization data into HomeWidgetTodayData.plist") HomeWidgetTodayData.write(items: initializeHomeWidgetData(type: HomeWidgetTodayData.self)) - WidgetCenter.shared.reloadTodayTimelines() + isReloadRequired = true } if !HomeWidgetThisWeekData.cacheDataExists() { DDLogInfo("StatsWidgets: Writing initialization data into HomeWidgetThisWeekData.plist") HomeWidgetThisWeekData.write(items: initializeHomeWidgetData(type: HomeWidgetThisWeekData.self)) - WidgetCenter.shared.reloadThisWeekTimelines() + isReloadRequired = true } if !HomeWidgetAllTimeData.cacheDataExists() { DDLogInfo("StatsWidgets: Writing initialization data into HomeWidgetAllTimeData.plist") HomeWidgetAllTimeData.write(items: initializeHomeWidgetData(type: HomeWidgetAllTimeData.self)) - WidgetCenter.shared.reloadAllTimeTimelines() + isReloadRequired = true + } + + if isReloadRequired { + WidgetCenter.shared.reloadAllTimelines() } } @@ -273,9 +281,8 @@ private extension StatsWidgetsStore { HomeWidgetAllTimeData.delete() userDefaults?.setValue(nil, forKey: AppConfiguration.Widget.Stats.userDefaultsSiteIdKey) - WidgetCenter.shared.reloadTodayTimelines() - WidgetCenter.shared.reloadThisWeekTimelines() - WidgetCenter.shared.reloadAllTimeTimelines() + + WidgetCenter.shared.reloadAllTimelines() } /// Observes WPSigninDidFinishNotification and wordpressLoginFinishedJetpackLogin notifications and initializes the widget. @@ -319,6 +326,24 @@ private extension StatsWidgetsStore { } } +private extension StatsWidgetsStore { + func storeCredentials() { + guard let token = AccountHelper.authToken else { return } + + do { + try SFHFKeychainUtils.storeUsername( + AppConfiguration.Widget.Stats.keychainTokenKey, + andPassword: token, + forServiceName: AppConfiguration.Widget.Stats.keychainServiceName, + accessGroup: WPAppKeychainAccessGroup, + updateExisting: true + ) + } catch { + DDLogDebug("Error while saving Widgets OAuth token: \(error)") + } + } +} + extension StatsViewController { @objc func initializeStatsWidgetsIfNeeded() { diff --git a/WordPress/Classes/Utility/AccountHelper.swift b/WordPress/Classes/Utility/AccountHelper.swift index 4493fd8c99d6..47548faa89e1 100644 --- a/WordPress/Classes/Utility/AccountHelper.swift +++ b/WordPress/Classes/Utility/AccountHelper.swift @@ -43,6 +43,12 @@ import Foundation return account?.defaultBlog?.dotComID } + static var authToken: String? { + let context = ContextManager.sharedInstance().mainContext + let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) + return account?.authToken + } + static func logBlogsAndAccounts(context: NSManagedObjectContext) { let allBlogs = (try? BlogQuery().blogs(in: context)) ?? [] let blogsByAccount = Dictionary(grouping: allBlogs, by: { $0.account }) diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index a28645e1960c..1b92d5fcc6e5 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -5,7 +5,6 @@ enum FeatureFlag: Int, CaseIterable { case bloggingPrompts case jetpackDisconnect case debugMenu - case lockScreenWidget case siteIconCreator case statsNewInsights case betaSiteDesigns @@ -31,8 +30,6 @@ enum FeatureFlag: Int, CaseIterable { return BuildConfiguration.current == .localDeveloper case .debugMenu: return BuildConfiguration.current ~= [.localDeveloper, .a8cBranchTest, .a8cPrereleaseTesting] - case .lockScreenWidget: - return false case .siteIconCreator: return BuildConfiguration.current != .appStore case .statsNewInsights: @@ -83,8 +80,6 @@ extension FeatureFlag { return "Jetpack disconnect" case .debugMenu: return "Debug menu" - case .lockScreenWidget: - return "iOS 16 Widget in Lock Screen" case .siteIconCreator: return "Site Icon Creator" case .statsNewInsights: @@ -121,8 +116,6 @@ extension FeatureFlag: OverridableFlag { switch self { case .debugMenu: return false - case .lockScreenWidget: - return false default: return true } diff --git a/WordPress/JetpackStatsWidgets/Helpers/HomeWidgetDataFileReader.swift b/WordPress/JetpackStatsWidgets/Helpers/HomeWidgetDataFileReader.swift index 0cd91b1652f3..aa223220fa80 100644 --- a/WordPress/JetpackStatsWidgets/Helpers/HomeWidgetDataFileReader.swift +++ b/WordPress/JetpackStatsWidgets/Helpers/HomeWidgetDataFileReader.swift @@ -9,4 +9,8 @@ final class HomeWidgetDataFileReader: WidgetDataCacheReader { return T.read()?[siteID] } + + func widgetData() -> [T]? { + return T.read()?.map { $0.value } + } } diff --git a/WordPress/JetpackStatsWidgets/Helpers/WidgetDataReader.swift b/WordPress/JetpackStatsWidgets/Helpers/WidgetDataReader.swift index 1790fcb4ccdf..327c5b36fe3e 100644 --- a/WordPress/JetpackStatsWidgets/Helpers/WidgetDataReader.swift +++ b/WordPress/JetpackStatsWidgets/Helpers/WidgetDataReader.swift @@ -2,6 +2,7 @@ import Foundation protocol WidgetDataCacheReader { func widgetData(for siteID: String) -> T? + func widgetData() -> [T]? } enum WidgetDataReadError: Error { @@ -30,40 +31,33 @@ final class WidgetDataReader { /// - configuration: Configuration of the Widget Site Selection Intent /// - defaultSiteID: ID of the default site in the account /// - Returns: Widget data - func widgetData(for configuration: SelectSiteIntent, defaultSiteID: Int?) -> T? { - - /// If configuration.site.identifier has value but there's no widgetData, it means that this identifier comes from previously logged in account - if let selectedSite = configuration.site?.identifier, - let widgetData: T = cacheReader.widgetData(for: selectedSite) { - return widgetData - } else if let defaultSiteID = defaultSiteID { - return cacheReader.widgetData(for: String(defaultSiteID)) - } else { - return nil - } - } - func widgetData( for configuration: SelectSiteIntent, - defaultSiteID: Int?, - isJetpack: Bool + defaultSiteID: Int? ) -> Result { guard let defaults = userDefaults else { return .failure(.noData) } - guard let defaultSiteID = defaultSiteID else { + + if let selectedSite = configuration.site?.identifier, + let widgetData: T = cacheReader.widgetData(for: selectedSite) { + return .success(widgetData) + } else if let defaultSiteID = defaultSiteID, + let widgetData: T = cacheReader.widgetData(for: String(defaultSiteID)) { + return .success(widgetData) + } else { let loggedIn = defaults.bool(forKey: AppConfiguration.Widget.Stats.userDefaultsLoggedInKey) if loggedIn { - return .failure(.noSite) + /// In rare cases there could be no default site and no defaultSiteId set + if let firstSiteData: T = cacheReader.widgetData()?.sorted(by: { $0.siteID < $1.siteID }).first { + return .success(firstSiteData) + } else { + return .failure(.noSite) + } } else { return .failure(.loggedOut) } } - guard let widgetData = widgetData(for: configuration, defaultSiteID: defaultSiteID) else { - return .failure(.noData) - } - - return .success(widgetData) } } diff --git a/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenAllTimePostsBestViewsStatWidgetConfig.swift b/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenAllTimePostsBestViewsStatWidgetConfig.swift index 2b41bb0d847b..2bdbd3d14a98 100644 --- a/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenAllTimePostsBestViewsStatWidgetConfig.swift +++ b/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenAllTimePostsBestViewsStatWidgetConfig.swift @@ -5,13 +5,6 @@ struct LockScreenAllTimePostsBestViewsStatWidgetConfig: LockScreenStatsWidgetCon typealias WidgetData = HomeWidgetAllTimeData typealias ViewProvider = LockScreenMultiStatWidgetViewProvider - var supportFamilies: [WidgetFamily] { - guard AppConfiguration.isJetpack, FeatureFlag.lockScreenWidget.enabled else { - return [] - } - return [.accessoryRectangular] - } - var displayName: String { LocalizableStrings.allTimePostMostViewsWidgetPreviewTitle } diff --git a/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenAllTimeViewsStatWidgetConfig.swift b/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenAllTimeViewsStatWidgetConfig.swift index 278142e6e3e5..72ea174a500f 100644 --- a/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenAllTimeViewsStatWidgetConfig.swift +++ b/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenAllTimeViewsStatWidgetConfig.swift @@ -5,13 +5,6 @@ struct LockScreenAllTimeViewsStatWidgetConfig: LockScreenStatsWidgetConfig { typealias WidgetData = HomeWidgetAllTimeData typealias ViewProvider = LockScreenSingleStatWidgetViewProvider - var supportFamilies: [WidgetFamily] { - guard AppConfiguration.isJetpack, FeatureFlag.lockScreenWidget.enabled else { - return [] - } - return [.accessoryRectangular] - } - var displayName: String { LocalizableStrings.allTimeViewsWidgetPreviewTitle } diff --git a/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenAllTimeViewsVisitorsStatWidgetConfig.swift b/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenAllTimeViewsVisitorsStatWidgetConfig.swift index 63100a11b020..b4e2113e8831 100644 --- a/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenAllTimeViewsVisitorsStatWidgetConfig.swift +++ b/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenAllTimeViewsVisitorsStatWidgetConfig.swift @@ -5,13 +5,6 @@ struct LockScreenAllTimeViewsVisitorsStatWidgetConfig: LockScreenStatsWidgetConf typealias WidgetData = HomeWidgetAllTimeData typealias ViewProvider = LockScreenMultiStatWidgetViewProvider - var supportFamilies: [WidgetFamily] { - guard AppConfiguration.isJetpack, FeatureFlag.lockScreenWidget.enabled else { - return [] - } - return [.accessoryRectangular] - } - var displayName: String { LocalizableStrings.allTimeViewsVisitorsWidgetPreviewTitle } diff --git a/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenStatsWidgetConfig.swift b/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenStatsWidgetConfig.swift index dfd6e1bf4081..013f7488f891 100644 --- a/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenStatsWidgetConfig.swift +++ b/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenStatsWidgetConfig.swift @@ -11,3 +11,13 @@ protocol LockScreenStatsWidgetConfig { var placeholderContent: WidgetData { get } var viewProvider: ViewProvider { get } } + +extension LockScreenStatsWidgetConfig { + var supportFamilies: [WidgetFamily] { + if #available(iOSApplicationExtension 16.0, *) { + return [.accessoryRectangular] + } else { + return [] + } + } +} diff --git a/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenTodayLikesCommentsStatWidgetConfig.swift b/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenTodayLikesCommentsStatWidgetConfig.swift index c654da5a5a08..e4acbe2cc405 100644 --- a/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenTodayLikesCommentsStatWidgetConfig.swift +++ b/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenTodayLikesCommentsStatWidgetConfig.swift @@ -5,13 +5,6 @@ struct LockScreenTodayLikesCommentsStatWidgetConfig: LockScreenStatsWidgetConfig typealias WidgetData = HomeWidgetTodayData typealias ViewProvider = LockScreenMultiStatWidgetViewProvider - var supportFamilies: [WidgetFamily] { - guard AppConfiguration.isJetpack, FeatureFlag.lockScreenWidget.enabled else { - return [] - } - return [.accessoryRectangular] - } - var displayName: String { LocalizableStrings.todayLikesCommentsWidgetPreviewTitle } diff --git a/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenTodayViewsStatWidgetConfig.swift b/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenTodayViewsStatWidgetConfig.swift index b260a7966891..3ccbc69472ec 100644 --- a/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenTodayViewsStatWidgetConfig.swift +++ b/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenTodayViewsStatWidgetConfig.swift @@ -5,13 +5,6 @@ struct LockScreenTodayViewsStatWidgetConfig: LockScreenStatsWidgetConfig { typealias WidgetData = HomeWidgetTodayData typealias ViewProvider = LockScreenSingleStatWidgetViewProvider - var supportFamilies: [WidgetFamily] { - guard AppConfiguration.isJetpack, FeatureFlag.lockScreenWidget.enabled else { - return [] - } - return [.accessoryRectangular] - } - var displayName: String { LocalizableStrings.todayViewsWidgetPreviewTitle } diff --git a/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenTodayViewsVisitorsStatWidgetConfig.swift b/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenTodayViewsVisitorsStatWidgetConfig.swift index 3557f2f0b57d..21230c8ca54a 100644 --- a/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenTodayViewsVisitorsStatWidgetConfig.swift +++ b/WordPress/JetpackStatsWidgets/LockScreenWidgets/Configs/LockScreenTodayViewsVisitorsStatWidgetConfig.swift @@ -5,13 +5,6 @@ struct LockScreenTodayViewsVisitorsStatWidgetConfig: LockScreenStatsWidgetConfig typealias WidgetData = HomeWidgetTodayData typealias ViewProvider = LockScreenMultiStatWidgetViewProvider - var supportFamilies: [WidgetFamily] { - guard AppConfiguration.isJetpack, FeatureFlag.lockScreenWidget.enabled else { - return [] - } - return [.accessoryRectangular] - } - var displayName: String { LocalizableStrings.todayViewsVisitorsWidgetPreviewTitle } diff --git a/WordPress/JetpackStatsWidgets/LockScreenWidgets/LockScreenSiteListProvider.swift b/WordPress/JetpackStatsWidgets/LockScreenWidgets/LockScreenSiteListProvider.swift index e824de4ab251..7e7803487681 100644 --- a/WordPress/JetpackStatsWidgets/LockScreenWidgets/LockScreenSiteListProvider.swift +++ b/WordPress/JetpackStatsWidgets/LockScreenWidgets/LockScreenSiteListProvider.swift @@ -21,15 +21,18 @@ struct LockScreenSiteListProvider: IntentTimelineProvider { } func getSnapshot(for configuration: SelectSiteIntent, in context: Context, completion: @escaping (LockScreenStatsWidgetEntry) -> Void) { - let content = widgetDataLoader.widgetData(for: configuration, defaultSiteID: defaultSiteID) ?? placeholderContent - completion(.siteSelected(content, context)) + switch widgetDataLoader.widgetData(for: configuration, defaultSiteID: defaultSiteID) { + case .success(let widgetData): + completion(.siteSelected(widgetData, context)) + case .failure: + completion(.siteSelected(placeholderContent, context)) + } } func getTimeline(for configuration: SelectSiteIntent, in context: Context, completion: @escaping (Timeline>) -> Void) { switch widgetDataLoader.widgetData( for: configuration, - defaultSiteID: defaultSiteID, - isJetpack: AppConfiguration.isJetpack + defaultSiteID: defaultSiteID ) { case .success(let widgetData): let date = Date() diff --git a/WordPress/JetpackStatsWidgets/SiteListProvider.swift b/WordPress/JetpackStatsWidgets/SiteListProvider.swift index b9cf4680e269..b5ef54f7834c 100644 --- a/WordPress/JetpackStatsWidgets/SiteListProvider.swift +++ b/WordPress/JetpackStatsWidgets/SiteListProvider.swift @@ -24,16 +24,18 @@ struct SiteListProvider: IntentTimelineProvider { } func getSnapshot(for configuration: SelectSiteIntent, in context: Context, completion: @escaping (StatsWidgetEntry) -> Void) { - - let content = widgetDataLoader.widgetData(for: configuration, defaultSiteID: defaultSiteID) ?? placeholderContent - completion(.siteSelected(content, context)) + switch widgetDataLoader.widgetData(for: configuration, defaultSiteID: defaultSiteID) { + case .success(let widgetData): + completion(.siteSelected(widgetData, context)) + case .failure: + completion(.siteSelected(placeholderContent, context)) + } } func getTimeline(for configuration: SelectSiteIntent, in context: Context, completion: @escaping (Timeline) -> Void) { switch widgetDataLoader.widgetData( for: configuration, - defaultSiteID: defaultSiteID, - isJetpack: AppConfiguration.isJetpack + defaultSiteID: defaultSiteID ) { case .success(let widgetData): let date = Date() diff --git a/WordPress/WordPressTest/Widgets/WidgetDataReaderTests.swift b/WordPress/WordPressTest/Widgets/WidgetDataReaderTests.swift index c58eea2f92df..4c00034a0bc2 100644 --- a/WordPress/WordPressTest/Widgets/WidgetDataReaderTests.swift +++ b/WordPress/WordPressTest/Widgets/WidgetDataReaderTests.swift @@ -2,52 +2,52 @@ import XCTest @testable import WordPress final class WidgetDataReaderTests: XCTestCase { - func testNoSite() { + func testNoSiteWhenWidgetDataNotFound() { let intent = SelectSiteIntent() - intent.site = Site(identifier: nil, display: "") + intent.site = Site(identifier: "test", display: "") let sut = makeSUT( makeUserDefaults(suiteName: #function), - makeCacheReader(isCacheExisted: true), + makeCacheReader(isCacheExisted: false), isLoggedIn: true ) - verifyWidgetStatus(sut, configuration: intent, defaultSiteID: nil, isJetpack: true, expectNoSite: true) + verifyWidgetStatus(sut, configuration: intent, defaultSiteID: 123, expectNoSite: true) } - func testLoggedOut() { + func testSiteSelectedWithNoDefaultSiteAvailable() { let intent = SelectSiteIntent() intent.site = Site(identifier: nil, display: "") let sut = makeSUT( makeUserDefaults(suiteName: #function), makeCacheReader(isCacheExisted: true), - isLoggedIn: false + isLoggedIn: true ) - verifyWidgetStatus(sut, configuration: intent, defaultSiteID: nil, isJetpack: true, expectLoggedOut: true) + verifyWidgetStatus(sut, configuration: intent, defaultSiteID: nil, expectSiteSelected: true) } - func testNoDataWhenNoUserDefaults() { + func testLoggedOut() { let intent = SelectSiteIntent() intent.site = Site(identifier: nil, display: "") let sut = makeSUT( - nil, + makeUserDefaults(suiteName: #function), makeCacheReader(isCacheExisted: true), isLoggedIn: false ) - verifyWidgetStatus(sut, configuration: intent, defaultSiteID: 123, isJetpack: true, expectNoData: true) + verifyWidgetStatus(sut, configuration: intent, defaultSiteID: nil, expectLoggedOut: true) } - func testNoDataWhenWidgetDataNotFound() { + func testNoDataWhenNoUserDefaults() { let intent = SelectSiteIntent() - intent.site = Site(identifier: "test", display: "") + intent.site = Site(identifier: nil, display: "") let sut = makeSUT( - makeUserDefaults(suiteName: #function), - makeCacheReader(isCacheExisted: false), - isLoggedIn: true + nil, + makeCacheReader(isCacheExisted: true), + isLoggedIn: false ) - verifyWidgetStatus(sut, configuration: intent, defaultSiteID: 123, isJetpack: true, expectNoData: true) + verifyWidgetStatus(sut, configuration: intent, defaultSiteID: 123, expectNoData: true) } func testSiteSelected() { @@ -59,7 +59,7 @@ final class WidgetDataReaderTests: XCTestCase { isLoggedIn: true ) - verifyWidgetStatus(sut, configuration: intent, defaultSiteID: 123, isJetpack: true, expectSiteSelected: true) + verifyWidgetStatus(sut, configuration: intent, defaultSiteID: 123, expectSiteSelected: true) } } @@ -87,7 +87,6 @@ extension WidgetDataReaderTests { _ sut: WidgetDataReader, configuration: SelectSiteIntent, defaultSiteID: Int?, - isJetpack: Bool, expectDisabled: Bool = false, expectNoData: Bool = false, expectNoSite: Bool = false, @@ -107,8 +106,7 @@ extension WidgetDataReaderTests { switch sut.widgetData( for: configuration, - defaultSiteID: defaultSiteID, - isJetpack: isJetpack + defaultSiteID: defaultSiteID ) { case .success: siteSelectedExpectation.fulfill() @@ -155,4 +153,12 @@ struct MockHomeWidgetDataFileReader: WidgetDataCacheReader { return nil } } + + func widgetData() -> [T]? { + if isMockDataReturned { + return [mockData] as? [T] + } else { + return nil + } + } }