Selaa lähdekoodia

Switch App Distribution SDK from AppAuth to FirebaseInstallations (#6126)

* Adding error handling and returning correct error codes

* * Handling auth cancellation a few other error cases

* Adding some logging for EAP (Using NSLogs temporarily. DONOT MERGE)

* Add in a setter for clientID to allow overrides (#5514)

* Add in a setter for clientID to allow overrides

* Update styling

* Adding some result parsing logs

* Fixing code hash logging

* Add FirebaseAppDistribution to ZipBuilder

* * Making sure keychain is accesible after first unlock even if app is backgrounded (#5563)
* Fix auth persistence logging and invalid type casting (#5631)
* Logging app id requesting releases (#5655)

* AppDistribution keychain access fixes (#5683)

* Using the GULKeychainUtils instead of custom keychain utility. Also added better logging

* Updating keychain name and fixing tests

* Code review feedback updates

* Making serviceID unique per app

* Adding gmp app id to the keychain along with the bundle id

* Moving Keychain Utility functions in the AuthPersistence class and deleting the utility class

* Lazy initialize auth state (#5755)

* Lazy initialize auth state

* Making sure we dont log an error is auth state is not persisted in the keychain

* Switch from NSLog to FIRFADLogger (#5756)

* Add FIRLogger
* Switch from NSLog to FIRFADLogger

* * Use FIS Auth for authenticating the tester
* Remove AppAuth and start wiring up interceptor to do sign in

* Extracting registration flow UI in interceptor

* Adding support for iOS 9 and 10

* Create app distribution api service to handle tester api calls. (#6060)

* Pull out calls to the FAD API to make them reuseable

* Fix tests and run styl updates

* Uncomment FIRFADLog Statements

* Use [] accessor rather than . accessor

* Re-running style updates

* Trying to get these checks to pass the first time around

* App distribution signin persist (#6063)

* Pull out calls to the FAD API to make them reuseable

* Fix tests and run styl updates

* Add in UserDefaults storage after a tester has signed in

* Add testing for the main AppDistribution file

* Ran style.sh

* Remove FIRFADLocalStorage and call GULUserDefaults directly. Update tests to stop mocking on teardown.

* Add support for dynamic url schemes and fix two crashes (#6081)

* Add support for dynamic url schemes and fix two crashes

  * Add support for dynamic url schemes in the format appdistribution-<app id without colons>
  * Fix crash when launching an app directly via a URL scheme
  * Fix a crash on iOS 10 simulator caused by calling NSLogv twice
  * Remove uses of NSLog
  * Fix warnings

* Switch encodedAppId to be a class method

* Fix styles

* Create changelog and readme in preparation for release. (#6095)

Just changing markdown files so will not wait for gha checks.

* Change slices property from copy to strong (#6097)

Fix lint warning

* Change error handlers to return bool values instead of void (#6101)

* Mapping sign in flow errors to app distribution errors. Also renaming AppDelegateInterceptor to FIRAppDistributionUIService (#6099)

* Add github action for App Distribution (#6094)

* Updating app distribution deployment target to iOS 9 (#6118)

* Making sure we return granular errors during sign in persistence. Also fixing some naming and comment issues (#6117)

* Encoding colons to dashes in the app id (#6119)

* Move Internal headers to Sources (#6106)

* Move Internal headers to Sources/ folder

* PR feedback

* Remove appDistribution from zip builder

* Respond to code review feedback (#6138)

* Initializing UIWindow using initWithWindowScene for ios 13 and removing UI code from main FIRAppDistribution.m (#6144)

* Initializing UIWindow using initWithWindowScene for ios 13. Also making sure we only reset window in the App delegate if a registration flow is in progress.

* Remove UI code from main class, add unit tests, and clean up UIService

* stylish

* Remove defines in favor of solely 'if (@available(iOS 13.0, *))'

* Respond to code review feedback

Co-authored-by: Cleo Schneider <cleoschneider@google.com>

Co-authored-by: Pranav Rajgopal <pranavrajgopal@google.com>
Co-authored-by: Cleo Schneider <cleoschneider@google.com>
Jeremy Durham 5 vuotta sitten
vanhempi
sitoutus
16e062c355
30 muutettua tiedostoa jossa 1961 lisäystä ja 765 poistoa
  1. 51 0
      .github/workflows/appdistribution.yml
  2. 7 3
      FirebaseAppDistribution.podspec
  3. 2 0
      FirebaseAppDistribution/CHANGELOG.md
  4. 19 0
      FirebaseAppDistribution/README.md
  5. 185 215
      FirebaseAppDistribution/Sources/FIRAppDistribution.m
  6. 0 42
      FirebaseAppDistribution/Sources/FIRAppDistributionAppDelegateInterceptor.m
  7. 0 122
      FirebaseAppDistribution/Sources/FIRAppDistributionAuthPersistence.m
  8. 0 79
      FirebaseAppDistribution/Sources/FIRAppDistributionKeychainUtility.m
  9. 1 1
      FirebaseAppDistribution/Sources/FIRAppDistributionMachO.h
  10. 3 3
      FirebaseAppDistribution/Sources/FIRAppDistributionMachO.m
  11. 0 0
      FirebaseAppDistribution/Sources/FIRAppDistributionMachOSlice.h
  12. 1 1
      FirebaseAppDistribution/Sources/FIRAppDistributionMachOSlice.m
  13. 1 1
      FirebaseAppDistribution/Sources/FIRAppDistributionRelease.m
  14. 62 0
      FirebaseAppDistribution/Sources/FIRAppDistributionUIService.h
  15. 259 0
      FirebaseAppDistribution/Sources/FIRAppDistributionUIService.m
  16. 94 0
      FirebaseAppDistribution/Sources/FIRFADApiService.h
  17. 201 0
      FirebaseAppDistribution/Sources/FIRFADApiService.m
  18. 25 0
      FirebaseAppDistribution/Sources/FIRFADLogger.h
  19. 52 0
      FirebaseAppDistribution/Sources/FIRFADLogger.m
  20. 2 14
      FirebaseAppDistribution/Sources/Private/FIRAppDistribution.h
  21. 0 41
      FirebaseAppDistribution/Sources/Private/FIRAppDistributionAppDelegateInterceptor.h
  22. 0 55
      FirebaseAppDistribution/Sources/Private/FIRAppDistributionAuthPersistence+Private.h
  23. 0 41
      FirebaseAppDistribution/Sources/Private/FIRAppDistributionKeychainUtility+Private.h
  24. 1 1
      FirebaseAppDistribution/Sources/Private/FIRAppDistributionRelease.h
  25. 9 2
      FirebaseAppDistribution/Sources/Public/FIRAppDistribution.h
  26. 0 137
      FirebaseAppDistribution/Tests/Unit/FIRAppDistributionAuthPersistenceTests.m
  27. 1 1
      FirebaseAppDistribution/Tests/Unit/FIRAppDistributionMachOTests.m
  28. 496 6
      FirebaseAppDistribution/Tests/Unit/FIRAppDistributionTests.m
  29. 487 0
      FirebaseAppDistribution/Tests/Unit/FIRFADApiServiceTests.m
  30. 2 0
      README.md

+ 51 - 0
.github/workflows/appdistribution.yml

@@ -0,0 +1,51 @@
+name: appdistribution
+
+on:
+  pull_request:
+    paths:
+    - 'FirebaseAppDistribution**'
+    - '.github/workflows/appdistribution.yml'
+    - 'Gemfile'
+  schedule:
+    # Run every day at 3am (PST) - cron uses UTC times
+    # This is set to 3 hours after zip workflow finishes so zip testing can run after.
+    - cron:  '0 11 * * *'
+
+jobs:
+  pod-lib-lint:
+    # Don't run on private repo unless it is a PR.
+    if: github.repository != 'FirebasePrivate/firebase-ios-sdk' || github.event_name == 'pull_request'
+
+    runs-on: macos-latest
+    strategy:
+      matrix:
+        target: [ios]
+    steps:
+    - uses: actions/checkout@v2
+    - name: Setup Bundler
+      run: scripts/setup_bundler.sh
+    - name: Build and test
+      run: |
+       scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseAppDistribution.podspec \
+         --platforms=${{ matrix.target }}
+
+  appdistribution-cron-only:
+    if: github.event_name == 'schedule' && github.repository != 'FirebasePrivate/firebase-ios-sdk'
+
+    runs-on: macos-latest
+    strategy:
+      matrix:
+        target: [ios]
+        flags: [
+          '--use-modular-headers',
+          '--use-libraries'
+        ]
+    needs: pod-lib-lint
+    steps:
+    - uses: actions/checkout@v2
+    - name: Setup Bundler
+      run: scripts/setup_bundler.sh
+    - name: PodLibLint App Distribution Cron
+      run: |
+       scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseAppDistribution.podspec \
+         --platforms=${{ matrix.target }} ${{ matrix.flags }}

+ 7 - 3
FirebaseAppDistribution.podspec

@@ -15,7 +15,7 @@ iOS SDK for App Distribution for Firebase.
     :tag => 'AppDistribution-' + s.version.to_s
   }
 
-  s.ios.deployment_target = '8.0'
+  s.ios.deployment_target = '9.0'
 
   s.cocoapods_version = '>= 1.4.0'
   s.static_framework = true
@@ -25,14 +25,18 @@ iOS SDK for App Distribution for Firebase.
   s.source_files = [
     base_dir + '**/*.{c,h,m,mm}',
     'FirebaseCore/Sources/Private/*.h',
+    'FirebaseInstallations/Source/Library/Private/*.h',
+    'GoogleDataTransport/GDTCORLibrary/Internal/*.h',
     'GoogleUtilities/AppDelegateSwizzler/Private/*.h',
+    'GoogleUtilities/UserDefaults/Private/*.h',
   ]
   s.public_header_files = base_dir + 'Public/*.h'
-  s.private_header_files = base_dir + 'Private/*.h'
 
   s.dependency 'FirebaseCore', '~> 6.8'
-  s.dependency 'AppAuth', '~> 1.2.0'
   s.dependency 'GoogleUtilities/AppDelegateSwizzler', '~> 6.7'
+  s.dependency 'GoogleUtilities/UserDefaults', '~> 6.7'
+  s.dependency 'FirebaseInstallations', '~> 1.5'
+  s.dependency 'GoogleDataTransport', '~> 7.0'
 
   s.pod_target_xcconfig = {
     'GCC_C_LANGUAGE_STANDARD' => 'c99',

+ 2 - 0
FirebaseAppDistribution/CHANGELOG.md

@@ -0,0 +1,2 @@
+# v0.1.0
+- Initial beta release.

+ 19 - 0
FirebaseAppDistribution/README.md

@@ -0,0 +1,19 @@
+# Firebase App Distribution SDK
+
+## Development
+
+### Prereqs
+
+- At least CocoaPods 1.6.0
+- Install [cocoapods-generate](https://github.com/square/cocoapods-generate)
+
+### To Develop
+
+- Run `pod gen FirebaseAppDistribution.podspec`
+- `open gen/FirebaseAppDistribution/FirebaseAppDistribution.xcworkspace`
+
+You are ready to develop, build, debug, and test FirebaseAppDistribution.
+
+### Running Unit Tests
+
+Open the generated workspace, choose the FirebaseAppDistribution-Unit-unit scheme and press Command-u.

+ 185 - 215
FirebaseAppDistribution/Sources/FIRAppDistribution.m

@@ -11,16 +11,19 @@
 // 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 "FIRAppDistribution+Private.h"
-#import "FIRAppDistributionAuthPersistence+Private.h"
-#import "FIRAppDistributionMachO+Private.h"
-#import "FIRAppDistributionRelease+Private.h"
+#import <Foundation/Foundation.h>
 
 #import "FirebaseCore/Sources/Private/FirebaseCoreInternal.h"
-
-#import "FIRAppDistributionAppDelegateInterceptor.h"
+#import "FirebaseInstallations/Source/Library/Private/FirebaseInstallationsInternal.h"
 #import "GoogleUtilities/AppDelegateSwizzler/Private/GULAppDelegateSwizzler.h"
+#import "GoogleUtilities/UserDefaults/Private/GULUserDefaults.h"
+
+#import "FirebaseAppDistribution/Sources/FIRAppDistributionMachO.h"
+#import "FirebaseAppDistribution/Sources/FIRAppDistributionUIService.h"
+#import "FirebaseAppDistribution/Sources/FIRFADApiService.h"
+#import "FirebaseAppDistribution/Sources/FIRFADLogger.h"
+#import "FirebaseAppDistribution/Sources/Private/FIRAppDistribution.h"
+#import "FirebaseAppDistribution/Sources/Private/FIRAppDistributionRelease.h"
 
 /// Empty protocol to register with FirebaseCore's component system.
 @protocol FIRAppDistributionInstanceProvider <NSObject>
@@ -28,45 +31,50 @@
 
 @interface FIRAppDistribution () <FIRLibrary, FIRAppDistributionInstanceProvider>
 @property(nonatomic) BOOL isTesterSignedIn;
+
+@property(nullable, nonatomic) FIRAppDistributionUIService *uiService;
+
 @end
 
-@implementation FIRAppDistribution
+NSString *const FIRAppDistributionErrorDomain = @"com.firebase.appdistribution";
+NSString *const FIRAppDistributionErrorDetailsKey = @"details";
 
-// The OAuth scope needed to authorize the App Distribution Tester API
-NSString *const kOIDScopeTesterAPI = @"https://www.googleapis.com/auth/cloud-platform";
+@implementation FIRAppDistribution
 
 // The App Distribution Tester API endpoint used to retrieve releases
-NSString *const kReleasesEndpointURL =
-    @"https://firebaseapptesters.googleapis.com/v1alpha/devices/-/testerApps/%@/releases";
-NSString *const kTesterAPIClientID =
-    @"319754533822-osu3v3hcci24umq6diathdm0dipds1fb.apps.googleusercontent.com";
-NSString *const kIssuerURL = @"https://accounts.google.com";
+NSString *const kReleasesEndpointURL = @"https://firebaseapptesters.googleapis.com/v1alpha/devices/"
+                                       @"-/testerApps/%@/installations/%@/releases";
+
 NSString *const kAppDistroLibraryName = @"fire-fad";
 
+NSString *const kReleasesKey = @"releases";
+NSString *const kLatestReleaseKey = @"latest";
+NSString *const kCodeHashKey = @"codeHash";
+
+NSString *const kAuthErrorMessage = @"Unable to authenticate the tester";
+NSString *const kAuthCancelledErrorMessage = @"Tester cancelled sign-in";
+NSString *const kFIRFADSignInStateKey = @"FIRFADSignInState";
+
+@synthesize isTesterSignedIn = _isTesterSignedIn;
+
+- (BOOL)isTesterSignedIn {
+  BOOL signInState = [[GULUserDefaults standardUserDefaults] boolForKey:kFIRFADSignInStateKey];
+  FIRFADInfoLog(@"Tester is %@signed in.", signInState ? @"" : @"not ");
+  return signInState;
+}
+
 #pragma mark - Singleton Support
 
 - (instancetype)initWithApp:(FIRApp *)app appInfo:(NSDictionary *)appInfo {
+  // FIRFADInfoLog(@"Initializing Firebase App Distribution");
   self = [super init];
 
   if (self) {
-    self.safariHostingViewController = [[UIViewController alloc] init];
-
     [GULAppDelegateSwizzler proxyOriginalDelegate];
-
-    FIRAppDistributionAppDelegatorInterceptor *interceptor =
-        [FIRAppDistributionAppDelegatorInterceptor sharedInstance];
-    [GULAppDelegateSwizzler registerAppDelegateInterceptor:interceptor];
-  }
-
-  NSError *authRetrievalError;
-  self.authState = [FIRAppDistributionAuthPersistence retrieveAuthState:&authRetrievalError];
-  // TODO (schnecle): replace NSLog statement with FIRLogger log statement
-  if (authRetrievalError) {
-    NSLog(@"Error retrieving token from keychain: %@", [authRetrievalError localizedDescription]);
+    self.uiService = [FIRAppDistributionUIService sharedInstance];
+    [GULAppDelegateSwizzler registerAppDelegateInterceptor:[self uiService]];
   }
 
-  self.isTesterSignedIn = self.authState ? YES : NO;
-
   return self;
 }
 
@@ -82,10 +90,7 @@ NSString *const kAppDistroLibraryName = @"fire-fad";
   FIRComponentCreationBlock creationBlock =
       ^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
     if (!container.app.isDefaultApp) {
-      // TODO: Implement error handling
-      @throw([NSException exceptionWithName:@"NotImplementedException"
-                                     reason:@"This code path is not implemented yet"
-                                   userInfo:nil]);
+      FIRFADErrorLog(@"Firebase App Distribution only works with the default app.");
       return nil;
     }
 
@@ -105,7 +110,6 @@ NSString *const kAppDistroLibraryName = @"fire-fad";
 
 + (instancetype)appDistribution {
   // The container will return the same instance since isCacheable is set
-
   FIRApp *defaultApp = [FIRApp defaultApp];  // Missing configure will be logged here.
 
   // Get the instance from the `FIRApp`'s container. This will create a new instance the
@@ -116,216 +120,182 @@ NSString *const kAppDistroLibraryName = @"fire-fad";
 
   // In the component creation block, we return an instance of `FIRAppDistribution`. Cast it and
   // return it.
+  FIRFADDebugLog(@"Instance returned: %@", instance);
   return (FIRAppDistribution *)instance;
 }
 
 - (void)signInTesterWithCompletion:(void (^)(NSError *_Nullable error))completion {
-  NSURL *issuer = [NSURL URLWithString:kIssuerURL];
-
-  [OIDAuthorizationService
-      discoverServiceConfigurationForIssuer:issuer
-                                 completion:^(OIDServiceConfiguration *_Nullable configuration,
-                                              NSError *_Nullable error) {
-                                   [self handleOauthDiscoveryCompletion:configuration
-                                                                  error:error
-                                        appDistributionSignInCompletion:completion];
-                                 }];
-}
+  FIRFADDebugLog(@"Prompting tester for sign in");
 
-- (void)signOutTester {
-  NSError *error;
-  BOOL didClearAuthState = [FIRAppDistributionAuthPersistence clearAuthState:&error];
-  // TODO (schnecle): Add in FIRLogger to report when we have failed to clear auth state
-  if (!didClearAuthState) {
-    NSLog(@"Error clearing token from keychain: %@", [error localizedDescription]);
+  if ([self isTesterSignedIn]) {
+    completion(nil);
+    return;
   }
 
-  self.authState = nil;
-  self.isTesterSignedIn = false;
-}
+  [[self uiService] initializeUIState];
+  FIRInstallations *installations = [FIRInstallations installations];
 
-- (void)fetchReleases:(FIRAppDistributionUpdateCheckCompletion)completion {
-  [self.authState performActionWithFreshTokens:^(NSString *_Nonnull accessToken,
-                                                 NSString *_Nonnull idToken,
-                                                 NSError *_Nullable error) {
+  // Get a Firebase Installation ID (FID).
+  [installations installationIDWithCompletion:^(NSString *__nullable identifier,
+                                                NSError *__nullable error) {
     if (error) {
-      // TODO (schnecle): Add in FIRLogger log statement
-      NSLog(@"Error fetching fresh tokens: %@", [error localizedDescription]);
-      [self signOutTester];
+      NSString *description = error.userInfo[NSLocalizedDescriptionKey]
+                                  ? error.userInfo[NSLocalizedDescriptionKey]
+                                  : @"Failed to retrieve Installation ID.";
+      completion([self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorUnknown
+                                             message:description]);
+
+      [[self uiService] resetUIState];
       return;
     }
 
-    // perform your API request using the tokens
-    NSURLSession *URLSession = [NSURLSession sharedSession];
-    NSMutableURLRequest *request = [[NSMutableURLRequest alloc] init];
-    NSString *URLString =
-        [NSString stringWithFormat:kReleasesEndpointURL, [[FIRApp defaultApp] options].googleAppID];
-    [request setURL:[NSURL URLWithString:URLString]];
-    [request setHTTPMethod:@"GET"];
-    [request setValue:[NSString stringWithFormat:@"Bearer %@", accessToken]
-        forHTTPHeaderField:@"Authorization"];
-
-    NSURLSessionDataTask *listReleasesDataTask = [URLSession
-        dataTaskWithRequest:request
-          completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
-            if (error) {
-              // TODO: Reformat error into error code
-              completion(nil, error);
-              return;
-            }
-
-            NSHTTPURLResponse *HTTPResponse = (NSHTTPURLResponse *)response;
-
-            if (HTTPResponse.statusCode == 200) {
-              [self handleReleasesAPIResponseWithData:data completion:completion];
-            } else {
-              // TODO: Handle non-200 http response
-              NSLog(@"ERROR - Non 200 service response - %@", HTTPResponse);
-              @throw([NSException exceptionWithName:@"NotImplementedException"
-                                             reason:@"This code path is not implemented yet"
-                                           userInfo:nil]);
-            }
-          }];
-
-    [listReleasesDataTask resume];
+    NSString *requestURL = [NSString
+        stringWithFormat:@"https://partnerdash.google.com/apps/appdistribution/pub/apps/%@/"
+                         @"installations/%@/buildalerts?appName=%@",
+                         [[FIRApp defaultApp] options].googleAppID, identifier, [self getAppName]];
+
+    FIRFADDebugLog(@"Registration URL: %@", requestURL);
+
+    [[self uiService]
+        appDistributionRegistrationFlow:[[NSURL alloc] initWithString:requestURL]
+                         withCompletion:^(NSError *_Nullable error) {
+                           FIRFADInfoLog(@"Tester sign in complete.");
+                           if (error) {
+                             completion(error);
+                             return;
+                           }
+                           [self persistTesterSignInStateAndHandleCompletion:completion];
+                         }];
   }];
 }
 
-- (void)handleOauthDiscoveryCompletion:(OIDServiceConfiguration *_Nullable)configuration
-                                 error:(NSError *_Nullable)error
-       appDistributionSignInCompletion:(void (^)(NSError *_Nullable error))completion {
-  if (!configuration) {
-    // TODO: Handle when we cannot get configuration
-    NSLog(@"ERROR - Cannot discover oauth config");
-    @throw([NSException exceptionWithName:@"NotImplementedException"
-                                   reason:@"This code path is not implemented yet"
-                                 userInfo:nil]);
-    return;
-  }
+- (void)persistTesterSignInStateAndHandleCompletion:(void (^)(NSError *_Nullable error))completion {
+  [FIRFADApiService
+      fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
+        if (error) {
+          FIRFADErrorLog(@"Tester Sign in persistence. Could not fetch releases with code %ld - %@",
+                         [error code], [error localizedDescription]);
+          completion([self mapFetchReleasesError:error]);
+          return;
+        }
+
+        [[GULUserDefaults standardUserDefaults] setBool:YES forKey:kFIRFADSignInStateKey];
+        completion(nil);
+      }];
+}
 
-  NSString *redirectURL = [@"dev.firebase.appdistribution."
-      stringByAppendingString:[[[NSBundle mainBundle] bundleIdentifier]
-                                  stringByAppendingString:@":/launch"]];
-
-  OIDAuthorizationRequest *request = [[OIDAuthorizationRequest alloc]
-      initWithConfiguration:configuration
-                   clientId:kTesterAPIClientID
-                     scopes:@[ OIDScopeOpenID, OIDScopeProfile, kOIDScopeTesterAPI ]
-                redirectURL:[NSURL URLWithString:redirectURL]
-               responseType:OIDResponseTypeCode
-       additionalParameters:nil];
-
-  [self setupUIWindowForLogin];
-
-  void (^processAuthState)(OIDAuthState *_Nullable authState, NSError *_Nullable error) = ^void(
-      OIDAuthState *_Nullable authState, NSError *_Nullable error) {
-    self.authState = authState;
-
-    // Capture errors in persistence but do not bubble them
-    // up
-    NSError *authPersistenceError;
-    if (authState) {
-      [FIRAppDistributionAuthPersistence persistAuthState:authState error:&authPersistenceError];
-    }
+- (NSString *)getAppName {
+  NSBundle *mainBundle = [NSBundle mainBundle];
 
-    // TODO (schnecle): Log errors in persistence using
-    // FIRLogger
-    if (authPersistenceError) {
-      NSLog(@"Error persisting token to keychain: %@", [error localizedDescription]);
-    }
-    self.isTesterSignedIn = self.authState ? YES : NO;
-    completion(error);
-  };
+  NSString *name = [mainBundle objectForInfoDictionaryKey:@"CFBundleName"];
 
-  // performs authentication request
-  [FIRAppDistributionAppDelegatorInterceptor sharedInstance].currentAuthorizationFlow =
-      [OIDAuthState authStateByPresentingAuthorizationRequest:request
-                                     presentingViewController:self.safariHostingViewController
-                                                     callback:processAuthState];
-}
+  if (name)
+    return
+        [name stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet
+                                                                     URLHostAllowedCharacterSet]];
 
-- (void)setupUIWindowForLogin {
-  if (self.window) {
-    return;
-  }
-  // Create an empty window + viewController to host the Safari UI.
-  self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
-  self.window.rootViewController = self.safariHostingViewController;
+  name = [mainBundle objectForInfoDictionaryKey:@"CFBundleDisplayName"];
 
-  // Place it at the highest level within the stack.
-  self.window.windowLevel = +CGFLOAT_MAX;
+  return [name stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet
+                                                                      URLHostAllowedCharacterSet]];
+}
 
-  // Run it.
-  [self.window makeKeyAndVisible];
+- (void)signOutTester {
+  FIRFADDebugLog(@"Tester is signed out.");
+  [[GULUserDefaults standardUserDefaults] setBool:NO forKey:kFIRFADSignInStateKey];
 }
 
-- (void)cleanupUIWindow {
-  if (self.window) {
-    self.window.hidden = YES;
-    self.window = nil;
-  }
+- (NSError *)NSErrorForErrorCodeAndMessage:(FIRAppDistributionError)errorCode
+                                   message:(NSString *)message {
+  NSDictionary *userInfo = @{FIRAppDistributionErrorDetailsKey : message};
+  return [NSError errorWithDomain:FIRAppDistributionErrorDomain code:errorCode userInfo:userInfo];
 }
 
-- (void)handleReleasesAPIResponseWithData:data
-                               completion:(FIRAppDistributionUpdateCheckCompletion)completion {
-  NSError *error = nil;
-  NSDictionary *object = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
-
-  NSArray *releaseList = [object objectForKey:@"releases"];
-  for (NSDictionary *releaseDict in releaseList) {
-    if (![[releaseDict objectForKey:@"latest"] boolValue]) continue;
-
-    NSString *codeHash = [releaseDict objectForKey:@"codeHash"];
-    FIRAppDistributionMachO *machO =
-        [[FIRAppDistributionMachO alloc] initWithPath:[[NSBundle mainBundle] executablePath]];
-
-    if (![codeHash isEqualToString:machO.codeHash]) {
-      FIRAppDistributionRelease *release =
-          [[FIRAppDistributionRelease alloc] initWithDictionary:releaseDict];
-      dispatch_async(dispatch_get_main_queue(), ^{
-        completion(release, nil);
-      });
+- (NSError *_Nullable)mapFetchReleasesError:(NSError *)error {
+  if ([error domain] == kFIRFADApiErrorDomain) {
+    FIRFADErrorLog(@"Failed to retrieve releases: %ld", (long)[error code]);
+    switch ([error code]) {
+      case FIRFADApiErrorTimeout:
+        return [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorNetworkFailure
+                                           message:@"Failed to fetch releases due to timeout."];
+      case FIRFADApiErrorUnauthenticated:
+      case FIRFADApiErrorUnauthorized:
+      case FIRFADApiTokenGenerationFailure:
+      case FIRFADApiInstallationIdentifierError:
+      case FIRFADApiErrorNotFound:
+        return [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorAuthenticationFailure
+                                           message:@"Could not authenticate tester"];
+      default:
+        return [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorUnknown
+                                           message:@"Failed to fetch releases for unknown reason."];
     }
   }
+
+  FIRFADErrorLog(@"Failed to retrieve releases with unexpected domain %@: %ld", [error domain],
+                 (long)[error code]);
+  return [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorUnknown
+                                     message:@"Failed to fetch releases for unknown reason."];
 }
+
+- (void)fetchNewLatestRelease:(FIRAppDistributionUpdateCheckCompletion)completion {
+  [FIRFADApiService
+      fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
+        if (error) {
+          dispatch_async(dispatch_get_main_queue(), ^{
+            completion(nil, [self mapFetchReleasesError:error]);
+          });
+          return;
+        }
+
+        for (NSDictionary *releaseDict in releases) {
+          if ([[releaseDict objectForKey:kLatestReleaseKey] boolValue]) {
+            FIRFADInfoLog(
+                @"Tester API - found latest release in response. Checking if code hash match");
+            NSString *codeHash = [releaseDict objectForKey:kCodeHashKey];
+            NSString *executablePath = [[NSBundle mainBundle] executablePath];
+            FIRAppDistributionMachO *machO =
+                [[FIRAppDistributionMachO alloc] initWithPath:executablePath];
+            FIRFADInfoLog(@"Code hash for the app on device - %@", machO.codeHash);
+            FIRFADInfoLog(@"Code hash for the release from the service response - %@", codeHash);
+            if (codeHash && ![codeHash isEqualToString:machO.codeHash]) {
+              FIRAppDistributionRelease *release =
+                  [[FIRAppDistributionRelease alloc] initWithDictionary:releaseDict];
+              dispatch_async(dispatch_get_main_queue(), ^{
+                FIRFADInfoLog(@"Found new release with version: %@", [release displayVersion]);
+                completion(release, nil);
+              });
+
+              return;
+            }
+          }
+        }
+        completion(nil, nil);
+      }];
+}
+
 - (void)checkForUpdateWithCompletion:(FIRAppDistributionUpdateCheckCompletion)completion {
-  if (self.isTesterSignedIn) {
-    [self fetchReleases:completion];
+  FIRFADInfoLog(@"CheckForUpdateWithCompletion");
+  if ([self isTesterSignedIn]) {
+    [self fetchNewLatestRelease:completion];
   } else {
-    UIAlertController *alert = [UIAlertController
-        alertControllerWithTitle:@"Enable in-app alerts"
-                         message:@"Sign in with your Firebase App Distribution Google account to "
-                                 @"turn on in-app alerts for new test releases."
-                  preferredStyle:UIAlertControllerStyleAlert];
-
-    UIAlertAction *yesButton =
-        [UIAlertAction actionWithTitle:@"Turn on"
-                                 style:UIAlertActionStyleDefault
-                               handler:^(UIAlertAction *action) {
-                                 [self signInTesterWithCompletion:^(NSError *_Nullable error) {
-                                   if (error) {
-                                     completion(nil, error);
-                                     return;
-                                   }
-
-                                   [self fetchReleases:completion];
-                                 }];
-                               }];
-
-    UIAlertAction *noButton = [UIAlertAction actionWithTitle:@"Not now"
-                                                       style:UIAlertActionStyleDefault
-                                                     handler:^(UIAlertAction *action) {
-                                                       // precaution to ensure window gets destroyed
-                                                       [self cleanupUIWindow];
-                                                       completion(nil, nil);
-                                                     }];
-
-    [alert addAction:noButton];
-    [alert addAction:yesButton];
-
-    // Create an empty window + viewController to host the Safari UI.
-    [self setupUIWindowForLogin];
-    [self.window.rootViewController presentViewController:alert animated:YES completion:nil];
+    FIRFADUIActionCompletion actionCompletion = ^(BOOL continued) {
+      if (continued) {
+        [self signInTesterWithCompletion:^(NSError *_Nullable error) {
+          if (error) {
+            completion(nil, error);
+            return;
+          }
+
+          [self fetchNewLatestRelease:completion];
+        }];
+      } else {
+        completion(
+            nil, [self NSErrorForErrorCodeAndMessage:FIRAppDistributionErrorAuthenticationCancelled
+                                             message:@"Tester cancelled authentication flow."]);
+      }
+    };
+
+    [[self uiService] showUIAlertWithCompletion:actionCompletion];
   }
 }
 @end

+ 0 - 42
FirebaseAppDistribution/Sources/FIRAppDistributionAppDelegateInterceptor.m

@@ -1,42 +0,0 @@
-// Copyright 2019 Google
-//
-// 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 "FIRAppDistributionAppDelegateInterceptor.h"
-#import <UIKit/UIKit.h>
-#import "AppAuth.h"
-
-@implementation FIRAppDistributionAppDelegatorInterceptor
-
-- (instancetype)init {
-  self = [super init];
-
-  return self;
-}
-
-+ (instancetype)sharedInstance {
-  static dispatch_once_t once;
-  static FIRAppDistributionAppDelegatorInterceptor *sharedInstance;
-  dispatch_once(&once, ^{
-    sharedInstance = [[FIRAppDistributionAppDelegatorInterceptor alloc] init];
-  });
-
-  return sharedInstance;
-}
-
-- (BOOL)application:(UIApplication *)application
-            openURL:(NSURL *)URL
-            options:(NSDictionary<NSString *, id> *)options {
-  return NO;
-}
-@end

+ 0 - 122
FirebaseAppDistribution/Sources/FIRAppDistributionAuthPersistence.m

@@ -1,122 +0,0 @@
-// Copyright 2020 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 "FIRAppDistributionAuthPersistence+Private.h"
-
-NS_ASSUME_NONNULL_BEGIN
-
-NSString *const kFIRAppDistributionAuthPersistenceErrorDomain =
-    @"com.firebase.app_distribution.auth_persistence";
-
-@implementation FIRAppDistributionAuthPersistence
-
-+ (void)handleAuthStateError:(NSError **_Nullable)error
-                 description:(NSString *)description
-                        code:(FIRAppDistributionKeychainError)code {
-  if (error) {
-    NSDictionary *userInfo = @{NSLocalizedDescriptionKey : description};
-    *error = [NSError errorWithDomain:kFIRAppDistributionAuthPersistenceErrorDomain
-                                 code:code
-                             userInfo:userInfo];
-  }
-}
-
-+ (BOOL)clearAuthState:(NSError **_Nullable)error {
-  NSMutableDictionary *keychainQuery = [self getKeyChainQuery];
-  BOOL success = [FIRAppDistributionKeychainUtility deleteKeychainItem:keychainQuery];
-
-  if (!success) {
-    NSString *description = NSLocalizedString(
-        @"Failed to clear auth state from keychain. Tester will overwrite data on sign in.",
-        @"Error message for failure to retrieve auth state from keychain");
-    [self handleAuthStateError:error
-                   description:description
-                          code:FIRAppDistributionErrorTokenDeletionFailure];
-    return NO;
-  }
-
-  return YES;
-}
-
-+ (OIDAuthState *)retrieveAuthState:(NSError **_Nullable)error {
-  NSMutableDictionary *keychainQuery = [self getKeyChainQuery];
-  [keychainQuery setObject:(id)kCFBooleanTrue forKey:(id)kSecReturnData];
-  [keychainQuery setObject:(id)kSecMatchLimitOne forKey:(id)kSecMatchLimit];
-  NSData *passwordData = [FIRAppDistributionKeychainUtility fetchKeychainItemMatching:keychainQuery
-                                                                                error:NULL];
-  NSData *result = nil;
-
-  if (!passwordData) {
-    NSString *description = NSLocalizedString(
-        @"Failed to retrieve auth state from keychain. Tester will have to sign in again.",
-        @"Error message for failure to retrieve auth state from keychain");
-    [self handleAuthStateError:error
-                   description:description
-                          code:FIRAppDistributionErrorTokenRetrievalFailure];
-    return nil;
-  }
-
-  result = [passwordData copy];
-
-  if (!result) {
-    NSString *description =
-        NSLocalizedString(@"Failed to unarchive auth state. Tester will have to sign in again.",
-                          @"Error message for failure to retrieve auth state from keychain");
-    [self handleAuthStateError:error
-                   description:description
-                          code:FIRAppDistributionErrorTokenRetrievalFailure];
-    return nil;
-  }
-
-  OIDAuthState *authState = [FIRAppDistributionKeychainUtility unarchiveKeychainResult:result];
-
-  return authState;
-}
-
-+ (BOOL)persistAuthState:(OIDAuthState *)authState error:(NSError **_Nullable)error {
-  NSData *authorizationData = [FIRAppDistributionKeychainUtility archiveDataForKeychain:authState];
-  NSMutableDictionary *keychainQuery = [self getKeyChainQuery];
-  BOOL success = NO;
-  BOOL hasAuthState = [self retrieveAuthState:NULL];
-  if (hasAuthState) {
-    success = [FIRAppDistributionKeychainUtility updateKeychainItem:keychainQuery
-                                                 withDataDictionary:authorizationData];
-  } else {
-    success = [FIRAppDistributionKeychainUtility addKeychainItem:keychainQuery
-                                              withDataDictionary:authorizationData];
-  }
-
-  if (!success) {
-    NSString *description = NSLocalizedString(
-        @"Failed to persist auth state. Tester will have to sign in again after app close.",
-        @"Error message for failure to persist auth state to keychain");
-    [self handleAuthStateError:error
-                   description:description
-                          code:FIRAppDistributionErrorTokenPersistenceFailure];
-    return NO;
-  }
-
-  return YES;
-}
-
-+ (NSMutableDictionary *)getKeyChainQuery {
-  NSMutableDictionary *keychainQuery = [NSMutableDictionary
-      dictionaryWithObjectsAndKeys:(id)kSecClassGenericPassword, (id)kSecClass, @"OAuth",
-                                   (id)kSecAttrGeneric, @"OAuth", (id)kSecAttrAccount,
-                                   @"fire-fad-auth", (id)kSecAttrService, nil];
-  return keychainQuery;
-}
-
-@end
-
-NS_ASSUME_NONNULL_END

+ 0 - 79
FirebaseAppDistribution/Sources/FIRAppDistributionKeychainUtility.m

@@ -1,79 +0,0 @@
-// Copyright 2020 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 <AppAuth/AppAuth.h>
-#import "FIRAppDistributionKeychainUtility+Private.h"
-
-NSString *const kFIRAppDistributionKeychainErrorDomain = @"com.firebase.app_distribution.keychain";
-
-@implementation FIRAppDistributionKeychainUtility
-
-+ (void)handleAuthStateError:(NSError **_Nullable)error
-                 description:(NSString *)description
-                        code:(int)code {
-  if (error) {
-    NSDictionary *userInfo = @{NSLocalizedDescriptionKey : description};
-    *error = [NSError errorWithDomain:kFIRAppDistributionKeychainErrorDomain
-                                 code:code
-                             userInfo:userInfo];
-  }
-}
-
-+ (BOOL)addKeychainItem:(nonnull NSMutableDictionary *)keychainQuery
-     withDataDictionary:(nonnull NSData *)data {
-  [keychainQuery setObject:data forKey:(id)kSecValueData];
-  OSStatus status = SecItemAdd((CFDictionaryRef)keychainQuery, NULL);
-
-  return status == noErr ? YES : NO;
-}
-
-+ (BOOL)updateKeychainItem:(nonnull NSMutableDictionary *)keychainQuery
-        withDataDictionary:(nonnull NSData *)data {
-  OSStatus status =
-      SecItemUpdate((CFDictionaryRef)keychainQuery, (CFDictionaryRef) @{(id)kSecValueData : data});
-  return status == noErr ? YES : NO;
-}
-
-+ (BOOL)deleteKeychainItem:(nonnull NSMutableDictionary *)keychainQuery {
-  OSStatus status = SecItemDelete((CFDictionaryRef)keychainQuery);
-
-  return status != errSecSuccess && status != errSecItemNotFound ? NO : YES;
-}
-
-+ (NSData *)fetchKeychainItemMatching:(nonnull NSMutableDictionary *)keychainQuery
-                                error:(NSError **_Nullable)error {
-  NSData *keychainItem;
-  OSStatus status = SecItemCopyMatching((CFDictionaryRef)keychainQuery, (void *)&keychainItem);
-
-  if (status != noErr || 0 == [keychainItem length]) {
-    if (error) {
-      NSString *description =
-          NSLocalizedString(@"Failed to fetch keychain item.",
-                            @"Error message for failure to retrieve auth state from keychain");
-      [self handleAuthStateError:error description:description code:0];
-      return nil;
-    }
-  }
-
-  return keychainItem;
-}
-
-+ (OIDAuthState *)unarchiveKeychainResult:(NSData *)result {
-  return (OIDAuthState *)[NSKeyedUnarchiver unarchiveObjectWithData:result];
-}
-
-+ (NSData *)archiveDataForKeychain:(OIDAuthState *)data {
-  return [NSKeyedArchiver archivedDataWithRootObject:data];
-}
-
-@end

+ 1 - 1
FirebaseAppDistribution/Sources/Private/FIRAppDistributionMachO+Private.h → FirebaseAppDistribution/Sources/FIRAppDistributionMachO.h

@@ -15,7 +15,7 @@
  */
 
 #import <Foundation/Foundation.h>
-#import "FIRAppDistributionMachOSlice+Private.h"
+#import "FirebaseAppDistribution/Sources/FIRAppDistributionMachOSlice.h"
 
 NS_ASSUME_NONNULL_BEGIN
 

+ 3 - 3
FirebaseAppDistribution/Sources/FIRAppDistributionMachO.m

@@ -14,16 +14,16 @@
  * limitations under the License.
  */
 
+#import "FirebaseAppDistribution/Sources/FIRAppDistributionMachO.h"
 #import <CommonCrypto/CommonHMAC.h>
 #include <mach-o/arch.h>
 #import <mach-o/fat.h>
 #import <mach-o/loader.h>
-#import "FIRAppDistributionMachO+Private.h"
-#import "FIRAppDistributionMachOSlice+Private.h"
+#import "FirebaseAppDistribution/Sources/FIRAppDistributionMachOSlice.h"
 
 @interface FIRAppDistributionMachO ()
 @property(nonatomic, copy) NSFileHandle* file;
-@property(nonatomic, copy) NSMutableArray* slices;
+@property(nonatomic, strong) NSMutableArray* slices;
 @end
 
 @implementation FIRAppDistributionMachO

+ 0 - 0
FirebaseAppDistribution/Sources/Private/FIRAppDistributionMachOSlice+Private.h → FirebaseAppDistribution/Sources/FIRAppDistributionMachOSlice.h


+ 1 - 1
FirebaseAppDistribution/Sources/FIRAppDistributionMachOSlice.m

@@ -14,9 +14,9 @@
  * limitations under the License.
  */
 
+#import "FirebaseAppDistribution/Sources/FIRAppDistributionMachOSlice.h"
 #import <mach-o/fat.h>
 #import <mach-o/loader.h>
-#import "FIRAppDistributionMachOSlice+Private.h"
 
 NS_ASSUME_NONNULL_BEGIN
 

+ 1 - 1
FirebaseAppDistribution/Sources/FIRAppDistributionRelease.m

@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-#import "FIRAppDistributionRelease.h"
+#import "FirebaseAppDistribution/Sources/Private/FIRAppDistributionRelease.h"
 
 @interface FIRAppDistributionRelease ()
 @property(nonatomic, copy) NSString *displayVersion;

+ 62 - 0
FirebaseAppDistribution/Sources/FIRAppDistributionUIService.h

@@ -0,0 +1,62 @@
+// Copyright 2020 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 <AuthenticationServices/AuthenticationServices.h>
+#import <Foundation/Foundation.h>
+#import <SafariServices/SafariServices.h>
+#import <UIKit/UIKit.h>
+
+#import "FirebaseAppDistribution/Sources/Private/FIRAppDistribution.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ *  The completion handler invoked when a button is clicked from the UI prompt indicating if a user
+ * clicked continue YES or cancelled NO
+ */
+typedef void (^FIRFADUIActionCompletion)(BOOL continued)
+    NS_SWIFT_NAME(AppDistributionActionCompletion);
+
+/// An instance of this class provides UI elements required for the App Distribution tester
+/// authentication flow as an AppDelegate interceptor.
+@interface FIRAppDistributionUIService : NSObject <UIApplicationDelegate,
+                                                   ASWebAuthenticationPresentationContextProviding,
+                                                   SFSafariViewControllerDelegate>
+
+/// Returns the FIRAppDistributionAppDelegatorInterceptor singleton.
+/// Always register just this singleton as the app delegate interceptor. This instance is
+/// retained. The App Delegate Swizzler only retains weak references and so this is needed.
++ (instancetype)sharedInstance;
+
+typedef void (^AppDistributionRegistrationFlowCompletion)(NSError *_Nullable error);
+
+@property(nullable, nonatomic) UIViewController *safariHostingViewController;
+
+@property(nullable, nonatomic) UIWindow *window;
+
+@property(nullable, nonatomic) AppDistributionRegistrationFlowCompletion registrationFlowCompletion;
+
+- (void)appDistributionRegistrationFlow:(NSURL *)URL
+                         withCompletion:(AppDistributionRegistrationFlowCompletion)completion;
+
+- (void)showUIAlert:(UIAlertController *)alertController;
+
+- (void)showUIAlertWithCompletion:(FIRFADUIActionCompletion)completion;
+
+- (void)initializeUIState;
+
+- (void)resetUIState;
+@end
+
+NS_ASSUME_NONNULL_END

+ 259 - 0
FirebaseAppDistribution/Sources/FIRAppDistributionUIService.m

@@ -0,0 +1,259 @@
+// Copyright 2019 Google
+//
+// 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 "FirebaseAppDistribution/Sources/FIRAppDistributionUIService.h"
+#import "FirebaseAppDistribution/Sources/FIRFADLogger.h"
+#import "FirebaseAppDistribution/Sources/Public/FIRAppDistribution.h"
+#import "FirebaseCore/Sources/Private/FirebaseCoreInternal.h"
+
+#import <AuthenticationServices/AuthenticationServices.h>
+#import <SafariServices/SafariServices.h>
+#import <UIKit/UIKit.h>
+
+@implementation FIRAppDistributionUIService
+
+API_AVAILABLE(ios(9.0))
+SFSafariViewController *_safariVC;
+
+API_AVAILABLE(ios(12.0))
+ASWebAuthenticationSession *_webAuthenticationVC;
+
+API_AVAILABLE(ios(11.0))
+SFAuthenticationSession *_safariAuthenticationVC;
+
+- (instancetype)init {
+  self = [super init];
+
+  self.safariHostingViewController = [[UIViewController alloc] init];
+
+  return self;
+}
+
++ (instancetype)sharedInstance {
+  static dispatch_once_t once;
+  static FIRAppDistributionUIService *sharedInstance;
+  dispatch_once(&once, ^{
+    sharedInstance = [[FIRAppDistributionUIService alloc] init];
+  });
+
+  return sharedInstance;
+}
+
++ (NSString *)encodedAppId {
+  return [[[FIRApp defaultApp] options].googleAppID stringByReplacingOccurrencesOfString:@":"
+                                                                              withString:@"-"];
+}
+
++ (NSError *)getAppDistributionError:(FIRAppDistributionError)appDistributionErrorCode {
+  NSString *message = appDistributionErrorCode == FIRAppDistributionErrorAuthenticationCancelled
+                          ? @"User cancelled sign-in flow"
+                          : @"Failed to authenticate the user";
+  NSDictionary *userInfo = @{FIRAppDistributionErrorDetailsKey : message};
+  return [NSError errorWithDomain:FIRAppDistributionErrorDomain
+                             code:appDistributionErrorCode
+                         userInfo:userInfo];
+}
+
++ (NSError *_Nullable)mapErrorToAppDistributionError:(NSError *_Nullable)error {
+  if (!error) {
+    return nil;
+  }
+
+  if (@available(iOS 12.0, *)) {
+    if ([error code] == ASWebAuthenticationSessionErrorCodeCanceledLogin) {
+      return [self getAppDistributionError:FIRAppDistributionErrorAuthenticationCancelled];
+    }
+  } else if (@available(iOS 11.0, *)) {
+    if ([error code] == SFAuthenticationErrorCanceledLogin) {
+      return [self getAppDistributionError:FIRAppDistributionErrorAuthenticationCancelled];
+    }
+  }
+
+  return [self getAppDistributionError:FIRAppDistributionErrorAuthenticationFailure];
+}
+
+- (void)appDistributionRegistrationFlow:(NSURL *)URL
+                         withCompletion:(void (^)(NSError *_Nullable error))completion {
+  NSString *callbackURL =
+      [NSString stringWithFormat:@"appdistribution-%@", [[self class] encodedAppId]];
+
+  FIRFADInfoLog(@"Registration URL: %@", URL);
+  FIRFADInfoLog(@"Callback URL: %@", callbackURL);
+
+  if (@available(iOS 12.0, *)) {
+    ASWebAuthenticationSession *authenticationVC = [[ASWebAuthenticationSession alloc]
+              initWithURL:URL
+        callbackURLScheme:callbackURL
+        completionHandler:^(NSURL *_Nullable callbackURL, NSError *_Nullable error) {
+          [self resetUIState];
+          [self logRegistrationCompletion:error authType:[ASWebAuthenticationSession description]];
+          NSError *_Nullable appDistributionError =
+              [[self class] mapErrorToAppDistributionError:error];
+          completion(appDistributionError);
+        }];
+
+    if (@available(iOS 13.0, *)) {
+      authenticationVC.presentationContextProvider = self;
+    }
+
+    _webAuthenticationVC = authenticationVC;
+
+    [authenticationVC start];
+  } else if (@available(iOS 11.0, *)) {
+    _safariAuthenticationVC = [[SFAuthenticationSession alloc]
+              initWithURL:URL
+        callbackURLScheme:callbackURL
+        completionHandler:^(NSURL *_Nullable callbackURL, NSError *_Nullable error) {
+          [self resetUIState];
+          [self logRegistrationCompletion:error authType:[SFAuthenticationSession description]];
+          NSError *_Nullable appDistributionError =
+              [[self class] mapErrorToAppDistributionError:error];
+          completion(appDistributionError);
+        }];
+
+    [_safariAuthenticationVC start];
+  } else if (@available(iOS 9.0, *)) {
+    SFSafariViewController *safariVC = [[SFSafariViewController alloc] initWithURL:URL];
+
+    safariVC.delegate = self;
+    _safariVC = safariVC;
+    [self->_safariHostingViewController presentViewController:safariVC animated:YES completion:nil];
+    self.registrationFlowCompletion = completion;
+  }
+}
+
+- (void)showUIAlert:(UIAlertController *)alertController {
+  [self initializeUIState];
+  [self.window.rootViewController presentViewController:alertController
+                                               animated:YES
+                                             completion:nil];
+}
+
+- (void)showUIAlertWithCompletion:(FIRFADUIActionCompletion)completion {
+  UIAlertController *alert = [UIAlertController
+      alertControllerWithTitle:@"Enable in-app alerts"
+                       message:@"Sign in with your Firebase App Distribution Google account to "
+                               @"turn on in-app alerts for new test releases."
+                preferredStyle:UIAlertControllerStyleAlert];
+
+  UIAlertAction *yesButton = [UIAlertAction actionWithTitle:@"Turn on"
+                                                      style:UIAlertActionStyleDefault
+                                                    handler:^(UIAlertAction *action) {
+                                                      completion(YES);
+                                                    }];
+
+  UIAlertAction *noButton = [UIAlertAction actionWithTitle:@"Not now"
+                                                     style:UIAlertActionStyleDefault
+                                                   handler:^(UIAlertAction *action) {
+                                                     [self resetUIState];
+                                                     completion(NO);
+                                                   }];
+
+  [alert addAction:noButton];
+  [alert addAction:yesButton];
+
+  // Create an empty window + viewController to host the Safari UI.
+  [self showUIAlert:alert];
+}
+
+- (BOOL)application:(UIApplication *)application
+            openURL:(NSURL *)URL
+            options:(NSDictionary<NSString *, id> *)options {
+  if (self.registrationFlowCompletion) {
+    FIRFADDebugLog(@"Continuing registration flow: %@", [self registrationFlowCompletion]);
+    [self resetUIState];
+    if (@available(iOS 9.0, *)) {
+      [self logRegistrationCompletion:nil authType:[SFSafariViewController description]];
+    }
+    self.registrationFlowCompletion(nil);
+  }
+  return NO;
+}
+
+- (void)logRegistrationCompletion:(NSError *)error authType:(NSString *)authType {
+  if (error) {
+    FIRFADErrorLog(@"Failed to complete App Distribution registration flow. Auth type - %@, Error "
+                   @"- %@: %ld. Details - %@",
+                   authType, [error domain], (long)[error code], [error localizedDescription]);
+  } else {
+    FIRFADInfoLog(@"App Distribution Registration complete. Auth type - %@", authType);
+  }
+}
+
+- (void)initializeUIState {
+  if (self.window) {
+    return;
+  }
+
+  if (@available(iOS 13.0, *)) {
+    UIWindowScene *foregroundedScene = nil;
+    for (UIWindowScene *connectedScene in [UIApplication sharedApplication].connectedScenes) {
+      if (connectedScene.activationState == UISceneActivationStateForegroundActive) {
+        foregroundedScene = connectedScene;
+        break;
+      }
+    }
+
+    if (foregroundedScene) {
+      self.window = [[UIWindow alloc] initWithWindowScene:foregroundedScene];
+    } else {
+      FIRFADErrorLog(@"No foreground scene found. Cannot display new build alert.");
+      return;
+    }
+  } else {
+    self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
+  }
+  self.window.rootViewController = self.safariHostingViewController;
+
+  // Place it at the highest level within the stack.
+  self.window.windowLevel = +CGFLOAT_MAX;
+
+  // Run it.
+  [self.window makeKeyAndVisible];
+}
+
+- (void)resetUIState {
+  if (self.window) {
+    self.window.hidden = YES;
+    self.window = nil;
+  }
+
+  self.registrationFlowCompletion = nil;
+
+  if (@available(iOS 11.0, *)) {
+    _safariAuthenticationVC = nil;
+  } else if (@available(iOS 12.0, *)) {
+    _webAuthenticationVC = nil;
+  } else if (@available(iOS 9.0, *)) {
+    _safariVC = nil;
+  }
+}
+
+- (void)safariViewControllerDidFinish:(SFSafariViewController *)controller NS_AVAILABLE_IOS(9.0) {
+  NSError *error =
+      [[self class] getAppDistributionError:FIRAppDistributionErrorAuthenticationCancelled];
+  [self logRegistrationCompletion:error authType:[SFSafariViewController description]];
+
+  if (self.registrationFlowCompletion) {
+    self.registrationFlowCompletion(error);
+  }
+  [self resetUIState];
+}
+
+- (ASPresentationAnchor)presentationAnchorForWebAuthenticationSession:
+    (ASWebAuthenticationSession *)session API_AVAILABLE(ios(13.0)) {
+  return self.safariHostingViewController.view.window;
+}
+
+@end

+ 94 - 0
FirebaseAppDistribution/Sources/FIRFADApiService.h

@@ -0,0 +1,94 @@
+// Copyright 2020 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 <Foundation/Foundation.h>
+
+#import "FirebaseAppDistribution/Sources/Private/FIRAppDistributionRelease.h"
+#import "FirebaseInstallations/Source/Library/Private/FirebaseInstallationsInternal.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ *  @related FIRFADApiError
+ *
+ *  The completion handler invoked when the list releases request returns.
+ *  If the call fails we return the appropriate `error code`, described by
+ *  `AppDistributionApiError`.
+ *
+ *  @param releases  The releases that are available to be installed.
+ *  @param error     The error describing why the new build request failed.
+ */
+typedef void (^FIRFADFetchReleasesCompletion)(NSArray *_Nullable releases, NSError *_Nullable error)
+    NS_SWIFT_NAME(AppDistributionFetchReleasesCompletion);
+
+/**
+ *  @related FIRFADApiError
+ *
+ *  The completion handler invoked when the list releases request returns.
+ *  If the call fails we return the appropriate `error code`, described by
+ *  `AppDistributionApiError`.
+ *
+ *  @param identifier  The firebase installation identifier
+ *  @param authTokenResult The installation auth token result.
+ *  @param error     The error describing why the new build request failed.
+ */
+typedef void (^FIRFADGenerateAuthTokenCompletion)(
+    NSString *_Nullable identifier,
+    FIRInstallationsAuthTokenResult *_Nullable authTokenResult,
+    NSError *_Nullable error) NS_SWIFT_NAME(AppDistributionGenerateAuthTokenCompletion);
+
+// Label exceptions from AppDistributionApi calls.
+FOUNDATION_EXPORT NSString *const kFIRFADApiErrorDomain;
+
+// A service encapsulating calls to the App Distribtuion Tester API
+@interface FIRFADApiService : NSObject
+
+// Fetch releases from the AppDistribution Tester API
++ (void)fetchReleasesWithCompletion:(FIRFADFetchReleasesCompletion)completion;
+
+// Generate a Installation Auth Token and fetch the installation id
++ (void)generateAuthTokenWithCompletion:(FIRFADGenerateAuthTokenCompletion)completion;
+
+@end
+
+/**
+ *  @enum AppDistributionApiError
+ */
+typedef NS_ENUM(NSUInteger, FIRFADApiError) {
+  // Timeout error.
+  FIRFADApiErrorTimeout = 0,
+
+  // Token generation error
+  FIRFADApiTokenGenerationFailure = 1,
+
+  // Installation Identifier not found error
+  FIRFADApiInstallationIdentifierError = 2,
+
+  // Authentication failed
+  FIRFADApiErrorUnauthenticated = 3,
+
+  // Authorization failed
+  FIRFADApiErrorUnauthorized = 4,
+
+  // Releases or tester not found
+  FIRFADApiErrorNotFound = 5,
+
+  // Api request failure for unknown reason
+  FIRApiErrorUnknownFailure = 6,
+
+  // Failure to parse Api response
+  FIRApiErrorParseFailure = 7,
+
+} NS_SWIFT_NAME(AppDistributionApiError);
+
+NS_ASSUME_NONNULL_END

+ 201 - 0
FirebaseAppDistribution/Sources/FIRFADApiService.m

@@ -0,0 +1,201 @@
+// Copyright 2020 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 "FirebaseAppDistribution/Sources/FIRFADApiService.h"
+#import <Foundation/Foundation.h>
+#import "FirebaseAppDistribution/Sources/FIRFADLogger.h"
+#import "FirebaseCore/Sources/Private/FirebaseCoreInternal.h"
+#import "FirebaseInstallations/Source/Library/Private/FirebaseInstallationsInternal.h"
+
+NSString *const kFIRFADApiErrorDomain = @"com.firebase.appdistribution.api";
+NSString *const kFIRFADApiErrorDetailsKey = @"details";
+NSString *const kHTTPGet = @"GET";
+// The App Distribution Tester API endpoint used to retrieve releases
+NSString *const kReleasesEndpointURLTemplate =
+    @"https://firebaseapptesters.googleapis.com/v1alpha/devices/"
+    @"-/testerApps/%@/installations/%@/releases";
+NSString *const kInstallationAuthHeader = @"X-Goog-Firebase-Installations-Auth";
+NSString *const kApiHeaderKey = @"X-Goog-Api-Key";
+NSString *const kResponseReleasesKey = @"releases";
+
+@implementation FIRFADApiService
+
++ (void)generateAuthTokenWithCompletion:(FIRFADGenerateAuthTokenCompletion)completion {
+  FIRInstallations *installations = [FIRInstallations installations];
+
+  // Get a FIS Authentication Token.
+  [installations authTokenWithCompletion:^(
+                     FIRInstallationsAuthTokenResult *_Nullable authTokenResult,
+                     NSError *_Nullable error) {
+    if ([self handleError:&error
+              description:@"Failed to generate Firebase Installation Auth Token."
+                     code:FIRFADApiTokenGenerationFailure]) {
+      FIRFADErrorLog(@"Error getting fresh auth tokens. Error: %@", [error localizedDescription]);
+
+      completion(nil, nil, error);
+      return;
+    }
+
+    [installations installationIDWithCompletion:^(NSString *__nullable identifier,
+                                                  NSError *__nullable error) {
+      if ([self handleError:&error
+                description:@"Failed to fetch Firebase Installation ID."
+                       code:FIRFADApiInstallationIdentifierError]) {
+        FIRFADErrorLog(@"Error getting installation id. Error: %@", [error localizedDescription]);
+
+        completion(nil, nil, error);
+
+        return;
+      }
+
+      completion(identifier, authTokenResult, nil);
+    }];
+  }];
+}
+
++ (NSMutableURLRequest *)createHTTPRequest:(NSString *)method
+                                   withUrl:(NSString *)urlString
+                             withAuthToken:(FIRInstallationsAuthTokenResult *)authTokenResult {
+  NSMutableURLRequest *request = [[NSMutableURLRequest alloc] init];
+
+  FIRFADInfoLog(@"Requesting releases for app id - %@", [[FIRApp defaultApp] options].googleAppID);
+  [request setURL:[NSURL URLWithString:urlString]];
+  [request setHTTPMethod:method];
+  [request setValue:authTokenResult.authToken forHTTPHeaderField:kInstallationAuthHeader];
+  [request setValue:[[FIRApp defaultApp] options].APIKey forHTTPHeaderField:kApiHeaderKey];
+  return request;
+}
+
++ (NSArray *)handleReleaseResponse:(NSData *)data
+                          response:(NSURLResponse *)response
+                             error:(NSError **_Nullable)error {
+  NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
+  FIRFADInfoLog(@"HTTPResonse status code %ld response %@", (long)[httpResponse statusCode],
+                httpResponse);
+
+  if ([self handleHttpResponseError:httpResponse error:error]) {
+    FIRFADErrorLog(@"App Tester API service error - %@", [*error localizedDescription]);
+    return nil;
+  }
+
+  return [self parseApiResponseWithData:data error:error];
+}
+
++ (void)fetchReleasesWithCompletion:(FIRFADFetchReleasesCompletion)completion {
+  void (^executeFetch)(NSString *_Nullable, FIRInstallationsAuthTokenResult *, NSError *_Nullable) =
+      ^(NSString *_Nullable identifier, FIRInstallationsAuthTokenResult *authTokenResult,
+        NSError *_Nullable error) {
+        NSString *urlString =
+            [NSString stringWithFormat:kReleasesEndpointURLTemplate,
+                                       [[FIRApp defaultApp] options].googleAppID, identifier];
+        NSMutableURLRequest *request = [self createHTTPRequest:@"GET"
+                                                       withUrl:urlString
+                                                 withAuthToken:authTokenResult];
+
+        FIRFADInfoLog(@"Url : %@, Auth token: %@ API KEY: %@", urlString, authTokenResult.authToken,
+                      [[FIRApp defaultApp] options].APIKey);
+
+        NSURLSessionDataTask *listReleasesDataTask = [[NSURLSession sharedSession]
+            dataTaskWithRequest:request
+              completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
+                NSArray *releases = [self handleReleaseResponse:data
+                                                       response:response
+                                                          error:&error];
+                dispatch_async(dispatch_get_main_queue(), ^{
+                  completion(releases, error);
+                });
+              }];
+
+        [listReleasesDataTask resume];
+      };
+
+  [self generateAuthTokenWithCompletion:executeFetch];
+}
+
++ (BOOL)handleHttpResponseError:(NSHTTPURLResponse *)httpResponse error:(NSError **_Nullable)error {
+  if (*error || !httpResponse) {
+    return [self handleError:error
+                 description:@"Unknown http error occurred"
+                        code:FIRApiErrorUnknownFailure];
+    ;
+  }
+
+  if ([httpResponse statusCode] != 200) {
+    *error = [self createErrorFromStatusCode:[httpResponse statusCode]];
+    return YES;
+  }
+
+  return NO;
+}
+
++ (NSError *)createErrorFromStatusCode:(NSInteger)statusCode {
+  if (statusCode == 401) {
+    return [self createErrorWithDescription:@"Tester not authenticated."
+                                       code:FIRFADApiErrorUnauthenticated];
+  }
+
+  if (statusCode == 403 || statusCode == 400) {
+    return [self createErrorWithDescription:@"Tester not authorized."
+                                       code:FIRFADApiErrorUnauthorized];
+  }
+
+  if (statusCode == 404) {
+    return [self createErrorWithDescription:@"Tester or releases not found"
+                                       code:FIRFADApiErrorUnauthorized];
+  }
+
+  if (statusCode == 408 || statusCode == 504) {
+    return [self createErrorWithDescription:@"Request timeout." code:FIRFADApiErrorTimeout];
+  }
+
+  FIRFADErrorLog(@"Encountered unmapped status code: %ld", (long)statusCode);
+  NSString *description = [NSString stringWithFormat:@"Unknown status code: %ld", (long)statusCode];
+  return [self createErrorWithDescription:description code:FIRApiErrorUnknownFailure];
+}
+
++ (BOOL)handleError:(NSError **_Nullable)error
+        description:(NSString *)description
+               code:(FIRFADApiError)code {
+  if (*error) {
+    *error = [self createErrorWithDescription:description code:code];
+    return YES;
+  }
+
+  return NO;
+}
+
++ (NSError *)createErrorWithDescription:description code:(FIRFADApiError)code {
+  NSDictionary *userInfo = @{NSLocalizedDescriptionKey : description};
+  return [NSError errorWithDomain:kFIRFADApiErrorDomain code:code userInfo:userInfo];
+}
+
++ (NSArray *_Nullable)parseApiResponseWithData:(NSData *)data error:(NSError **_Nullable)error {
+  NSDictionary *serializedResponse = [NSJSONSerialization JSONObjectWithData:data
+                                                                     options:0
+                                                                       error:error];
+  if (*error) {
+    FIRFADErrorLog(@"Tester API - Error deserializing json response");
+    NSString *description = (*error).userInfo[NSLocalizedDescriptionKey]
+                                ? (*error).userInfo[NSLocalizedDescriptionKey]
+                                : @"Failed to parse response";
+    [self handleError:error description:description code:FIRApiErrorParseFailure];
+
+    return nil;
+  }
+
+  NSArray *releases = [serializedResponse objectForKey:kResponseReleasesKey];
+
+  return releases;
+}
+
+@end

+ 25 - 0
FirebaseAppDistribution/Sources/FIRFADLogger.h

@@ -0,0 +1,25 @@
+
+// Copyright 2019 Google
+//
+// 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 <Foundation/Foundation.h>
+
+__BEGIN_DECLS
+
+void FIRFADDebugLog(NSString *message, ...);
+void FIRFADInfoLog(NSString *message, ...);
+void FIRFADWarningLog(NSString *message, ...);
+void FIRFADErrorLog(NSString *message, ...);
+
+__END_DECLS

+ 52 - 0
FirebaseAppDistribution/Sources/FIRFADLogger.m

@@ -0,0 +1,52 @@
+// Copyright 2019 Google
+//
+// 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 "FirebaseAppDistribution/Sources/FIRFADLogger.h"
+#import "FirebaseCore/Sources/Private/FIRLogger.h"
+
+FIRLoggerService kFIRLoggerAppDistribution = @"[Firebase/AppDistribution]";
+
+NSString *const AppDistributionMessageCode = @"I-FAD000000";
+
+void FIRFADDebugLog(NSString *message, ...) {
+  va_list args_ptr;
+  va_start(args_ptr, message);
+  FIRLogBasic(FIRLoggerLevelDebug, kFIRLoggerAppDistribution, AppDistributionMessageCode, message,
+              args_ptr);
+  va_end(args_ptr);
+}
+
+void FIRFADInfoLog(NSString *message, ...) {
+  va_list args_ptr;
+  va_start(args_ptr, message);
+  FIRLogBasic(FIRLoggerLevelInfo, kFIRLoggerAppDistribution, AppDistributionMessageCode, message,
+              args_ptr);
+  va_end(args_ptr);
+}
+
+void FIRFADWarningLog(NSString *message, ...) {
+  va_list args_ptr;
+  va_start(args_ptr, message);
+  FIRLogBasic(FIRLoggerLevelWarning, kFIRLoggerAppDistribution, AppDistributionMessageCode, message,
+              args_ptr);
+  va_end(args_ptr);
+}
+
+void FIRFADErrorLog(NSString *message, ...) {
+  va_list args_ptr;
+  va_start(args_ptr, message);
+  FIRLogBasic(FIRLoggerLevelError, kFIRLoggerAppDistribution, AppDistributionMessageCode, message,
+              args_ptr);
+  va_end(args_ptr);
+}

+ 2 - 14
FirebaseAppDistribution/Sources/Private/FIRAppDistribution+Private.h → FirebaseAppDistribution/Sources/Private/FIRAppDistribution.h

@@ -12,8 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-#import <AppAuth/AppAuth.h>
-#import "FIRAppDistribution.h"
+#import "FirebaseAppDistribution/Sources/FIRAppDistributionUIService.h"
+#import "FirebaseAppDistribution/Sources/Public/FIRAppDistribution.h"
 
 #define STR(x) STR_EXPAND(x)
 #define STR_EXPAND(x) #x
@@ -21,18 +21,6 @@
 NS_ASSUME_NONNULL_BEGIN
 
 @interface FIRAppDistribution ()
-/**
- * Current view controller presenting the `SFSafariViewController` if any.
- */
-@property(nullable, nonatomic) UIViewController *safariHostingViewController;
-
-/**
- * Current auth state for app distribution tester
- */
-@property(nullable, nonatomic) OIDAuthState *authState;
-
-@property(nullable, nonatomic) UIWindow *window;
-
 @end
 
 NS_ASSUME_NONNULL_END

+ 0 - 41
FirebaseAppDistribution/Sources/Private/FIRAppDistributionAppDelegateInterceptor.h

@@ -1,41 +0,0 @@
-// Copyright 2020 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 <Foundation/Foundation.h>
-#import <UIKit/UIKit.h>
-
-NS_ASSUME_NONNULL_BEGIN
-
-@protocol OIDExternalUserAgentSession;
-
-/// An instance of this class is meant to be registered as an AppDelegate interceptor, and
-/// implements the logic that my SDK needs to perform when certain app delegate methods are invoked.
-@interface FIRAppDistributionAppDelegatorInterceptor : NSObject <UIApplicationDelegate>
-
-/// Returns the MYAppDelegateInterceptor singleton.
-/// Always register just this singleton as the app delegate interceptor. This instance is
-/// retained. The App Delegate Swizzler only retains weak references and so this is needed.
-+ (instancetype)sharedInstance;
-
-/*! @brief The authorization flow session which receives the return URL from
-   \SFSafariViewController.
-    @discussion We need to store this in the app delegate as it's that delegate which receives the
-        incoming URL on UIApplicationDelegate.application:openURL:options:. This property will be
-        nil, except when an authorization flow is in progress.
- */
-@property(nonatomic, strong, nullable) id<OIDExternalUserAgentSession> currentAuthorizationFlow;
-
-@end
-
-NS_ASSUME_NONNULL_END

+ 0 - 55
FirebaseAppDistribution/Sources/Private/FIRAppDistributionAuthPersistence+Private.h

@@ -1,55 +0,0 @@
-// Copyright 2020 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 <AppAuth/AppAuth.h>
-#import "FIRAppDistributionKeychainUtility+Private.h"
-
-NS_ASSUME_NONNULL_BEGIN
-
-// Label exceptions from AppDistributionAuthPersistence calls.
-FOUNDATION_EXPORT NSString *const kFIRAppDistributionAuthPersistenceErrorDomain;
-
-/**
- *  The set of error codes that may be returned from internal calls to persist Tester authentication
- *  to the keychain. These should never be returned to the user.
- *  @enum FIRAppDistributionKeychainError
- */
-typedef NS_ENUM(NSUInteger, FIRAppDistributionKeychainError) {
-  // Authentication token persistence error
-  FIRAppDistributionErrorTokenPersistenceFailure = 0,
-
-  // Authentication token retrieval error
-  FIRAppDistributionErrorTokenRetrievalFailure = 1,
-
-  // Authentication token deletion error
-  FIRAppDistributionErrorTokenDeletionFailure = 2,
-} NS_SWIFT_NAME(AppDistributionKeychainError);
-
-@interface FIRAppDistributionAuthPersistence : NSObject
-
-- (instancetype)init NS_UNAVAILABLE;
-
-// Handle null checking, creation, and formatting of an error encountered
-+ (void)handleAuthStateError:(NSError **_Nullable)error
-                 description:(NSString *)description
-                        code:(FIRAppDistributionKeychainError)code;
-
-+ (BOOL)persistAuthState:(OIDAuthState *)authState error:(NSError **_Nullable)error;
-
-+ (BOOL)clearAuthState:(NSError **_Nullable)error;
-
-+ (OIDAuthState *)retrieveAuthState:(NSError **_Nullable)error;
-
-@end
-
-NS_ASSUME_NONNULL_END

+ 0 - 41
FirebaseAppDistribution/Sources/Private/FIRAppDistributionKeychainUtility+Private.h

@@ -1,41 +0,0 @@
-// Copyright 2020 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 <AppAuth/AppAuth.h>
-
-NS_ASSUME_NONNULL_BEGIN
-
-/// @brief Wraps keychain operations to encapsulate interactions with CF data structures
-@interface FIRAppDistributionKeychainUtility : NSObject
-
-/// @brief Store an item in the keychain
-+ (BOOL)addKeychainItem:(NSMutableDictionary *)keychainQuery withDataDictionary:(NSData *)data;
-
-/// @brief Update an item in the keychain
-+ (BOOL)updateKeychainItem:(NSMutableDictionary *)keychainQuery withDataDictionary:(NSData *)data;
-
-/// @brief Delete an item in the keychain
-+ (BOOL)deleteKeychainItem:(NSMutableDictionary *)keychainQuery;
-
-/// @brief Fetch the item matching the keychain query from the keychain
-+ (NSData *)fetchKeychainItemMatching:(nonnull NSMutableDictionary *)keychainQuery
-                                error:(NSError **_Nullable)error;
-
-/// @brief Unarchive the authentication state from the keychain result
-+ (OIDAuthState *)unarchiveKeychainResult:(NSData *)result;
-
-/// @brief Archive the authentication data for persistence to the keychain
-+ (NSData *)archiveDataForKeychain:(OIDAuthState *)data;
-@end
-
-NS_ASSUME_NONNULL_END

+ 1 - 1
FirebaseAppDistribution/Sources/Private/FIRAppDistributionRelease+Private.h → FirebaseAppDistribution/Sources/Private/FIRAppDistributionRelease.h

@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-#import "FIRAppDistributionRelease.h"
+#import "FirebaseAppDistribution/Sources/Public/FIRAppDistributionRelease.h"
 
 NS_ASSUME_NONNULL_BEGIN
 

+ 9 - 2
FirebaseAppDistribution/Sources/Public/FIRAppDistribution.h

@@ -13,9 +13,7 @@
 // limitations under the License.
 
 @class FIRAppDistributionRelease;
-#import <AppAuth/AppAuth.h>
 #import <Foundation/Foundation.h>
-#import <UIKit/UIKit.h>
 
 NS_ASSUME_NONNULL_BEGIN
 
@@ -67,6 +65,7 @@ NS_SWIFT_NAME(AppDistribution)
  * Sign out App Distribution tester
  */
 - (void)signOutTester;
+
 /**
  * Accesses the singleton App Distribution instance.
  *
@@ -76,6 +75,14 @@ NS_SWIFT_NAME(AppDistribution)
 
 @end
 
+// The error domain for codes in the FIRAppDistributionError enum.
+FOUNDATION_EXPORT NSString *const FIRAppDistributionErrorDomain
+    NS_SWIFT_NAME(AppDistributionErrorDomain);
+
+// The key for finding error details in the NSError userInfo.
+FOUNDATION_EXPORT NSString *const FIRAppDistributionErrorDetailsKey
+    NS_SWIFT_NAME(FunctionsErrorDetailsKey);
+
 /**
  *  @enum AppDistributionError
  */

+ 0 - 137
FirebaseAppDistribution/Tests/Unit/FIRAppDistributionAuthPersistenceTests.m

@@ -1,137 +0,0 @@
-//
-// Copyright 2020 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 <OCMock/OCMock.h>
-#import <XCTest/XCTest.h>
-
-#import <AppAuth/AppAuth.h>
-#import <FirebaseAppDistribution/FIRAppDistributionAuthPersistence+Private.h>
-#import <FirebaseAppDistribution/FIRAppDistributionKeychainUtility+Private.h>
-
-@interface FIRAppDistributionAuthPersistenceTests : XCTestCase
-@end
-
-@implementation FIRAppDistributionAuthPersistenceTests {
-  NSMutableDictionary *_mockKeychainQuery;
-  id _mockAuthorizationData;
-  id _mockOIDAuthState;
-  id _mockKeychainUtility;
-  id _partialMockAuthPersitence;
-}
-
-- (void)setUp {
-  [super setUp];
-  _mockKeychainQuery = [NSMutableDictionary
-      dictionaryWithObjectsAndKeys:(id) @"thing one", (id) @"another thing", nil];
-  _mockKeychainUtility = OCMClassMock([FIRAppDistributionKeychainUtility class]);
-  _mockAuthorizationData = [@"this is some password stuff" dataUsingEncoding:NSUTF8StringEncoding];
-  _mockOIDAuthState = OCMClassMock([OIDAuthState class]);
-  OCMStub(ClassMethod([_mockKeychainUtility unarchiveKeychainResult:[OCMArg any]]))
-      .andReturn(_mockOIDAuthState);
-  OCMStub(ClassMethod([_mockKeychainUtility archiveDataForKeychain:[OCMArg any]]))
-      .andReturn(_mockAuthorizationData);
-}
-
-- (void)tearDown {
-  [super tearDown];
-}
-
-- (void)testPersistAuthStateSuccess {
-  OCMStub(ClassMethod([_mockKeychainUtility addKeychainItem:[OCMArg any]
-                                         withDataDictionary:[OCMArg any]]))
-      .andReturn(YES);
-  NSError *error;
-  XCTAssertTrue([FIRAppDistributionAuthPersistence persistAuthState:_mockOIDAuthState
-                                                              error:&error]);
-  XCTAssertNil(error);
-}
-
-- (void)testPersistAuthStateFailure {
-  OCMStub(ClassMethod([_mockKeychainUtility addKeychainItem:[OCMArg any]
-                                         withDataDictionary:[OCMArg any]]))
-      .andReturn(NO);
-  NSError *error;
-  XCTAssertFalse([FIRAppDistributionAuthPersistence persistAuthState:_mockOIDAuthState
-                                                               error:&error]);
-  XCTAssertNotNil(error);
-  XCTAssertEqual([error domain], kFIRAppDistributionAuthPersistenceErrorDomain);
-  XCTAssertEqual([error code], FIRAppDistributionErrorTokenPersistenceFailure);
-}
-
-- (void)testOverwriteAuthStateSuccess {
-  OCMStub(ClassMethod([_mockKeychainUtility fetchKeychainItemMatching:[OCMArg any]
-                                                                error:[OCMArg setTo:nil]]))
-      .andReturn(_mockAuthorizationData);
-  OCMStub(ClassMethod([_mockKeychainUtility updateKeychainItem:[OCMArg any]
-                                            withDataDictionary:[OCMArg any]]))
-      .andReturn(YES);
-  NSError *error;
-  XCTAssertTrue([FIRAppDistributionAuthPersistence persistAuthState:_mockOIDAuthState
-                                                              error:&error]);
-  XCTAssertNil(error);
-}
-
-- (void)testOverwriteAuthStateFailure {
-  OCMStub(ClassMethod([_mockKeychainUtility fetchKeychainItemMatching:[OCMArg any]
-                                                                error:[OCMArg setTo:nil]]))
-      .andReturn(_mockAuthorizationData);
-  OCMStub(ClassMethod([_mockKeychainUtility updateKeychainItem:[OCMArg any]
-                                            withDataDictionary:[OCMArg any]]))
-      .andReturn(NO);
-  NSError *error;
-  XCTAssertFalse([FIRAppDistributionAuthPersistence persistAuthState:_mockOIDAuthState
-                                                               error:&error]);
-  XCTAssertNotNil(error);
-  XCTAssertEqual([error domain], kFIRAppDistributionAuthPersistenceErrorDomain);
-  XCTAssertEqual([error code], FIRAppDistributionErrorTokenPersistenceFailure);
-}
-
-- (void)testRetrieveAuthStateSuccess {
-  OCMStub(ClassMethod([_mockKeychainUtility fetchKeychainItemMatching:[OCMArg any]
-                                                                error:[OCMArg setTo:nil]]))
-      .andReturn(_mockAuthorizationData);
-  NSError *error;
-  XCTAssertTrue([[FIRAppDistributionAuthPersistence retrieveAuthState:&error]
-      isKindOfClass:[OIDAuthState class]]);
-  XCTAssertNil(error);
-}
-
-- (void)testRetrieveAuthStateFailure {
-  OCMStub(ClassMethod([_mockKeychainUtility fetchKeychainItemMatching:[OCMArg any]
-                                                                error:[OCMArg setTo:nil]]))
-      .andReturn(nil);
-  NSError *error;
-  XCTAssertFalse([FIRAppDistributionAuthPersistence retrieveAuthState:&error]);
-  XCTAssertNotNil(error);
-  XCTAssertEqual([error domain], kFIRAppDistributionAuthPersistenceErrorDomain);
-  XCTAssertEqual([error code], FIRAppDistributionErrorTokenRetrievalFailure);
-}
-
-- (void)testClearAuthStateSuccess {
-  OCMStub(ClassMethod([_mockKeychainUtility deleteKeychainItem:[OCMArg any]])).andReturn(YES);
-  NSError *error;
-  XCTAssertTrue([FIRAppDistributionAuthPersistence clearAuthState:&error]);
-  XCTAssertNil(error);
-}
-
-- (void)testClearAuthStateFailure {
-  OCMStub(ClassMethod([_mockKeychainUtility deleteKeychainItem:[OCMArg any]])).andReturn(NO);
-  NSError *error;
-  XCTAssertFalse([FIRAppDistributionAuthPersistence clearAuthState:&error]);
-  XCTAssertNotNil(error);
-  XCTAssertEqual([error domain], kFIRAppDistributionAuthPersistenceErrorDomain);
-  XCTAssertEqual([error code], FIRAppDistributionErrorTokenDeletionFailure);
-}
-
-@end

+ 1 - 1
FirebaseAppDistribution/Tests/Unit/FIRAppDistributionMachOTests.m

@@ -15,7 +15,7 @@
 #import <Foundation/Foundation.h>
 #import <XCTest/XCTest.h>
 
-#import "FIRAppDistributionMachO+Private.h"
+#import "FirebaseAppDistribution/Sources/FIRAppDistributionMachO.h"
 #import "FirebaseCore/Sources/Private/FirebaseCoreInternal.h"
 
 @interface FIRAppDistributionMachOTests : XCTestCase

+ 496 - 6
FirebaseAppDistribution/Tests/Unit/FIRAppDistributionTests.m

@@ -13,12 +13,19 @@
 // limitations under the License.
 
 #import <Foundation/Foundation.h>
+#import <OCMock/OCMock.h>
 #import <XCTest/XCTest.h>
 
-#import "FirebaseAppDistribution/FIRAppDistribution.h"
+#import "FirebaseAppDistribution/Sources/FIRAppDistributionMachO.h"
+#import "FirebaseAppDistribution/Sources/FIRAppDistributionUIService.h"
+#import "FirebaseAppDistribution/Sources/FIRFADApiService.h"
+#import "FirebaseAppDistribution/Sources/Private/FIRAppDistribution.h"
 #import "FirebaseCore/Sources/Private/FirebaseCoreInternal.h"
+#import "FirebaseInstallations/Source/Library/Private/FirebaseInstallationsInternal.h"
+#import "GoogleUtilities/AppDelegateSwizzler/Private/GULAppDelegateSwizzler.h"
+#import "GoogleUtilities/UserDefaults/Private/GULUserDefaults.h"
 
-@interface FIRAppDistributionSampleTests : XCTestCase
+@interface FIRAppDistributionTests : XCTestCase
 
 @property(nonatomic, strong) FIRAppDistribution *appDistribution;
 
@@ -28,19 +35,502 @@
 
 - (instancetype)initWithApp:(FIRApp *)app appInfo:(NSDictionary *)appInfo;
 
+- (void)fetchNewLatestRelease:(FIRAppDistributionUpdateCheckCompletion)completion;
+
+- (NSError *)mapFetchReleasesError:(NSError *)error;
+
 @end
 
-@implementation FIRAppDistributionSampleTests
+@implementation FIRAppDistributionTests {
+  id _mockFIRAppClass;
+  id _mockFIRFADApiService;
+  id _mockFIRAppDistributionUIService;
+  id _mockFIRInstallations;
+  id _mockInstallationToken;
+  id _mockMachO;
+  NSString *_mockAuthToken;
+  NSString *_mockInstallationId;
+  NSArray *_mockReleases;
+  NSString *_mockCodeHash;
+}
 
 - (void)setUp {
   [super setUp];
+  _mockAuthToken = @"this-is-an-auth-token";
+  _mockCodeHash = @"this-is-a-fake-code-hash";
+  _mockFIRAppClass = OCMClassMock([FIRApp class]);
+  _mockFIRFADApiService = OCMClassMock([FIRFADApiService class]);
+  _mockFIRAppDistributionUIService = OCMPartialMock([FIRAppDistributionUIService sharedInstance]);
+  _mockFIRInstallations = OCMClassMock([FIRInstallations class]);
+  _mockInstallationToken = OCMClassMock([FIRInstallationsAuthTokenResult class]);
+  _mockMachO = OCMClassMock([FIRAppDistributionMachO class]);
+  id mockBundle = OCMClassMock([NSBundle class]);
+  OCMStub([_mockFIRAppClass defaultApp]).andReturn(_mockFIRAppClass);
+  OCMStub([_mockFIRAppDistributionUIService initializeUIState]);
+  OCMStub([_mockFIRInstallations installations]).andReturn(_mockFIRInstallations);
+  OCMStub([_mockInstallationToken authToken]).andReturn(_mockAuthToken);
+  OCMStub([_mockMachO alloc]).andReturn(_mockMachO);
+  OCMStub([_mockMachO initWithPath:OCMOCK_ANY]).andReturn(_mockMachO);
+  OCMStub([mockBundle mainBundle]).andReturn(mockBundle);
+  OCMStub([mockBundle executablePath]).andReturn(@"this-is-a-fake-executablePath");
 
   NSDictionary<NSString *, NSString *> *dict = [[NSDictionary<NSString *, NSString *> alloc] init];
-  self.appDistribution = [[FIRAppDistribution alloc] initWithApp:nil appInfo:dict];
+  self.appDistribution = [[FIRAppDistribution alloc] initWithApp:_mockFIRAppClass appInfo:dict];
+
+  _mockInstallationId = @"this-id-is-fake-ccccc";
+  _mockReleases = @[
+    @{
+      @"codeHash" : @"this-is-another-code-hash",
+      @"displayVersion" : @"1.0.0",
+      @"buildVersion" : @"111",
+      @"releaseNotes" : @"This is a release",
+      @"downloadUrl" : @"http://faketyfakefake.download"
+    },
+    @{
+      @"latest" : @YES,
+      @"codeHash" : _mockCodeHash,
+      @"displayVersion" : @"1.0.1",
+      @"buildVersion" : @"112",
+      @"releaseNotes" : @"This is a release too",
+      @"downloadUrl" : @"http://faketyfakefake.download"
+    }
+  ];
+}
+
+- (void)tearDown {
+  [super tearDown];
+  [[GULUserDefaults standardUserDefaults] removeObjectForKey:@"FIRFADSignInState"];
+  [_mockFIRAppClass stopMocking];
+  [_mockFIRFADApiService stopMocking];
+  [_mockFIRAppDistributionUIService stopMocking];
+  [_mockFIRInstallations stopMocking];
+  [_mockInstallationToken stopMocking];
+  [_mockMachO stopMocking];
+}
+
+- (void)mockInstallationIdCompletion:(NSString *_Nullable)identifier
+                               error:(NSError *_Nullable)error {
+  [OCMStub([_mockFIRInstallations installationIDWithCompletion:OCMOCK_ANY])
+      andDo:^(NSInvocation *invocation) {
+        __unsafe_unretained void (^handler)(NSString *identifier, NSError *_Nullable error);
+        [invocation getArgument:&handler atIndex:2];
+        handler(identifier, error);
+      }];
+}
+
+- (void)verifyInstallationIdCompletion {
+  OCMVerify([_mockFIRInstallations installationIDWithCompletion:OCMOCK_ANY]);
+}
+
+- (void)mockUIServiceRegistrationCompletion:(NSError *_Nullable)error {
+  [OCMStub([_mockFIRAppDistributionUIService appDistributionRegistrationFlow:OCMOCK_ANY
+                                                              withCompletion:OCMOCK_ANY])
+      andDo:^(NSInvocation *invocation) {
+        __unsafe_unretained void (^handler)(NSError *_Nullable error);
+        [invocation getArgument:&handler atIndex:3];
+        handler(error);
+      }];
+}
+
+- (void)verifyRegistrationCompletion {
+  OCMVerify([_mockFIRAppDistributionUIService appDistributionRegistrationFlow:OCMOCK_ANY
+                                                               withCompletion:OCMOCK_ANY]);
+}
+
+- (void)rejectRegistrationCompletion {
+  OCMReject([_mockFIRAppDistributionUIService appDistributionRegistrationFlow:OCMOCK_ANY
+                                                               withCompletion:OCMOCK_ANY]);
+}
+
+- (void)mockUIServiceShowUICompletion:(BOOL)continued {
+  [OCMStub([_mockFIRAppDistributionUIService showUIAlertWithCompletion:OCMOCK_ANY])
+      andDo:^(NSInvocation *invocation) {
+        __unsafe_unretained void (^handler)(BOOL continued);
+        [invocation getArgument:&handler atIndex:2];
+        handler(continued);
+      }];
+}
+
+- (void)verifyShowUICompletion {
+  OCMVerify([_mockFIRAppDistributionUIService showUIAlertWithCompletion:OCMOCK_ANY]);
+}
+
+- (void)rejectShowUICompletion {
+  OCMReject([_mockFIRAppDistributionUIService showUIAlertWithCompletion:OCMOCK_ANY]);
+}
+
+- (void)mockFetchReleasesCompletion:(NSArray *)releases error:(NSError *)error {
+  [OCMStub([_mockFIRFADApiService fetchReleasesWithCompletion:OCMOCK_ANY])
+      andDo:^(NSInvocation *invocation) {
+        __unsafe_unretained void (^handler)(NSArray *releases, NSError *_Nullable error);
+        [invocation getArgument:&handler atIndex:2];
+        handler(releases, error);
+      }];
+}
+
+- (void)verifyFetchReleasesCompletion {
+  OCMVerify([_mockFIRFADApiService fetchReleasesWithCompletion:[OCMArg any]]);
+}
+
+- (void)rejectFetchReleasesCompletion {
+  OCMReject([_mockFIRFADApiService fetchReleasesWithCompletion:[OCMArg any]]);
+}
+
+- (void)testInitWithApp {
+  XCTAssertNotNil([self appDistribution]);
+}
+
+- (void)testSignInWithCompletionPersistSignInStateSuccess {
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUIServiceRegistrationCompletion:nil];
+  [self mockFetchReleasesCompletion:_mockReleases error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Persist sign in state succeeds."];
+
+  [[self appDistribution] signInTesterWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNil(error);
+    [expectation fulfill];
+  }];
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  XCTAssertTrue([[self appDistribution] isTesterSignedIn]);
+  [self verifyInstallationIdCompletion];
+  [self verifyRegistrationCompletion];
+  [self verifyFetchReleasesCompletion];
+}
+
+- (void)testSignInWithCompletionInstallationIDNotFoundFailure {
+  NSError *mockError =
+      [NSError errorWithDomain:@"this.is.fake"
+                          code:3
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  [self mockInstallationIdCompletion:_mockInstallationId error:mockError];
+  [self mockUIServiceRegistrationCompletion:nil];
+  [self mockFetchReleasesCompletion:_mockReleases error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Persist sign in state fails."];
+
+  [[self appDistribution] signInTesterWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNotNil(error);
+    XCTAssertEqual([error code], FIRAppDistributionErrorUnknown);
+    [expectation fulfill];
+  }];
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  XCTAssertFalse([[self appDistribution] isTesterSignedIn]);
+  [self verifyInstallationIdCompletion];
+  [self rejectRegistrationCompletion];
+  [self rejectFetchReleasesCompletion];
+}
+
+- (void)testSignInWithCompletionDelegateFailureDoesNotPersist {
+  NSError *mockError =
+      [NSError errorWithDomain:@"fake.app.delegate.domain"
+                          code:4
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUIServiceRegistrationCompletion:mockError];
+  [self mockFetchReleasesCompletion:_mockReleases error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:
+                @"Persist sign in state fails when the delegate recieves a failure."];
+
+  [[self appDistribution] signInTesterWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNotNil(error);
+    XCTAssertEqual([error code], 4);
+    [expectation fulfill];
+  }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  XCTAssertFalse([[self appDistribution] isTesterSignedIn]);
+  [self verifyInstallationIdCompletion];
+  [self verifyRegistrationCompletion];
+  [self rejectFetchReleasesCompletion];
+}
+
+- (void)testSignInWithCompletionFetchReleasesFailureDoesNotPersist {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:FIRFADApiErrorUnauthenticated
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUIServiceRegistrationCompletion:nil];
+  [self mockFetchReleasesCompletion:_mockReleases error:mockError];
+  XCTestExpectation *expectation = [self
+      expectationWithDescription:@"Persist sign in state fails when we fail to fetch releases."];
+  [[self appDistribution] signInTesterWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNotNil(error);
+    XCTAssertEqual([error code], FIRAppDistributionErrorAuthenticationFailure);
+    XCTAssertEqual([error domain], FIRAppDistributionErrorDomain);
+    [expectation fulfill];
+  }];
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  XCTAssertFalse([[self appDistribution] isTesterSignedIn]);
+  [self verifyInstallationIdCompletion];
+  [self verifyRegistrationCompletion];
+  [self verifyFetchReleasesCompletion];
+}
+
+- (void)testSignOutSuccess {
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUIServiceRegistrationCompletion:nil];
+  [self mockFetchReleasesCompletion:_mockReleases error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Persist sign out state succeeds."];
+
+  [[self appDistribution] signInTesterWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertTrue([[self appDistribution] isTesterSignedIn]);
+    XCTAssertNil(error);
+    [expectation fulfill];
+  }];
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [[self appDistribution] signOutTester];
+  XCTAssertFalse([[self appDistribution] isTesterSignedIn]);
+  [self verifyInstallationIdCompletion];
+  [self verifyRegistrationCompletion];
+  [self verifyFetchReleasesCompletion];
+}
+
+- (void)testFetchNewLatestReleaseSuccess {
+  [self mockFetchReleasesCompletion:_mockReleases error:nil];
+  OCMStub([_mockMachO codeHash]).andReturn(@"this-is-old");
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch latest release succeeds."];
+  [[self appDistribution] fetchNewLatestRelease:^(FIRAppDistributionRelease *_Nullable release,
+                                                  NSError *_Nullable error) {
+    XCTAssertNotNil(release);
+    XCTAssertNil(error);
+    [expectation fulfill];
+  }];
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyFetchReleasesCompletion];
+}
+
+- (void)testFetchNewLatestReleaseNoNewRelease {
+  [self mockFetchReleasesCompletion:_mockReleases error:nil];
+  OCMStub([_mockMachO codeHash]).andReturn(_mockCodeHash);
+
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch latest release with no new release succeeds."];
+
+  [[self appDistribution] fetchNewLatestRelease:^(FIRAppDistributionRelease *_Nullable release,
+                                                  NSError *_Nullable error) {
+    XCTAssertNil(release);
+    XCTAssertNil(error);
+    [expectation fulfill];
+  }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyFetchReleasesCompletion];
+}
+
+- (void)testFetchNewLatestReleaseFailure {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:FIRFADApiErrorTimeout
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  [self mockFetchReleasesCompletion:nil error:mockError];
+  OCMStub([_mockMachO codeHash]).andReturn(@"this-is-old");
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"Fetch latest release fails."];
+
+  [[self appDistribution] fetchNewLatestRelease:^(FIRAppDistributionRelease *_Nullable release,
+                                                  NSError *_Nullable error) {
+    XCTAssertNil(release);
+    XCTAssertNotNil(error);
+    XCTAssertEqual([error code], FIRAppDistributionErrorNetworkFailure);
+    XCTAssertEqual([error domain], FIRAppDistributionErrorDomain);
+    [expectation fulfill];
+  }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyFetchReleasesCompletion];
+  OCMReject([_mockMachO codeHash]);
+}
+
+- (void)testCheckForUpdateWithCompletionTesterSignedIn {
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUIServiceRegistrationCompletion:nil];
+  [self mockFetchReleasesCompletion:_mockReleases error:nil];
+  [self mockUIServiceShowUICompletion:NO];
+
+  // Sign in the tester
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Persist sign in state succeeds."];
+
+  [[self appDistribution] signInTesterWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNil(error);
+    [expectation fulfill];
+  }];
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  XCTAssertTrue([[self appDistribution] isTesterSignedIn]);
+
+  // Should Call check for update without calling the UIService
+  XCTestExpectation *checkForUpdateExpectation =
+      [self expectationWithDescription:@"Check for update does not prompt user"];
+  [[self appDistribution]
+      checkForUpdateWithCompletion:^(FIRAppDistributionRelease *_Nullable release,
+                                     NSError *_Nullable error) {
+        XCTAssertNil(error);
+        XCTAssertNotNil(release);
+        [checkForUpdateExpectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ checkForUpdateExpectation ] timeout:5.0];
+  [self rejectShowUICompletion];
+}
+
+- (void)testCheckForUpdateWithCompletionClicksYesSuccess {
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUIServiceRegistrationCompletion:nil];
+  [self mockFetchReleasesCompletion:_mockReleases error:nil];
+  [self mockUIServiceShowUICompletion:YES];
+  OCMStub([_mockMachO codeHash]).andReturn(@"this-is-old");
+
+  XCTestExpectation *checkForUpdateExpectation =
+      [self expectationWithDescription:@"Check for update does prompt user"];
+  [[self appDistribution]
+      checkForUpdateWithCompletion:^(FIRAppDistributionRelease *_Nullable release,
+                                     NSError *_Nullable error) {
+        XCTAssertNil(error);
+        XCTAssertNotNil(release);
+        [checkForUpdateExpectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ checkForUpdateExpectation ] timeout:5.0];
+  [self verifyShowUICompletion];
+  OCMVerify([_mockMachO codeHash]);
+}
+
+- (void)testCheckForUpdateWithCompletionClicksYesFailure {
+  NSError *mockError =
+      [NSError errorWithDomain:@"this.is.fake"
+                          code:3
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  [self mockInstallationIdCompletion:_mockInstallationId error:mockError];
+  [self mockUIServiceRegistrationCompletion:nil];
+  [self mockFetchReleasesCompletion:_mockReleases error:nil];
+  [self mockUIServiceShowUICompletion:YES];
+
+  XCTestExpectation *checkForUpdateExpectation =
+      [self expectationWithDescription:@"Check for update does prompt user"];
+  [[self appDistribution]
+      checkForUpdateWithCompletion:^(FIRAppDistributionRelease *_Nullable release,
+                                     NSError *_Nullable error) {
+        XCTAssertNotNil(error);
+        XCTAssertNil(release);
+        [checkForUpdateExpectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ checkForUpdateExpectation ] timeout:5.0];
+  [self verifyShowUICompletion];
+}
+
+- (void)testCheckForUpdateWithCompletionClicksNo {
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUIServiceRegistrationCompletion:nil];
+  [self mockFetchReleasesCompletion:_mockReleases error:nil];
+  [self mockUIServiceShowUICompletion:NO];
+
+  XCTestExpectation *checkForUpdateExpectation =
+      [self expectationWithDescription:@"Check for update does prompt user"];
+  [[self appDistribution]
+      checkForUpdateWithCompletion:^(FIRAppDistributionRelease *_Nullable release,
+                                     NSError *_Nullable error) {
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRAppDistributionErrorAuthenticationCancelled);
+        XCTAssertNil(release);
+        [checkForUpdateExpectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ checkForUpdateExpectation ] timeout:5.0];
+  [self verifyShowUICompletion];
+}
+
+- (void)testHandleFetchReleasesErrorTimeout {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:FIRFADApiErrorTimeout
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  NSError *handledError = [[self appDistribution] mapFetchReleasesError:mockError];
+  XCTAssertNotNil(handledError);
+  XCTAssertEqual([handledError code], FIRAppDistributionErrorNetworkFailure);
+  XCTAssertEqual([handledError domain], FIRAppDistributionErrorDomain);
+}
+
+- (void)testHandleFetchReleasesErrorUnauthenticated {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:FIRFADApiErrorUnauthenticated
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  NSError *handledError = [[self appDistribution] mapFetchReleasesError:mockError];
+  XCTAssertNotNil(handledError);
+  XCTAssertEqual([handledError code], FIRAppDistributionErrorAuthenticationFailure);
+  XCTAssertEqual([handledError domain], FIRAppDistributionErrorDomain);
+}
+
+- (void)testHandleFetchReleasesErrorUnauthorized {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:FIRFADApiErrorUnauthorized
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  NSError *handledError = [[self appDistribution] mapFetchReleasesError:mockError];
+  XCTAssertNotNil(handledError);
+  XCTAssertEqual([handledError code], FIRAppDistributionErrorAuthenticationFailure);
+  XCTAssertEqual([handledError domain], FIRAppDistributionErrorDomain);
+}
+
+- (void)testHandleFetchReleasesErrorTokenGenerationFailure {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:FIRFADApiTokenGenerationFailure
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  NSError *handledError = [[self appDistribution] mapFetchReleasesError:mockError];
+  XCTAssertNotNil(handledError);
+  XCTAssertEqual([handledError code], FIRAppDistributionErrorAuthenticationFailure);
+  XCTAssertEqual([handledError domain], FIRAppDistributionErrorDomain);
+}
+
+- (void)testHandleFetchReleasesErrorInstallationIdentifierFailure {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:FIRFADApiInstallationIdentifierError
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  NSError *handledError = [[self appDistribution] mapFetchReleasesError:mockError];
+  XCTAssertNotNil(handledError);
+  XCTAssertEqual([handledError code], FIRAppDistributionErrorAuthenticationFailure);
+  XCTAssertEqual([handledError domain], FIRAppDistributionErrorDomain);
+}
+
+- (void)testHandleFetchReleasesErrorNotFound {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:FIRFADApiErrorNotFound
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  NSError *handledError = [[self appDistribution] mapFetchReleasesError:mockError];
+  XCTAssertNotNil(handledError);
+  XCTAssertEqual([handledError code], FIRAppDistributionErrorAuthenticationFailure);
+  XCTAssertEqual([handledError domain], FIRAppDistributionErrorDomain);
+}
+
+- (void)testHandleFetchReleasesErrorApiDomainErrorUnknown {
+  NSError *mockError =
+      [NSError errorWithDomain:kFIRFADApiErrorDomain
+                          code:209
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  NSError *handledError = [[self appDistribution] mapFetchReleasesError:mockError];
+  XCTAssertNotNil(handledError);
+  XCTAssertEqual([handledError code], FIRAppDistributionErrorUnknown);
+  XCTAssertEqual([handledError domain], FIRAppDistributionErrorDomain);
 }
 
-- (void)testGetSingleton {
-  XCTAssertNotNil(self.appDistribution);
+- (void)testHandleFetchReleasesErrorUnknownDomainError {
+  NSError *mockError =
+      [NSError errorWithDomain:@"this.is.not.an.api.failure"
+                          code:4
+                      userInfo:@{NSLocalizedDescriptionKey : @"This is unfortunate."}];
+  NSError *handledError = [[self appDistribution] mapFetchReleasesError:mockError];
+  XCTAssertNotNil(handledError);
+  XCTAssertEqual([handledError code], FIRAppDistributionErrorUnknown);
+  XCTAssertEqual([handledError domain], FIRAppDistributionErrorDomain);
 }
 
 @end

+ 487 - 0
FirebaseAppDistribution/Tests/Unit/FIRFADApiServiceTests.m

@@ -0,0 +1,487 @@
+//
+// Copyright 2020 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 <Foundation/Foundation.h>
+#import <OCMock/OCMock.h>
+#import <XCTest/XCTest.h>
+
+#import "FirebaseAppDistribution/Sources/FIRFADApiService.h"
+#import "FirebaseCore/Sources/Private/FirebaseCoreInternal.h"
+#import "FirebaseInstallations/Source/Library/Private/FirebaseInstallationsInternal.h"
+
+NSString *const kFakeErrorDomain = @"test.failure.domain";
+
+@interface FIRFADApiServiceTests : XCTestCase
+@end
+
+@implementation FIRFADApiServiceTests {
+  id _mockFIRAppClass;
+  id _mockURLSession;
+  id _mockFIRInstallations;
+  id _mockInstallationToken;
+  NSString *_mockAuthToken;
+  NSString *_mockInstallationId;
+  NSDictionary *_mockReleases;
+}
+
+- (void)setUp {
+  [super setUp];
+  _mockFIRAppClass = OCMClassMock([FIRApp class]);
+  _mockURLSession = OCMClassMock([NSURLSession class]);
+  _mockFIRInstallations = OCMClassMock([FIRInstallations class]);
+  _mockInstallationToken = OCMClassMock([FIRInstallationsAuthTokenResult class]);
+  _mockAuthToken = @"this-is-an-auth-token";
+  OCMStub([_mockFIRAppClass defaultApp]).andReturn(_mockFIRAppClass);
+  OCMStub([_mockURLSession sharedSession]).andReturn(_mockURLSession);
+  OCMStub([_mockFIRInstallations installations]).andReturn(_mockFIRInstallations);
+  OCMStub([_mockInstallationToken authToken]).andReturn(_mockAuthToken);
+
+  _mockInstallationId = @"this-id-is-fake-ccccc";
+  _mockReleases = @{
+    @"releases" : @[
+      @{
+        @"displayVersion" : @"1.0.0",
+        @"buildVersion" : @"111",
+        @"releaseNotes" : @"This is a release",
+        @"downloadURL" : @"http://faketyfakefake.download"
+      },
+      @{
+        @"latest" : @YES,
+        @"displayVersion" : @"1.0.1",
+        @"buildVersion" : @"112",
+        @"releaseNotes" : @"This is a release too",
+        @"downloadURL" : @"http://faketyfakefake.download"
+      }
+    ]
+  };
+}
+
+- (void)tearDown {
+  [super tearDown];
+  [_mockFIRAppClass stopMocking];
+  [_mockFIRInstallations stopMocking];
+  [_mockInstallationToken stopMocking];
+  [_mockURLSession stopMocking];
+}
+
+- (void)mockInstallationAuthCompletion:(FIRInstallationsAuthTokenResult *_Nullable)token
+                                 error:(NSError *_Nullable)error {
+  [OCMStub([_mockFIRInstallations authTokenWithCompletion:OCMOCK_ANY])
+      andDo:^(NSInvocation *invocation) {
+        void (^handler)(FIRInstallationsAuthTokenResult *_Nullable authTokenResult,
+                        NSError *_Nullable error);
+        [invocation getArgument:&handler atIndex:2];
+        handler(token, error);
+      }];
+}
+
+- (void)verifyInstallationAuthCompletion {
+  OCMVerify([_mockFIRInstallations authTokenWithCompletion:[OCMArg isNotNil]]);
+}
+
+- (void)rejectInstallationAuthCompletion {
+  OCMReject([_mockFIRInstallations authTokenWithCompletion:[OCMArg isNotNil]]);
+}
+
+- (void)mockInstallationIdCompletion:(NSString *_Nullable)identifier
+                               error:(NSError *_Nullable)error {
+  [OCMStub([_mockFIRInstallations installationIDWithCompletion:OCMOCK_ANY])
+      andDo:^(NSInvocation *invocation) {
+        void (^handler)(NSString *identifier, NSError *_Nullable error);
+        [invocation getArgument:&handler atIndex:2];
+        handler(identifier, error);
+      }];
+}
+
+- (void)verifyInstallationIdCompletion {
+  OCMVerify([_mockFIRInstallations installationIDWithCompletion:[OCMArg isNotNil]]);
+}
+
+- (void)rejectInstallationIdCompletion {
+  OCMReject([_mockFIRInstallations installationIDWithCompletion:[OCMArg isNotNil]]);
+}
+
+- (void)mockUrlSessionResponse:(NSDictionary *)dictionary
+                      response:(NSURLResponse *)response
+                         error:(NSError *)error {
+  NSData *data = [NSJSONSerialization dataWithJSONObject:dictionary options:0 error:&error];
+  [self mockUrlSessionResponseWithData:data response:response error:error];
+}
+
+- (void)mockUrlSessionResponseWithData:(NSData *)data
+                              response:(NSURLResponse *)response
+                                 error:(NSError *)error {
+  [OCMStub([_mockURLSession dataTaskWithRequest:[OCMArg any]
+                              completionHandler:[OCMArg any]]) andDo:^(NSInvocation *invocation) {
+    void (^handler)(NSData *data, NSURLResponse *response, NSError *error);
+    [invocation getArgument:&handler atIndex:3];
+    handler(data, response, error);
+  }];
+}
+
+- (void)verifyUrlSessionResponseWithData {
+  OCMVerify([_mockURLSession dataTaskWithRequest:[OCMArg isNotNil]
+                               completionHandler:[OCMArg isNotNil]]);
+}
+
+- (void)rejectUrlSessionResponseWithData {
+  OCMReject([_mockURLSession dataTaskWithRequest:[OCMArg isNotNil]
+                               completionHandler:[OCMArg isNotNil]]);
+}
+
+- (void)testGenerateAuthTokenWithCompletionSuccess {
+  [self mockInstallationAuthCompletion:_mockInstallationToken error:nil];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Generate auth token succeeds."];
+
+  [FIRFADApiService
+      generateAuthTokenWithCompletion:^(NSString *_Nullable identifier,
+                                        FIRInstallationsAuthTokenResult *_Nullable authTokenResult,
+                                        NSError *_Nullable error) {
+        XCTAssertNotNil(authTokenResult);
+        XCTAssertNotNil(identifier);
+        XCTAssertNil(error);
+        XCTAssertEqual(identifier, self->_mockInstallationId);
+        XCTAssertEqual([authTokenResult authToken], self -> _mockAuthToken);
+        [expectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyInstallationAuthCompletion];
+  [self verifyInstallationIdCompletion];
+}
+
+- (void)testGenerateAuthTokenWithCompletionAuthTokenFailure {
+  [self mockInstallationAuthCompletion:nil
+                                 error:[NSError errorWithDomain:kFakeErrorDomain
+                                                           code:1
+                                                       userInfo:@{}]];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Generate auth token fails to generate auth token."];
+
+  [FIRFADApiService
+      generateAuthTokenWithCompletion:^(NSString *_Nullable identifier,
+                                        FIRInstallationsAuthTokenResult *_Nullable authTokenResult,
+                                        NSError *_Nullable error) {
+        XCTAssertNil(identifier);
+        XCTAssertNil(authTokenResult);
+        XCTAssertNotNil(error);
+        XCTAssertEqual(error.code, FIRFADApiTokenGenerationFailure);
+        [expectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyInstallationAuthCompletion];
+  [self rejectInstallationIdCompletion];
+}
+
+- (void)testGenerateAuthTokenWithCompletionIDFailure {
+  [self mockInstallationAuthCompletion:_mockInstallationToken error:nil];
+  [self mockInstallationIdCompletion:nil
+                               error:[NSError errorWithDomain:kFakeErrorDomain
+                                                         code:1
+                                                     userInfo:@{}]];
+
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Generate auth token fails to find ID."];
+
+  [FIRFADApiService
+      generateAuthTokenWithCompletion:^(NSString *_Nullable identifier,
+                                        FIRInstallationsAuthTokenResult *_Nullable authTokenResult,
+                                        NSError *_Nullable error) {
+        XCTAssertNil(identifier);
+        XCTAssertNil(authTokenResult);
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRFADApiInstallationIdentifierError);
+        [expectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyInstallationAuthCompletion];
+  [self verifyInstallationIdCompletion];
+}
+
+- (void)testFetchReleasesWithCompletionSuccess {
+  NSHTTPURLResponse *fakeResponse = OCMClassMock([NSHTTPURLResponse class]);
+  OCMStub([fakeResponse statusCode]).andReturn(200);
+  [self mockInstallationAuthCompletion:_mockInstallationToken error:nil];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUrlSessionResponse:_mockReleases response:fakeResponse error:nil];
+
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch releases succeeds with two releases."];
+
+  [FIRFADApiService
+      fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
+        XCTAssertNil(error);
+        XCTAssertNotNil(releases);
+        XCTAssertEqual([releases count], 2);
+        [expectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyInstallationAuthCompletion];
+  [self verifyInstallationIdCompletion];
+  [self verifyUrlSessionResponseWithData];
+  OCMVerify([fakeResponse statusCode]);
+}
+
+- (void)testFetchReleasesWithCompletionUnknownFailure {
+  NSHTTPURLResponse *fakeResponse = OCMClassMock([NSHTTPURLResponse class]);
+  OCMStub([fakeResponse statusCode]).andReturn(200);
+  [self mockInstallationAuthCompletion:_mockInstallationToken error:nil];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUrlSessionResponse:_mockReleases
+                      response:fakeResponse
+                         error:[NSError errorWithDomain:kFakeErrorDomain code:1 userInfo:@{}]];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch releases fails with unknown error."];
+
+  [FIRFADApiService
+      fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
+        XCTAssertNil(releases);
+        XCTAssertNotNil(error);
+        XCTAssertEqual(error.code, FIRApiErrorUnknownFailure);
+        [expectation fulfill];
+      }];
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyInstallationAuthCompletion];
+  [self verifyInstallationIdCompletion];
+  [self verifyUrlSessionResponseWithData];
+  OCMVerify([fakeResponse statusCode]);
+}
+
+- (void)testFetchReleasesWithCompletionUnauthenticatedFailure {
+  NSHTTPURLResponse *fakeResponse = OCMClassMock([NSHTTPURLResponse class]);
+  OCMStub([fakeResponse statusCode]).andReturn(401);
+  [self mockInstallationAuthCompletion:_mockInstallationToken error:nil];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUrlSessionResponse:_mockReleases response:fakeResponse error:nil];
+
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch releases fails with unknown error."];
+
+  [FIRFADApiService
+      fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
+        XCTAssertNil(releases);
+        XCTAssertNotNil(error);
+        XCTAssertEqual(error.code, FIRFADApiErrorUnauthenticated);
+        [expectation fulfill];
+      }];
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyInstallationAuthCompletion];
+  [self verifyInstallationIdCompletion];
+  [self verifyUrlSessionResponseWithData];
+  OCMVerify([fakeResponse statusCode]);
+}
+
+- (void)testFetchReleasesWithCompletionUnauthorized400Failure {
+  NSHTTPURLResponse *fakeResponse = OCMClassMock([NSHTTPURLResponse class]);
+  OCMStub([fakeResponse statusCode]).andReturn(400);
+  [self mockInstallationAuthCompletion:_mockInstallationToken error:nil];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUrlSessionResponse:_mockReleases response:fakeResponse error:nil];
+
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch releases rejects with a 400."];
+
+  [FIRFADApiService
+      fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
+        XCTAssertNil(releases);
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRFADApiErrorUnauthorized);
+        [expectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyInstallationAuthCompletion];
+  [self verifyInstallationIdCompletion];
+  [self verifyUrlSessionResponseWithData];
+  OCMVerify([fakeResponse statusCode]);
+}
+
+- (void)testFetchReleasesWithCompletionUnauthorized403Failure {
+  NSHTTPURLResponse *fakeResponse = OCMClassMock([NSHTTPURLResponse class]);
+  OCMStub([fakeResponse statusCode]).andReturn(403);
+  [self mockInstallationAuthCompletion:_mockInstallationToken error:nil];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUrlSessionResponse:_mockReleases response:fakeResponse error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch releases rejects with a 403."];
+
+  [FIRFADApiService
+      fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
+        XCTAssertNil(releases);
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRFADApiErrorUnauthorized);
+        [expectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyInstallationAuthCompletion];
+  [self verifyInstallationIdCompletion];
+  [self verifyUrlSessionResponseWithData];
+  OCMVerify([fakeResponse statusCode]);
+}
+
+- (void)testFetchReleasesWithCompletionUnauthorized404Failure {
+  NSHTTPURLResponse *fakeResponse = OCMClassMock([NSHTTPURLResponse class]);
+  OCMStub([fakeResponse statusCode]).andReturn(404);
+  [self mockInstallationAuthCompletion:_mockInstallationToken error:nil];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUrlSessionResponse:_mockReleases response:fakeResponse error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch releases rejects with a 404."];
+
+  [FIRFADApiService
+      fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
+        XCTAssertNil(releases);
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRFADApiErrorUnauthorized);
+        [expectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyInstallationAuthCompletion];
+  [self verifyInstallationIdCompletion];
+  [self verifyUrlSessionResponseWithData];
+  OCMVerify([fakeResponse statusCode]);
+}
+
+- (void)testFetchReleasesWithCompletionTimeout408Failure {
+  NSHTTPURLResponse *fakeResponse = OCMClassMock([NSHTTPURLResponse class]);
+  OCMStub([fakeResponse statusCode]).andReturn(408);
+  [self mockInstallationAuthCompletion:_mockInstallationToken error:nil];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUrlSessionResponse:_mockReleases response:fakeResponse error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch releases rejects with 408."];
+
+  [FIRFADApiService
+      fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
+        XCTAssertNil(releases);
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRFADApiErrorTimeout);
+        [expectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyInstallationAuthCompletion];
+  [self verifyInstallationIdCompletion];
+  [self verifyUrlSessionResponseWithData];
+  OCMVerify([fakeResponse statusCode]);
+}
+
+- (void)testFetchReleasesWithCompletionTimeout504Failure {
+  NSHTTPURLResponse *fakeResponse = OCMClassMock([NSHTTPURLResponse class]);
+  OCMStub([fakeResponse statusCode]).andReturn(504);
+  [self mockInstallationAuthCompletion:_mockInstallationToken error:nil];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUrlSessionResponse:_mockReleases response:fakeResponse error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch releases rejects with a 504."];
+
+  [FIRFADApiService
+      fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
+        XCTAssertNil(releases);
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRFADApiErrorTimeout);
+        [expectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyInstallationAuthCompletion];
+  [self verifyInstallationIdCompletion];
+  [self verifyUrlSessionResponseWithData];
+  OCMVerify([fakeResponse statusCode]);
+}
+
+- (void)testFetchReleasesWithCompletionUnknownStatusCodeFailure {
+  NSHTTPURLResponse *fakeResponse = OCMClassMock([NSHTTPURLResponse class]);
+  OCMStub([fakeResponse statusCode]).andReturn(500);
+  [self mockInstallationAuthCompletion:_mockInstallationToken error:nil];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUrlSessionResponse:_mockReleases response:fakeResponse error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch releases rejects with a 500."];
+
+  [FIRFADApiService
+      fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
+        XCTAssertNil(releases);
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRApiErrorUnknownFailure);
+        [expectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyInstallationAuthCompletion];
+  [self verifyInstallationIdCompletion];
+  [self verifyUrlSessionResponseWithData];
+  OCMVerify([fakeResponse statusCode]);
+}
+
+- (void)testFetchReleasesWithCompletionNoReleasesFoundSuccess {
+  NSHTTPURLResponse *fakeResponse = OCMClassMock([NSHTTPURLResponse class]);
+  OCMStub([fakeResponse statusCode]).andReturn(200);
+  [self mockInstallationAuthCompletion:_mockInstallationToken error:nil];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self mockUrlSessionResponse:@{@"releases" : @[]} response:fakeResponse error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch releases rejects with a not found exception."];
+
+  [FIRFADApiService
+      fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
+        XCTAssertNotNil(releases);
+        XCTAssertNil(error);
+        XCTAssertEqual([releases count], 0);
+        [expectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyInstallationAuthCompletion];
+  [self verifyInstallationIdCompletion];
+  [self verifyUrlSessionResponseWithData];
+  OCMVerify([fakeResponse statusCode]);
+}
+
+- (void)testFetchReleasesWithCompletionParsingFailure {
+  NSHTTPURLResponse *fakeResponse = OCMClassMock([NSHTTPURLResponse class]);
+  OCMStub([fakeResponse statusCode]).andReturn(200);
+  [self mockInstallationAuthCompletion:_mockInstallationToken error:nil];
+  [self mockInstallationIdCompletion:_mockInstallationId error:nil];
+  [self
+      mockUrlSessionResponseWithData:[@"malformed{json[data" dataUsingEncoding:NSUTF8StringEncoding]
+                            response:fakeResponse
+                               error:nil];
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"Fetch releases rejects with a parsing failure."];
+
+  [FIRFADApiService
+      fetchReleasesWithCompletion:^(NSArray *_Nullable releases, NSError *_Nullable error) {
+        XCTAssertNil(releases);
+        XCTAssertNotNil(error);
+        XCTAssertEqual([error code], FIRApiErrorParseFailure);
+        [expectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ expectation ] timeout:5.0];
+  [self verifyInstallationAuthCompletion];
+  [self verifyInstallationIdCompletion];
+  [self verifyUrlSessionResponseWithData];
+  OCMVerify([fakeResponse statusCode]);
+}
+
+@end

+ 2 - 0
README.md

@@ -3,6 +3,7 @@
 [![Platform](https://img.shields.io/cocoapods/p/Firebase.svg?style=flat)](https://cocoapods.org/pods/Firebase)
 
 [![Actions Status][gh-abtesting-badge]][gh-actions]
+[![Actions Status][gh-appdistribution-badge]][gh-actions]
 [![Actions Status][gh-auth-badge]][gh-actions]
 [![Actions Status][gh-core-badge]][gh-actions]
 [![Actions Status][gh-crashlytics-badge]][gh-actions]
@@ -280,6 +281,7 @@ Your use of Firebase is governed by the
 
 [gh-actions]: https://github.com/firebase/firebase-ios-sdk/actions
 [gh-abtesting-badge]: https://github.com/firebase/firebase-ios-sdk/workflows/abtesting/badge.svg
+[gh-appdistribution-badge]: https://github.com/firebase/firebase-ios-sdk/workflows/appdistribution/badge.svg
 [gh-auth-badge]: https://github.com/firebase/firebase-ios-sdk/workflows/auth/badge.svg
 [gh-core-badge]: https://github.com/firebase/firebase-ios-sdk/workflows/core/badge.svg
 [gh-crashlytics-badge]: https://github.com/firebase/firebase-ios-sdk/workflows/crashlytics/badge.svg