Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
27.1
-----
* [*] [internal] In-app updates: guard the flexible update notice against double-posting when two update checks race [#25666]


27.0
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import WordPressFlux
import XCTest

@testable import WordPress
Expand Down Expand Up @@ -190,6 +191,67 @@ final class AppUpdateCoordinatorTests: XCTestCase {
XCTAssertFalse(presenter.didShowNotice)
XCTAssertTrue(presenter.didShowBlockingUpdate)
}

// MARK: - AppUpdatePresenter flexible notice guard

func testShowNoticePostsExactlyOneFlexibleNotice() throws {
// Given
let dispatcher = ActionDispatcher()
let noticeStore = NoticeStore(dispatcher: dispatcher)
let presenter = AppUpdatePresenter(noticeStore: noticeStore, dispatcher: dispatcher)
let appStoreInfo = try makeAppStoreInfo()

// When
presenter.showNotice(using: appStoreInfo)

// Then
XCTAssertEqual(noticeStore.currentNotice?.tag, AppUpdatePresenter.flexibleUpdateNoticeTag)
}

func testShowNoticeSuppressesSecondFlexibleNoticeWhileOneIsShowing() throws {
// Given
let dispatcher = ActionDispatcher()
let noticeStore = NoticeStore(dispatcher: dispatcher)
let presenter = AppUpdatePresenter(noticeStore: noticeStore, dispatcher: dispatcher)
let appStoreInfo = try makeAppStoreInfo()
presenter.showNotice(using: appStoreInfo)
let firstNotice = try XCTUnwrap(noticeStore.currentNotice)

// When a second presentation races in while the first is still showing
presenter.showNotice(using: appStoreInfo)

// Then the current notice is still the first one and nothing was queued
XCTAssertEqual(noticeStore.currentNotice, firstNotice)
// Dismissing the current notice leaves no queued duplicate behind
ActionDispatcher.dispatch(NoticeAction.dismiss, dispatcher: dispatcher)
XCTAssertNil(noticeStore.currentNotice)
}

func testShowNoticeCanPostAgainAfterPreviousNoticeIsCleared() throws {
// Given
let dispatcher = ActionDispatcher()
let noticeStore = NoticeStore(dispatcher: dispatcher)
let presenter = AppUpdatePresenter(noticeStore: noticeStore, dispatcher: dispatcher)
let appStoreInfo = try makeAppStoreInfo()
presenter.showNotice(using: appStoreInfo)
XCTAssertNotNil(noticeStore.currentNotice)

// When the first notice is cleared and a later legitimate cycle posts again
ActionDispatcher.dispatch(NoticeAction.dismiss, dispatcher: dispatcher)
XCTAssertNil(noticeStore.currentNotice)
presenter.showNotice(using: appStoreInfo)

// Then the guard does not permanently latch
XCTAssertEqual(noticeStore.currentNotice?.tag, AppUpdatePresenter.flexibleUpdateNoticeTag)
}

private func makeAppStoreInfo() throws -> AppStoreLookupResponse.AppStoreInfo {
let data = try Bundle.test.json(named: "app-store-lookup-response")
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let response = try decoder.decode(AppStoreLookupResponse.self, from: data)
return try XCTUnwrap(response.results.first)
}
}

private final class MockAppStoreSearchService: AppStoreSearchProtocol {
Expand Down
24 changes: 22 additions & 2 deletions WordPress/Classes/Services/AppUpdate/AppUpdatePresenter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,35 @@ protocol AppUpdatePresenterProtocol {
}

final class AppUpdatePresenter: AppUpdatePresenterProtocol {
/// Stable tag used to identify the flexible in-app-update notice so a second
/// presentation can be suppressed while one is already showing.
static let flexibleUpdateNoticeTag: Notice.Tag = "in-app-update-flexible"

private let noticeStore: NoticeStore
private let dispatcher: ActionDispatcher

init(
noticeStore: NoticeStore = StoreContainer.shared.notice,
dispatcher: ActionDispatcher = .global
) {
self.noticeStore = noticeStore
self.dispatcher = dispatcher
}

func showNotice(using appStoreInfo: AppStoreLookupResponse.AppStoreInfo) {
guard noticeStore.currentNotice?.tag != Self.flexibleUpdateNoticeTag else {
// Don't post another flexible update notice if one is already showing
return
}
let viewModel = AppStoreInfoViewModel(appStoreInfo)
let notice = Notice(
title: viewModel.title,
message: viewModel.message,
feedbackType: .warning,
style: InAppUpdateNoticeStyle(),
actionTitle: viewModel.updateButtonTitle,
cancelTitle: viewModel.cancelButtonTitle
cancelTitle: viewModel.cancelButtonTitle,
tag: Self.flexibleUpdateNoticeTag
) { accepted in
if accepted {
WPAnalytics.track(.inAppUpdateAccepted, properties: ["type": "flexible"])
Expand All @@ -26,7 +46,7 @@ final class AppUpdatePresenter: AppUpdatePresenterProtocol {
WPAnalytics.track(.inAppUpdateDismissed)
}
}
ActionDispatcher.dispatch(NoticeAction.post(notice))
ActionDispatcher.dispatch(NoticeAction.post(notice), dispatcher: dispatcher)
WPAnalytics.track(.inAppUpdateShown, properties: ["type": "flexible"])
}

Expand Down