purchases-ios icon indicating copy to clipboard operation
purchases-ios copied to clipboard

Delay file system access when app is prewarmed while locked to avoid losing entitlements and wrong user ids

Open KaiOelfke opened this issue 11 months ago • 10 comments

Describe the bug

For certain versions of iOS under certain conditions iOS can prewarm apps. RevenueCat writes about this in the documentation. There's also some community discussion.

The documentation only writes about issues when not using anonymous identifiers. But the problem scope is bigger. When an app is launched for prewarming, access to the file system is not possible, which includes UserDefaults (which are used by RevenueCat during the configuration).

This resulted in end users reporting losing access (they lost entitlements and got a new anonymous ID) after a reboot until they restored purchases. I fixed with a workaround of detecting the prewarming state and in this case configuring RevenueCat later. But as prewarming will only get more common with the popularity of live activities and the dynamic island a mission critical SDK like RevenueCat should mitigate this internally. Most developers don't know about prewarming and the behavior of completeFileProtectionUntilFirstUserAuthentication, which is the default for most files. So this results in very difficult bugs and frustrated end users.

  1. Environment
    1. Platform: iOS
    2. SDK version: 5.14.3
    3. StoreKit version:
      • [ ] StoreKit 1 (default on versions <5.0.0. Can be enabled in versions >=5.0.0 with .with(storeKitVersion: .storeKit1))
      • [x] StoreKit 2 (default on versions >=5.0.0)
    4. OS version: iOS 18.1.1
    5. Xcode version: 16.1
    6. Device and/or simulator:
      • [x] Device
      • [ ] Simulator
    7. Environment:
      • [x] Sandbox
      • [x] TestFlight
      • [x] Production
    8. How widespread is the issue. Percentage of devices affected.
  2. Debug logs that reproduce the issue. Complete logs with Purchases.logLevel = .verbose will help us debug this issue.

I can't access logs as it's not available, when iOS does prewarming while the device is locked after the reboot.

  1. Steps to reproduce, with a description of expected vs. actual behavior
  • Create a sample app with a live activity for the lock screen / dynamic island and add RevenueCat SDK
  • Configure Purchases in AppDelegate init or didFinishLaunchingWithOptions
  • Run app and schedule live activity, lock device and allow live activities on the lock screen
  • Reboot device
  • Wait up to 60 sec while locked after reboot
  • Unlock device
  • Open sample app

Expected: RevenueCat and UserDefaults works normally Actual: All UserDefaults.standard accesses return wrong values resulting in all kinds of other potential issues, RevenueCat cache is returning wrong values

I made a sample here that still needs adjustment to use and configure RevenueCat.

https://github.com/pointfreeco/swift-composable-architecture/discussions/3440

  1. Other information (e.g. stacktraces, related issues, suggestions how to fix, links for us to have context, eg. stackoverflow, etc.)

  2. Additional context Add any other context about the problem here.

KaiOelfke avatar Dec 28 '24 16:12 KaiOelfke

👀 We've just linked this issue to our internal tracker and notified the team. Thank you for reporting, we're checking this out!

RCGitBot avatar Dec 28 '24 16:12 RCGitBot

@KaiOelfke This blog post goes into detail about the same issue, that UserDefaults became unreliable. Especially in the context of Live Activities. There's also a proposed solution to this dilemma in a follow-up blog post - a replacement for the UserDefaults.

This obviously doesn't help, if dependencies are relying on UserDefaults. RC advices in their documentation to move the initialisation of the SDK from didFinishLaunchingWithOptions to the viewDidLoad of the first ViewController.

In certain cases on iOS 15 devices, iOS may prewarm your app - this essentially means your app will be launched silently in the background to improve app launch times for your users. If you are not using RevenueCat's anonymous IDs as described above, and are instead providing your own app user ID on configuration, do not call configure in application:didFinishLaunchingWithOptions:. Instead, call the configure method in your root view controller's initialization method.

Source

