Skip to content

Commit

Permalink
Merge pull request #19710 from wordpress-mobile/feature/cmf-remove-ex…
Browse files Browse the repository at this point in the history
…port-flag-after-import

Jetpack Migration: Provide a path for JP to trigger data export in WP
  • Loading branch information
twstokes authored Dec 2, 2022
2 parents 43e56ac + 524c371 commit 90c17f5
Show file tree
Hide file tree
Showing 11 changed files with 354 additions and 72 deletions.
9 changes: 9 additions & 0 deletions WordPress/Classes/System/WordPressAppDelegate+openURL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ import AutomatticTracks
return true
}

/// WordPress only. Handle deeplink from JP that requests data export.
let wordPressExportRouter = MigrationDeepLinkRouter(urlForScheme: URL(string: AppScheme.wordpressMigrationV1.rawValue),
routes: [WordPressExportRoute()])
if AppConfiguration.isWordPress,
wordPressExportRouter.canHandle(url: url) {
wordPressExportRouter.handle(url: url)
return true
}

if url.scheme == JetpackNotificationMigrationService.wordPressScheme {
return JetpackNotificationMigrationService.shared.handleNotificationMigrationOnWordPress()
}
Expand Down
7 changes: 4 additions & 3 deletions WordPress/Classes/System/WordPressAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -197,12 +197,13 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate {
updateFeatureFlags()
updateRemoteConfig()

#if JETPACK
#if JETPACK
// JetpackWindowManager is only available in the Jetpack target.
if let windowManager = windowManager as? JetpackWindowManager,
windowManager.shouldImportMigrationData {
windowManager.importAndShowMigrationContent(nil, failureCompletion: nil)
windowManager.importAndShowMigrationContent()
}
#endif
#endif
}

