Skip to content

Commit be75d66

Browse files
authored
Merge pull request #19754 from wordpress-mobile/feature/cmf-delete-exported-data-on-logout
Jetpack Migration: Clean up exported content on WP log out
2 parents 99166f7 + 85638fe commit be75d66

File tree

10 files changed

+216
-9
lines changed

10 files changed

+216
-9
lines changed

WordPress/Classes/Services/BlogService.m

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,7 @@ - (void)removeBlog:(Blog *)blog
481481
[accountService purgeAccountIfUnused:account];
482482
}
483483

484-
[[ContextManager sharedInstance] saveContext:self.managedObjectContext];
484+
[[ContextManager sharedInstance] saveContextAndWait:self.managedObjectContext];
485485
[WPAnalytics refreshMetadata];
486486
}
487487

WordPress/Classes/System/WordPressAppDelegate.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,9 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate {
330330
setupWordPressExtensions()
331331

332332
if FeatureFlag.contentMigration.enabled {
333+
// To prevent race condition, initialize the shared instance synchronously so it can listen to account change notifications.
334+
let _ = ContentMigrationCoordinator.shared
335+
333336
// Start proactively exporting WP data in the background if the conditions are fulfilled.
334337
// This needs to be called after `setupWordPressExtensions` because it updates the stored data.
335338
DispatchQueue.global().async {

WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduler.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,17 @@ class BloggingRemindersScheduler {
158158
}
159159
}
160160

161+
/// Deletes backup reminders if it exists.
162+
///
163+
static func deleteBackupReminders() {
164+
guard FeatureFlag.contentMigration.enabled,
165+
let sharedFileURL = sharedDataFileURL() else {
166+
return
167+
}
168+
169+
try? FileManager.default.removeItem(at: sharedFileURL)
170+
}
171+
161172
private static func copyStoreToSharedFile() {
162173
guard let store = try? defaultStore(),
163174
let sharedFileUrl = sharedDataFileURL() else {

WordPress/Classes/Utility/CoreDataHelper.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,13 +220,12 @@ extension CoreDataStack {
220220
}
221221
}
222222

223+
// MARK: - Database Migration
224+
223225
/// Creates a copy of the current open store and saves it to the specified destination
224226
/// - Parameter backupLocation: Location to save the store copy to
225227
func createStoreCopy(to backupLocation: URL) throws {
226-
let (backupLocation, shmLocation, walLocation) = databaseFiles(for: backupLocation)
227-
try? FileManager.default.removeItem(at: backupLocation)
228-
try? FileManager.default.removeItem(at: shmLocation)
229-
try? FileManager.default.removeItem(at: walLocation)
228+
try? removeBackupData(from: backupLocation)
230229
guard let storeCoordinator = mainContext.persistentStoreCoordinator,
231230
let store = storeCoordinator.persistentStores.first else {
232231
throw ContextManager.ContextManagerError.missingCoordinatorOrStore
@@ -245,6 +244,15 @@ extension CoreDataStack {
245244
withType: storeCopy.type)
246245
}
247246

247+
/// Removes any copy of the store from the backup location.
248+
/// - Parameter backupLocation: Where the backup store is located.
249+
func removeBackupData(from location: URL) throws {
250+
let (backupLocation, shmLocation, walLocation) = databaseFiles(for: location)
251+
try FileManager.default.removeItem(at: backupLocation)
252+
try FileManager.default.removeItem(at: shmLocation)
253+
try FileManager.default.removeItem(at: walLocation)
254+
}
255+
248256
/// Replaces the current active store with the database at the specified location.
249257
///
250258
/// The following steps are performed:

WordPress/Classes/Utility/Migration/ContentMigrationCoordinator.swift

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,37 @@
11
/// Encapsulates logic related to content migration from WordPress to Jetpack.
22
///
3-
class ContentMigrationCoordinator {
3+
@objc class ContentMigrationCoordinator: NSObject {
44

5-
static var shared: ContentMigrationCoordinator = {
5+
@objc static var shared: ContentMigrationCoordinator = {
66
.init()
77
}()
88

99
// MARK: Dependencies
1010

1111
private let coreDataStack: CoreDataStack
1212
private let dataMigrator: ContentDataMigrating
13+
private let notificationCenter: NotificationCenter
1314
private let userPersistentRepository: UserPersistentRepository
1415
private let eligibilityProvider: ContentMigrationEligibilityProvider
1516
private let tracker: MigrationAnalyticsTracker
1617

1718
init(coreDataStack: CoreDataStack = ContextManager.shared,
1819
dataMigrator: ContentDataMigrating = DataMigrator(),
20+
notificationCenter: NotificationCenter = .default,
1921
userPersistentRepository: UserPersistentRepository = UserDefaults.standard,
2022
eligibilityProvider: ContentMigrationEligibilityProvider = AppConfiguration(),
2123
tracker: MigrationAnalyticsTracker = .init()) {
2224
self.coreDataStack = coreDataStack
2325
self.dataMigrator = dataMigrator
26+
self.notificationCenter = notificationCenter
2427
self.userPersistentRepository = userPersistentRepository
2528
self.eligibilityProvider = eligibilityProvider
2629
self.tracker = tracker
30+
31+
super.init()
32+
33+
// register for account change notification.
34+
ensureBackupDataDeletedOnLogout()
2735
}
2836

2937
enum ContentMigrationCoordinatorError: LocalizedError {
@@ -99,9 +107,25 @@ class ContentMigrationCoordinator {
99107
completion?()
100108
}
101109
}
110+
111+
/// Attempts to clean up the exported data by re-exporting user content if they're still eligible, or deleting them otherwise.
112+
/// Re-exporting user content ensures that the exported data will match the latest state of Account and Blogs.
113+
///
114+
@objc func cleanupExportedDataIfNeeded() {
115+
// try to re-export the user content if they're still eligible.
116+
startAndDo { [weak self] result in
117+
switch result {
118+
case .failure(let error) where error == .ineligible:
119+
// if the user is no longer eligible, ensure that any exported contents are deleted.
120+
self?.dataMigrator.deleteExportedData()
121+
default:
122+
break
123+
}
124+
}
125+
}
102126
}
103127

104-
// MARK: - Preflights Local Draft Check
128+
// MARK: - Private Helpers
105129

106130
private extension ContentMigrationCoordinator {
107131

@@ -119,11 +143,26 @@ private extension ContentMigrationCoordinator {
119143
return count == 0
120144
}
121145

146+
/// When the user logs out, ensure that any exported data is deleted if it exists at the backup location.
147+
/// This prevents the user from entering the migration flow and immediately gets shown with a login pop-up (since we couldn't migrate the authToken anymore).
148+
///
149+
func ensureBackupDataDeletedOnLogout() {
150+
notificationCenter.addObserver(forName: .WPAccountDefaultWordPressComAccountChanged, object: nil, queue: nil) { [weak self] notification in
151+
// nil notification object means it's a logout event.
152+
guard let self,
153+
notification.object == nil else {
154+
return
155+
}
156+
157+
self.cleanupExportedDataIfNeeded()
158+
}
159+
}
122160
}
123161

124162
// MARK: - Content Migration Eligibility Provider
125163

126164
protocol ContentMigrationEligibilityProvider {
165+
/// Determines if we should export user's content data in the current app state.
127166
var isEligibleForMigration: Bool { get }
128167
}
129168

WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1699,6 +1699,11 @@ - (void)confirmRemoveSite
16991699
BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context];
17001700
[blogService removeBlog:self.blog];
17011701
[[WordPressAppDelegate shared] trackLogoutIfNeeded];
1702+
1703+
if ([Feature enabled:FeatureFlagContentMigration] && [AppConfiguration isWordPress]) {
1704+
[ContentMigrationCoordinator.shared cleanupExportedDataIfNeeded];
1705+
}
1706+
17021707
[self.navigationController popToRootViewControllerAnimated:YES];
17031708
}
17041709

WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController.m

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,11 @@ - (void)confirmRemoveSiteForIndexPath:(NSIndexPath *)indexPath
709709
NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext];
710710
BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context];
711711
[blogService removeBlog:blog];
712+
713+
if ([Feature enabled:FeatureFlagContentMigration] && [AppConfiguration isWordPress]) {
714+
[ContentMigrationCoordinator.shared cleanupExportedDataIfNeeded];
715+
}
716+
712717
[self.tableView reloadData];
713718
}
714719

WordPress/Jetpack/Classes/Utility/DataMigrator.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
protocol ContentDataMigrating {
2+
/// Exports user content data to a shared location that's accessible by the Jetpack app.
3+
///
4+
/// - Parameter completion: Closure called after the export process completes.
25
func exportData(completion: ((Result<Void, DataMigrationError>) -> Void)?)
6+
7+
/// Imports user's WordPress content data from the shared location.
8+
///
9+
/// - Parameter completion: Closure called after the export process completes.
310
func importData(completion: ((Result<Void, DataMigrationError>) -> Void)?)
11+
12+
/// Deletes any exported user content at the shared location if it exists.
13+
func deleteExportedData()
414
}
515

616
enum DataMigrationError: LocalizedError {
@@ -83,6 +93,26 @@ extension DataMigrator: ContentDataMigrating {
8393
BloggingRemindersScheduler.handleRemindersMigration()
8494
completion?(.success(()))
8595
}
96+
97+
func deleteExportedData() {
98+
guard let backupLocation,
99+
let sharedDefaults else {
100+
return
101+
}
102+
103+
// mark this as false regardless if any of the steps below fails.
104+
// this serves as the first stopgap that prevents the migration process on the Jetpack side.
105+
isDataReadyToMigrate = false
106+
107+
// remove database backup
108+
try? coreDataStack.removeBackupData(from: backupLocation)
109+
110+
// remove user defaults backup
111+
sharedDefaults.removeObject(forKey: DefaultsWrapper.dictKey)
112+
113+
// remove blogging reminders backup
114+
BloggingRemindersScheduler.deleteBackupReminders()
115+
}
86116
}
87117

88118
// MARK: - Private Functions

WordPress/WordPressTest/ContentMigrationCoordinatorTests.swift

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ final class ContentMigrationCoordinatorTests: CoreDataTestCase {
88

99
private var mockEligibilityProvider: MockEligibilityProvider!
1010
private var mockDataMigrator: MockDataMigrator!
11+
private var mockNotificationCenter: MockNotificationCenter!
1112
private var mockPersistentRepository: InMemoryUserDefaults!
1213
private var coordinator: ContentMigrationCoordinator!
1314

@@ -16,13 +17,15 @@ final class ContentMigrationCoordinatorTests: CoreDataTestCase {
1617

1718
mockEligibilityProvider = MockEligibilityProvider()
1819
mockDataMigrator = MockDataMigrator()
20+
mockNotificationCenter = MockNotificationCenter()
1921
mockPersistentRepository = InMemoryUserDefaults()
2022
coordinator = makeCoordinator()
2123
}
2224

2325
override func tearDown() {
2426
mockEligibilityProvider = nil
2527
mockDataMigrator = nil
28+
mockNotificationCenter = nil
2629
mockPersistentRepository = nil
2730
coordinator = nil
2831

@@ -181,6 +184,57 @@ final class ContentMigrationCoordinatorTests: CoreDataTestCase {
181184
wait(for: [expect], timeout: timeout)
182185
}
183186

187+
// MARK: Export data cleanup tests
188+
189+
func test_cleanupExportedData_givenUserIsIneligible_shouldDeleteData() {
190+
// Given
191+
mockEligibilityProvider.isEligibleForMigration = false
192+
193+
// When
194+
coordinator.cleanupExportedDataIfNeeded()
195+
196+
// Then
197+
XCTAssertTrue(mockDataMigrator.deleteExportedDataCalled)
198+
}
199+
200+
func test_cleanupExportedData_givenUserIsEligible_shouldExportData() {
201+
// When
202+
coordinator.cleanupExportedDataIfNeeded()
203+
204+
// Then
205+
XCTAssertTrue(mockDataMigrator.exportCalled)
206+
}
207+
208+
func test_coordinatorShouldObserveLogoutNotifications() {
209+
XCTAssertNotNil(mockNotificationCenter.observerBlock)
210+
XCTAssertNotNil(mockNotificationCenter.observedNotificationName)
211+
XCTAssertEqual(mockNotificationCenter.observedNotificationName, Foundation.Notification.Name.WPAccountDefaultWordPressComAccountChanged)
212+
}
213+
214+
func test_givenLoginNotifications_coordinatorShouldDoNothing() {
215+
// Given
216+
let loginNotification = mockNotificationCenter.makeLoginNotification()
217+
218+
// When
219+
mockNotificationCenter.observerBlock?(loginNotification)
220+
221+
// Then
222+
XCTAssertFalse(mockDataMigrator.exportCalled)
223+
XCTAssertFalse(mockDataMigrator.deleteExportedDataCalled)
224+
}
225+
226+
func test_givenLogoutNotifications_coordinatorShouldPerformCleanup() {
227+
// Given
228+
mockEligibilityProvider.isEligibleForMigration = false
229+
let logoutNotification = mockNotificationCenter.makeLogoutNotification()
230+
231+
// When
232+
mockNotificationCenter.observerBlock?(logoutNotification)
233+
234+
// Then
235+
XCTAssertFalse(mockDataMigrator.exportCalled)
236+
XCTAssertTrue(mockDataMigrator.deleteExportedDataCalled)
237+
}
184238
}
185239

186240
// MARK: - Helpers
@@ -192,14 +246,15 @@ private extension ContentMigrationCoordinatorTests {
192246
}
193247

194248
final class MockEligibilityProvider: ContentMigrationEligibilityProvider {
195-
var isEligibleForMigration: Bool = true
249+
var isEligibleForMigration = true
196250
}
197251

198252
final class MockDataMigrator: ContentDataMigrating {
199253
var exportErrorToReturn: DataMigrationError? = nil
200254
var exportCalled = false
201255
var importErrorToReturn: DataMigrationError? = nil
202256
var importCalled = false
257+
var deleteExportedDataCalled = false
203258

204259
func exportData(completion: ((Result<Void, DataMigrationError>) -> Void)? = nil) {
205260
exportCalled = true
@@ -218,11 +273,16 @@ private extension ContentMigrationCoordinatorTests {
218273
}
219274
completion?(.failure(importErrorToReturn))
220275
}
276+
277+
func deleteExportedData() {
278+
deleteExportedDataCalled = true
279+
}
221280
}
222281

223282
func makeCoordinator() -> ContentMigrationCoordinator {
224283
return .init(coreDataStack: contextManager,
225284
dataMigrator: mockDataMigrator,
285+
notificationCenter: mockNotificationCenter,
226286
userPersistentRepository: mockPersistentRepository,
227287
eligibilityProvider: mockEligibilityProvider)
228288
}
@@ -235,3 +295,25 @@ private extension ContentMigrationCoordinatorTests {
235295
}
236296

237297
}
298+
299+
private final class MockNotificationCenter: NotificationCenter {
300+
var observedNotificationName: NSNotification.Name? = nil
301+
var observerBlock: ((Foundation.Notification) -> Void)? = nil
302+
303+
override func addObserver(forName name: NSNotification.Name?,
304+
object obj: Any?,
305+
queue: OperationQueue?,
306+
using block: @escaping @Sendable (Foundation.Notification) -> Void) -> NSObjectProtocol {
307+
observedNotificationName = name
308+
observerBlock = block
309+
return NSNull()
310+
}
311+
312+
func makeLoginNotification() -> Foundation.Notification {
313+
return Foundation.Notification(name: .WPAccountDefaultWordPressComAccountChanged, object: String())
314+
}
315+
316+
func makeLogoutNotification() -> Foundation.Notification {
317+
return Foundation.Notification(name: .WPAccountDefaultWordPressComAccountChanged, object: nil)
318+
}
319+
}

0 commit comments

Comments
 (0)