I still wonder if RC is aware of how unreliable UserDefaults are at times. It seems that UserDefaults are unavailable at different times than just during the prewarm period.

There is also this discussion in the Apple's developer forum, where one of Apple's engineers is clarifying a few things about prewarming.

It would be really nice to have a follow-up on this topic, since we can't follow RC's internal tickets.

KptnTanyel avatar Feb 18 '25 09:02 KptnTanyel

Thank you. I know the blog post. I can't use this library for some internal reasons and have my own workarounds in place. Using such a library isn't enough anyway. It's necessary to delay the setup of the RevenueCat SDK until this bug is addressed, if an app must support prewarming (e.g. has live activities). Depending on the iOS version UserDefaults will not work until the the next app launch or until the file system becomes available. With this library you can protect your app data, but it's still a very broken experience, if a given user suddenly lost all his entitlements etc. This is exactly what happened to my users until I added the logic to delay RC SDK setup during prewarming until the device is unlocked.

KaiOelfke avatar Feb 18 '25 09:02 KaiOelfke

Hey team, sorry for the delay here - appreciate the feedback on user defaults and internally. I can share this internally, but I'm going to close out this issue for now. I can confirm at this time that we don't currently have an update or change on the recommendation in the documentation.

kmurphy-rc avatar Mar 28 '25 19:03 kmurphy-rc

Hey folks 👋 re-opening this issue so we can use it for discussion.

Thanks for sharing the blogpost! I was actually not aware that it affected Live Activities, that makes it a bigger issue than I previously thought.

It does look like workarounds are not trivial. We haven't had the best of times with UserDefaults altogether either, we are looking into solutions. One advantage of UserDefaults is that it provides us with an easy way to share data for some setups like App Groups.

aboedo avatar Mar 31 '25 20:03 aboedo