func applicationWillResignActive(_ application: UIApplication) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/// A router that specifically handles deeplinks.
/// Note that the capability of this router is very limited; it can only handle up to one path component (e.g.: `wordpress://intent`).
///
/// This is meant to be used during the WP->JP migratory period. Once we decide to move on from this phase, this class may be removed.
///
struct MigrationDeepLinkRouter: LinkRouter {

let routes: [Route]

/// when this is set, the router ensures that the URL has a scheme that matches this value.
private var scheme: String? = nil

init(routes: [Route]) {
self.routes = routes
}

init(scheme: String?, routes: [Route]) {
self.init(routes: routes)
self.scheme = scheme
}

init(urlForScheme: URL?, routes: [Route]) {
self.init(scheme: urlForScheme?.scheme, routes: routes)
}

func canHandle(url: URL) -> Bool {
// if the scheme is set, check if the URL fulfills the requirement.
if let scheme, url.scheme != scheme {
return false
}

/// deeplinks have their paths start at `host`, unlike universal links.
/// e.g. wordpress://intent -> "intent" is the URL's host.
///
/// Ensure that the deeplink URL has a "host" that we can run against the `routes`' path.
guard let deepLinkPath = url.host else {
return false
}

return routes
.map { $0.path.removingPrefix("/") }
.contains { $0 == deepLinkPath }
}

func handle(url: URL, shouldTrack track: Bool = false, source: DeepLinkSource? = nil) {
guard let deepLinkPath = url.host,
let route = routes.filter({ $0.path.removingPrefix("/") == deepLinkPath }).first else {
return
}

// there's no need to pass any arguments or parameters since most of the migration deeplink routes are standalone.
route.action.perform([:], source: nil, router: self)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/// Triggers the data export process on WordPress.
///
/// Note: this is only meant to be used in WordPress!
///
struct WordPressExportRoute: Route {
let path = "/export-213"
let section: DeepLinkSection? = nil
var action: NavigationAction {
return self
}
}

extension WordPressExportRoute: NavigationAction {
func perform(_ values: [String: String], source: UIViewController?, router: LinkRouter) {
guard AppConfiguration.isWordPress else {
return
}

ContentMigrationCoordinator.shared.startAndDo { _ in
// Regardless of the result, redirect the user back to Jetpack.
let jetpackUrl: URL? = {
var components = URLComponents()
components.scheme = JetpackNotificationMigrationService.jetpackScheme
return components.url
}()

guard let url = jetpackUrl,
UIApplication.shared.canOpenURL(url) else {
DDLogError("WordPressExportRoute: Cannot redirect back to the Jetpack app.")
return
}

UIApplication.shared.open(url)
}
}
}
77 changes: 56 additions & 21 deletions WordPress/Jetpack/Classes/System/JetpackWindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class JetpackWindowManager: WindowManager {
private var cancellable: AnyCancellable?

/// Migration events tracking
private let migrationTacker = MigrationAnalyticsTracker()
private let migrationTracker = MigrationAnalyticsTracker()

var shouldImportMigrationData: Bool {
return !AccountHelper.isLoggedIn && !UserPersistentStoreFactory.instance().isJPContentImportComplete
Expand All @@ -20,17 +20,8 @@ class JetpackWindowManager: WindowManager {
}

guard AccountHelper.isLoggedIn else {
let shouldImportMigrationData = shouldImportMigrationData
self.migrationTacker.trackContentImportEligibility(eligible: shouldImportMigrationData)

if shouldImportMigrationData {
importAndShowMigrationContent(blog) { [weak self] in
self?.showSignInUI()
}
} else {
showSignInUI()
}

self.migrationTracker.trackContentImportEligibility(eligible: shouldImportMigrationData)
shouldImportMigrationData ? importAndShowMigrationContent(blog) : showSignInUI()
return
}

Expand All @@ -39,34 +30,49 @@ class JetpackWindowManager: WindowManager {
AccountHelper.logOutDefaultWordPressComAccount()
}

func importAndShowMigrationContent(_ blog: Blog?, failureCompletion: (() -> ())?) {
func importAndShowMigrationContent(_ blog: Blog? = nil) {
self.migrationTracker.trackWordPressMigrationEligibility()

DataMigrator().importData() { [weak self] result in
guard let self else {
return
}

switch result {
case .success:
self.migrationTacker.trackContentImportSucceeded()
self.migrationTracker.trackContentImportSucceeded()
UserPersistentStoreFactory.instance().isJPContentImportComplete = true
NotificationCenter.default.post(name: .WPAccountDefaultWordPressComAccountChanged, object: self)
self.showMigrationUIIfNeeded(blog)
self.sendMigrationEmail()
case .failure(let error):
self.migrationTacker.trackContentImportFailed(reason: error.localizedDescription)
failureCompletion?()
self.migrationTracker.trackContentImportFailed(reason: error.localizedDescription)
self.handleMigrationFailure(error)
}
}
}
}

// MARK: - Private Helpers

private extension JetpackWindowManager {

var shouldShowMigrationUI: Bool {
return FeatureFlag.contentMigration.enabled && AccountHelper.isLoggedIn
}

private func sendMigrationEmail() {
var isCompatibleWordPressAppPresent: Bool {
MigrationAppDetection.getWordPressInstallationState() == .wordPressInstalledAndMigratable
}

func sendMigrationEmail() {
Task {
let service = try? MigrationEmailService()
try? await service?.sendMigrationEmail()
}
}

private func showMigrationUIIfNeeded(_ blog: Blog?) {
func showMigrationUIIfNeeded(_ blog: Blog?) {
guard shouldShowMigrationUI else {
return
}
Expand All @@ -83,12 +89,41 @@ class JetpackWindowManager: WindowManager {
self.show(container.makeInitialViewController())
}

private func switchToAppUI(for blog: Blog?) {
func switchToAppUI(for blog: Blog?) {
cancellable = nil
showAppUI(for: blog)
}

private var shouldShowMigrationUI: Bool {
return FeatureFlag.contentMigration.enabled && AccountHelper.isLoggedIn
/// Shown when the WordPress pre-flight process hasn't ran, but WordPress is installed.
/// Note: We don't know if the user has ever logged into WordPress at this point, only
/// that they have a version compatible with migrating.
/// - Parameter schemeUrl: Deep link URL used to open the WordPress app
func showLoadWordPressUI(schemeUrl: URL) {
let actions = MigrationLoadWordPressViewModel.Actions()
let loadWordPressViewModel = MigrationLoadWordPressViewModel(actions: actions)
let loadWordPressViewController = MigrationLoadWordPressViewController(viewModel: loadWordPressViewModel)
actions.primary = {
UIApplication.shared.open(schemeUrl)
}
actions.secondary = { [weak self] in
loadWordPressViewController.dismiss(animated: true) {
self?.showSignInUI()
}
}
self.show(loadWordPressViewController)
}

func handleMigrationFailure(_ error: DataMigrationError) {
guard
case .dataNotReadyToImport = error,
isCompatibleWordPressAppPresent,
let schemeUrl = URL(string: "\(AppScheme.wordpressMigrationV1.rawValue)\(WordPressExportRoute().path.removingPrefix("/"))")
else {
showSignInUI()
return
}

/// WordPress is a compatible version for migrations, but needs to be loaded to prepare the data
showLoadWordPressUI(schemeUrl: schemeUrl)
}
}
44 changes: 36 additions & 8 deletions WordPress/Jetpack/Classes/Utility/DataMigrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,18 @@ protocol ContentDataMigrating {
enum DataMigrationError: LocalizedError {
case databaseCopyError
case sharedUserDefaultsNil
case dataNotReadyToImport

var errorDescription: String? {
switch self {
case .databaseCopyError: return "The database couldn't be copied from shared directory"
case .sharedUserDefaultsNil: return "Shared user defaults not found"
case .dataNotReadyToImport: return "The data wasn't ready to import"
}
}
}

final class DataMigrator {

/// `DefaultsWrapper` is used to single out a dictionary for the migration process.
/// This way we can delete just the value for its key and leave the rest of shared defaults untouched.
private struct DefaultsWrapper {
static let dictKey = "defaults_staging_dictionary"
let defaultsDict: [String: Any]
}

private let coreDataStack: CoreDataStack
private let backupLocation: URL?
private let keychainUtils: KeychainUtils
Expand Down Expand Up @@ -57,14 +51,27 @@ extension DataMigrator: ContentDataMigrating {
return
}
BloggingRemindersScheduler.handleRemindersMigration()

isDataReadyToMigrate = true

completion?(.success(()))
}

func importData(completion: ((Result<Void, DataMigrationError>) -> Void)? = nil) {
guard isDataReadyToMigrate else {
completion?(.failure(.dataNotReadyToImport))
return
}

guard let backupLocation, restoreDatabase(from: backupLocation) else {
completion?(.failure(.databaseCopyError))
return
}

/// Upon successful database restoration, the backup files in the App Group will be deleted.
/// This means that the exported data is no longer complete when the user attempts another migration.
isDataReadyToMigrate = false

guard populateFromSharedDefaults() else {
completion?(.failure(.sharedUserDefaultsNil))
return
Expand All @@ -81,6 +88,23 @@ extension DataMigrator: ContentDataMigrating {
// MARK: - Private Functions

private extension DataMigrator {
/// `DefaultsWrapper` is used to single out a dictionary for the migration process.
/// This way we can delete just the value for its key and leave the rest of shared defaults untouched.
struct DefaultsWrapper {
static let dictKey = "defaults_staging_dictionary"
let defaultsDict: [String: Any]
}

/// Convenience wrapper to check whether the export data is ready to be imported.
/// The value is stored in the App Group space so it is accessible from both apps.
var isDataReadyToMigrate: Bool {
get {
sharedDefaults?.bool(forKey: .dataReadyToMigrateKey) ?? false
}
set {
sharedDefaults?.set(newValue, forKey: .dataReadyToMigrateKey)
}
}

func copyDatabase(to destination: URL) -> Bool {
do {
Expand Down Expand Up @@ -129,3 +153,7 @@ private extension DataMigrator {
return true
}
}

private extension String {
static let dataReadyToMigrateKey = "wp_data_migration_ready"
}

This file was deleted.

Loading

0 comments on commit 90c17f5

Please sign in to comment.