FIRAppDistribution.m 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  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. @implementation FIRAppDistribution
  31. // The OAuth scope needed to authorize the App Distribution Tester API
  32. NSString *const kOIDScopeTesterAPI = @"https://www.googleapis.com/auth/cloud-platform";
  33. // The App Distribution Tester API endpoint used to retrieve releases
  34. NSString *const kReleasesEndpointURL =
  35. @"https://firebaseapptesters.googleapis.com/v1alpha/devices/-/testerApps/%@/releases";
  36. NSString *const kTesterAPIClientID =
  37. @"319754533822-osu3v3hcci24umq6diathdm0dipds1fb.apps.googleusercontent.com";
  38. NSString *const kIssuerURL = @"https://accounts.google.com";
  39. NSString *const kAppDistroLibraryName = @"fire-fad";
  40. #pragma mark - Singleton Support
  41. - (instancetype)initWithApp:(FIRApp *)app appInfo:(NSDictionary *)appInfo {
  42. self = [super init];
  43. if (self) {
  44. self.safariHostingViewController = [[UIViewController alloc] init];
  45. [GULAppDelegateSwizzler proxyOriginalDelegate];
  46. FIRAppDistributionAppDelegatorInterceptor *interceptor =
  47. [FIRAppDistributionAppDelegatorInterceptor sharedInstance];
  48. [GULAppDelegateSwizzler registerAppDelegateInterceptor:interceptor];
  49. }
  50. NSError *authRetrievalError;
  51. self.authState = [FIRAppDistributionAuthPersistence retrieveAuthState:&authRetrievalError];
  52. // TODO (schnecle): replace NSLog statement with FIRLogger log statement
  53. if (authRetrievalError) {
  54. NSLog(@"Error retrieving token from keychain: %@", [authRetrievalError localizedDescription]);
  55. }
  56. self.isTesterSignedIn = self.authState ? YES : NO;
  57. return self;
  58. }
  59. + (void)load {
  60. NSString *version =
  61. [NSString stringWithUTF8String:(const char *const)STR_EXPAND(FIRAppDistribution_VERSION)];
  62. [FIRApp registerInternalLibrary:(Class<FIRLibrary>)self
  63. withName:kAppDistroLibraryName
  64. withVersion:version];
  65. }
  66. + (NSArray<FIRComponent *> *)componentsToRegister {
  67. FIRComponentCreationBlock creationBlock =
  68. ^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
  69. if (!container.app.isDefaultApp) {
  70. // TODO: Implement error handling
  71. @throw([NSException exceptionWithName:@"NotImplementedException"
  72. reason:@"This code path is not implemented yet"
  73. userInfo:nil]);
  74. return nil;
  75. }
  76. *isCacheable = YES;
  77. return [[FIRAppDistribution alloc] initWithApp:container.app
  78. appInfo:NSBundle.mainBundle.infoDictionary];
  79. };
  80. FIRComponent *component =
  81. [FIRComponent componentWithProtocol:@protocol(FIRAppDistributionInstanceProvider)
  82. instantiationTiming:FIRInstantiationTimingEagerInDefaultApp
  83. dependencies:@[]
  84. creationBlock:creationBlock];
  85. return @[ component ];
  86. }
  87. + (instancetype)appDistribution {
  88. // The container will return the same instance since isCacheable is set
  89. FIRApp *defaultApp = [FIRApp defaultApp]; // Missing configure will be logged here.
  90. // Get the instance from the `FIRApp`'s container. This will create a new instance the
  91. // first time it is called, and since `isCacheable` is set in the component creation
  92. // block, it will return the existing instance on subsequent calls.
  93. id<FIRAppDistributionInstanceProvider> instance =
  94. FIR_COMPONENT(FIRAppDistributionInstanceProvider, defaultApp.container);
  95. // In the component creation block, we return an instance of `FIRAppDistribution`. Cast it and
  96. // return it.
  97. return (FIRAppDistribution *)instance;
  98. }
  99. - (void)signInTesterWithCompletion:(void (^)(NSError *_Nullable error))completion {
  100. NSURL *issuer = [NSURL URLWithString:kIssuerURL];
  101. [OIDAuthorizationService
  102. discoverServiceConfigurationForIssuer:issuer
  103. completion:^(OIDServiceConfiguration *_Nullable configuration,
  104. NSError *_Nullable error) {
  105. [self handleOauthDiscoveryCompletion:configuration
  106. error:error
  107. appDistributionSignInCompletion:completion];
  108. }];
  109. }
  110. - (void)signOutTester {
  111. NSError *error;
  112. BOOL didClearAuthState = [FIRAppDistributionAuthPersistence clearAuthState:&error];
  113. // TODO (schnecle): Add in FIRLogger to report when we have failed to clear auth state
  114. if (!didClearAuthState) {
  115. NSLog(@"Error clearing token from keychain: %@", [error localizedDescription]);
  116. }
  117. self.authState = nil;
  118. self.isTesterSignedIn = false;
  119. }
  120. - (void)fetchReleases:(FIRAppDistributionUpdateCheckCompletion)completion {
  121. [self.authState performActionWithFreshTokens:^(NSString *_Nonnull accessToken,
  122. NSString *_Nonnull idToken,
  123. NSError *_Nullable error) {
  124. if (error) {
  125. // TODO (schnecle): Add in FIRLogger log statement
  126. NSLog(@"Error fetching fresh tokens: %@", [error localizedDescription]);
  127. [self signOutTester];
  128. return;
  129. }
  130. // perform your API request using the tokens
  131. NSURLSession *URLSession = [NSURLSession sharedSession];
  132. NSMutableURLRequest *request = [[NSMutableURLRequest alloc] init];
  133. NSString *URLString =
  134. [NSString stringWithFormat:kReleasesEndpointURL, [[FIRApp defaultApp] options].googleAppID];
  135. [request setURL:[NSURL URLWithString:URLString]];
  136. [request setHTTPMethod:@"GET"];
  137. [request setValue:[NSString stringWithFormat:@"Bearer %@", accessToken]
  138. forHTTPHeaderField:@"Authorization"];
  139. NSURLSessionDataTask *listReleasesDataTask = [URLSession
  140. dataTaskWithRequest:request
  141. completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
  142. if (error) {
  143. // TODO: Reformat error into error code
  144. completion(nil, error);
  145. return;
  146. }
  147. NSHTTPURLResponse *HTTPResponse = (NSHTTPURLResponse *)response;
  148. if (HTTPResponse.statusCode == 200) {
  149. [self handleReleasesAPIResponseWithData:data completion:completion];
  150. } else {
  151. // TODO: Handle non-200 http response
  152. NSLog(@"ERROR - Non 200 service response - %@", HTTPResponse);
  153. @throw([NSException exceptionWithName:@"NotImplementedException"
  154. reason:@"This code path is not implemented yet"
  155. userInfo:nil]);
  156. }
  157. }];
  158. [listReleasesDataTask resume];
  159. }];
  160. }
  161. - (void)handleOauthDiscoveryCompletion:(OIDServiceConfiguration *_Nullable)configuration
  162. error:(NSError *_Nullable)error
  163. appDistributionSignInCompletion:(void (^)(NSError *_Nullable error))completion {
  164. if (!configuration) {
  165. // TODO: Handle when we cannot get configuration
  166. NSLog(@"ERROR - Cannot discover oauth config");
  167. @throw([NSException exceptionWithName:@"NotImplementedException"
  168. reason:@"This code path is not implemented yet"
  169. userInfo:nil]);
  170. return;
  171. }
  172. NSString *redirectURL = [@"dev.firebase.appdistribution."
  173. stringByAppendingString:[[[NSBundle mainBundle] bundleIdentifier]
  174. stringByAppendingString:@":/launch"]];
  175. OIDAuthorizationRequest *request = [[OIDAuthorizationRequest alloc]
  176. initWithConfiguration:configuration
  177. clientId:kTesterAPIClientID
  178. scopes:@[ OIDScopeOpenID, OIDScopeProfile, kOIDScopeTesterAPI ]
  179. redirectURL:[NSURL URLWithString:redirectURL]
  180. responseType:OIDResponseTypeCode
  181. additionalParameters:nil];
  182. [self setupUIWindowForLogin];
  183. void (^processAuthState)(OIDAuthState *_Nullable authState, NSError *_Nullable error) = ^void(
  184. OIDAuthState *_Nullable authState, NSError *_Nullable error) {
  185. self.authState = authState;
  186. // Capture errors in persistence but do not bubble them
  187. // up
  188. NSError *authPersistenceError;
  189. if (authState) {
  190. [FIRAppDistributionAuthPersistence persistAuthState:authState error:&authPersistenceError];
  191. }
  192. // TODO (schnecle): Log errors in persistence using
  193. // FIRLogger
  194. if (authPersistenceError) {
  195. NSLog(@"Error persisting token to keychain: %@", [error localizedDescription]);
  196. }
  197. self.isTesterSignedIn = self.authState ? YES : NO;
  198. completion(error);
  199. };
  200. // performs authentication request
  201. [FIRAppDistributionAppDelegatorInterceptor sharedInstance].currentAuthorizationFlow =
  202. [OIDAuthState authStateByPresentingAuthorizationRequest:request
  203. presentingViewController:self.safariHostingViewController
  204. callback:processAuthState];
  205. }
  206. - (void)setupUIWindowForLogin {
  207. if (self.window) {
  208. return;
  209. }
  210. // Create an empty window + viewController to host the Safari UI.
  211. self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  212. self.window.rootViewController = self.safariHostingViewController;
  213. // Place it at the highest level within the stack.
  214. self.window.windowLevel = +CGFLOAT_MAX;
  215. // Run it.
  216. [self.window makeKeyAndVisible];
  217. }
  218. - (void)cleanupUIWindow {
  219. if (self.window) {
  220. self.window.hidden = YES;
  221. self.window = nil;
  222. }
  223. }
  224. - (void)handleReleasesAPIResponseWithData:data
  225. completion:(FIRAppDistributionUpdateCheckCompletion)completion {
  226. NSError *error = nil;
  227. NSDictionary *object = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
  228. NSArray *releaseList = [object objectForKey:@"releases"];
  229. for (NSDictionary *releaseDict in releaseList) {
  230. if (![[releaseDict objectForKey:@"latest"] boolValue]) continue;
  231. NSString *codeHash = [releaseDict objectForKey:@"codeHash"];
  232. FIRAppDistributionMachO *machO =
  233. [[FIRAppDistributionMachO alloc] initWithPath:[[NSBundle mainBundle] executablePath]];
  234. if (![codeHash isEqualToString:machO.codeHash]) {
  235. FIRAppDistributionRelease *release =
  236. [[FIRAppDistributionRelease alloc] initWithDictionary:releaseDict];
  237. dispatch_async(dispatch_get_main_queue(), ^{
  238. completion(release, nil);
  239. });
  240. }
  241. }
  242. }
  243. - (void)checkForUpdateWithCompletion:(FIRAppDistributionUpdateCheckCompletion)completion {
  244. if (self.isTesterSignedIn) {
  245. [self fetchReleases:completion];
  246. } else {
  247. UIAlertController *alert = [UIAlertController
  248. alertControllerWithTitle:@"Enable in-app alerts"
  249. message:@"Sign in with your Firebase App Distribution Google account to "
  250. @"turn on in-app alerts for new test releases."
  251. preferredStyle:UIAlertControllerStyleAlert];
  252. UIAlertAction *yesButton =
  253. [UIAlertAction actionWithTitle:@"Turn on"
  254. style:UIAlertActionStyleDefault
  255. handler:^(UIAlertAction *action) {
  256. [self signInTesterWithCompletion:^(NSError *_Nullable error) {
  257. if (error) {
  258. completion(nil, error);
  259. return;
  260. }
  261. [self fetchReleases:completion];
  262. }];
  263. }];
  264. UIAlertAction *noButton = [UIAlertAction actionWithTitle:@"Not now"
  265. style:UIAlertActionStyleDefault
  266. handler:^(UIAlertAction *action) {
  267. // precaution to ensure window gets destroyed
  268. [self cleanupUIWindow];
  269. completion(nil, nil);
  270. }];
  271. [alert addAction:noButton];
  272. [alert addAction:yesButton];
  273. // Create an empty window + viewController to host the Safari UI.
  274. [self setupUIWindowForLogin];
  275. [self.window.rootViewController presentViewController:alert animated:YES completion:nil];
  276. }
  277. }
  278. @end