An app with an active live activity before reboot is the only 100% reliable way to reproduce prewarming that I know. So you should have such a sample and run manual tests for this scenario. The problem isn't just with UserDefaults, but that there's no file system access. What makes accessing UserDefaults bad on iOS 17 or lower is that UserDefaults caches incorrect values (when there's no file system access) and this won't refresh until restarting the app process. And in my testing UserDefaults on iOS 17 and lower seems to read all key value pairs for caching upon the first access.

Before my workarounds customers with iOS < 18 lost their entitlement on prewarming until they force quit the app or do a restore. And all other state in my app code and other dependencies was corrupt until a force quit.

With iOS 18 there seems to be some change that UserDefaults at least refreshes the cache from the actual on disk values once the file system becomes available.

My workaround is basically checking for the availability of the file system by writing an empty file with file protection. If the file system is available everything can proceed normally. UserDefaults can be used. Dependencies can be setup. If the file system is not available nothing can be setup that requires file system access. I delay things and wait for events like willEnterForegroundNotification, protectedDataDidBecomeAvailableNotification or until another file writing check succeeds.

try Data("".utf8).write(to: url, options: .completeFileProtectionUntilFirstUserAuthentication)

With these changes my app keeps it's live activity running after rebooting and when I open the app the entitlements and customer info is all correct.

KaiOelfke avatar Apr 01 '25 03:04 KaiOelfke

Hi @KaiOelfke! First of all, thank you for the extremely detailed report and for bringing it to our attention. Also, thanks to @KptnTanyel for the additional context. And sorry it took so long for us to answer!

We are studying solutions for this, but they all come with tradeoffs which make it difficult to solve.

  • We could make a change in how the SDK implements the Purchases.configure to consider these edge cases. For example, making Purchases.configure an asynchronous call or even keeping Purchases.configure synchronous but making the actual implementation asynchronously. This way, we would only finish the SDK configuration once we know that we can properly access the file system. The tricky part would be that the behavior of the SDK between the configure call and the moment it finishes configuring would be undetermined, with potential breaking changes in either direction we take.
  • Another option would be to implement our own solution to replace UserDefaults that does not use .completeFileProtectionUntilFirstUserAuthentication so that it can be accessed regardless of the lock state of the device. This has some tradeoffs, especially regarding data security, since we would need to store user information and their purchases history unencrypted.

We are well aware of this and are taking it into serious consideration. However, because any change to address this could potentially impact other developers in many ways, we want to be extra cautious as we consider all possible solutions and tradeoffs.

Do you have any extra thoughts on this? If you’ve discovered a different workaround or anything that we might be missing, we’d love to hear about it.

Thank you again for bringing this up, we really appreciate it.

ajpallares avatar Jun 26 '25 14:06 ajpallares

We are well aware of this and are taking it into serious consideration. However, because any change to address this could potentially impact other developers in many ways, we want to be extra cautious as we consider all possible solutions and tradeoffs.

RevenueCat is a mission-critical SDK so I expect this from any update, but this particular issue is indeed quite difficult. And you can only test this manually on a device with a live activity (maybe there's other 100% reproducible ways that I don't know). Otherwise you can at most mock such a environment for tests.

Most apps don't have a live activity so I agree that these SDK use cases shouldn't be negatively affected anyhow.

For apps with only non live activity based prewarming scenarios the percentage can be so low that it can be ok to silently crash in these situations. But that should be a developer choice. Why intentionally crash? Well, it avoids corrupting data issue that Christian Selig explains in his blog posts and e.g. RC won't cache corrupt entitlements and other data. The user doesn't see the crash as it's behind the scenes of the locked device. The user can eventually unlock the device and launch the app.

But for live activity apps or apps that somehow run into prewarming more often a different setup path is needed. A first step here could be to acknowledge this and document it more on RC documentation with an explicit mention of live activities. I found almost nothing on this, when I ran into this and customers gave me feedback that their data and purchases were gone. So it took me a lot of time.

I don't know any other way to solve this other than delayed / lazy or async setup, but this all means the same thing of accessing the disk only when you can. I also found the Apple documentation and the few Apple developer forums posts inaccurate. With the live activity there's different amounts of progress the prewarming does. For SwiftUI lifecycle apps prewarming may run until App.init(), willFinishLaunchingWithOptions, didFinishLaunchingWithOptions or further. So the last option is my root SwiftUI View that sets up the root model, when the view appears and the model isn't setup yet.

VStack {
  if let rootModel {
    AppView(rootModel: rootModel)
  } else {
    VStack {
      if showButton {
        Button("Setup failed due to a locked device after rebooting. Please restart app.") {
          exit(0)
        }
      } else {
        Color.clear
      }
    }
    .task {
      setupRootModelIfPossible()
      while true {
        do {
          try await Task.sleep(for: .seconds(2))
        } catch {
          break
        }
        setupRootModelIfPossible()
        showButton = rootModel == nil
      }
    }
  }
}

This code isn't perfect for sure, but it works. Ideally, prewarming stops earlier and some place like App.init(), willFinishLaunchingWithOptions, didFinishLaunchingWithOptions already executes setupRootModelIfPossible() successfully. But when I test rebooting with a live activity on my devices I sometimes ran into the the Button showing on launch. But the app then successfully loads once the Task.sleep() finishes. I'd prefer to not have this waiting loop, but in my testing other signals like protectedDataDidBecomeAvailableNotification or UIApplication lifecycle notifications aren't fully reliable. But I also listen for those for to do setupRootModelIfPossible().

To check for a prewarming state I have this helper:

let url = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).txt")
    defer {
      try? FileManager.default.removeItem(at: url)
    }
    do {
      try Data("".utf8).write(to: url, options: .completeFileProtectionUntilFirstUserAuthentication)
      return true
    } catch {
      return false
    }

There's probably more ways to check for this like accessing the Keychain, but I found this the easiest to work with.

setupRootModelIfPossible checks for a prewarming state and only does the setup, if disk access is possible. This means Purchases.configure() is called when the disk is accessible.

