ShareClient.swift 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. //
  2. // ShareClient.h
  3. // ShareClient
  4. //
  5. // Created by Mark Wilson on 5/7/16.
  6. // Copyright © 2016 Mark Wilson. All rights reserved.
  7. //
  8. import Foundation
  9. public struct ShareGlucose {
  10. public let glucose: UInt16
  11. public let trend: UInt8
  12. public let timestamp: Date
  13. }
  14. public enum ShareError: Error {
  15. case httpError(Error)
  16. // some possible values of errorCode:
  17. // SSO_AuthenticateAccountNotFound
  18. // SSO_AuthenticatePasswordInvalid
  19. // SSO_AuthenticateMaxAttemptsExceeed
  20. case loginError(errorCode: String)
  21. case fetchError
  22. case dataError(reason: String)
  23. case dateError
  24. }
  25. public enum KnownShareServers: String {
  26. case US="https://share2.dexcom.com"
  27. case NON_US="https://shareous1.dexcom.com"
  28. /*
  29. To enable Loop to use a custom share server:
  30. - remove the comment marker on line 44 and change the value of CUSTOM
  31. - remove the comment markers on lines 34 and 35 in ShareClientUI/ShareService+UI.swift
  32. Note: The URL in CUSTOM must start with 'https://' (NOT 'http://')
  33. You can find installation instructions for one such custom share server at
  34. https://github.com/dabear/NightscoutShareServer
  35. */
  36. // case CUSTOM="https://yourusernameshareserver.herokuapp.com"
  37. }
  38. // From the Dexcom Share iOS app, via @bewest and @shanselman:
  39. // https://github.com/bewest/share2nightscout-bridge
  40. private let dexcomUserAgent = "Dexcom Share/3.0.2.11 CFNetwork/711.2.23 Darwin/14.0.0"
  41. private let dexcomApplicationId = "d89443d2-327c-4a6f-89e5-496bbb0317db"
  42. private let dexcomLoginPath = "/ShareWebServices/Services/General/LoginPublisherAccountByName"
  43. private let dexcomLatestGlucosePath = "/ShareWebServices/Services/Publisher/ReadPublisherLatestGlucoseValues"
  44. private let maxReauthAttempts = 2
  45. // TODO use an HTTP library which supports JSON and futures instead of callbacks.
  46. // using cocoapods in a playground appears complicated
  47. // ¯\_(ツ)_/¯
  48. private func dexcomPOST(_ url: URL, JSONData: [String: AnyObject]? = nil, callback: @escaping (Error?, String?) -> Void) {
  49. var data: Data?
  50. if let JSONData = JSONData {
  51. guard let encoded = try? JSONSerialization.data(withJSONObject: JSONData, options:[]) else {
  52. return callback(ShareError.dataError(reason: "Failed to encode JSON for POST to " + url.absoluteString), nil)
  53. }
  54. data = encoded
  55. }
  56. var request = URLRequest(url: url)
  57. request.httpMethod = "POST"
  58. request.addValue("application/json", forHTTPHeaderField: "Content-Type")
  59. request.addValue("application/json", forHTTPHeaderField: "Accept")
  60. request.addValue(dexcomUserAgent, forHTTPHeaderField: "User-Agent")
  61. request.httpBody = data
  62. URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) in
  63. if error != nil {
  64. callback(error, nil)
  65. } else {
  66. callback(nil, String(data: data!, encoding: .utf8))
  67. }
  68. }).resume()
  69. }
  70. public class ShareClient {
  71. public let username: String
  72. public let password: String
  73. private let shareServer:String
  74. private var token: String?
  75. public init(username: String, password: String, shareServer:String=KnownShareServers.US.rawValue) {
  76. self.username = username
  77. self.password = password
  78. self.shareServer = shareServer
  79. }
  80. public convenience init(username: String, password: String, shareServer:KnownShareServers=KnownShareServers.US) {
  81. self.init(username: username, password: password, shareServer:shareServer.rawValue)
  82. }
  83. public func fetchLast(_ n: Int, callback: @escaping (ShareError?, [ShareGlucose]?) -> Void) {
  84. fetchLastWithRetries(n, remaining: maxReauthAttempts, callback: callback)
  85. }
  86. private func ensureToken(_ callback: @escaping (ShareError?) -> Void) {
  87. if token != nil {
  88. callback(nil)
  89. } else {
  90. fetchToken() { (error, token) in
  91. if error != nil {
  92. callback(error)
  93. } else {
  94. self.token = token
  95. callback(nil)
  96. }
  97. }
  98. }
  99. }
  100. private func fetchToken(_ callback: @escaping (ShareError?, String?) -> Void) {
  101. let data = [
  102. "accountName": username,
  103. "password": password,
  104. "applicationId": dexcomApplicationId
  105. ]
  106. guard let url = URL(string: shareServer + dexcomLoginPath) else {
  107. return callback(ShareError.fetchError, nil)
  108. }
  109. dexcomPOST(url, JSONData: data as [String : AnyObject]?) { (error, response) in
  110. if let error = error {
  111. return callback(.httpError(error), nil)
  112. }
  113. guard let response = response,
  114. let data = response.data(using: .utf8),
  115. let decoded = try? JSONSerialization.jsonObject(with: data, options: .allowFragments)
  116. else {
  117. return callback(.loginError(errorCode: "unknown"), nil)
  118. }
  119. if let token = decoded as? String {
  120. // success is a JSON-encoded string containing the token
  121. callback(nil, token)
  122. } else {
  123. // failure is a JSON object containing the error reason
  124. let errorCode = (decoded as? [String: String])?["Code"] ?? "unknown"
  125. callback(.loginError(errorCode: errorCode), nil)
  126. }
  127. }
  128. }
  129. private func fetchLastWithRetries(_ n: Int, remaining: Int, callback: @escaping (ShareError?, [ShareGlucose]?) -> Void) {
  130. ensureToken() { (error) in
  131. guard error == nil else {
  132. return callback(error, nil)
  133. }
  134. guard var components = URLComponents(string: self.shareServer + dexcomLatestGlucosePath) else {
  135. return callback(.fetchError, nil)
  136. }
  137. components.queryItems = [
  138. URLQueryItem(name: "sessionId", value: self.token),
  139. URLQueryItem(name: "minutes", value: String(1440)),
  140. URLQueryItem(name: "maxCount", value: String(n))
  141. ]
  142. guard let url = components.url else {
  143. return callback(.fetchError, nil)
  144. }
  145. dexcomPOST(url) { (error, response) in
  146. if let error = error {
  147. return callback(.httpError(error), nil)
  148. }
  149. do {
  150. guard let response = response else {
  151. throw ShareError.fetchError
  152. }
  153. let decoded = try? JSONSerialization.jsonObject(with: response.data(using: .utf8)!, options: [])
  154. guard let sgvs = decoded as? Array<AnyObject> else {
  155. if remaining > 0 {
  156. self.token = nil
  157. return self.fetchLastWithRetries(n, remaining: remaining - 1, callback: callback)
  158. } else {
  159. throw ShareError.dataError(reason: "Failed to decode SGVs as array after trying to reauth: " + response)
  160. }
  161. }
  162. var transformed: Array<ShareGlucose> = []
  163. for sgv in sgvs {
  164. if let glucose = sgv["Value"] as? Int, let trend = sgv["Trend"] as? Int, let wt = sgv["WT"] as? String {
  165. transformed.append(ShareGlucose(
  166. glucose: UInt16(glucose),
  167. trend: UInt8(trend),
  168. timestamp: try self.parseDate(wt)
  169. ))
  170. } else {
  171. throw ShareError.dataError(reason: "Failed to decode an SGV record: " + response)
  172. }
  173. }
  174. callback(nil, transformed)
  175. } catch let error as ShareError {
  176. callback(error, nil)
  177. } catch {
  178. callback(.fetchError, nil)
  179. }
  180. }
  181. }
  182. }
  183. private func parseDate(_ wt: String) throws -> Date {
  184. // wt looks like "/Date(1462404576000)/"
  185. let re = try NSRegularExpression(pattern: "\\((.*)\\)")
  186. if let match = re.firstMatch(in: wt, range: NSMakeRange(0, wt.count)) {
  187. #if swift(>=4)
  188. let matchRange = match.range(at: 1)
  189. #else
  190. let matchRange = match.rangeAt(1)
  191. #endif
  192. let epoch = Double((wt as NSString).substring(with: matchRange))! / 1000
  193. return Date(timeIntervalSince1970: epoch)
  194. } else {
  195. throw ShareError.dateError
  196. }
  197. }
  198. }