Lock App Features Behind a Paywall and Enforce the Lock in Code
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
featureisProcessCounterUnlocked
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.
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:
- Read the real value from disk/defaults/…
- Wrap it in a paywall check before returning
- 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.