For the particular app with which I ran into this the majority of users have a live activity either half the day or whole day. That's why it affected many people. Apps with short living or less frequent live activities may can get away with the background crashing I mentioned above as the number of reboots while the activity is running will be small enough.

For the RC SDK I don't know what data that is stored on disk and when it's read and written and what data is sensitive and what not and the security implications of that. I think you should keep the configure call synchronous. Internally the SDK could check for a prewarming state and proceed based on that. You could also add a config parameter to the configure call that defines how the RC SDK should deal with it. In the prewarming state the user can't interact with the app as the device wasn't ever unlocked after reboot. So there shouldn't be a need to load anything at this time. How does RC handle a first app launch after install with no network connectivity? In this case there won't be any cached data on disk and nothing can be loaded. I guess the SDK will gracefully recover later when there's connectivity and accessing certain methods in the meantime will throw an error. So in rare cases the SDK and the code integrating the SDK needs to be able to handle the unavailability of data anyhow and refresh later. Of course, it can be quite a bit of work to audit all code that may access the file system and to delay that.

You could also make the documentation explicit and say that RevenueCat doesn't support this. And that app developers like me have the responsibility to ship code like I did to make sure the configure call only happens later. For more complex apps there's a high likelihood that there's more SDKs and dependencies other than RC that need the delayed setup as well as they also access the disk. The most problematic here is UserDefaults as it gives wrong values without errors. Normal file system APIs throw.

KaiOelfke avatar Jun 27 '25 02:06 KaiOelfke

Hey @KaiOelfke, thanks again for all the digging you’ve done here, for your thorough messages, and for bearing with us while we work through this edge case. We’ve also given serious thought to other approaches discussed in this issue—like listening to prewarming state changes to only continue with SDK configuration when file system access is possible—but we felt they risk either changing existing behaviors in impactful ways or involve making changes to the public APIs. With that in mind, here’s the approach we’re leaning toward:

  1. Detect file system access availability. On Purchases.configure, attempt a file system operation and catch any failure (in the same vein as your helper code above).
  2. Log a warning & skip setup. In the failure case, we’ll log a warning (with a link to our docs) and make configure a no-op so that the RevenueCat SDK is not marked as configured.
  3. Crash on first use. As a consequence, calling Purchases.shared after a no-op configure call will trigger a crash. Note that this is already the existing behavior when calling Purchases.shared before the SDK is configured. As you mentioned in your comment above, we should fail fast rather than ending up in a silent, half-configured state that could lead to data corruption.
  4. Update the documentation explaining this edge case, specifying how the SDK behaves in this scenario and pointing developers to use the Purchases.isConfigured check to prevent the potential crash.

These changes imply that, under certain circumstances (e.g. during prewarming), calling Purchases.configure does not necessarily mean Purchases.isConfigured is going to be true. In theory, this can be considered a behavioral change. In practice, we believe it’s justified as it will fix an inconsistent behavior that could generate corrupted data, potentially.

In terms of integration, it removes the need for developers to deal with the complexity of checking for a prewarming state. Purchases.configure could be called at any time, without risking data loss or corruption. The caveat is that Purchases.isConfigured should be checked afterwards to make sure the SDK configuration could actually happen.

We feel this is the most straightforward and reliable path for now, as it avoids any data loss or corruption, while also allowing us to build on it in the future. We also think this approach is better than directly crashing on Purchases.configure because, as you so accurately pointed out, crashing should be a developer’s choice.

Does this line up with what you had in mind?

Again, thank you so much for your insightful input on this topic.

ajpallares avatar Aug 06 '25 11:08 ajpallares

Thank you for communicating your internal thoughts and decisions. This solution makes a lot of sense to me. The documentation is important as the log from 2) is pretty much invisible unless a developer reproduces this and manages to attach a console / debugger just in time somehow. I'm not aware of any way to run into this with Xcode.

Developers with live activities or other prewarming triggers can then simply attempt later configurations of RC at appropriate times, when it's not configured yet.

KaiOelfke avatar Aug 06 '25 11:08 KaiOelfke