Perfect Implementation of One-Time Offers or Trials in iOS (Swift)
iOS DeviceCheck follows you everywhere
While writing the previous Call Directory Extension, I accidentally discovered this obscure API. Although it’s not something new (announced at WWDC 2017/iOS ≥11 support) and the implementation is very simple, I still did a little research and testing and organized this article as a record.
What can DeviceCheck do?
Allows developers to identify and mark the user’s device
Since iOS ≥ 6, developers cannot obtain the unique identifier (UUID) of the user’s device. The compromise is to use IDFV combined with KeyChain (for details, refer to this article), but in situations like changing iCloud accounts or resetting the phone, the UUID will still reset. It cannot guarantee the uniqueness of the device. If used for storing and judging some business logic, such as the first free trial, users might exploit the loophole by constantly changing accounts or resetting the phone to get unlimited trials.
Although DeviceCheck cannot provide a UUID that will never change, it can “store” information. Each device is given 2 bits of cloud storage space by Apple. By sending a temporary identification token generated by the device to Apple, you can write/read the 2 bits of information.
2 bits? What can be stored?
Only four states can be combined, so the functionality is limited.
Comparison with original storage methods:
✓ Indicates data is still there
p.s. I sacrificed my own phone for actual testing, and the results matched. Even if I logged out and changed iCloud, cleared all data, restored all settings, and returned to the factory initial state, I could still retrieve the value after reinstalling the app.
Main operation process:
The iOS app generates a temporary token for device identification through the DeviceCheck API, sends it to the backend, which then combines the developer’s private key information and developer information into JWT format and sends it to the Apple server. The backend processes the result returned by Apple and sends it back to the iOS app.
Application of DeviceCheck
Here is a screenshot of DeviceCheck from WWDC2017:
Since each device can only store 2 bits of information, the possible applications are limited to what the official mentions, such as whether the device has been trialed, paid, or blacklisted, etc., and only one can be implemented.
Support: iOS ≥ 11
Let’s start!
After understanding the basic information, let’s get started!
iOS APP side:
1
2
3
4
5
6
7
8
9
10
import DeviceCheck
//....
//
DCDevice.current.generateToken { dataOrNil, errorOrNil in
guard let data = dataOrNil else { return }
let deviceToken = data.base64EncodedString()
//...
//POST deviceToken to the backend, let the backend query the Apple server, and then return the result to the app for processing
}
As described in the process, the app only needs to obtain the temporary identification token (deviceToken)!
Next, send the deviceToken to our backend API for processing.
Backend:
The key part is the backend processing
1. First, log in to the Developer Console Note down the Team ID
2. Then click on the sidebar Certificates, IDs & Profiles to go to the certificate management platform
Select “Keys” -> “All” -> Top right corner “+”
Step 1. Create a new Key, check “DeviceCheck”
Step 2. “Confirm”
Finished.
After completing the last step, note down the Key ID and click “Download” to download the privateKey.p8 private key file.
At this point, you have all the necessary information for push notifications:
- Team ID
- Key ID
- privateKey.p8
3. Combine according to Apple’s JWT (JSON Web Token) format
Algorithm: ES256
1
2
3
4
5
6
7
8
9
10
11
12
//HEADER:
{
"alg": "ES256",
"kid": Key ID
}
//PAYLOAD:
{
"iss": Team ID,
"iat": request timestamp (Unix Timestamp, EX: 1556549164),
"exp": expiration timestamp (Unix Timestamp, EX: 1557000000)
}
//Timestamps must be in integer format!
Get the combined JWT string: xxxxxx.xxxxxx.xxxxxx
4. Send the data to the Apple server & get the return result
Like APNS push notifications, there are separate environments for development and production:
- Development environment: api.development.devicecheck.apple.com (For some reason, my development environment requests always fail)
- Production environment: api.devicecheck.apple.com
DeviceCheck API provides two operations: 1. Query stored data: https://api.devicecheck.apple.com/v1/query_two_bits
1
2
3
4
5
6
7
//Headers:
Authorization: Bearer xxxxxx.xxxxxx.xxxxxx (combined JWT string)
//Content:
device_token: deviceToken (the device token to query)
transaction_id: UUID().uuidString (query identifier, using UUID here)
timestamp: request timestamp (milliseconds), note! This is in milliseconds (EX: 1556549164000)
Return status:
Return Content:
1
2
3
4
5
{
"bit0": Int: The first bit of the 2 bits data: 0 or 1,
"bit1": Int: The second bit of the 2 bits data: 0 or 1,
"last_update_time": String: "Last update time YYYY-MM"
}
p.s. You read it right, the last update time can only be displayed up to year-month
2. Write Storage Data: https://api.devicecheck.apple.com/v1/update_two_bits
1
2
3
4
5
6
7
8
9
//Headers:
Authorization: Bearer xxxxxx.xxxxxx.xxxxxx (combined JWT string)
//Content:
device_token:deviceToken (Device Token to query)
transaction_id:UUID().uuidString (Query identifier, here directly represented by UUID)
timestamp: Request timestamp (milliseconds), note! This is in milliseconds (EX: 1556549164000)
bit0: The first bit of the 2 bits data: 0 or 1
bit1: The second bit of the 2 bits data: 0 or 1
5. Get Apple Server Return Result
Return Status:
Return Content: None, return status 200 indicates a successful write!
6. Backend API Returns Result to APP
The APP responds to the corresponding status and it’s done!
Backend Supplement:
It’s been a long time since I touched PHP, if interested, please refer to iOS11で追加されたDeviceCheckについて for the requestToken.php part
Swift Version Demo:
Since I can’t provide backend implementation and not everyone knows PHP, here is a pure iOS (Swift) example that handles backend tasks (generating JWT, sending data to Apple) directly in the APP for reference!
You can simulate all content without writing backend code.
⚠ Please note for testing and demonstration purposes only, not recommended for production environment ⚠
Special thanks to Ethan Huang for providing CupertinoJWT which supports generating JWT content within the iOS APP!
Main Demo Code and Interface:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
import UIKit
import DeviceCheck
import CupertinoJWT
extension String {
var queryEncode: String {
return self.addingPercentEncoding(withAllowedCharacters: .whitespacesAndNewlines)?.replacingOccurrences(of: "+", with: "%2B") ?? ""
}
}
class ViewController: UIViewController {
@IBOutlet weak var getBtn: UIButton!
@IBOutlet weak var statusBtn: UIButton!
@IBAction func getBtnClick(_ sender: Any) {
DCDevice.current.generateToken { dataOrNil, errorOrNil in
guard let data = dataOrNil else { return }
let deviceToken = data.base64EncodedString()
//In a real situation:
//POST deviceToken to backend, let backend query Apple server, then return the result to the APP
//!!!!!! The following is for testing and demonstration purposes only, not recommended for production environment!!!!!!
//!!!!!! Do not expose your PRIVATE KEY casually!!!!!!
let p8 = """
-----BEGIN PRIVATE KEY-----
-----END PRIVATE KEY-----
"""
let keyID = "" //Your KEY ID
let teamID = "" //Your Developer Team ID: https://developer.apple.com/account/#/membership
let jwt = JWT(keyID: keyID, teamID: teamID, issueDate: Date(), expireDuration: 60 * 60)
do {
let token = try jwt.sign(with: p8)
var request = URLRequest(url: URL(string: "https://api.devicecheck.apple.com/v1/update_two_bits")!)
request.httpMethod = "POST"
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let json: [String: Any] = ["device_token": deviceToken, "transaction_id": UUID().uuidString, "timestamp": Int(Date().timeIntervalSince1970.rounded()) * 1000, "bit0": true, "bit1": false]
request.httpBody = try? JSONSerialization.data(withJSONObject: json)
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
guard let data = data else {
return
}
print(String(data: data, encoding: String.Encoding.utf8))
DispatchQueue.main.async {
self.getBtn.isHidden = true
self.statusBtn.isSelected = true
}
}
task.resume()
} catch {
// Handle error
}
//!!!!!! The above is for testing and demonstration purposes only, not recommended for production environment!!!!!!
//
}
}
override func viewDidLoad() {
super.viewDidLoad()
DCDevice.current.generateToken { dataOrNil, errorOrNil in
guard let data = dataOrNil else { return }
let deviceToken = data.base64EncodedString()
//In a real situation:
//POST deviceToken to backend, let backend query Apple server, then return the result to the APP
//!!!!!! The following is for testing and demonstration purposes only, not recommended for production environment!!!!!!
//!!!!!! Do not expose your PRIVATE KEY casually!!!!!!
let p8 = """
-----BEGIN PRIVATE KEY-----
-----END PRIVATE KEY-----
"""
let keyID = "" //Your KEY ID
let teamID = "" //Your Developer Team ID: https://developer.apple.com/account/#/membership
let jwt = JWT(keyID: keyID, teamID: teamID, issueDate: Date(), expireDuration: 60 * 60)
do {
let token = try jwt.sign(with: p8)
var request = URLRequest(url: URL(string: "https://api.devicecheck.apple.com/v1/query_two_bits")!)
request.httpMethod = "POST"
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let json: [String: Any] = ["device_token": deviceToken, "transaction_id": UUID().uuidString, "timestamp": Int(Date().timeIntervalSince1970.rounded()) * 1000]
request.httpBody = try? JSONSerialization.data(withJSONObject: json)
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
guard let data = data, let json = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers) as? [String: Any], let status = json["bit0"] as? Int else {
return
}
print(json)
if status == 1 {
DispatchQueue.main.async {
self.getBtn.isHidden = true
self.statusBtn.isSelected = true
}
}
}
task.resume()
} catch {
// Handle error
}
//!!!!!! The above is for testing and demonstration purposes only, not recommended for production environment!!!!!!
//
}
// Do any additional setup after loading the view.
}
}
Screenshot
This is a one-time discount claim, each device can only claim once!
Complete project download:
If you have any questions or comments, feel free to contact me.
===
===
This article was first published in Traditional Chinese on Medium ➡️ View Here