| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573 |
- // Copyright 2023 Google LLC
- //
- // Licensed under the Apache License, Version 2.0 (the "License");
- // you may not use this file except in compliance with the License.
- // You may obtain a copy of the License at
- //
- // http://www.apache.org/licenses/LICENSE-2.0
- //
- // Unless required by applicable law or agreed to in writing, software
- // distributed under the License is distributed on an "AS IS" BASIS,
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- // See the License for the specific language governing permissions and
- // limitations under the License.
- import FirebaseCore
- import FirebaseInstallations
- import Foundation
- import UIKit
- // Avoids exposing internal APIs to Swift users
- @_implementationOnly import FirebaseCoreInternal
- enum Strings {
- static let errorDomain = "com.firebase.appdistribution.api"
- static let errorDetailsKey = "details"
- static let httpGet = "GET"
- static let httpPost = "POST"
- static let releaseEndpointUrlTemplate =
- "https://firebaseapptesters.googleapis.com/v1alpha/devices/-/testerApps/%@/installations/%@/releases"
- static let findReleaseEndpointUrlTemplate =
- "https://firebaseapptesters.googleapis.com/v1alpha/projects/%@/installations/%@/releases:find?compositeBinaryId.displayVersion=%@&compositeBinaryId.buildVersion=%@&compositeBinaryId.codeHash=%@"
- static let createFeedbackEndpointUrlTemplate =
- "https://firebaseapptesters.googleapis.com/v1alpha/%@/feedbackReports"
- static let uploadImageEndpointUrlTemplate =
- "https://firebaseapptesters.googleapis.com/upload/v1alpha/%@:uploadArtifact"
- static let commitFeedbackEndpointUrlTemplate =
- "https://firebaseapptesters.googleapis.com/v1alpha/%@:commit"
- static let installationsAuthHeader = "X-Goog-Firebase-Installations-Auth"
- static let apiHeaderKey = "X-Goog-Api-Key"
- static let apiBundleKey = "X-Ios-Bundle-Identifier"
- static let responseReleaseKey = "releases"
- static let compositeBinaryIdQueryParamName = "compositeBinaryId"
- static let uploadArtifactTypeQueryParamName = "type"
- static let uploadArtifactScreenshotType = "SCREENSHOT"
- static let GoogleUploadProtocolHeader = "X-Goog-Upload-Protocol"
- static let GoogleUploadProtocolRaw = "raw"
- static let GoogleUploadFileNameHeader = "X-Goog-Upload-File-Name"
- static let GoogleUploadFileName = "screenshot.png"
- static let contentTypeHeader = "Content-Type"
- static let jsonContentType = "application/json"
- }
- enum AppDistributionApiError: NSInteger {
- case ApiErrorTimeout = 0
- case ApiTokenGenerationFailure = 1
- case ApiInstallationIdentifierError = 2
- case ApiErrorUnauthenticated = 3
- case ApiErrorUnauthorized = 4
- case ApiErrorNotFound = 5
- case ApiErrorUnknownFailure = 6
- case ApiErrorParseFailure = 7
- }
- struct CompositeBinaryId: Codable {
- var displayVersion: String
- var buildVersion: String
- var codeHash: String
- }
- struct FindReleaseResponse: Codable {
- var release: String
- }
- struct FeedbackReport: Codable {
- var name: String?
- var text: String?
- }
- @objc(FIRFADApiServiceSwift) open class ApiService: NSObject {
- @objc(generateAuthTokenWithCompletion:) public static func generateAuthToken(completion: @escaping (_ identifier: String?,
- _ authTokenResult: InstallationsAuthTokenResult?,
- _ error: Error?)
- -> Void) {
- generateAuthToken(installations: Installations.installations(), completion: completion)
- }
- static func generateAuthToken(installations: InstallationsProtocol,
- completion: @escaping (_ identifier: String?,
- _ authTokenResult: InstallationsAuthTokenResult?,
- _ error: Error?)
- -> Void) {
- installations.authToken(completion: { authTokenResult, error in
- var fadError: Error? = error
- if self.handleError(
- error: &fadError,
- description: "Failed to generate Firebase installation auth token",
- code: .ApiTokenGenerationFailure
- ) {
- Logger
- .logError(String(format: "Error getting auth token. Error: %@",
- error?.localizedDescription ?? ""))
- completion(nil, nil, fadError)
- return
- }
- installations.installationID(completion: { identifier, error in
- var fadError: Error? = error
- if self.handleError(
- error: &fadError,
- description: "Failed to generate Firebase installation id",
- code: .ApiInstallationIdentifierError
- ) {
- Logger
- .logError(String(format: "Error getting installation ID. Error: %@",
- error?.localizedDescription ?? ""))
- completion(nil, nil, fadError)
- return
- }
- completion(identifier, authTokenResult, nil)
- })
- })
- }
- @objc(fetchReleasesWithCompletion:) public static func fetchReleases(completion: @escaping (_ releases: [
- Any,
- ]?,
- _ error: Error?)
- -> Void) {
- guard let app = FirebaseApp.app() else {
- return
- }
- fetchReleases(
- app: app,
- installations: Installations.installations(),
- urlSession: URLSession.shared,
- completion: completion
- )
- }
- static func fetchReleases(app: FirebaseApp, installations: InstallationsProtocol,
- urlSession: URLSession, completion: @escaping (_ releases: [Any]?,
- _ error: Error?)
- -> Void) {
- Logger.logInfo(String(
- format: "Requesting release for app id - %@",
- app.options.googleAppID
- ))
- generateAuthToken(installations: installations) { identifier, authTokenResult, error in
- let urlString = String(
- format: Strings.releaseEndpointUrlTemplate,
- app.options.googleAppID,
- identifier!
- )
- let request = self.createHttpRequest(
- app: app,
- method: Strings.httpGet,
- url: urlString,
- authTokenResult: authTokenResult!
- )
- Logger.logInfo(String(
- format: "Url: %@ Auth token: %@ Api Key: %@",
- urlString,
- authTokenResult?.authToken ?? "",
- app.options.apiKey ?? ""
- ))
- let listReleaseDataTask = urlSession
- .dataTask(with: request as URLRequest) { data, response, error in
- var fadError = error
- let releasesResponse = self.handleResponse(data: data,
- response: response,
- error: &fadError,
- returnType: [String: [Any]].self)
- DispatchQueue.main.async {
- completion(releasesResponse?[Strings.responseReleaseKey], fadError)
- }
- }
- listReleaseDataTask.resume()
- }
- }
- @objc(findReleaseWithDisplayVersion:buildVersion:codeHash:completion:)
- public static func findRelease(displayVersion: String, buildVersion: String, codeHash: String,
- completion: @escaping (_ releaseName: String?, _ error: Error?)
- -> Void) {
- findRelease(
- app: FirebaseApp.app()!,
- installations: Installations.installations(),
- urlSession: URLSession.shared,
- displayVersion: displayVersion,
- buildVersion: buildVersion,
- codeHash: codeHash,
- completion: completion
- )
- }
- private static func buildFindReleaseUrl(projectNumber: String, identifier: String,
- displayVersion: String, buildVersion: String,
- codeHash: String) -> String {
- return String(
- format: Strings.findReleaseEndpointUrlTemplate,
- projectNumber,
- identifier,
- displayVersion,
- buildVersion,
- codeHash
- )
- }
- static func findRelease(app: FirebaseApp, installations: InstallationsProtocol,
- urlSession: URLSession, displayVersion: String,
- buildVersion: String, codeHash: String,
- completion: @escaping (_ releaseName: String?, _ error: Error?)
- -> Void) {
- generateAuthToken(installations: installations) { identifier, authTokenResult, error in
- // TODO(tundeagboola) The backend may not accept project ID here in which case
- // we'll have to figure out a way to get the project number
- let urlString = buildFindReleaseUrl(
- projectNumber: app.options.gcmSenderID,
- identifier: identifier!,
- displayVersion: displayVersion,
- buildVersion: buildVersion,
- codeHash: codeHash
- )
- let request = self.createHttpRequest(
- app: app,
- method: Strings.httpGet,
- url: URL(string: urlString),
- authTokenResult: authTokenResult!
- )
- let findReleaseDataTask = urlSession
- .dataTask(with: request as URLRequest) { data, response, error in
- var fadError = error
- let findReleaseResponse = self.handleCodableResponse(
- data: data,
- response: response,
- error: &fadError,
- returnType: FindReleaseResponse.self
- )
- DispatchQueue.main.async {
- completion(findReleaseResponse?.release, fadError)
- }
- }
- findReleaseDataTask.resume()
- }
- }
- @objc(createFeedbackWithReleaseName:feedbackText:completion:)
- public static func createFeedback(releaseName: String,
- feedbackText: String,
- completion: @escaping (_ feedbackName: String?, _ error: Error?)
- -> Void) {
- createFeedback(
- app: FirebaseApp.app()!,
- installations: Installations.installations(),
- urlSession: URLSession.shared,
- releaseName: releaseName,
- feedbackText: feedbackText,
- completion: completion
- )
- }
- static func createFeedback(app: FirebaseApp, installations: InstallationsProtocol,
- urlSession: URLSession, releaseName: String,
- feedbackText: String,
- completion: @escaping (_ feedbackName: String?, _ error: Error?)
- -> Void) {
- generateAuthToken(installations: installations) { identifier, authTokenResult, error in
- guard let authTokenResult = authTokenResult else {
- completion(nil, error)
- return
- }
- let urlString = String(
- format: Strings.createFeedbackEndpointUrlTemplate,
- releaseName
- )
- let request = createHttpRequest(
- app: app,
- method: Strings.httpPost,
- url: urlString,
- authTokenResult: authTokenResult
- )
- request.setValue(Strings.jsonContentType, forHTTPHeaderField: Strings.contentTypeHeader)
- let feedbackReport = FeedbackReport(text: feedbackText)
- request.httpBody = try? JSONEncoder().encode(feedbackReport)
- let createFeedbackTask = urlSession
- .dataTask(with: request as URLRequest) { data, response, error in
- var fadError = error
- let feedback = self.handleCodableResponse(
- data: data,
- response: response,
- error: &fadError,
- returnType: FeedbackReport.self
- )
- DispatchQueue.main.async {
- completion(feedback?.name, fadError)
- }
- }
- createFeedbackTask.resume()
- }
- }
- @objc(uploadImageWithFeedbackName:image:completion:)
- public static func uploadImage(feedbackName: String,
- image: UIImage,
- completion: @escaping (_ error: Error?)
- -> Void) {
- uploadImage(
- app: FirebaseApp.app()!,
- installations: Installations.installations(),
- urlSession: URLSession.shared,
- feedbackName: feedbackName,
- image: image,
- completion: completion
- )
- }
- static func uploadImage(app: FirebaseApp, installations: InstallationsProtocol,
- urlSession: URLSession, feedbackName: String,
- image: UIImage,
- completion: @escaping (_ error: Error?)
- -> Void) {
- generateAuthToken(installations: installations) { identifier, authTokenResult, error in
- guard let authTokenResult = authTokenResult else {
- completion(error)
- return
- }
- let urlString = String(
- format: Strings.uploadImageEndpointUrlTemplate,
- feedbackName
- )
- guard var urlComponents = URLComponents(string: urlString) else {
- // TODO(tundeagboola) We should throw exceptions here insead of piping errors
- Logger.logError("Unable to build URL for uploadArtifact request")
- return
- }
- urlComponents.queryItems = [URLQueryItem(
- name: Strings.uploadArtifactTypeQueryParamName,
- value: Strings.uploadArtifactScreenshotType
- )]
- guard let url = urlComponents.url else {
- // TODO(tundeagboola) We should throw exceptions here insead of piping errors
- Logger.logError("Unable to build URL for uploadArtifact request")
- return
- }
- let request = createHttpRequest(
- app: app,
- method: Strings.httpPost,
- url: url,
- authTokenResult: authTokenResult
- )
- request.setValue(
- Strings.GoogleUploadProtocolHeader,
- forHTTPHeaderField: Strings.GoogleUploadProtocolRaw
- )
- request.setValue(
- Strings.GoogleUploadFileNameHeader,
- forHTTPHeaderField: Strings.GoogleUploadFileName
- )
- // TODO(tundeagboola) Add support for jpegs
- request.httpBody = image.pngData()
- let uploadImageTask = urlSession
- .dataTask(with: request as URLRequest) { data, response, error in
- var fadError = error
- self.handleError(httpResponse: response as? HTTPURLResponse, error: &fadError)
- DispatchQueue.main.async {
- completion(fadError)
- }
- }
- uploadImageTask.resume()
- }
- }
- @objc(commitFeedbackWithFeedbackName:completion:)
- public static func commitFeedback(feedbackName: String,
- completion: @escaping (_ error: Error?)
- -> Void) {
- commitFeedback(
- app: FirebaseApp.app()!,
- installations: Installations.installations(),
- urlSession: URLSession.shared,
- feedbackName: feedbackName,
- completion: completion
- )
- }
- static func commitFeedback(app: FirebaseApp, installations: InstallationsProtocol,
- urlSession: URLSession,
- feedbackName: String,
- completion: @escaping (_ error: Error?)
- -> Void) {
- generateAuthToken(installations: installations) { identifier, authTokenResult, error in
- guard let authTokenResult = authTokenResult else {
- completion(error)
- return
- }
- let urlString = String(
- format: Strings.commitFeedbackEndpointUrlTemplate,
- feedbackName
- )
- let request = createHttpRequest(
- app: app,
- method: Strings.httpPost,
- url: urlString,
- authTokenResult: authTokenResult
- )
- let commitFeedbackTask = urlSession
- .dataTask(with: request as URLRequest) { data, response, error in
- var fadError = error
- self.handleError(httpResponse: response as? HTTPURLResponse, error: &fadError)
- DispatchQueue.main.async {
- completion(fadError)
- }
- }
- commitFeedbackTask.resume()
- }
- }
- @discardableResult
- static func handleError(httpResponse: HTTPURLResponse?, error: inout Error?) -> Bool {
- // TODO(tundeagboola) We should be throwing errors instead of piping the error object through
- if error != nil || httpResponse == nil {
- return handleError(
- error: &error,
- description: "Unknown http error occurred",
- code: .ApiErrorUnknownFailure
- )
- } else if httpResponse?.statusCode != 200 {
- error = createError(statusCode: httpResponse!.statusCode)
- return true
- }
- return false
- }
- @discardableResult
- static func handleError(error: inout Error?, description: String?,
- code: AppDistributionApiError) -> Bool {
- // TODO(tundeagboola) We should be throwing errors instead of piping the error object through
- if error != nil {
- error = createError(description: description!, code: code)
- return true
- }
- return false
- }
- static func createError(description: String, code: AppDistributionApiError) -> Error {
- let userInfo = [NSLocalizedDescriptionKey: description]
- return NSError(domain: Strings.errorDomain, code: code.rawValue, userInfo: userInfo)
- }
- static func createError(statusCode: NSInteger) -> Error {
- switch statusCode {
- case 401:
- return createError(description: "Tester not authenticated", code: .ApiErrorUnauthenticated)
- case 403, 400:
- return createError(description: "Tester not authorized", code: .ApiErrorUnauthorized)
- case 404:
- return createError(description: "Tester or releases not found", code: .ApiErrorUnauthorized)
- case 408, 504:
- return createError(description: "Request timeout", code: .ApiErrorTimeout)
- default:
- print("Unknown status code")
- }
- let description = String(format: "Unknown status code: %ld", statusCode)
- return createError(description: description, code: .ApiErrorUnknownFailure)
- }
- static func createHttpRequest(app: FirebaseApp, method: String, url: String,
- authTokenResult: InstallationsAuthTokenResult)
- -> NSMutableURLRequest {
- return createHttpRequest(
- app: app,
- method: method,
- url: URL(string: url),
- authTokenResult: authTokenResult
- )
- }
- static func createHttpRequest(app: FirebaseApp, method: String, url: URL?,
- authTokenResult: InstallationsAuthTokenResult)
- -> NSMutableURLRequest {
- let request = NSMutableURLRequest()
- request.url = url
- request.httpMethod = method
- request.setValue(authTokenResult.authToken, forHTTPHeaderField: Strings.installationsAuthHeader)
- request.setValue(
- app.options.apiKey,
- forHTTPHeaderField: Strings.apiHeaderKey
- )
- request.setValue(Bundle.main.bundleIdentifier, forHTTPHeaderField: Strings.apiBundleKey)
- return request
- }
- static func validateResponse(response: URLResponse?, error: inout Error?) -> Bool {
- guard let response = response else {
- handleError(
- error: &error,
- description: "URLResponse is nil",
- code: .ApiErrorUnknownFailure
- )
- return false
- }
- let httpResponse = response as! HTTPURLResponse
- Logger
- .logInfo(String(format: "HTTPResponse status code %ld, response %@", httpResponse.statusCode,
- httpResponse))
- if handleError(httpResponse: httpResponse, error: &error) {
- Logger
- .logError(String(format: "App tester API service error: %@",
- error?.localizedDescription ?? ""))
- return false
- }
- return true
- }
- static func handleCodableResponse<T: Codable>(data: Data?, response: URLResponse?,
- error: inout Error?,
- returnType: T.Type) -> T? {
- if !validateResponse(response: response, error: &error) {
- return nil
- }
- guard let data = data else {
- return nil
- }
- do {
- return try JSONDecoder().decode(T.self, from: data)
- } catch let thrownError {
- handleApiParserError(thrownError, &error)
- return nil
- }
- }
- static func handleResponse<T>(data: Data?, response: URLResponse?,
- error: inout Error?, returnType: T.Type) -> T? {
- if !validateResponse(response: response, error: &error) {
- return nil
- }
- guard let data = data else {
- return nil
- }
- do {
- return try JSONSerialization.jsonObject(
- with: data,
- options: JSONSerialization.ReadingOptions(rawValue: 0)
- ) as? T
- } catch let thrownError {
- handleApiParserError(thrownError, &error)
- return nil
- }
- }
- static func handleApiParserError(_ thrownError: Error, _ error: inout Error?) {
- let description: String = (thrownError as NSError)
- .userInfo[NSLocalizedDescriptionKey] as? String ?? "Failed to parse response"
- error = thrownError
- handleError(error: &error, description: description, code: .ApiErrorParseFailure)
- Logger.logError("Tester API - Error deserializing json response")
- }
- }
|