Lock App Features Behind a Paywall and Enforce the Lock in Code

null

I stumbled upon an interesting coding problem in a recent macOS project related to in-app purchases. IAP can be represented by a feature option set in code. How do you secure UserDefaults access in such a way that accessing values can be locked via the IAP available feature options? (This also applies to tiered licenses, like a “Basic” and a “Pro” feature set.)

Let’s say you represent the purchase-able features as such:

struct Feature: OptionSet {
    let rawValue: Int

    init(rawValue: Int) {
        self.rawValue = rawValue
    }

    static let export = Feature(rawValue: 1 << 1)
    static let analysis = Feature(rawValue: 1 << 2)
    static let advancedProcessCounter = Feature(rawValue: 1 << 3)
}

You can use these in code like this:

let purchasedFeatures: Feature = [.analysis, .export]

Let’s say you can get there through a service object that looks at the current license to figure out which in-app purchase is unlocked. For the sake of this post, it doesn’t matter if you use Apple’s API, the Paddle SDK, or a home-grown solution.

class LicenseReader {
    func purchasedFeatures() -> Feature { ... }
}

You store the value for advancedProcessCounter in a file or UserDefaults, because that’s simple.

private let advancedProcessCounterKey = "Advanced.processCount"

class AdvancedStuff {
    var processCounter: Int {
        get {
            return UserDefaults.standard.integer(forKey: advancedProcessCounterKey)
        }
        set {
            UserDefaults.standard.set(newValue, forKey: advancedProcessCounterKey)
        }
    }
    
    // Here be code that displays/uses the counter value ...
}

Now let’s hide this behind a paywall.

In your first approach, you ask the LicenseReader for available features directly and either read/write the value, or default to 0 if the user didn’t purchase that advanced thingie. It’s a valid approach to test if the paywall works at all:

private let advancedProcessCounterKey = "Advanced.processCount"

class AdvancedStuff {
    private let licenseReader: LicenseReader = ...
    private var isProcessCounterUnlocked: Bool {
        return licenseReader.purchasedFeatures().contains(.advancedProcessCounter)
    }
    
    var processCounter: Int {
        get {
            guard isProcessCounterUnlocked else { return 0 }
            return UserDefaults.standard.integer(forKey: advancedProcessCounterKey)
        }
        set {
            guard isProcessCounterUnlocked else { return }
            UserDefaults.standard.set(newValue, forKey: advancedProcessCounterKey)
        }
    }
    
    // Here be code that displays/uses the counter value ...
}
  • LicenseReader.purchasedFeatures is used to test for availability of the .advancedProcessCounter feature
  • isProcessCounterUnlocked wraps this check and prevents access to the stored value

You run the app, things work. Nice.

Now you modularize the growing app and want to extract the processCounter reading/writing into another object. This way, your app doesn’t need to know if the setting is stored in UserDefaults, a PList somewhere else, or in the cloud at this point and can defer the knowledge to a pluggable component.

Only now you notice that the I/O of the setting is closely coupled to the LicenseReader, which makes reorganizing the code harder. You don’t want to dependency inject the LicenseReader, either.

The whole license-reading business should not be relevant for reading the value from its source.

But you want the value to be “locked” behind a paywall as strictly as possible.

Deadbolt Door Lock photo courtesy of Tony Webster, CC BY 2.0

Encapsulate Value Access in a Paywall Object

Currently, the processCounter: Int property directly returns the stored value or a fallback if the paywall prevents access. How can you strictly require a feature availability check but still reduce the functionality of the defaults reader to simple reading the value?

You can split this into multiple steps:

  1. Read the real value from disk/defaults/…
  2. Wrap it in a paywall check before returning
  3. Satisfy the paywall check when the value is used

If we were thinking about value transformations, it’d be of the form:

() -> (value: Value, required: Feature) -> (available: Feature) -> Value?

We’ll have a look at an object-based approach now.

Instead of directly running a query method on LicenseReader right now, you defer this call to a later point in time. And also abstract away the details.

class SettingsReader {
    var advancedProcessCounter: Paywall<Int> {
        // 1) Read the real value
        let value = UserDefaults.standard.int(forKey: advancedProcessCounterKey)

        // 2) Wrap it in a deferred paywall check
        return Paywall(value: value, 
                       requiredFeatures: .advancedProcessCounter)
    }
}

With this code, the real value is read, there’s no check at this very point, but we can still express a feature requirement.

Here’s a Paywall implementation:

struct Paywall<Value> {
    private let value: Value
    let requiredFeatures: Feature
    
    init(value: Value, requiredFeatures: Feature) {
        self.value = value
        self.requiredFeatures = requiredFeatures
    }
    
    func value(availableFeatures: Feature) -> Value? {
        var covered = requiredFeatures
        covered.formIntersection(availableFeatures)
        guard covered == requiredFeatures else { return nil }
        return value
    }
}

The magic happens in Paywall.value(availableFeatures:): it protects access to the value. You only get the value if at least all required features are passed in as the parameter. If one or more are missing, you get back nil.

Now your client code, like view controllers, can provide the glue:

class AdvancedComputationViewController: UIViewController {
    let settingsReader: SettingsReader = ...
    let licenseReader: LicenseReader = ...
    
    var processCount: Int {
        let paywall: Paywall<Int> = settingsReader.advancedProcessCount
        let value: Int? = paywall.value(availableFeatures: licenseReader.purchasedFeatures())
        return value ?? 0
    }
    
    // ...
}

You see how the LicenseReader is used to attempt to unlock the Paywall<Int>. If it fails, you return the default value 0 (same as in the very beginning).

The Paywall type forces you to pause for a second when you want access to protected details. You need to figure out a way to pass in the availableFeatures option set, or else you cannot get to the value. The type forces you to remember that this value is locked.

The knowledge about “being locked” is encapsulated in the Paywall type.

A more naive implementation could have read the real process count and then implemented the paywall-check in place:

var naiveProcessCountWithPaywall: Int {
    let value = UserDefaults.standard.int(forKey: advancedProcessCounterKey)

    // The paywall is here
    guard licenseReader.purchasedFeatures().contains(.advancesProcessCounter) else { return 0 }

    return value
}

Problem is: you can easily overlook that this is supposed to be a paywall-protected value. Forgetting the guard statement ruins the whole reason of making the feature purchase-able. Oops.

A type like my Paywall prototype above makes the type system do the work. That’s a beautifully, Swift-y way to help avoid problems instead having to remember them and write unit tests to catch errors.

It also helps splitting your app into multiple, decoupled modules. The SettingsReader does not need to know how available features and the license code is accessed. This is useful to e.g. inline your license-accessor code, making discovery of license-related code harder for crackers.