@rovq/react-native 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +76 -2
- package/Rovq.podspec +19 -0
- package/android/build.gradle +86 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
- package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +173 -0
- package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +188 -0
- package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
- package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
- package/android/src/main/java/com/rovq/engine/RovqEngine.kt +543 -0
- package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
- package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
- package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
- package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +94 -0
- package/android/src/main/java/com/rovq/engine/UpdateService.kt +139 -0
- package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
- package/android/src/react/java/com/rovq/engine/react/Rovq.kt +101 -0
- package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
- package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
- package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
- package/ios/Engine/EngineState.swift +119 -0
- package/ios/Engine/ManifestVerifier.swift +197 -0
- package/ios/Engine/PackageDownloader.swift +231 -0
- package/ios/Engine/PackageRules.swift +38 -0
- package/ios/Engine/ProgressThrottle.swift +20 -0
- package/ios/Engine/RovqEngine.swift +633 -0
- package/ios/Engine/RovqError.swift +35 -0
- package/ios/Engine/SafePackageExtractor.swift +107 -0
- package/ios/Engine/UpdateInstaller.swift +126 -0
- package/ios/Engine/UpdateService.swift +158 -0
- package/ios/Engine/ZipReader.swift +378 -0
- package/ios/React/RovqHost.swift +253 -0
- package/ios/React/RovqModule.mm +89 -0
- package/package.json +74 -3
- package/react-native.config.js +11 -0
- package/src/NativeRovq.ts +72 -0
- package/src/errors.ts +44 -0
- package/src/index.ts +228 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Extracts a package without trusting it, even though its signature was already verified.
|
|
4
|
+
/// If a signing key ever leaks, this keeps an attacker inside the package folder, so the
|
|
5
|
+
/// files of the app itself stay out of reach.
|
|
6
|
+
struct SafePackageExtractor {
|
|
7
|
+
/// - Parameter destination: an empty folder that only this extraction uses.
|
|
8
|
+
func extract(packageFile: URL, destination: URL) throws {
|
|
9
|
+
do {
|
|
10
|
+
let reader = try ZipReader(url: packageFile)
|
|
11
|
+
try extractEntries(reader, destination: destination)
|
|
12
|
+
} catch let error as ZipError {
|
|
13
|
+
throw invalid(error.message)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private func extractEntries(_ reader: ZipReader, destination: URL) throws {
|
|
18
|
+
let root = destination.resolvingSymlinksInPath().standardizedFileURL.path + "/"
|
|
19
|
+
var seen = Set<String>()
|
|
20
|
+
var hasBundle = false
|
|
21
|
+
var totalSize: Int64 = 0
|
|
22
|
+
|
|
23
|
+
if reader.entries.count > PackageRules.maxEntries { throw invalid("Too many files") }
|
|
24
|
+
|
|
25
|
+
for entry in reader.entries {
|
|
26
|
+
if entry.isFolder { throw invalid("Folder entries are not allowed") }
|
|
27
|
+
if let problem = PackageRules.findPathProblem(entry.name) {
|
|
28
|
+
throw invalid("The file \(entry.name) \(problem)")
|
|
29
|
+
}
|
|
30
|
+
if !seen.insert(PackageRules.pathIdentity(entry.name)).inserted {
|
|
31
|
+
throw invalid("The file \(entry.name) appears more than once")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let target = destination.appendingPathComponent(entry.name)
|
|
35
|
+
// A second, independent check: whatever the rules allow, the real path must stay
|
|
36
|
+
// inside the folder.
|
|
37
|
+
let parent = target.deletingLastPathComponent()
|
|
38
|
+
try createFolder(parent)
|
|
39
|
+
let resolved = parent.resolvingSymlinksInPath().standardizedFileURL.path + "/"
|
|
40
|
+
if !resolved.hasPrefix(root) { throw invalid("The file \(entry.name) leaves the folder") }
|
|
41
|
+
|
|
42
|
+
let written = try write(
|
|
43
|
+
entry,
|
|
44
|
+
from: reader,
|
|
45
|
+
to: target,
|
|
46
|
+
allowance: PackageRules.maxTotalSize - totalSize
|
|
47
|
+
)
|
|
48
|
+
totalSize += written
|
|
49
|
+
if entry.name == PackageRules.bundleFileName {
|
|
50
|
+
if written == 0 { throw invalid("The bundle file is empty") }
|
|
51
|
+
hasBundle = true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if !hasBundle { throw invalid("The bundle file is missing") }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private func write(
|
|
58
|
+
_ entry: ZipEntry,
|
|
59
|
+
from reader: ZipReader,
|
|
60
|
+
to target: URL,
|
|
61
|
+
allowance: Int64
|
|
62
|
+
) throws -> Int64 {
|
|
63
|
+
guard FileManager.default.createFile(atPath: target.path, contents: nil) else {
|
|
64
|
+
throw RovqError(.storageFailed, "Could not create \(entry.name)")
|
|
65
|
+
}
|
|
66
|
+
let output: FileHandle
|
|
67
|
+
do {
|
|
68
|
+
output = try FileHandle(forWritingTo: target)
|
|
69
|
+
} catch {
|
|
70
|
+
throw RovqError(.storageFailed, "Could not create \(entry.name)")
|
|
71
|
+
}
|
|
72
|
+
defer { try? output.close() }
|
|
73
|
+
|
|
74
|
+
let written: Int64
|
|
75
|
+
do {
|
|
76
|
+
written = try reader.extract(entry, allowance: allowance) { chunk in
|
|
77
|
+
do {
|
|
78
|
+
try output.write(contentsOf: chunk)
|
|
79
|
+
} catch {
|
|
80
|
+
throw RovqError(.storageFailed, "Could not write \(entry.name)")
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Forces the bytes to disk, because the release becomes active by renaming its
|
|
84
|
+
// folder, and that rename must not outrun the data.
|
|
85
|
+
try output.synchronize()
|
|
86
|
+
} catch let error as RovqError {
|
|
87
|
+
throw error
|
|
88
|
+
} catch let error as ZipError {
|
|
89
|
+
throw error
|
|
90
|
+
} catch {
|
|
91
|
+
throw RovqError(.storageFailed, "Could not write \(entry.name)")
|
|
92
|
+
}
|
|
93
|
+
return written
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private func createFolder(_ url: URL) throws {
|
|
97
|
+
do {
|
|
98
|
+
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
|
99
|
+
} catch {
|
|
100
|
+
throw RovqError(.storageFailed, "Could not create \(url.lastPathComponent)")
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private func invalid(_ message: String) -> RovqError {
|
|
105
|
+
RovqError(.invalidPackage, message)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// What the server offered. None of this is trusted until the manifest has been verified.
|
|
4
|
+
public struct OfferedUpdate: Equatable {
|
|
5
|
+
public let manifest: String
|
|
6
|
+
public let signature: String
|
|
7
|
+
public let keyId: String
|
|
8
|
+
public let downloadURL: String
|
|
9
|
+
|
|
10
|
+
public init(manifest: String, signature: String, keyId: String, downloadURL: String) {
|
|
11
|
+
self.manifest = manifest
|
|
12
|
+
self.signature = signature
|
|
13
|
+
self.keyId = keyId
|
|
14
|
+
self.downloadURL = downloadURL
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// A verified release, fully extracted in its own folder.
|
|
19
|
+
public struct InstalledRelease: Equatable {
|
|
20
|
+
public let manifest: VerifiedManifest
|
|
21
|
+
public let folder: URL
|
|
22
|
+
public let bundleFile: URL
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Takes an offered update to a verified folder on disk, without activating it.
|
|
26
|
+
///
|
|
27
|
+
/// Each release gets its own folder, which keeps the design the same as Android, and makes
|
|
28
|
+
/// the final rename what decides that a release is complete.
|
|
29
|
+
public final class UpdateInstaller {
|
|
30
|
+
private let updatesFolder: URL
|
|
31
|
+
private let workFolder: URL
|
|
32
|
+
// Outside the work folder, because a partial download must survive a failure or a restart.
|
|
33
|
+
private let partialFolder: URL
|
|
34
|
+
private let verifier: ManifestVerifier
|
|
35
|
+
private let downloader: PackageDownloader
|
|
36
|
+
private let extractor = SafePackageExtractor()
|
|
37
|
+
private let lock = NSLock()
|
|
38
|
+
|
|
39
|
+
/// - Parameter rootFolder: a folder only the engine uses, inside the app's private storage.
|
|
40
|
+
public init(rootFolder: URL, verifier: ManifestVerifier, downloader: PackageDownloader) {
|
|
41
|
+
updatesFolder = rootFolder.appendingPathComponent("updates")
|
|
42
|
+
workFolder = rootFolder.appendingPathComponent("work")
|
|
43
|
+
partialFolder = rootFolder.appendingPathComponent("partial")
|
|
44
|
+
self.verifier = verifier
|
|
45
|
+
self.downloader = downloader
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// - Parameter highestSequence: the highest sequence this device has accepted in this stream.
|
|
49
|
+
/// - Throws: `RovqError` if any step fails. Nothing is left behind in that case.
|
|
50
|
+
public func install(
|
|
51
|
+
offer: OfferedUpdate,
|
|
52
|
+
highestSequence: Int64,
|
|
53
|
+
onProgress: @escaping (Int64, Int64) -> Void = { _, _ in }
|
|
54
|
+
) throws -> InstalledRelease {
|
|
55
|
+
lock.lock()
|
|
56
|
+
defer { lock.unlock() }
|
|
57
|
+
|
|
58
|
+
// Verified first, so an unsigned update causes no network traffic or disk use.
|
|
59
|
+
let manifest = try verifier.verify(
|
|
60
|
+
manifest: offer.manifest,
|
|
61
|
+
signature: offer.signature,
|
|
62
|
+
keyId: offer.keyId,
|
|
63
|
+
highestSequence: highestSequence
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
let folder = updatesFolder.appendingPathComponent(manifest.releaseId, isDirectory: true)
|
|
67
|
+
// Folders appear only through the atomic rename below, so one that exists is complete.
|
|
68
|
+
var isFolder: ObjCBool = false
|
|
69
|
+
if !FileManager.default.fileExists(atPath: folder.path, isDirectory: &isFolder) || !isFolder.boolValue {
|
|
70
|
+
try installFiles(offer: offer, manifest: manifest, folder: folder, onProgress: onProgress)
|
|
71
|
+
}
|
|
72
|
+
return InstalledRelease(
|
|
73
|
+
manifest: manifest,
|
|
74
|
+
folder: folder,
|
|
75
|
+
bundleFile: folder.appendingPathComponent(PackageRules.bundleFileName)
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private func installFiles(
|
|
80
|
+
offer: OfferedUpdate,
|
|
81
|
+
manifest: VerifiedManifest,
|
|
82
|
+
folder: URL,
|
|
83
|
+
onProgress: @escaping (Int64, Int64) -> Void
|
|
84
|
+
) throws {
|
|
85
|
+
let files = FileManager.default
|
|
86
|
+
// Removes anything an earlier run left when the app was killed or the phone lost power.
|
|
87
|
+
try? files.removeItem(at: workFolder)
|
|
88
|
+
let extracted = workFolder.appendingPathComponent("extracted", isDirectory: true)
|
|
89
|
+
do {
|
|
90
|
+
try files.createDirectory(at: extracted, withIntermediateDirectories: true)
|
|
91
|
+
try files.createDirectory(at: updatesFolder, withIntermediateDirectories: true)
|
|
92
|
+
try files.createDirectory(at: partialFolder, withIntermediateDirectories: true)
|
|
93
|
+
} catch {
|
|
94
|
+
throw RovqError(.storageFailed, "Could not create the engine folders")
|
|
95
|
+
}
|
|
96
|
+
defer { try? files.removeItem(at: workFolder) }
|
|
97
|
+
|
|
98
|
+
let packageFile = partialFolder.appendingPathComponent("\(manifest.packageHash).zip")
|
|
99
|
+
// Only the current offer is worth finishing.
|
|
100
|
+
for file in (try? files.contentsOfDirectory(at: partialFolder, includingPropertiesForKeys: nil)) ?? []
|
|
101
|
+
where file.lastPathComponent != packageFile.lastPathComponent {
|
|
102
|
+
try? files.removeItem(at: file)
|
|
103
|
+
}
|
|
104
|
+
do {
|
|
105
|
+
try downloader.download(
|
|
106
|
+
url: offer.downloadURL,
|
|
107
|
+
manifest: manifest,
|
|
108
|
+
target: packageFile,
|
|
109
|
+
onProgress: onProgress
|
|
110
|
+
)
|
|
111
|
+
try extractor.extract(packageFile: packageFile, destination: extracted)
|
|
112
|
+
do {
|
|
113
|
+
try files.moveItem(at: extracted, to: folder)
|
|
114
|
+
} catch {
|
|
115
|
+
throw RovqError(.storageFailed, "Could not move the release into place")
|
|
116
|
+
}
|
|
117
|
+
try? files.removeItem(at: packageFile)
|
|
118
|
+
} catch {
|
|
119
|
+
// A file that failed anything but the network is not worth continuing from.
|
|
120
|
+
if (error as? RovqError)?.code != .downloadFailed {
|
|
121
|
+
try? files.removeItem(at: packageFile)
|
|
122
|
+
}
|
|
123
|
+
throw error
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
private let requestTimeout: TimeInterval = 30
|
|
4
|
+
|
|
5
|
+
/// What the device tells the server when asking for updates.
|
|
6
|
+
public struct CheckRequest: Equatable {
|
|
7
|
+
public let deploymentKey: String
|
|
8
|
+
public let deviceId: String
|
|
9
|
+
public let runtimeVersion: String
|
|
10
|
+
public let binaryVersion: String
|
|
11
|
+
public let currentReleaseId: String?
|
|
12
|
+
public let highestSequence: Int64
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// The server's answer. It is advice only, because `OfferedUpdate` is verified before use.
|
|
16
|
+
public enum CheckResponse: Equatable {
|
|
17
|
+
case none
|
|
18
|
+
case rollback
|
|
19
|
+
case update(OfferedUpdate)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// A key's signed statement that it must no longer be trusted. The engine verifies it.
|
|
23
|
+
public struct KeyRevocation: Equatable {
|
|
24
|
+
public let keyId: String
|
|
25
|
+
public let statement: String
|
|
26
|
+
public let signature: String
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public struct CheckAnswer: Equatable {
|
|
30
|
+
public let response: CheckResponse
|
|
31
|
+
public let revocations: [KeyRevocation]
|
|
32
|
+
|
|
33
|
+
public init(response: CheckResponse, revocations: [KeyRevocation] = []) {
|
|
34
|
+
self.response = response
|
|
35
|
+
self.revocations = revocations
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// The server API the engine needs, kept separate so tests can replace it.
|
|
40
|
+
public protocol UpdateService {
|
|
41
|
+
/// - Throws: if the server cannot be reached or answers with an error.
|
|
42
|
+
func check(_ request: CheckRequest) throws -> CheckAnswer
|
|
43
|
+
|
|
44
|
+
/// - Throws: if the events could not be delivered.
|
|
45
|
+
func report(deploymentKey: String, deviceId: String, events: [QueuedEvent]) throws
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public final class HTTPUpdateService: UpdateService {
|
|
49
|
+
private let apiURL: String
|
|
50
|
+
private let allowInsecureURLs: Bool
|
|
51
|
+
|
|
52
|
+
public init(apiURL: String, allowInsecureURLs: Bool = false) {
|
|
53
|
+
self.apiURL = apiURL
|
|
54
|
+
self.allowInsecureURLs = allowInsecureURLs
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public func check(_ request: CheckRequest) throws -> CheckAnswer {
|
|
58
|
+
var body: [String: Any] = [
|
|
59
|
+
"deploymentKey": request.deploymentKey,
|
|
60
|
+
"deviceId": request.deviceId,
|
|
61
|
+
"platform": "IOS",
|
|
62
|
+
"runtimeVersion": request.runtimeVersion,
|
|
63
|
+
"binaryVersion": request.binaryVersion,
|
|
64
|
+
"highestSequence": request.highestSequence,
|
|
65
|
+
]
|
|
66
|
+
if let current = request.currentReleaseId { body["currentReleaseId"] = current }
|
|
67
|
+
|
|
68
|
+
let response = try post("/device/updates/check", body: body)
|
|
69
|
+
let revocations = readRevocations(response)
|
|
70
|
+
switch response["action"] as? String {
|
|
71
|
+
case "NONE":
|
|
72
|
+
return CheckAnswer(response: .none, revocations: revocations)
|
|
73
|
+
case "ROLLBACK":
|
|
74
|
+
return CheckAnswer(response: .rollback, revocations: revocations)
|
|
75
|
+
case "UPDATE":
|
|
76
|
+
guard let update = response["update"] as? [String: Any],
|
|
77
|
+
let manifest = update["manifest"] as? String,
|
|
78
|
+
let signature = update["signature"] as? String,
|
|
79
|
+
let keyId = update["keyId"] as? String,
|
|
80
|
+
let downloadURL = update["downloadUrl"] as? String
|
|
81
|
+
else { throw RovqError(.downloadFailed, "The server sent an unreadable response") }
|
|
82
|
+
return CheckAnswer(
|
|
83
|
+
response: .update(
|
|
84
|
+
OfferedUpdate(manifest: manifest, signature: signature, keyId: keyId, downloadURL: downloadURL)),
|
|
85
|
+
revocations: revocations
|
|
86
|
+
)
|
|
87
|
+
default:
|
|
88
|
+
throw RovqError(.downloadFailed, "The server sent an unknown action")
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private func readRevocations(_ response: [String: Any]) -> [KeyRevocation] {
|
|
93
|
+
let list = response["revokedKeys"] as? [[String: Any]] ?? []
|
|
94
|
+
return list.compactMap { item in
|
|
95
|
+
guard let keyId = item["keyId"] as? String,
|
|
96
|
+
let statement = item["statement"] as? String,
|
|
97
|
+
let signature = item["signature"] as? String
|
|
98
|
+
else { return nil }
|
|
99
|
+
return KeyRevocation(keyId: keyId, statement: statement, signature: signature)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
public func report(deploymentKey: String, deviceId: String, events: [QueuedEvent]) throws {
|
|
104
|
+
let list: [[String: Any]] = events.map { event in
|
|
105
|
+
var json: [String: Any] = [
|
|
106
|
+
"clientEventId": event.clientEventId,
|
|
107
|
+
"type": event.type,
|
|
108
|
+
"occurredAt": event.occurredAt,
|
|
109
|
+
]
|
|
110
|
+
if let releaseId = event.releaseId { json["releaseId"] = releaseId }
|
|
111
|
+
if let errorCode = event.errorCode { json["errorCode"] = errorCode }
|
|
112
|
+
return json
|
|
113
|
+
}
|
|
114
|
+
_ = try post(
|
|
115
|
+
"/device/events",
|
|
116
|
+
body: ["deploymentKey": deploymentKey, "deviceId": deviceId, "events": list]
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// Blocks until finished, so call it away from the main thread.
|
|
121
|
+
private func post(_ path: String, body: [String: Any]) throws -> [String: Any] {
|
|
122
|
+
let base = apiURL.hasSuffix("/") ? String(apiURL.dropLast()) : apiURL
|
|
123
|
+
guard let url = URL(string: base + path), let scheme = url.scheme?.lowercased() else {
|
|
124
|
+
throw RovqError(.downloadFailed, "The API URL is not valid")
|
|
125
|
+
}
|
|
126
|
+
if scheme != "https" && !allowInsecureURLs {
|
|
127
|
+
throw RovqError(.insecureURL, "The API URL must use HTTPS")
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
var request = URLRequest(url: url, timeoutInterval: requestTimeout)
|
|
131
|
+
request.httpMethod = "POST"
|
|
132
|
+
request.setValue("application/json", forHTTPHeaderField: "content-type")
|
|
133
|
+
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
134
|
+
|
|
135
|
+
let finished = DispatchSemaphore(value: 0)
|
|
136
|
+
var answer: Data?
|
|
137
|
+
var status = 0
|
|
138
|
+
var failure: Error?
|
|
139
|
+
let session = URLSession(configuration: .ephemeral)
|
|
140
|
+
session.dataTask(with: request) { data, response, error in
|
|
141
|
+
answer = data
|
|
142
|
+
status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
143
|
+
failure = error
|
|
144
|
+
finished.signal()
|
|
145
|
+
}.resume()
|
|
146
|
+
finished.wait()
|
|
147
|
+
session.finishTasksAndInvalidate()
|
|
148
|
+
|
|
149
|
+
if let failure {
|
|
150
|
+
throw RovqError(.downloadFailed, failure.localizedDescription)
|
|
151
|
+
}
|
|
152
|
+
guard status == 200 else { throw RovqError(.downloadFailed, "The server answered \(status)") }
|
|
153
|
+
guard let answer, let json = try? JSONSerialization.jsonObject(with: answer) as? [String: Any] else {
|
|
154
|
+
throw RovqError(.downloadFailed, "The server sent an unreadable response")
|
|
155
|
+
}
|
|
156
|
+
return json
|
|
157
|
+
}
|
|
158
|
+
}
|