FIRAppDistribution.m 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. // Copyright 2020 Google LLC
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. #import "FIRAppDistribution+Private.h"
  15. #import "FIRAppDistributionAuthPersistence+Private.h"
  16. #import "FIRAppDistributionMachO+Private.h"
  17. #import "FIRAppDistributionRelease+Private.h"
  18. #import <FirebaseCore/FIRAppInternal.h>
  19. #import <FirebaseCore/FIRComponent.h>
  20. #import <FirebaseCore/FIRComponentContainer.h>
  21. #import <FirebaseCore/FIROptions.h>
  22. #import <GoogleUtilities/GULAppDelegateSwizzler.h>
  23. #import "FIRAppDistributionAppDelegateInterceptor.h"
  24. /// Empty protocol to register with FirebaseCore's component system.
  25. @protocol FIRAppDistributionInstanceProvider <NSObject>
  26. @end
  27. @interface FIRAppDistribution () <FIRLibrary, FIRAppDistributionInstanceProvider>
  28. @property(nonatomic) BOOL isTesterSignedIn;
  29. @end
  30. NSString *const FIRAppDistributionErrorDomain = @"com.firebase.appdistribution";
  31. NSString *const FIRAppDistributionErrorDetailsKey = @"details";
  32. @implementation FIRAppDistribution
  33. // The OAuth scope needed to authorize the App Distribution Tester API
  34. NSString *const kOIDScopeTesterAPI = @"https://www.googleapis.com/auth/cloud-platform";
  35. // The App Distribution Tester API endpoint used to retrieve releases
  36. NSString *const kReleasesEndpointURL =
  37. @"https://firebaseapptesters.googleapis.com/v1alpha/devices/-/testerApps/%@/releases";
  38. NSString *const kTesterAPIClientID =
  39. @"319754533822-osu3v3hcci24umq6diathdm0dipds1fb.apps.googleusercontent.com";
  40. NSString *const kIssuerURL = @"https://accounts.google.com";
  41. NSString *const kAppDistroLibraryName = @"fire-fad";
  42. NSString *const kReleasesKey = @"releases";
  43. NSString *const kLatestReleaseKey = @"latest";
  44. NSString *const kCodeHashKey = @"codeHash";
  45. NSString *const kAuthErrorMessage = @"Unable to authenticate the tester";
  46. NSString *const kAuthCancelledErrorMessage = @"Tester cancelled sign-in";
  47. #pragma mark - Singleton Support
  48. - (instancetype)initWithApp:(FIRApp *)app appInfo:(NSDictionary *)appInfo {
  49. self = [super init];
  50. if (self) {
  51. self.safariHostingViewController = [[UIViewController alloc] init];
  52. [GULAppDelegateSwizzler proxyOriginalDelegate];
  53. FIRAppDistributionAppDelegatorInterceptor *interceptor =
  54. [FIRAppDistributionAppDelegatorInterceptor sharedInstance];
  55. [GULAppDelegateSwizzler registerAppDelegateInterceptor:interceptor];
  56. }
  57. NSError *authRetrievalError;
  58. self.authState = [FIRAppDistributionAuthPersistence retrieveAuthState:&authRetrievalError];
  59. // TODO (schnecle): replace NSLog statement with FIRLogger log statement
  60. if (authRetrievalError) {
  61. NSLog(@"Error retrieving token from keychain: %@", [authRetrievalError localizedDescription]);
  62. }
  63. self.isTesterSignedIn = self.authState ? YES : NO;
  64. return self;
  65. }
  66. + (void)load {
  67. NSString *version =
  68. [NSString stringWithUTF8String:(const char *const)STR_EXPAND(FIRAppDistribution_VERSION)];
  69. [FIRApp registerInternalLibrary:(Class<FIRLibrary>)self
  70. withName:kAppDistroLibraryName
  71. withVersion:version];
  72. }
  73. + (NSArray<FIRComponent *> *)componentsToRegister {
  74. FIRComponentCreationBlock creationBlock =
  75. ^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
  76. if (!container.app.isDefaultApp) {
  77. // TODO: Remove this and log error
  78. @throw([NSException exceptionWithName:@"NotImplementedException"
  79. reason:@"This code path is not implemented yet"
  80. userInfo:nil]);
  81. return nil;
  82. }
  83. *isCacheable = YES;
  84. return [[FIRAppDistribution alloc] initWithApp:container.app
  85. appInfo:NSBundle.mainBundle.infoDictionary];
  86. };
  87. FIRComponent *component =
  88. [FIRComponent componentWithProtocol:@protocol(FIRAppDistributionInstanceProvider)
  89. instantiationTiming:FIRInstantiationTimingEagerInDefaultApp
  90. dependencies:@[]
  91. creationBlock:creationBlock];
  92. return @[ component ];
  93. }
  94. + (instancetype)appDistribution {
  95. // The container will return the same instance since isCacheable is set
  96. FIRApp *defaultApp = [FIRApp defaultApp]; // Missing configure will be logged here.
  97. // Get the instance from the `FIRApp`'s container. This will create a new instance the
  98. // first time it is called, and since `isCacheable` is set in the component creation
  99. // block, it will return the existing instance on subsequent calls.
  100. id<FIRAppDistributionInstanceProvider> instance =
  101. FIR_COMPONENT(FIRAppDistributionInstanceProvider, defaultApp.container);
  102. // In the component creation block, we return an instance of `FIRAppDistribution`. Cast it and
  103. // return it.
  104. return (FIRAppDistribution *)instance;
  105. }
  106. - (void)signInTesterWithCompletion:(void (^)(NSError *_Nullable error))completion {
  107. NSURL *issuer = [NSURL URLWithString:kIssuerURL];
  108. [OIDAuthorizationService
  109. discoverServiceConfigurationForIssuer:issuer
  110. completion:^(OIDServiceConfiguration *_Nullable configuration,
  111. NSError *_Nullable error) {
  112. [self handleOauthDiscoveryCompletion:configuration
  113. error:error
  114. appDistributionSignInCompletion:completion];
  115. }];
  116. }
  117. - (void)signOutTester {
  118. NSError *error;
  119. BOOL didClearAuthState = [FIRAppDistributionAuthPersistence clearAuthState:&error];
  120. // TODO (schnecle): Add in FIRLogger to report when we have failed to clear auth state
  121. if (!didClearAuthState) {
  122. NSLog(@"Error clearing token from keychain: %@", [error localizedDescription]);
  123. }
  124. self.authState = nil;
  125. self.isTesterSignedIn = false;
  126. }
  127. - (NSError *)NSErrorForErrorCodeAndMessage:(FIRAppDistributionError)errorCode
  128. message:(NSString *)message {
  129. NSDictionary *userInfo = @{FIRAppDistributionErrorDetailsKey : message};
  130. return [NSError errorWithDomain:FIRAppDistributionErrorDomain code:errorCode userInfo:userInfo];
  131. }
  132. - (void)fetchReleases:(FIRAppDistributionUpdateCheckCompletion)completion {
  133. [self.authState performActionWithFreshTokens:^(NSString *_Nonnull accessToken,
  134. NSString *_Nonnull idToken,
  135. NSError *_Nullable error) {
  136. if (error) {
  137. // TODO: Do we need a less aggresive strategy here? maybe a retry?
  138. [self signOutTester];
  139. NSError *HTTPError =
  140. [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorAuthenticationFailure
  141. message:kAuthErrorMessage];
  142. dispatch_async(dispatch_get_main_queue(), ^{
  143. completion(nil, HTTPError);
  144. });
  145. return;
  146. }
  147. // perform your API request using the tokens
  148. NSURLSession *URLSession = [NSURLSession sharedSession];
  149. NSMutableURLRequest *request = [[NSMutableURLRequest alloc] init];
  150. NSString *URLString =
  151. [NSString stringWithFormat:kReleasesEndpointURL, [[FIRApp defaultApp] options].googleAppID];
  152. [request setURL:[NSURL URLWithString:URLString]];
  153. [request setHTTPMethod:@"GET"];
  154. [request setValue:[NSString stringWithFormat:@"Bearer %@", accessToken]
  155. forHTTPHeaderField:@"Authorization"];
  156. NSURLSessionDataTask *listReleasesDataTask = [URLSession
  157. dataTaskWithRequest:request
  158. completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
  159. NSHTTPURLResponse *HTTPResponse = (NSHTTPURLResponse *)response;
  160. if (error || HTTPResponse.statusCode != 200) {
  161. NSError *HTTPError = nil;
  162. if (HTTPResponse == nil && error) {
  163. // Handles network timeouts or no internet connectivity
  164. NSString *message = error.userInfo[NSLocalizedDescriptionKey]
  165. ? error.userInfo[NSLocalizedDescriptionKey]
  166. : @"";
  167. HTTPError =
  168. [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorNetworkFailure
  169. message:message];
  170. } else if (HTTPResponse.statusCode == 401) {
  171. // TODO: Maybe sign out tester?
  172. HTTPError =
  173. [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorAuthenticationFailure
  174. message:kAuthErrorMessage];
  175. } else {
  176. HTTPError = [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorUnknown
  177. message:@""];
  178. }
  179. dispatch_async(dispatch_get_main_queue(), ^{
  180. completion(nil, HTTPError);
  181. });
  182. } else {
  183. [self handleReleasesAPIResponseWithData:data completion:completion];
  184. }
  185. }];
  186. [listReleasesDataTask resume];
  187. }];
  188. }
  189. - (void)handleOauthDiscoveryCompletion:(OIDServiceConfiguration *_Nullable)configuration
  190. error:(NSError *_Nullable)error
  191. appDistributionSignInCompletion:(void (^)(NSError *_Nullable error))completion {
  192. if (!configuration) {
  193. // TODO: Handle when we cannot get configuration
  194. NSLog(@"ERROR - Cannot discover oauth config");
  195. NSError *error =
  196. [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorAuthenticationFailure
  197. message:kAuthErrorMessage];
  198. completion(error);
  199. return;
  200. }
  201. NSString *redirectURL = [@"dev.firebase.appdistribution."
  202. stringByAppendingString:[[[NSBundle mainBundle] bundleIdentifier]
  203. stringByAppendingString:@":/launch"]];
  204. OIDAuthorizationRequest *request = [[OIDAuthorizationRequest alloc]
  205. initWithConfiguration:configuration
  206. clientId:kTesterAPIClientID
  207. scopes:@[ OIDScopeOpenID, OIDScopeProfile, kOIDScopeTesterAPI ]
  208. redirectURL:[NSURL URLWithString:redirectURL]
  209. responseType:OIDResponseTypeCode
  210. additionalParameters:nil];
  211. [self setupUIWindowForLogin];
  212. void (^processAuthState)(OIDAuthState *_Nullable authState, NSError *_Nullable error) = ^void(
  213. OIDAuthState *_Nullable authState, NSError *_Nullable error) {
  214. [self cleanupUIWindow];
  215. if (error) {
  216. NSError *signInError = nil;
  217. if (error.code == OIDErrorCodeUserCanceledAuthorizationFlow) {
  218. // User cancelled auth flow
  219. signInError =
  220. [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorAuthenticationCancelled
  221. message:kAuthCancelledErrorMessage];
  222. } else {
  223. // Error in the auth flow
  224. signInError =
  225. [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorAuthenticationFailure
  226. message:kAuthErrorMessage];
  227. }
  228. completion(signInError);
  229. return;
  230. }
  231. self.authState = authState;
  232. // Capture errors in persistence but do not bubble them
  233. // up
  234. NSError *authPersistenceError;
  235. if (authState) {
  236. [FIRAppDistributionAuthPersistence persistAuthState:authState error:&authPersistenceError];
  237. }
  238. // TODO (schnecle): Log errors in persistence using
  239. // FIRLogger
  240. if (authPersistenceError) {
  241. NSLog(@"Error persisting token to keychain: %@", [error localizedDescription]);
  242. }
  243. self.isTesterSignedIn = self.authState ? YES : NO;
  244. completion(nil);
  245. };
  246. // performs authentication request
  247. [FIRAppDistributionAppDelegatorInterceptor sharedInstance].currentAuthorizationFlow =
  248. [OIDAuthState authStateByPresentingAuthorizationRequest:request
  249. presentingViewController:self.safariHostingViewController
  250. callback:processAuthState];
  251. }
  252. - (void)setupUIWindowForLogin {
  253. if (self.window) {
  254. return;
  255. }
  256. // Create an empty window + viewController to host the Safari UI.
  257. self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  258. self.window.rootViewController = self.safariHostingViewController;
  259. // Place it at the highest level within the stack.
  260. self.window.windowLevel = +CGFLOAT_MAX;
  261. // Run it.
  262. [self.window makeKeyAndVisible];
  263. }
  264. - (void)cleanupUIWindow {
  265. if (self.window) {
  266. self.window.hidden = YES;
  267. self.window = nil;
  268. }
  269. }
  270. - (void)handleReleasesAPIResponseWithData:data
  271. completion:(FIRAppDistributionUpdateCheckCompletion)completion {
  272. NSError *error = nil;
  273. NSDictionary *serializedResponse = [NSJSONSerialization JSONObjectWithData:data
  274. options:0
  275. error:&error];
  276. if (error) {
  277. NSString *message =
  278. error.userInfo[NSLocalizedDescriptionKey] ? error.userInfo[NSLocalizedDescriptionKey] : @"";
  279. NSError *error = [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorUnknown
  280. message:message];
  281. dispatch_async(dispatch_get_main_queue(), ^{
  282. completion(nil, error);
  283. });
  284. return;
  285. }
  286. NSArray *releaseList = [serializedResponse objectForKey:kReleasesKey];
  287. for (NSDictionary *releaseDict in releaseList) {
  288. if ([[releaseDict objectForKey:kLatestReleaseKey] boolValue]) {
  289. NSString *codeHash = [releaseDict objectForKey:kCodeHashKey];
  290. NSString *executablePath = [[NSBundle mainBundle] executablePath];
  291. FIRAppDistributionMachO *machO =
  292. [[FIRAppDistributionMachO alloc] initWithPath:executablePath];
  293. if (codeHash && ![codeHash isEqualToString:machO.codeHash]) {
  294. FIRAppDistributionRelease *release =
  295. [[FIRAppDistributionRelease alloc] initWithDictionary:releaseDict];
  296. dispatch_async(dispatch_get_main_queue(), ^{
  297. completion(release, nil);
  298. });
  299. return;
  300. }
  301. break;
  302. }
  303. }
  304. dispatch_async(dispatch_get_main_queue(), ^{
  305. completion(nil, nil);
  306. });
  307. }
  308. - (void)checkForUpdateWithCompletion:(FIRAppDistributionUpdateCheckCompletion)completion {
  309. if (self.isTesterSignedIn) {
  310. [self fetchReleases:completion];
  311. } else {
  312. UIAlertController *alert = [UIAlertController
  313. alertControllerWithTitle:@"Enable in-app alerts"
  314. message:@"Sign in with your Firebase App Distribution Google account to "
  315. @"turn on in-app alerts for new test releases."
  316. preferredStyle:UIAlertControllerStyleAlert];
  317. UIAlertAction *yesButton =
  318. [UIAlertAction actionWithTitle:@"Turn on"
  319. style:UIAlertActionStyleDefault
  320. handler:^(UIAlertAction *action) {
  321. [self signInTesterWithCompletion:^(NSError *_Nullable error) {
  322. if (error) {
  323. completion(nil, error);
  324. return;
  325. }
  326. [self fetchReleases:completion];
  327. }];
  328. }];
  329. UIAlertAction *noButton = [UIAlertAction actionWithTitle:@"Not now"
  330. style:UIAlertActionStyleDefault
  331. handler:^(UIAlertAction *action) {
  332. // precaution to ensure window gets destroyed
  333. [self cleanupUIWindow];
  334. completion(nil, nil);
  335. }];
  336. [alert addAction:noButton];
  337. [alert addAction:yesButton];
  338. // Create an empty window + viewController to host the Safari UI.
  339. [self setupUIWindowForLogin];
  340. [self.window.rootViewController presentViewController:alert animated:YES completion:nil];
  341. }
  342. }
  343. @end