/** * EotpLicense.swift — eOTP DEVELOPER license check for iOS 13+ (single file, no external libraries, Swift 5.7+). * * Usage: * EotpLicense.check(apiKey: "eo_live_xxxxxxxx") { r in * print(r.licensed ? "Licensed: \(r.project ?? "") tier \(r.tier ?? 0)" : "Not licensed: \(r.error ?? "")") * } * * Calls GET https://eotp.com.vn/api/v1/license?app= with header X-Api-Key. Call it once when the app starts. * Your FREE project is upgraded to DEVELOPER automatically once eOTP has both: * - license checks with the right bundle id from at least 2 different networks (for example Wi-Fi and 4G), and * - at least 1 real OTP sent and verified through the API (/otp/send + /otp/verify from your server). * OTPs sent in test mode (fixed code 000000) do not count. * Result.unlockedNow is true on the call that upgraded the project; Result.evidence shows the progress. * * API key is a public identifier; never ship the API secret in an app. * Sending OTP must still go through your own server (API key + secret stay server side). * * Uses URLSession with an 8 second timeout; the completion is always called on the main queue. * * Error codes (Result.error): unknown_key, missing_app, app_mismatch, project_not_approved, * store_unavailable, rate_limited, network_error, bad_response, http_. */ import Foundation public enum EotpLicense { private static let endpoint = "https://eotp.com.vn/api/v1/license" private static let timeout: TimeInterval = 8 /// Integration evidence reported by the server (nil when the field is missing). public struct Evidence: Sendable { public let pingsOk: Int? public let subnets: Int? public let otpVerified: Int? public let requiredSubnets: Int? public let requiredOtpVerified: Int? } public struct Result: Sendable { public let ok: Bool public let licensed: Bool public let project: String? public let tier: Int? public let error: String? /// true only on the call that upgraded the project to DEVELOPER; nil when not reported. public let unlockedNow: Bool? /// Progress towards DEVELOPER; nil on errors or when not reported. public let evidence: Evidence? } /// Checks the DEVELOPER license of this app. /// - Parameters: /// - apiKey: project API key (eo_live_…). Do NOT pass the API secret. /// - appId: bundle identifier registered on the App Store; defaults to Bundle.main.bundleIdentifier. /// - completion: receives the Result on the main queue. public static func check(apiKey: String, appId: String? = nil, completion: @escaping (Result) -> Void) { let box = Completion(completion) @Sendable func finish(_ r: Result) { DispatchQueue.main.async { box.call(r) } } let app = appId ?? Bundle.main.bundleIdentifier ?? "" guard var comps = URLComponents(string: endpoint) else { finish(failure("bad_request")) return } comps.queryItems = [URLQueryItem(name: "app", value: app)] guard let url = comps.url else { finish(failure("bad_request")) return } var req = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout) req.httpMethod = "GET" req.setValue(apiKey, forHTTPHeaderField: "X-Api-Key") req.setValue("application/json", forHTTPHeaderField: "Accept") URLSession.shared.dataTask(with: req) { data, response, err in if err != nil { finish(failure("network_error")) return } let status = (response as? HTTPURLResponse)?.statusCode ?? 0 finish(parse(status: status, data: data)) }.resume() } /// Holds the caller's completion so it can cross into URLSession's @Sendable callback without forcing callers to /// pass a @Sendable closure (keeps the pre-Sendable call sites compiling in Swift 5 and Swift 6 modes). Safe because /// the completion is only ever invoked on the main queue. private final class Completion: @unchecked Sendable { private let fn: (Result) -> Void init(_ fn: @escaping (Result) -> Void) { self.fn = fn } func call(_ r: Result) { fn(r) } } private static func failure(_ error: String) -> Result { return Result(ok: false, licensed: false, project: nil, tier: nil, error: error, unlockedNow: nil, evidence: nil) } /// JSON number → Int (JSONSerialization gives NSNumber; note `is Bool` is also true for NSNumber 0/1, so do not test it). private static func int(_ v: Any?) -> Int? { return (v as? NSNumber)?.intValue } private static func parse(status: Int, data: Data?) -> Result { let success = (200...299).contains(status) guard let data = data, let obj = try? JSONSerialization.jsonObject(with: data), let json = obj as? [String: Any] else { return failure(success ? "bad_response" : "http_\(status)") } let ok = success && (json["ok"] as? Bool ?? false) let project = json["project"] as? String let tier = int(json["tier"]) var error: String? = nil if !ok { let e = json["error"] as? String ?? "" error = e.isEmpty ? "http_\(status)" : e } var evidence: Evidence? = nil if let ev = json["evidence"] as? [String: Any] { let req = ev["required"] as? [String: Any] evidence = Evidence(pingsOk: int(ev["pings_ok"]), subnets: int(ev["subnets"]), otpVerified: int(ev["otp_verified"]), requiredSubnets: int(req?["subnets"]), requiredOtpVerified: int(req?["otp_verified"])) } return Result(ok: ok, licensed: ok && (json["licensed"] as? Bool ?? false), project: project, tier: tier, error: error, unlockedNow: json["unlocked_now"] as? Bool, evidence: evidence) } }