Ver Fonte

Firebase Installations as standalone SDK (#3376)

Maksym Malyhin há 6 anos atrás
pai
commit
8e73ddb222
60 ficheiros alterados com 6144 adições e 0 exclusões
  1. 13 0
      .travis.yml
  2. 55 0
      FirebaseInstallations.podspec
  3. 54 0
      FirebaseInstallations/Source/Library/Errors/FIRInstallationsErrorUtil.h
  4. 116 0
      FirebaseInstallations/Source/Library/Errors/FIRInstallationsErrorUtil.m
  5. 33 0
      FirebaseInstallations/Source/Library/Errors/FIRInstallationsHTTPError.h
  6. 78 0
      FirebaseInstallations/Source/Library/Errors/FIRInstallationsHTTPError.m
  7. 184 0
      FirebaseInstallations/Source/Library/FIRInstallations.m
  8. 30 0
      FirebaseInstallations/Source/Library/FIRInstallationsAuthTokenResult.m
  9. 27 0
      FirebaseInstallations/Source/Library/FIRInstallationsAuthTokenResultInternal.h
  10. 82 0
      FirebaseInstallations/Source/Library/FIRInstallationsItem.h
  11. 101 0
      FirebaseInstallations/Source/Library/FIRInstallationsItem.m
  12. 23 0
      FirebaseInstallations/Source/Library/FIRInstallationsVersion.m
  13. 71 0
      FirebaseInstallations/Source/Library/FIRSecureStorage.h
  14. 331 0
      FirebaseInstallations/Source/Library/FIRSecureStorage.m
  15. 48 0
      FirebaseInstallations/Source/Library/IIDMigration/FIRInstallationsIIDStore.h
  16. 237 0
      FirebaseInstallations/Source/Library/IIDMigration/FIRInstallationsIIDStore.m
  17. 58 0
      FirebaseInstallations/Source/Library/InstallationsAPI/FIRInstallationsAPIService.h
  18. 269 0
      FirebaseInstallations/Source/Library/InstallationsAPI/FIRInstallationsAPIService.m
  19. 53 0
      FirebaseInstallations/Source/Library/InstallationsAPI/FIRInstallationsItem+RegisterInstallationAPI.h
  20. 140 0
      FirebaseInstallations/Source/Library/InstallationsAPI/FIRInstallationsItem+RegisterInstallationAPI.m
  21. 42 0
      FirebaseInstallations/Source/Library/InstallationsIDController/FIRInstallationsIDController.h
  22. 323 0
      FirebaseInstallations/Source/Library/InstallationsIDController/FIRInstallationsIDController.m
  23. 58 0
      FirebaseInstallations/Source/Library/InstallationsIDController/FIRInstallationsSingleOperationPromiseCache.h
  24. 67 0
      FirebaseInstallations/Source/Library/InstallationsIDController/FIRInstallationsSingleOperationPromiseCache.m
  25. 35 0
      FirebaseInstallations/Source/Library/InstallationsIDController/FIRInstallationsStatus.h
  26. 71 0
      FirebaseInstallations/Source/Library/InstallationsStore/FIRInstallationsStore.h
  27. 131 0
      FirebaseInstallations/Source/Library/InstallationsStore/FIRInstallationsStore.m
  28. 58 0
      FirebaseInstallations/Source/Library/InstallationsStore/FIRInstallationsStoredAuthToken.h
  29. 69 0
      FirebaseInstallations/Source/Library/InstallationsStore/FIRInstallationsStoredAuthToken.m
  30. 47 0
      FirebaseInstallations/Source/Library/InstallationsStore/FIRInstallationsStoredItem.h
  31. 67 0
      FirebaseInstallations/Source/Library/InstallationsStore/FIRInstallationsStoredItem.m
  32. 115 0
      FirebaseInstallations/Source/Library/Public/FIRInstallations.h
  33. 33 0
      FirebaseInstallations/Source/Library/Public/FIRInstallationsAuthTokenResult.h
  34. 34 0
      FirebaseInstallations/Source/Library/Public/FIRInstallationsErrors.h
  35. 19 0
      FirebaseInstallations/Source/Library/Public/FIRInstallationsVersion.h
  36. 3 0
      FirebaseInstallations/Source/Tests/Fixture/APIGenerateTokenResponseInvalidRefreshToken.json
  37. 4 0
      FirebaseInstallations/Source/Tests/Fixture/APIGenerateTokenResponseSuccess.json
  38. 8 0
      FirebaseInstallations/Source/Tests/Fixture/APIRegisterInstallationResponseSuccess.json
  39. 195 0
      FirebaseInstallations/Source/Tests/Integration/FIRInstallationsIntegrationTests.m
  40. 28 0
      FirebaseInstallations/Source/Tests/Resources/GoogleService-Info.plist
  41. 480 0
      FirebaseInstallations/Source/Tests/Unit/FIRInstallationsAPIServiceTests.m
  42. 111 0
      FirebaseInstallations/Source/Tests/Unit/FIRInstallationsHTTPErrorTests.m
  43. 783 0
      FirebaseInstallations/Source/Tests/Unit/FIRInstallationsIDControllerTests.m
  44. 116 0
      FirebaseInstallations/Source/Tests/Unit/FIRInstallationsIIDStoreTests.m
  45. 59 0
      FirebaseInstallations/Source/Tests/Unit/FIRInstallationsItemTests.m
  46. 247 0
      FirebaseInstallations/Source/Tests/Unit/FIRInstallationsStoreTests.m
  47. 50 0
      FirebaseInstallations/Source/Tests/Unit/FIRInstallationsStoredAuthTokenTests.m
  48. 59 0
      FirebaseInstallations/Source/Tests/Unit/FIRInstallationsStoredItemTests.m
  49. 236 0
      FirebaseInstallations/Source/Tests/Unit/FIRInstallationsTests.m
  50. 201 0
      FirebaseInstallations/Source/Tests/Unit/FIRSecureStorageTests.m
  51. 34 0
      FirebaseInstallations/Source/Tests/Utils/FIRInstallations+Tests.h
  52. 27 0
      FirebaseInstallations/Source/Tests/Utils/FIRInstallationsErrorUtil+Tests.h
  53. 32 0
      FirebaseInstallations/Source/Tests/Utils/FIRInstallationsErrorUtil+Tests.m
  54. 31 0
      FirebaseInstallations/Source/Tests/Utils/FIRInstallationsItem+Tests.h
  55. 58 0
      FirebaseInstallations/Source/Tests/Utils/FIRInstallationsItem+Tests.m
  56. 30 0
      FirebaseInstallations/Source/Tests/Utils/FIRKeyedArchivingUtils.h
  57. 79 0
      FirebaseInstallations/Source/Tests/Utils/FIRKeyedArchivingUtils.m
  58. 33 0
      FirebaseInstallations/Source/Tests/Utils/FIRTestKeychain.h
  59. 64 0
      FirebaseInstallations/Source/Tests/Utils/FIRTestKeychain.m
  60. 4 0
      scripts/if_changed.sh

+ 13 - 0
.travis.yml

@@ -396,6 +396,19 @@ jobs:
       script:
         - travis_retry ./CocoapodsIntegrationTest/scripts/build_with_environment.sh --gemfile=./CocoapodsIntegrationTest/TestEnvironments/${POD_CONFIG_DIR}/Gemfile --podfile=./CocoapodsIntegrationTest/TestEnvironments/${POD_CONFIG_DIR}/Podfile
 
+    # FIS
+    - stage: test
+      env:
+        - PROJECT=Installations PLATFORM=iOS METHOD=pod-lib-lint
+      before_install:
+        - ./scripts/if_changed.sh ./scripts/install_prereqs.sh
+      script:
+        - travis_retry ./scripts/if_changed.sh ./scripts/pod_lib_lint.rb FirebaseInstallations.podspec --platforms=ios,tvos
+        - travis_retry ./scripts/if_changed.sh ./scripts/pod_lib_lint.rb FirebaseInstallations.podspec --platforms=macos --allow-warnings # TODO: Fix FBLPromises warnings for macOS.
+        - travis_retry ./scripts/if_changed.sh ./scripts/pod_lib_lint.rb FirebaseInstallations.podspec --use-libraries
+        - travis_retry ./scripts/if_changed.sh ./scripts/pod_lib_lint.rb FirebaseInstallations.podspec --use-modular-headers --platforms=ios,tvos
+        - travis_retry ./scripts/if_changed.sh ./scripts/pod_lib_lint.rb FirebaseInstallations.podspec --use-modular-headers --platforms=macos --allow-warnings # TODO: Fix FBLPromises warnings for macOS.
+
   allow_failures:
     # Run fuzz tests only on cron jobs.
     - stage: test

+ 55 - 0
FirebaseInstallations.podspec

@@ -0,0 +1,55 @@
+Pod::Spec.new do |s|
+    s.name             = 'FirebaseInstallations'
+    s.version          = '0.1.0'
+    s.summary          = 'Firebase Installations for iOS'
+
+    s.description      = <<-DESC
+    Firebase Installations for iOS.
+                         DESC
+
+    s.homepage         = 'https://firebase.google.com'
+    s.license          = { :type => 'Apache', :file => 'LICENSE' }
+    s.authors          = 'Google, Inc.'
+
+    s.source           = {
+      :git => 'https://github.com/firebase/firebase-ios-sdk.git',
+      :tag => 'Installations-' + s.version.to_s
+    }
+    s.social_media_url = 'https://twitter.com/Firebase'
+    s.ios.deployment_target = '8.0'
+    s.osx.deployment_target = '10.11'
+    s.tvos.deployment_target = '10.0'
+
+    s.cocoapods_version = '>= 1.4.0'
+    s.static_framework = true
+    s.prefix_header_file = false
+
+    base_dir = "FirebaseInstallations/Source/"
+    s.source_files = base_dir + 'Library/**/*.[mh]'
+    s.public_header_files = base_dir + 'Library/Public/*.h'
+    s.pod_target_xcconfig = {
+      'GCC_C_LANGUAGE_STANDARD' => 'c99',
+      'GCC_PREPROCESSOR_DEFINITIONS' =>
+        'FIRInstallations_LIB_VERSION=' + String(s.version)
+    }
+    s.framework = 'Security'
+    s.dependency 'FirebaseCore', '~> 6.0'
+    s.dependency 'PromisesObjC', '~> 1.2'
+    s.dependency 'GoogleUtilities/UserDefaults', '~> 6.2'
+
+    s.test_spec 'unit' do |unit_tests|
+      unit_tests.source_files = base_dir + 'Tests/Unit/**/*.[mh]',
+                                base_dir + 'Tests/Utils/**/*.[mh]'
+      unit_tests.resources = base_dir + 'Tests/Fixture/**/*'
+      unit_tests.requires_app_host = true
+      unit_tests.dependency 'OCMock'
+      unit_tests.dependency 'FirebaseInstanceID', '~> 4.2.0' # The version before FirebaseInstanceID updated to use FirebaseInstallations under the hood.
+    end
+
+    s.test_spec 'integration' do |int_tests|
+      int_tests.source_files = base_dir + 'Tests/Integration/**/*.[mh]'
+      int_tests.resources = base_dir + 'Tests/Resources/**/*'
+      int_tests.requires_app_host = true
+      int_tests.dependency 'OCMock'
+    end
+  end

+ 54 - 0
FirebaseInstallations/Source/Library/Errors/FIRInstallationsErrorUtil.h

@@ -0,0 +1,54 @@
+/*
+ * 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>
+
+#import <FirebaseInstallations/FIRInstallationsErrors.h>
+
+@class FIRInstallationsHTTPError;
+
+NS_ASSUME_NONNULL_BEGIN
+
+void FIRInstallationsItemSetErrorToPointer(NSError *error, NSError **pointer);
+
+@interface FIRInstallationsErrorUtil : NSObject
+
++ (NSError *)keyedArchiverErrorWithException:(NSException *)exception;
++ (NSError *)keyedArchiverErrorWithError:(NSError *)error;
+
++ (NSError *)keychainErrorWithFunction:(NSString *)keychainFunction status:(OSStatus)status;
+
++ (NSError *)installationItemNotFoundForAppID:(NSString *)appID appName:(NSString *)appName;
+
++ (NSError *)JSONSerializationError:(NSError *)error;
+
++ (NSError *)networkErrorWithError:(NSError *)error;
+
++ (NSError *)FIDRegestrationErrorWithResponseMissingField:(NSString *)missingFieldName;
+
++ (FIRInstallationsHTTPError *)APIErrorWithHTTPResponse:(NSHTTPURLResponse *)HTTPResponse
+                                                   data:(nullable NSData *)data;
++ (BOOL)isAPIError:(NSError *)error withHTTPCode:(NSInteger)HTTPCode;
+
+/**
+ * Returns the passed error if it is already in the public domain or a new error with the passed
+ * error at `NSUnderlyingErrorKey`.
+ */
++ (NSError *)publicDomainErrorWithError:(NSError *)error;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 116 - 0
FirebaseInstallations/Source/Library/Errors/FIRInstallationsErrorUtil.m

@@ -0,0 +1,116 @@
+/*
+ * 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 "FIRInstallationsErrorUtil.h"
+
+#import "FIRInstallationsHTTPError.h"
+
+NSString *const kFirebaseInstallationsErrorDomain = @"com.firebase.installations";
+
+void FIRInstallationsItemSetErrorToPointer(NSError *error, NSError **pointer) {
+  if (pointer != NULL) {
+    *pointer = error;
+  }
+}
+
+@implementation FIRInstallationsErrorUtil
+
++ (NSError *)keyedArchiverErrorWithException:(NSException *)exception {
+  NSString *failureReason = [NSString
+      stringWithFormat:@"NSKeyedArchiver exception with name: %@, reason: %@, userInfo: %@",
+                       exception.name, exception.reason, exception.userInfo];
+  return [self installationsErrorWithCode:FIRInstallationsErrorCodeUnknown
+                            failureReason:failureReason
+                          underlyingError:nil];
+}
+
++ (NSError *)keyedArchiverErrorWithError:(NSError *)error {
+  NSString *failureReason = [NSString stringWithFormat:@"NSKeyedArchiver error."];
+  return [self installationsErrorWithCode:FIRInstallationsErrorCodeUnknown
+                            failureReason:failureReason
+                          underlyingError:error];
+}
+
++ (NSError *)keychainErrorWithFunction:(NSString *)keychainFunction status:(OSStatus)status {
+  NSString *failureReason = [NSString stringWithFormat:@"%@ (%li)", keychainFunction, (long)status];
+  return [self installationsErrorWithCode:FIRInstallationsErrorCodeKeychain
+                            failureReason:failureReason
+                          underlyingError:nil];
+}
+
++ (NSError *)installationItemNotFoundForAppID:(NSString *)appID appName:(NSString *)appName {
+  NSString *failureReason =
+      [NSString stringWithFormat:@"Installation for appID %@ appName %@ not found", appID, appName];
+  return [self installationsErrorWithCode:FIRInstallationsErrorCodeUnknown
+                            failureReason:failureReason
+                          underlyingError:nil];
+}
+
++ (FIRInstallationsHTTPError *)APIErrorWithHTTPResponse:(NSHTTPURLResponse *)HTTPResponse
+                                                   data:(nullable NSData *)data {
+  return [[FIRInstallationsHTTPError alloc] initWithHTTPResponse:HTTPResponse data:data];
+}
+
++ (BOOL)isAPIError:(NSError *)error withHTTPCode:(NSInteger)HTTPCode {
+  if (![error isKindOfClass:[FIRInstallationsHTTPError class]]) {
+    return NO;
+  }
+
+  return [(FIRInstallationsHTTPError *)error HTTPResponse].statusCode == HTTPCode;
+}
+
++ (NSError *)JSONSerializationError:(NSError *)error {
+  NSString *failureReason = [NSString stringWithFormat:@"Failed to serialize JSON data."];
+  return [self installationsErrorWithCode:FIRInstallationsErrorCodeUnknown
+                            failureReason:failureReason
+                          underlyingError:nil];
+}
+
++ (NSError *)FIDRegestrationErrorWithResponseMissingField:(NSString *)missingFieldName {
+  NSString *failureReason = [NSString
+      stringWithFormat:@"A required response field with name %@ is missing", missingFieldName];
+  return [self installationsErrorWithCode:FIRInstallationsErrorCodeUnknown
+                            failureReason:failureReason
+                          underlyingError:nil];
+}
+
++ (NSError *)networkErrorWithError:(NSError *)error {
+  return [self installationsErrorWithCode:FIRInstallationsErrorCodeServerUnreachable
+                            failureReason:@"Network connection error."
+                          underlyingError:error];
+}
+
++ (NSError *)publicDomainErrorWithError:(NSError *)error {
+  if ([error.domain isEqualToString:kFirebaseInstallationsErrorDomain]) {
+    return error;
+  }
+
+  return [self installationsErrorWithCode:FIRInstallationsErrorCodeUnknown
+                            failureReason:nil
+                          underlyingError:error];
+}
+
++ (NSError *)installationsErrorWithCode:(FIRInstallationsErrorCode)code
+                          failureReason:(nullable NSString *)failureReason
+                        underlyingError:(nullable NSError *)underlyingError {
+  NSMutableDictionary *userInfo = [NSMutableDictionary dictionary];
+  userInfo[NSUnderlyingErrorKey] = underlyingError;
+  userInfo[NSLocalizedFailureReasonErrorKey] = failureReason;
+
+  return [NSError errorWithDomain:kFirebaseInstallationsErrorDomain code:code userInfo:userInfo];
+}
+
+@end

+ 33 - 0
FirebaseInstallations/Source/Library/Errors/FIRInstallationsHTTPError.h

@@ -0,0 +1,33 @@
+/*
+ * 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>
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** Represents an error caused by an unexpected API response. */
+@interface FIRInstallationsHTTPError : NSError
+
+@property(nonatomic, readonly) NSHTTPURLResponse *HTTPResponse;
+@property(nonatomic, readonly, nonnull) NSData *data;
+
+- (instancetype)init NS_UNAVAILABLE;
+
+- (instancetype)initWithHTTPResponse:(NSHTTPURLResponse *)HTTPResponse data:(nullable NSData *)data;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 78 - 0
FirebaseInstallations/Source/Library/Errors/FIRInstallationsHTTPError.m

@@ -0,0 +1,78 @@
+/*
+ * 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 "FIRInstallationsHTTPError.h"
+#import "FIRInstallationsErrorUtil.h"
+
+@implementation FIRInstallationsHTTPError
+
+- (instancetype)initWithHTTPResponse:(NSHTTPURLResponse *)HTTPResponse
+                                data:(nullable NSData *)data {
+  NSDictionary *userInfo = [FIRInstallationsHTTPError userInfoWithHTTPResponse:HTTPResponse
+                                                                          data:data];
+  self = [super
+      initWithDomain:kFirebaseInstallationsErrorDomain
+                code:[FIRInstallationsHTTPError errorCodeWithHTTPCode:HTTPResponse.statusCode]
+            userInfo:userInfo];
+  if (self) {
+    _HTTPResponse = HTTPResponse;
+    _data = data;
+  }
+  return self;
+}
+
++ (FIRInstallationsErrorCode)errorCodeWithHTTPCode:(NSInteger)HTTPCode {
+  return FIRInstallationsErrorCodeUnknown;
+}
+
++ (NSDictionary *)userInfoWithHTTPResponse:(NSHTTPURLResponse *)HTTPResponse
+                                      data:(nullable NSData *)data {
+  NSString *responseString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
+  NSString *failureReason = [NSString
+      stringWithFormat:@"The server responded with an error. HTTP response: %@\nResponse body: %@",
+                       HTTPResponse, responseString];
+  return @{NSLocalizedFailureReasonErrorKey : failureReason};
+}
+
+#pragma mark - NSCopying
+
+- (id)copyWithZone:(NSZone *)zone {
+  return [[FIRInstallationsHTTPError alloc] initWithHTTPResponse:self.HTTPResponse data:self.data];
+}
+
+#pragma mark - NSSecureCoding
+
+- (nullable instancetype)initWithCoder:(NSCoder *)coder {
+  NSHTTPURLResponse *HTTPResponse = [coder decodeObjectOfClass:[NSHTTPURLResponse class]
+                                                        forKey:@"HTTPResponse"];
+  if (!HTTPResponse) {
+    return nil;
+  }
+  NSData *data = [coder decodeObjectOfClass:[NSData class] forKey:@"data"];
+
+  return [self initWithHTTPResponse:HTTPResponse data:data];
+}
+
+- (void)encodeWithCoder:(NSCoder *)coder {
+  [coder encodeObject:self.HTTPResponse forKey:@"HTTPResponse"];
+  [coder encodeObject:self.data forKey:@"data"];
+}
+
++ (BOOL)supportsSecureCoding {
+  return YES;
+}
+
+@end

+ 184 - 0
FirebaseInstallations/Source/Library/FIRInstallations.m

@@ -0,0 +1,184 @@
+/*
+ * 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 "FIRInstallations.h"
+
+#if __has_include(<FBLPromises/FBLPromises.h>)
+#import <FBLPromises/FBLPromises.h>
+#else
+#import "FBLPromises.h"
+#endif
+
+#import <FirebaseCore/FIRAppInternal.h>
+#import <FirebaseCore/FIRComponent.h>
+#import <FirebaseCore/FIRComponentContainer.h>
+#import <FirebaseCore/FIRLibrary.h>
+#import <FirebaseCore/FIRLogger.h>
+#import <FirebaseCore/FirebaseCore.h>
+
+#import "FIRInstallationsAuthTokenResultInternal.h"
+
+#import "FIRInstallationsErrorUtil.h"
+#import "FIRInstallationsIDController.h"
+#import "FIRInstallationsItem.h"
+#import "FIRInstallationsStoredAuthToken.h"
+#import "FIRInstallationsVersion.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@protocol FIRInstallationsInstanceProvider <FIRLibrary>
+@end
+
+@interface FIRInstallations () <FIRInstallationsInstanceProvider>
+@property(nonatomic, readonly) FIROptions *appOptions;
+@property(nonatomic, readonly) NSString *appName;
+
+@property(nonatomic, readonly) FIRInstallationsIDController *installationsIDController;
+
+@end
+
+@implementation FIRInstallations
+
+#pragma mark - Firebase component
+
++ (void)load {
+  [FIRApp registerInternalLibrary:(Class<FIRLibrary>)self
+                         withName:@"fire-install"
+                      withVersion:[NSString stringWithUTF8String:FIRInstallationsVersionStr]];
+}
+
++ (nonnull NSArray<FIRComponent *> *)componentsToRegister {
+  FIRComponentCreationBlock creationBlock =
+      ^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
+    *isCacheable = YES;
+    FIRInstallations *installations = [[FIRInstallations alloc] initWithApp:container.app];
+    return installations;
+  };
+
+  FIRComponent *installationsProvider =
+      [FIRComponent componentWithProtocol:@protocol(FIRInstallationsInstanceProvider)
+                      instantiationTiming:FIRInstantiationTimingAlwaysEager
+                             dependencies:@[]
+                            creationBlock:creationBlock];
+  return @[ installationsProvider ];
+}
+
+- (instancetype)initWithApp:(FIRApp *)app {
+  return [self initWitAppOptions:app.options appName:app.name];
+}
+
+- (instancetype)initWitAppOptions:(FIROptions *)appOptions appName:(NSString *)appName {
+  FIRInstallationsIDController *IDController =
+      [[FIRInstallationsIDController alloc] initWithGoogleAppID:appOptions.googleAppID
+                                                        appName:appName
+                                                         APIKey:appOptions.APIKey
+                                                      projectID:appOptions.projectID];
+  return [self initWithAppOptions:appOptions
+                          appName:appName
+        installationsIDController:IDController
+                prefetchAuthToken:YES];
+}
+
+/// The initializer is supposed to be used by tests to inject `installationsStore`.
+- (instancetype)initWithAppOptions:(FIROptions *)appOptions
+                           appName:(NSString *)appName
+         installationsIDController:(FIRInstallationsIDController *)installationsIDController
+                 prefetchAuthToken:(BOOL)prefetchAuthToken {
+  self = [super init];
+  if (self) {
+    _appOptions = [appOptions copy];
+    _appName = [appName copy];
+    _installationsIDController = installationsIDController;
+
+    // Pre-fetch auth token.
+    if (prefetchAuthToken) {
+      [self authTokenWithCompletion:^(FIRInstallationsAuthTokenResult *_Nullable tokenResult,
+                                      NSError *_Nullable error){
+      }];
+    }
+  }
+  return self;
+}
+
+#pragma mark - Public
+
++ (FIRInstallations *)installations {
+  FIRApp *defaultApp = [FIRApp defaultApp];
+  if (!defaultApp) {
+    [NSException raise:NSInternalInconsistencyException
+                format:@"The default FirebaseApp instance must be configured before the default"
+                       @"FirebaseApp instance can be initialized. One way to ensure that is to "
+                       @"call `[FIRApp configure];` (`FirebaseApp.configure()` in Swift) in the App"
+                       @" Delegate's `application:didFinishLaunchingWithOptions:` "
+                       @"(`application(_:didFinishLaunchingWithOptions:)` in Swift)."];
+  }
+
+  return [self installationsWithApp:defaultApp];
+}
+
++ (FIRInstallations *)installationsWithApp:(FIRApp *)app {
+  id<FIRInstallationsInstanceProvider> installations =
+      FIR_COMPONENT(FIRInstallationsInstanceProvider, app.container);
+  return (FIRInstallations *)installations;
+}
+
+- (void)installationIDWithCompletion:(FIRInstallationsIDHandler)completion {
+  [self.installationsIDController getInstallationItem]
+      .then(^id(FIRInstallationsItem *installation) {
+        completion(installation.firebaseInstallationID, nil);
+        return nil;
+      })
+      .catch(^(NSError *error) {
+        completion(nil, [FIRInstallationsErrorUtil publicDomainErrorWithError:error]);
+      });
+}
+
+- (void)authTokenWithCompletion:(FIRInstallationsTokenHandler)completion {
+  [self authTokenForcingRefresh:NO completion:completion];
+}
+
+- (void)authTokenForcingRefresh:(BOOL)forceRefresh
+                     completion:(FIRInstallationsTokenHandler)completion {
+  [self.installationsIDController getAuthTokenForcingRefresh:forceRefresh]
+      .then(^FIRInstallationsAuthTokenResult *(FIRInstallationsItem *installation) {
+        FIRInstallationsAuthTokenResult *result = [[FIRInstallationsAuthTokenResult alloc]
+             initWithToken:installation.authToken.token
+            expirationDate:installation.authToken.expirationDate];
+        return result;
+      })
+      .then(^id(FIRInstallationsAuthTokenResult *token) {
+        completion(token, nil);
+        return nil;
+      })
+      .catch(^void(NSError *error) {
+        completion(nil, [FIRInstallationsErrorUtil publicDomainErrorWithError:error]);
+      });
+}
+
+- (void)deleteWithCompletion:(void (^)(NSError *__nullable error))completion {
+  [self.installationsIDController deleteInstallation]
+      .then(^id(id result) {
+        completion(nil);
+        return nil;
+      })
+      .catch(^void(NSError *error) {
+        completion([FIRInstallationsErrorUtil publicDomainErrorWithError:error]);
+      });
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 30 - 0
FirebaseInstallations/Source/Library/FIRInstallationsAuthTokenResult.m

@@ -0,0 +1,30 @@
+/*
+ * 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 "FIRInstallationsAuthTokenResultInternal.h"
+
+@implementation FIRInstallationsAuthTokenResult
+
+- (instancetype)initWithToken:(NSString *)token expirationDate:(NSDate *)expirationDate {
+  self = [super init];
+  if (self) {
+    _authToken = [token copy];
+    _expirationDate = expirationDate;
+  }
+  return self;
+}
+
+@end

+ 27 - 0
FirebaseInstallations/Source/Library/FIRInstallationsAuthTokenResultInternal.h

@@ -0,0 +1,27 @@
+/*
+ * 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 <FirebaseInstallations/FIRInstallationsAuthTokenResult.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FIRInstallationsAuthTokenResult (Internal)
+
+- (instancetype)initWithToken:(NSString *)token expirationDate:(NSDate *)expirationTime;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 82 - 0
FirebaseInstallations/Source/Library/FIRInstallationsItem.h

@@ -0,0 +1,82 @@
+/*
+ * 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>
+
+#import "FIRInstallationsStatus.h"
+
+@class FIRInstallationsStoredItem;
+@class FIRInstallationsStoredAuthToken;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * The class represents the required installation ID and auth token data including possible states.
+ * The data is stored to Keychain via `FIRInstallationsStoredItem` which has only the storage
+ * relevant data and does not contain any logic. `FIRInstallationsItem` must be used on the logic
+ * level (not `FIRInstallationsStoredItem`).
+ */
+@interface FIRInstallationsItem : NSObject <NSCopying>
+
+/// A `FirebaseApp` identifier.
+@property(nonatomic, readonly) NSString *appID;
+/// A `FirebaseApp` name.
+@property(nonatomic, readonly) NSString *firebaseAppName;
+///  A stable identifier that uniquely identifies the app instance.
+@property(nonatomic, copy, nullable) NSString *firebaseInstallationID;
+/// The `refreshToken` is used to authorize the auth token requests.
+@property(nonatomic, copy, nullable) NSString *refreshToken;
+
+@property(nonatomic, nullable) FIRInstallationsStoredAuthToken *authToken;
+@property(nonatomic, assign) FIRInstallationsStatus registrationStatus;
+
+- (instancetype)initWithAppID:(NSString *)appID firebaseAppName:(NSString *)firebaseAppName;
+
+/**
+ * Populates `FIRInstallationsItem` properties with data from `FIRInstallationsStoredItem`.
+ * @param item An instance of `FIRInstallationsStoredItem` to get data from.
+ */
+- (void)updateWithStoredItem:(FIRInstallationsStoredItem *)item;
+
+/**
+ * Creates a stored item with data from the object.
+ * @return Returns a `FIRInstallationsStoredItem` instance with the data from the object.
+ */
+- (FIRInstallationsStoredItem *)storedItem;
+
+/**
+ * The installation identifier.
+ * @returns Returns a string uniquely identifying the installation.
+ */
+- (NSString *)identifier;
+
+/**
+ * The installation identifier.
+ * @param appID A `FirebaseApp` identifier.
+ * @param appName A `FirebaseApp` name.
+ * @returns Returns a string uniquely identifying the installation.
+ */
++ (NSString *)identifierWithAppID:(NSString *)appID appName:(NSString *)appName;
+
+/**
+ * Generate a new Firebase Installation Identifier.
+ * @return Returns a 22 characters long globally unique string created based on UUID.
+ */
++ (NSString *)generateFID;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 101 - 0
FirebaseInstallations/Source/Library/FIRInstallationsItem.m

@@ -0,0 +1,101 @@
+/*
+ * 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 "FIRInstallationsItem.h"
+
+#import "FIRInstallationsStoredAuthToken.h"
+#import "FIRInstallationsStoredItem.h"
+
+@implementation FIRInstallationsItem
+
+- (instancetype)initWithAppID:(NSString *)appID firebaseAppName:(NSString *)firebaseAppName {
+  self = [super init];
+  if (self) {
+    _appID = [appID copy];
+    _firebaseAppName = [firebaseAppName copy];
+  }
+  return self;
+}
+
+- (nonnull id)copyWithZone:(nullable NSZone *)zone {
+  FIRInstallationsItem *clone = [[FIRInstallationsItem alloc] initWithAppID:self.appID
+                                                            firebaseAppName:self.firebaseAppName];
+  clone.firebaseInstallationID = [self.firebaseInstallationID copy];
+  clone.refreshToken = [self.refreshToken copy];
+  clone.authToken = [self.authToken copy];
+  clone.registrationStatus = self.registrationStatus;
+
+  return clone;
+}
+
+- (void)updateWithStoredItem:(FIRInstallationsStoredItem *)item {
+  self.firebaseInstallationID = item.firebaseInstallationID;
+  self.refreshToken = item.refreshToken;
+  self.authToken = item.authToken;
+  self.registrationStatus = item.registrationStatus;
+}
+
+- (FIRInstallationsStoredItem *)storedItem {
+  FIRInstallationsStoredItem *storedItem = [[FIRInstallationsStoredItem alloc] init];
+  storedItem.firebaseInstallationID = self.firebaseInstallationID;
+  storedItem.refreshToken = self.refreshToken;
+  storedItem.authToken = self.authToken;
+  storedItem.registrationStatus = self.registrationStatus;
+  return storedItem;
+}
+
+- (nonnull NSString *)identifier {
+  return [[self class] identifierWithAppID:self.appID appName:self.firebaseAppName];
+}
+
++ (NSString *)identifierWithAppID:(NSString *)appID appName:(NSString *)appName {
+  return [appID stringByAppendingString:appName];
+}
+
++ (NSString *)generateFID {
+  NSUUID *uuid = [NSUUID UUID];
+  uuid_t uuidBytes;
+  [uuid getUUIDBytes:uuidBytes];
+
+  NSData *uuidData = [NSData dataWithBytes:uuidBytes length:16];
+
+  uint8_t prefix = 0b01110000;
+  NSMutableData *fidData = [NSMutableData dataWithBytes:&prefix length:1];
+
+  [fidData appendData:uuidData];
+  NSString *fidString = [self base64URLEncodedStringWithData:fidData];
+
+  // TODO: Consider implementation which does not modify UUID.
+
+  // A valid FID has exactly 22 base64 characters, which is 132 bits, or 16.5 bytes.
+  // Our generated ID has 16 bytes UUID + 1 byte prefix which after encoding with base64 will become
+  // 23 characters plus 1 character for "=" padding.
+
+  // Remove the 23rd character that was added because of the extra 4 bits at the
+  // end of our 17 byte data. It should be pretty safe to do because UUID ends with the random part,
+  // so we will not affect probability of the collisions much.
+  // Also remove the '=' padding.
+  return [fidString substringWithRange:NSMakeRange(0, 22)];
+}
+
++ (NSString *)base64URLEncodedStringWithData:(NSData *)data {
+  NSString *string = [data base64EncodedStringWithOptions:0];
+  string = [string stringByReplacingOccurrencesOfString:@"/" withString:@"_"];
+  string = [string stringByReplacingOccurrencesOfString:@"+" withString:@"-"];
+  return string;
+}
+
+@end

+ 23 - 0
FirebaseInstallations/Source/Library/FIRInstallationsVersion.m

@@ -0,0 +1,23 @@
+/*
+ * 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 "FIRInstallationsVersion.h"
+
+// Convert the macro to a string
+#define STR(x) STR_EXPAND(x)
+#define STR_EXPAND(x) #x
+
+const char *const FIRInstallationsVersionStr = (const char *const)STR(FIRInstallations_LIB_VERSION);

+ 71 - 0
FirebaseInstallations/Source/Library/FIRSecureStorage.h

@@ -0,0 +1,71 @@
+/*
+ * 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>
+
+@class FBLPromise<ValueType>;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// The class provides a convenient abstraction on top of the iOS Keychain API to save data.
+@interface FIRSecureStorage : NSObject
+
+/**
+ * Get an object by key.
+ * @param key The key.
+ * @param objectClass The expected object class required by `NSSecureCoding`.
+ * @param accessGroup The Keychain Access Group.
+ *
+ * @return Returns a promise. It is resolved with an object stored by key if exists. It is resolved
+ * with `nil` when the object not found. It fails on a Keychain error.
+ */
+- (FBLPromise<id<NSSecureCoding>> *)getObjectForKey:(NSString *)key
+                                        objectClass:(Class)objectClass
+                                        accessGroup:(nullable NSString *)accessGroup;
+
+/**
+ * Saves the given object by the given key.
+ * @param object The object to store.
+ * @param key The key to store the object. If there is an existing object by the key, it will be
+ * overriden.
+ * @param accessGroup The Keychain Access Group.
+ *
+ * @return Returns which is resolved with `[NSNull null]` on success.
+ */
+- (FBLPromise<NSNull *> *)setObject:(id<NSSecureCoding>)object
+                             forKey:(NSString *)key
+                        accessGroup:(nullable NSString *)accessGroup;
+
+/**
+ * Removes the object by the given key.
+ * @param key The key to store the object. If there is an existing object by the key, it will be
+ * overriden.
+ * @param accessGroup The Keychain Access Group.
+ *
+ * @return Returns which is resolved with `[NSNull null]` on success.
+ */
+- (FBLPromise<NSNull *> *)removeObjectForKey:(NSString *)key
+                                 accessGroup:(nullable NSString *)accessGroup;
+
+#if TARGET_OS_OSX
+/// If not `nil`, then only this keychain will be used to save and read data (see
+/// `kSecMatchSearchList` and `kSecUseKeychain`. It is mostly intended to be used by unit tests.
+@property(nonatomic, nullable) SecKeychainRef keychainRef;
+#endif  // TARGET_OSX
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 331 - 0
FirebaseInstallations/Source/Library/FIRSecureStorage.m

@@ -0,0 +1,331 @@
+/*
+ * 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 "FIRSecureStorage.h"
+#import <Security/Security.h>
+
+#if __has_include(<FBLPromises/FBLPromises.h>)
+#import <FBLPromises/FBLPromises.h>
+#else
+#import "FBLPromises.h"
+#endif
+
+#import "FIRInstallationsErrorUtil.h"
+
+@interface FIRSecureStorage ()
+@property(nonatomic, readonly) dispatch_queue_t keychainQueue;
+@property(nonatomic, readonly) dispatch_queue_t inMemoryCacheQueue;
+@property(nonatomic, readonly) NSString *service;
+@property(nonatomic, readonly) NSCache<NSString *, id<NSSecureCoding>> *inMemoryCache;
+@end
+
+@implementation FIRSecureStorage
+
+- (instancetype)init {
+  NSCache *cache = [[NSCache alloc] init];
+  // Cache up to 5 installations.
+  cache.countLimit = 5;
+  return [self initWithService:@"com.firebase.FIRInstallations.installations" cache:cache];
+}
+
+- (instancetype)initWithService:(NSString *)service cache:(NSCache *)cache {
+  self = [super init];
+  if (self) {
+    _keychainQueue = dispatch_queue_create(
+        "com.firebase.FIRInstallations.FIRSecureStorage.Keychain", DISPATCH_QUEUE_SERIAL);
+    _inMemoryCacheQueue = dispatch_queue_create(
+        "com.firebase.FIRInstallations.FIRSecureStorage.InMemoryCache", DISPATCH_QUEUE_SERIAL);
+    _service = [service copy];
+    _inMemoryCache = cache;
+  }
+  return self;
+}
+
+#pragma mark - Public
+
+- (FBLPromise<id<NSSecureCoding>> *)getObjectForKey:(NSString *)key
+                                        objectClass:(Class)objectClass
+                                        accessGroup:(nullable NSString *)accessGroup {
+  return [FBLPromise onQueue:self.inMemoryCacheQueue
+                          do:^id _Nullable {
+                            // Return cached object or fail otherwise.
+                            id object = [self.inMemoryCache objectForKey:key];
+                            return object
+                                       ?: [[NSError alloc]
+                                              initWithDomain:FBLPromiseErrorDomain
+                                                        code:FBLPromiseErrorCodeValidationFailure
+                                                    userInfo:nil];
+                          }]
+      .recover(^id _Nullable(NSError *error) {
+        // Look for the object in the keychain.
+        return [self getObjectFromKeychainForKey:key
+                                     objectClass:objectClass
+                                     accessGroup:accessGroup];
+      });
+}
+
+- (FBLPromise<NSNull *> *)setObject:(id<NSSecureCoding>)object
+                             forKey:(NSString *)key
+                        accessGroup:(nullable NSString *)accessGroup {
+  return [FBLPromise onQueue:self.inMemoryCacheQueue
+                          do:^id _Nullable {
+                            // Save to the in-memory cache first.
+                            [self.inMemoryCache setObject:object forKey:[key copy]];
+                            return [NSNull null];
+                          }]
+      .thenOn(self.keychainQueue, ^id(id result) {
+        // Then store the object to the keychain.
+        NSDictionary *query = [self keychainQueryWithKey:key accessGroup:accessGroup];
+        NSError *error;
+        NSData *encodedObject = [self archiveDataForObject:object error:&error];
+        if (!encodedObject) {
+          return error;
+        }
+
+        if (![self setItem:encodedObject withQuery:query error:&error]) {
+          return error;
+        }
+
+        return [NSNull null];
+      });
+}
+
+- (FBLPromise<NSNull *> *)removeObjectForKey:(NSString *)key
+                                 accessGroup:(nullable NSString *)accessGroup {
+  return [FBLPromise onQueue:self.inMemoryCacheQueue
+                          do:^id _Nullable {
+                            [self.inMemoryCache removeObjectForKey:key];
+                            return nil;
+                          }]
+      .thenOn(self.keychainQueue, ^id(id result) {
+        NSDictionary *query = [self keychainQueryWithKey:key accessGroup:accessGroup];
+
+        NSError *error;
+        if (![self removeItemWithQuery:query error:&error]) {
+          return error;
+        }
+
+        return [NSNull null];
+      });
+}
+
+#pragma mark - Private
+
+- (FBLPromise<id<NSSecureCoding>> *)getObjectFromKeychainForKey:(NSString *)key
+                                                    objectClass:(Class)objectClass
+                                                    accessGroup:(nullable NSString *)accessGroup {
+  // Look for the object in the keychain.
+  return [FBLPromise onQueue:self.keychainQueue
+                          do:^id {
+                            NSDictionary *query = [self keychainQueryWithKey:key
+                                                                 accessGroup:accessGroup];
+                            NSError *error;
+                            NSData *encodedObject = [self getItemWithQuery:query error:&error];
+
+                            if (error) {
+                              return error;
+                            }
+                            if (!encodedObject) {
+                              return nil;
+                            }
+                            id object = [self unarchivedObjectOfClass:objectClass
+                                                             fromData:encodedObject
+                                                                error:&error];
+                            if (error) {
+                              return error;
+                            }
+
+                            return object;
+                          }]
+      .thenOn(self.inMemoryCacheQueue,
+              ^id<NSSecureCoding> _Nullable(id<NSSecureCoding> _Nullable object) {
+                // Save object to the in-memory cache if exists and return the object.
+                if (object) {
+                  [self.inMemoryCache setObject:object forKey:[key copy]];
+                }
+                return object;
+              });
+}
+
+- (void)resetInMemoryCache {
+  [self.inMemoryCache removeAllObjects];
+}
+
+#pragma mark - Keychain
+
+- (NSMutableDictionary<NSString *, id> *)keychainQueryWithKey:(NSString *)key
+                                                  accessGroup:(nullable NSString *)accessGroup {
+  NSMutableDictionary<NSString *, id> *query = [NSMutableDictionary dictionary];
+
+  query[(__bridge NSString *)kSecClass] = (__bridge NSString *)kSecClassGenericPassword;
+  query[(__bridge NSString *)kSecAttrService] = self.service;
+  query[(__bridge NSString *)kSecAttrAccount] = key;
+
+  if (accessGroup) {
+    query[(__bridge NSString *)kSecAttrAccessGroup] = accessGroup;
+  }
+
+#if TARGET_OS_OSX
+  if (self.keychainRef) {
+    query[(__bridge NSString *)kSecUseKeychain] = (__bridge id)(self.keychainRef);
+    query[(__bridge NSString *)kSecMatchSearchList] = @[ (__bridge id)(self.keychainRef) ];
+  }
+#endif  // TARGET_OSX
+
+  return query;
+}
+
+- (nullable NSData *)archiveDataForObject:(id<NSSecureCoding>)object error:(NSError **)outError {
+  NSData *archiveData;
+  if (@available(macOS 10.13, iOS 11.0, tvOS 11.0, *)) {
+    NSError *error;
+    archiveData = [NSKeyedArchiver archivedDataWithRootObject:object
+                                        requiringSecureCoding:YES
+                                                        error:&error];
+    if (error && outError) {
+      *outError = [FIRInstallationsErrorUtil keyedArchiverErrorWithError:error];
+    }
+  } else {
+    @try {
+      NSMutableData *data = [NSMutableData data];
+      NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc] initForWritingWithMutableData:data];
+      archiver.requiresSecureCoding = YES;
+
+      [archiver encodeObject:object forKey:NSKeyedArchiveRootObjectKey];
+      [archiver finishEncoding];
+
+      archiveData = [data copy];
+    } @catch (NSException *exception) {
+      if (outError) {
+        *outError = [FIRInstallationsErrorUtil keyedArchiverErrorWithException:exception];
+      }
+    }
+  }
+
+  return archiveData;
+}
+
+- (nullable id)unarchivedObjectOfClass:(Class)class
+                              fromData:(NSData *)data
+                                 error:(NSError **)outError {
+  id object;
+  if (@available(macOS 10.13, iOS 11.0, tvOS 11.0, *)) {
+    NSError *error;
+    object = [NSKeyedUnarchiver unarchivedObjectOfClass:class fromData:data error:&error];
+    if (error && outError) {
+      *outError = [FIRInstallationsErrorUtil keyedArchiverErrorWithError:error];
+    }
+  } else {
+    @try {
+      NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:data];
+      unarchiver.requiresSecureCoding = YES;
+
+      object = [unarchiver decodeObjectOfClass:class forKey:NSKeyedArchiveRootObjectKey];
+    } @catch (NSException *exception) {
+      if (outError) {
+        *outError = [FIRInstallationsErrorUtil keyedArchiverErrorWithException:exception];
+      }
+    }
+  }
+
+  return object;
+}
+
+- (nullable NSData *)getItemWithQuery:(NSDictionary *)query
+                                error:(NSError *_Nullable *_Nullable)outError {
+  NSMutableDictionary *mutableQuery = [query mutableCopy];
+
+  mutableQuery[(__bridge id)kSecReturnData] = @YES;
+  mutableQuery[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne;
+
+  CFArrayRef result = NULL;
+  OSStatus status =
+      SecItemCopyMatching((__bridge CFDictionaryRef)mutableQuery, (CFTypeRef *)&result);
+
+  if (status == noErr && result != NULL) {
+    if (outError) {
+      *outError = nil;
+    }
+
+    return (__bridge_transfer NSData *)result;
+  }
+
+  if (status == errSecItemNotFound) {
+    if (outError) {
+      *outError = nil;
+    }
+  } else {
+    if (outError) {
+      *outError = [FIRInstallationsErrorUtil keychainErrorWithFunction:@"SecItemCopyMatching"
+                                                                status:status];
+    }
+  }
+  return nil;
+}
+
+- (BOOL)setItem:(NSData *)item
+      withQuery:(NSDictionary *)query
+          error:(NSError *_Nullable *_Nullable)outError {
+  NSData *existingItem = [self getItemWithQuery:query error:outError];
+  if (outError && *outError) {
+    return NO;
+  }
+
+  NSMutableDictionary *mutableQuery = [query mutableCopy];
+  mutableQuery[(__bridge id)kSecAttrAccessible] =
+      (__bridge id)kSecAttrAccessibleAlwaysThisDeviceOnly;
+
+  OSStatus status;
+  if (!existingItem) {
+    mutableQuery[(__bridge id)kSecValueData] = item;
+    status = SecItemAdd((__bridge CFDictionaryRef)mutableQuery, NULL);
+  } else {
+    NSDictionary *attributes = @{(__bridge id)kSecValueData : item};
+    status = SecItemUpdate((__bridge CFDictionaryRef)query, (__bridge CFDictionaryRef)attributes);
+  }
+
+  if (status == noErr) {
+    if (outError) {
+      *outError = nil;
+    }
+    return YES;
+  }
+
+  NSString *function = existingItem ? @"SecItemUpdate" : @"SecItemAdd";
+  if (outError) {
+    *outError = [FIRInstallationsErrorUtil keychainErrorWithFunction:function status:status];
+  }
+  return NO;
+}
+
+- (BOOL)removeItemWithQuery:(NSDictionary *)query error:(NSError *_Nullable *_Nullable)outError {
+  OSStatus status = SecItemDelete((__bridge CFDictionaryRef)query);
+
+  if (status == noErr || status == errSecItemNotFound) {
+    if (outError) {
+      *outError = nil;
+    }
+    return YES;
+  }
+
+  if (outError) {
+    *outError = [FIRInstallationsErrorUtil keychainErrorWithFunction:@"SecItemDelete"
+                                                              status:status];
+  }
+  return NO;
+}
+
+@end

+ 48 - 0
FirebaseInstallations/Source/Library/IIDMigration/FIRInstallationsIIDStore.h

@@ -0,0 +1,48 @@
+/*
+ * 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>
+
+@class FBLPromise<ValueType>;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** The class encapsulates a port of a piece FirebaseInstanceID logic required to migrate IID. */
+@interface FIRInstallationsIIDStore : NSObject
+
+/**
+ * Retrieves existing IID if present.
+ * @return Returns a promise that is resolved with IID string if IID has been found or rejected with
+ * an error otherwise.
+ */
+- (FBLPromise<NSString *> *)existingIID;
+
+/**
+ * Deletes existing IID if present.
+ * @return Returns a promise that is resolved with `[NSNull null]` if the IID was successfully.
+ * deleted or was not found. The promise is rejected otherwise.
+ */
+- (FBLPromise<NSNull *> *)deleteExistingIID;
+
+#if TARGET_OS_OSX
+/// If not `nil`, then only this keychain will be used to save and read data (see
+/// `kSecMatchSearchList` and `kSecUseKeychain`. It is mostly intended to be used by unit tests.
+@property(nonatomic, nullable) SecKeychainRef keychainRef;
+#endif  // TARGET_OSX
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 237 - 0
FirebaseInstallations/Source/Library/IIDMigration/FIRInstallationsIIDStore.m

@@ -0,0 +1,237 @@
+/*
+ * 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 "FIRInstallationsIIDStore.h"
+
+#if __has_include(<FBLPromises/FBLPromises.h>)
+#import <FBLPromises/FBLPromises.h>
+#else
+#import "FBLPromises.h"
+#endif
+
+#import <CommonCrypto/CommonDigest.h>
+#import "FIRInstallationsErrorUtil.h"
+
+static NSString *const kFIRInstallationsIIDKeyPairPublicTagPrefix =
+    @"com.google.iid.keypair.public-";
+static NSString *const kFIRInstallationsIIDKeyPairPrivateTagPrefix =
+    @"com.google.iid.keypair.private-";
+static NSString *const kFIRInstallationsIIDCreationTimePlistKey = @"|S|cre";
+
+@implementation FIRInstallationsIIDStore
+
+- (FBLPromise<NSString *> *)existingIID {
+  return [FBLPromise onQueue:dispatch_get_global_queue(QOS_CLASS_UTILITY, 0)
+                          do:^id _Nullable {
+                            if (![self hasPlistIIDFlag]) {
+                              return nil;
+                            }
+
+                            NSData *IIDPublicKeyData = [self IIDPublicKeyData];
+                            return [self IIDWithPublicKeyData:IIDPublicKeyData];
+                          }]
+      .validate(^BOOL(NSString *_Nullable IID) {
+        return IID.length > 0;
+      });
+}
+
+- (FBLPromise<NSNull *> *)deleteExistingIID {
+  return [FBLPromise onQueue:dispatch_get_global_queue(QOS_CLASS_UTILITY, 0)
+                          do:^id _Nullable {
+                            NSError *error;
+                            if (![self deleteIIDFlagFromPlist:&error]) {
+                              return error;
+                            }
+
+                            if (![self deleteIID:&error]) {
+                              return error;
+                            }
+
+                            return [NSNull null];
+                          }];
+}
+
+#pragma mark - IID decoding
+
+- (NSString *)IIDWithPublicKeyData:(NSData *)publicKeyData {
+  NSData *publicKeySHA1 = [self sha1WithData:publicKeyData];
+
+  const uint8_t *bytes = publicKeySHA1.bytes;
+  NSMutableData *identityData = [NSMutableData dataWithData:publicKeySHA1];
+
+  uint8_t b0 = bytes[0];
+  // Take the first byte and make the initial four 7 by initially making the initial 4 bits 0
+  // and then adding 0x70 to it.
+  b0 = 0x70 + (0xF & b0);
+  // failsafe should give you back b0 itself
+  b0 = (b0 & 0xFF);
+  [identityData replaceBytesInRange:NSMakeRange(0, 1) withBytes:&b0];
+  NSData *data = [identityData subdataWithRange:NSMakeRange(0, 8 * sizeof(Byte))];
+  return [self base64URLEncodedStringWithData:data];
+}
+
+- (NSData *)sha1WithData:(NSData *)data {
+  unsigned int outputLength = CC_SHA1_DIGEST_LENGTH;
+  unsigned char output[outputLength];
+  unsigned int length = (unsigned int)[data length];
+
+  CC_SHA1(data.bytes, length, output);
+  return [NSData dataWithBytes:output length:outputLength];
+}
+
+- (NSString *)base64URLEncodedStringWithData:(NSData *)data {
+  NSString *string = [data base64EncodedStringWithOptions:0];
+  string = [string stringByReplacingOccurrencesOfString:@"/" withString:@"_"];
+  string = [string stringByReplacingOccurrencesOfString:@"+" withString:@"-"];
+  string = [string stringByReplacingOccurrencesOfString:@"=" withString:@""];
+  return string;
+}
+
+#pragma mark - Keychain
+
+- (NSData *)IIDPublicKeyData {
+  NSString *tag = [self keychainKeyTagWithPrefix:kFIRInstallationsIIDKeyPairPublicTagPrefix];
+  NSDictionary *query = [self keyPairQueryWithTag:tag returnData:YES];
+
+  CFTypeRef keyRef = NULL;
+  OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)&keyRef);
+
+  if (status != noErr) {
+    if (keyRef) {
+      CFRelease(keyRef);
+    }
+    return nil;
+  }
+
+  return (__bridge NSData *)keyRef;
+}
+
+- (BOOL)deleteIID:(NSError **)outError {
+  if (![self deleteKeychainKeyWithTagPrefix:kFIRInstallationsIIDKeyPairPublicTagPrefix
+                                      error:outError]) {
+    return NO;
+  }
+
+  if (![self deleteKeychainKeyWithTagPrefix:kFIRInstallationsIIDKeyPairPrivateTagPrefix
+                                      error:outError]) {
+    return NO;
+  }
+
+  return YES;
+}
+
+- (BOOL)deleteKeychainKeyWithTagPrefix:(NSString *)tagPrefix error:(NSError **)outError {
+  NSString *keyTag = [self keychainKeyTagWithPrefix:kFIRInstallationsIIDKeyPairPublicTagPrefix];
+  NSDictionary *keyQuery = [self keyPairQueryWithTag:keyTag returnData:NO];
+
+  OSStatus status = SecItemDelete((__bridge CFDictionaryRef)keyQuery);
+
+  // When item is not found, it should NOT be considered as an error. The operation should
+  // continue.
+  if (status != noErr && status != errSecItemNotFound) {
+    FIRInstallationsItemSetErrorToPointer(
+        [FIRInstallationsErrorUtil keychainErrorWithFunction:@"SecItemDelete" status:status],
+        outError);
+    return NO;
+  }
+
+  return YES;
+}
+
+- (NSDictionary *)keyPairQueryWithTag:(NSString *)tag returnData:(BOOL)shouldReturnData {
+  NSMutableDictionary *query = [NSMutableDictionary dictionary];
+  NSData *tagData = [tag dataUsingEncoding:NSUTF8StringEncoding];
+
+  query[(__bridge id)kSecClass] = (__bridge id)kSecClassKey;
+  query[(__bridge id)kSecAttrApplicationTag] = tagData;
+  query[(__bridge id)kSecAttrKeyType] = (__bridge id)kSecAttrKeyTypeRSA;
+  if (shouldReturnData) {
+    query[(__bridge id)kSecReturnData] = @(YES);
+  }
+
+#if TARGET_OS_OSX
+  if (self.keychainRef) {
+    query[(__bridge NSString *)kSecMatchSearchList] = @[ (__bridge id)(self.keychainRef) ];
+  }
+#endif  // TARGET_OSX
+
+  return query;
+}
+
+- (NSString *)keychainKeyTagWithPrefix:(NSString *)prefix {
+  NSString *mainAppBundleID = [[NSBundle mainBundle] bundleIdentifier];
+  if (mainAppBundleID.length == 0) {
+    return nil;
+  }
+  return [NSString stringWithFormat:@"%@%@", prefix, mainAppBundleID];
+}
+
+- (NSString *)mainbundleIdentifier {
+  NSString *bundleIdentifier = [[NSBundle mainBundle] bundleIdentifier];
+  if (!bundleIdentifier.length) {
+    return nil;
+  }
+  return bundleIdentifier;
+}
+
+#pragma mark - Plist
+
+- (BOOL)deleteIIDFlagFromPlist:(NSError **)outError {
+  NSString *path = [self plistPath];
+  if (![[NSFileManager defaultManager] fileExistsAtPath:path]) {
+    return YES;
+  }
+
+  NSMutableDictionary *plistContent = [[NSMutableDictionary alloc] initWithContentsOfFile:path];
+  plistContent[kFIRInstallationsIIDCreationTimePlistKey] = nil;
+
+  if (@available(macOS 10.13, iOS 11.0, tvOS 11.0, *)) {
+    return [plistContent writeToURL:[NSURL fileURLWithPath:path] error:outError];
+  }
+
+  return [plistContent writeToFile:path atomically:YES];
+}
+
+- (BOOL)hasPlistIIDFlag {
+  NSString *path = [self plistPath];
+  if (![[NSFileManager defaultManager] fileExistsAtPath:path]) {
+    return NO;
+  }
+
+  NSDictionary *plistContent = [[NSDictionary alloc] initWithContentsOfFile:path];
+  return plistContent[kFIRInstallationsIIDCreationTimePlistKey] != nil;
+}
+
+- (NSString *)plistPath {
+  NSString *plistNameWithExtension = @"com.google.iid-keypair.plist";
+  NSString *_subDirectoryName = @"Google/FirebaseInstanceID";
+
+  NSArray *directoryPaths =
+      NSSearchPathForDirectoriesInDomains([self supportedDirectory], NSUserDomainMask, YES);
+  NSArray *components = @[ directoryPaths.lastObject, _subDirectoryName, plistNameWithExtension ];
+
+  return [NSString pathWithComponents:components];
+}
+
+- (NSSearchPathDirectory)supportedDirectory {
+#if TARGET_OS_TV
+  return NSCachesDirectory;
+#else
+  return NSApplicationSupportDirectory;
+#endif
+}
+
+@end

+ 58 - 0
FirebaseInstallations/Source/Library/InstallationsAPI/FIRInstallationsAPIService.h

@@ -0,0 +1,58 @@
+/*
+ * 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>
+
+@class FBLPromise<ValueType>;
+@class FIRInstallationsItem;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * The class is responsible for interacting with HTTP REST API for Installations.
+ */
+@interface FIRInstallationsAPIService : NSObject
+
+/**
+ * The default initializer.
+ * @param APIKey The Firebase project API key (see `FIROptions.APIKey`).
+ * @param projectID The Firebase project ID (see `FIROptions.projectID`).
+ */
+- (instancetype)initWithAPIKey:(NSString *)APIKey projectID:(NSString *)projectID;
+
+/**
+ * Sends a request to register a new FID to get auth and refresh tokens.
+ * @param installation The `FIRInstallationsItem` instance with the FID to register.
+ * @return A promise that is resolved with a new `FIRInstallationsItem` instance with valid tokens.
+ * It is rejected with an error in case of a failure.
+ */
+- (FBLPromise<FIRInstallationsItem *> *)registerInstallation:(FIRInstallationsItem *)installation;
+
+- (FBLPromise<FIRInstallationsItem *> *)refreshAuthTokenForInstallation:
+    (FIRInstallationsItem *)installation;
+
+/**
+ * Sends a request to delete the installation, related auth tokens and all related data from the
+ * server.
+ * @param installation The installation to delete.
+ * @return Returns a promise that is resolved with the passed installation on successful deletion or
+ * is rejected with an error otherwise.
+ */
+- (FBLPromise<FIRInstallationsItem *> *)deleteInstallation:(FIRInstallationsItem *)installation;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 269 - 0
FirebaseInstallations/Source/Library/InstallationsAPI/FIRInstallationsAPIService.m

@@ -0,0 +1,269 @@
+/*
+ * 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 "FIRInstallationsAPIService.h"
+
+#import <FirebaseInstallations/FIRInstallationsVersion.h>
+
+#if __has_include(<FBLPromises/FBLPromises.h>)
+#import <FBLPromises/FBLPromises.h>
+#else
+#import "FBLPromises.h"
+#endif
+
+#import "FIRInstallationsErrorUtil.h"
+#import "FIRInstallationsItem+RegisterInstallationAPI.h"
+
+NSString *const kFIRInstallationsAPIBaseURL = @"https://firebaseinstallations.googleapis.com";
+NSString *const kFIRInstallationsAPIKey = @"X-Goog-Api-Key";
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FIRInstallationsURLSessionResponse : NSObject
+@property(nonatomic) NSHTTPURLResponse *HTTPResponse;
+@property(nonatomic) NSData *data;
+
+- (instancetype)initWithResponse:(NSHTTPURLResponse *)response data:(nullable NSData *)data;
+@end
+
+@implementation FIRInstallationsURLSessionResponse
+
+- (instancetype)initWithResponse:(NSHTTPURLResponse *)response data:(nullable NSData *)data {
+  self = [super init];
+  if (self) {
+    _HTTPResponse = response;
+    _data = data ?: [NSData data];
+  }
+  return self;
+}
+
+@end
+
+@interface FIRInstallationsAPIService ()
+@property(nonatomic, readonly) NSURLSession *URLSession;
+@property(nonatomic, readonly) NSString *APIKey;
+@property(nonatomic, readonly) NSString *projectID;
+@end
+
+NS_ASSUME_NONNULL_END
+
+@implementation FIRInstallationsAPIService
+
+- (instancetype)initWithAPIKey:(NSString *)APIKey projectID:(NSString *)projectID {
+  NSURLSession *URLSession = [NSURLSession
+      sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]];
+  return [self initWithURLSession:URLSession APIKey:APIKey projectID:projectID];
+}
+
+/// The initializer for tests.
+- (instancetype)initWithURLSession:(NSURLSession *)URLSession
+                            APIKey:(NSString *)APIKey
+                         projectID:(NSString *)projectID {
+  self = [super init];
+  if (self) {
+    _URLSession = URLSession;
+    _APIKey = [APIKey copy];
+    _projectID = [projectID copy];
+  }
+  return self;
+}
+
+#pragma mark - Public
+
+- (FBLPromise<FIRInstallationsItem *> *)registerInstallation:(FIRInstallationsItem *)installation {
+  NSURLRequest *request = [self registerRequestWithInstallation:installation];
+  return [self sendURLRequest:request].then(
+      ^id _Nullable(FIRInstallationsURLSessionResponse *response) {
+        return [self registerredInstalationWithInstallation:installation serverResponse:response];
+      });
+}
+
+- (FBLPromise<FIRInstallationsItem *> *)refreshAuthTokenForInstallation:
+    (FIRInstallationsItem *)installation {
+  NSURLRequest *request = [self authTokenRequestWithInstallation:installation];
+  return [self sendURLRequest:request]
+      .then(^FBLPromise<FIRInstallationsStoredAuthToken *> *(
+          FIRInstallationsURLSessionResponse *response) {
+        return [self authTokenWithServerResponse:response];
+      })
+      .then(^FIRInstallationsItem *(FIRInstallationsStoredAuthToken *authToken) {
+        FIRInstallationsItem *updatedInstallation = [installation copy];
+        updatedInstallation.authToken = authToken;
+        return updatedInstallation;
+      });
+}
+
+- (FBLPromise<NSNull *> *)deleteInstallation:(FIRInstallationsItem *)installation {
+  NSURLRequest *request = [self deleteInstallationRequestWithInstallation:installation];
+  return [self sendURLRequest:request]
+      .then(^id(FIRInstallationsURLSessionResponse *response) {
+        return [self validateHTTPResponseSatatusCode:response];
+      })
+      .then(^id(id result) {
+        // Return the original installation on success.
+        return installation;
+      });
+}
+
+#pragma mark - Register Installation
+
+- (NSURLRequest *)registerRequestWithInstallation:(FIRInstallationsItem *)installation {
+  NSString *URLString = [NSString stringWithFormat:@"%@/v1/projects/%@/installations/",
+                                                   kFIRInstallationsAPIBaseURL, self.projectID];
+  NSURL *URL = [NSURL URLWithString:URLString];
+
+  NSDictionary *bodyDict = @{
+    @"fid" : installation.firebaseInstallationID,
+    @"authVersion" : @"FIS_v2",
+    @"appId" : installation.appID,
+    @"sdkVersion" : [self SDKVersion]
+  };
+
+  return [self requestWithURL:URL HTTPMethod:@"POST" bodyDict:bodyDict refreshToken:nil];
+}
+
+- (FBLPromise<FIRInstallationsItem *> *)
+    registerredInstalationWithInstallation:(FIRInstallationsItem *)installation
+                            serverResponse:(FIRInstallationsURLSessionResponse *)response {
+  return [self validateHTTPResponseSatatusCode:response].then(^id(id result) {
+    NSError *error;
+    FIRInstallationsItem *registeredInstallation =
+        [installation registeredInstallationWithJSONData:response.data
+                                                    date:[NSDate date]
+                                                   error:&error];
+    if (registeredInstallation == nil) {
+      return error;
+    }
+
+    return registeredInstallation;
+  });
+}
+
+#pragma mark - Auth token
+
+- (NSURLRequest *)authTokenRequestWithInstallation:(FIRInstallationsItem *)installation {
+  NSString *URLString =
+      [NSString stringWithFormat:@"%@/v1/projects/%@/installations/%@/authTokens:generate",
+                                 kFIRInstallationsAPIBaseURL, self.projectID,
+                                 installation.firebaseInstallationID];
+  NSURL *URL = [NSURL URLWithString:URLString];
+
+  NSDictionary *bodyDict = @{@"installation" : @{@"sdkVersion" : [self SDKVersion]}};
+  return [self requestWithURL:URL
+                   HTTPMethod:@"POST"
+                     bodyDict:bodyDict
+                 refreshToken:installation.refreshToken];
+}
+
+- (FBLPromise<FIRInstallationsStoredAuthToken *> *)authTokenWithServerResponse:
+    (FIRInstallationsURLSessionResponse *)response {
+  return [self validateHTTPResponseSatatusCode:response].then(^id(id result) {
+    NSError *error;
+    FIRInstallationsStoredAuthToken *token =
+        [FIRInstallationsItem authTokenWithGenerateTokenAPIJSONData:response.data
+                                                               date:[NSDate date]
+                                                              error:&error];
+    if (token == nil) {
+      return error;
+    }
+
+    return token;
+  });
+}
+
+#pragma mark - Delete Installation
+
+- (NSURLRequest *)deleteInstallationRequestWithInstallation:(FIRInstallationsItem *)installation {
+  NSString *URLString = [NSString stringWithFormat:@"%@/v1/projects/%@/installations/%@/",
+                                                   kFIRInstallationsAPIBaseURL, self.projectID,
+                                                   installation.firebaseInstallationID];
+  NSURL *URL = [NSURL URLWithString:URLString];
+
+  return [self requestWithURL:URL
+                   HTTPMethod:@"DELETE"
+                     bodyDict:@{}
+                 refreshToken:installation.refreshToken];
+}
+
+#pragma mark - URL Request
+- (NSURLRequest *)requestWithURL:(NSURL *)requestURL
+                      HTTPMethod:(NSString *)HTTPMethod
+                        bodyDict:(NSDictionary *)bodyDict
+                    refreshToken:(nullable NSString *)refreshToken {
+  NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:requestURL];
+  request.HTTPMethod = HTTPMethod;
+  [request addValue:self.APIKey forHTTPHeaderField:kFIRInstallationsAPIKey];
+  [self setJSONHTTPBody:bodyDict forRequest:request];
+  if (refreshToken) {
+    NSString *authHeader = [NSString stringWithFormat:@"FIS_v2 %@", refreshToken];
+    [request setValue:authHeader forHTTPHeaderField:@"Authorization"];
+  }
+  return [request copy];
+}
+
+- (FBLPromise<FIRInstallationsURLSessionResponse *> *)sendURLRequest:(NSURLRequest *)request {
+  return [FBLPromise async:^(FBLPromiseFulfillBlock fulfill, FBLPromiseRejectBlock reject) {
+    NSLog(@"Sending request: %@, body:%@, headers: %@", request,
+          [[NSString alloc] initWithData:request.HTTPBody encoding:NSUTF8StringEncoding],
+          request.allHTTPHeaderFields);
+    [[self.URLSession
+        dataTaskWithRequest:request
+          completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response,
+                              NSError *_Nullable error) {
+            if (error) {
+              reject(error);
+            } else {
+              fulfill([[FIRInstallationsURLSessionResponse alloc]
+                  initWithResponse:(NSHTTPURLResponse *)response
+                              data:data]);
+            }
+          }] resume];
+  }];
+}
+
+- (FBLPromise<FIRInstallationsURLSessionResponse *> *)validateHTTPResponseSatatusCode:
+    (FIRInstallationsURLSessionResponse *)response {
+  NSInteger statusCode = response.HTTPResponse.statusCode;
+  return [FBLPromise do:^id _Nullable {
+    if (statusCode < 200 || statusCode >= 300) {
+      NSLog(@"Unexpected API response: %@, body: %@", response.HTTPResponse,
+            [[NSString alloc] initWithData:response.data encoding:NSUTF8StringEncoding]);
+      return [FIRInstallationsErrorUtil APIErrorWithHTTPResponse:response.HTTPResponse
+                                                            data:response.data];
+    }
+    return response;
+  }];
+}
+
+- (NSString *)SDKVersion {
+  return [NSString stringWithUTF8String:FIRInstallationsVersionStr];
+}
+
+#pragma mark - JSON
+
+- (void)setJSONHTTPBody:(NSDictionary<NSString *, id> *)body
+             forRequest:(NSMutableURLRequest *)request {
+  [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
+
+  NSError *error;
+  NSData *JSONData = [NSJSONSerialization dataWithJSONObject:body options:0 error:&error];
+  if (JSONData == nil) {
+    // TODO: Log or return an error.
+  }
+  request.HTTPBody = JSONData;
+}
+
+@end

+ 53 - 0
FirebaseInstallations/Source/Library/InstallationsAPI/FIRInstallationsItem+RegisterInstallationAPI.h

@@ -0,0 +1,53 @@
+/*
+ * 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 "FIRInstallationsItem.h"
+
+@class FIRInstallationsStoredAuthToken;
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FIRInstallationsItem (RegisterInstallationAPI)
+
+/**
+ * Parses and validates the Register Installation API response and returns a corresponding
+ * `FIRInstallationsItem` instance on success.
+ * @param JSONData The data with JSON encoded API response.
+ * @param date The Auth Token expiration date will be calculated as `date` +
+ * `response.authToken.expiresIn`. For most of the cases `[NSDate date]` should be passed there. A
+ * different value may be passed e.g. for unit tests.
+ * @param outError A pointer to assign a specific `NSError` instance in case of failure. No error is
+ * assigned in case of success.
+ * @return Returns a new `FIRInstallationsItem` instance in the success case or `nil` otherwise.
+ */
+- (nullable FIRInstallationsItem *)registeredInstallationWithJSONData:(NSData *)JSONData
+                                                                 date:(NSDate *)date
+                                                                error:
+                                                                    (NSError *_Nullable *)outError;
+
++ (nullable FIRInstallationsStoredAuthToken *)authTokenWithGenerateTokenAPIJSONData:(NSData *)data
+                                                                               date:(NSDate *)date
+                                                                              error:(NSError **)
+                                                                                        outError;
+
++ (nullable FIRInstallationsStoredAuthToken *)authTokenWithJSONDict:
+                                                  (NSDictionary<NSString *, id> *)dict
+                                                               date:(NSDate *)date
+                                                              error:(NSError **)outError;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 140 - 0
FirebaseInstallations/Source/Library/InstallationsAPI/FIRInstallationsItem+RegisterInstallationAPI.m

@@ -0,0 +1,140 @@
+/*
+ * 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 "FIRInstallationsItem+RegisterInstallationAPI.h"
+
+#import "FIRInstallationsErrorUtil.h"
+#import "FIRInstallationsStoredAuthToken.h"
+
+@implementation FIRInstallationsItem (RegisterInstallationAPI)
+
+- (nullable FIRInstallationsItem *)
+    registeredInstallationWithJSONData:(NSData *)data
+                                  date:(NSDate *)date
+                                 error:(NSError *__autoreleasing _Nullable *_Nullable)outError {
+  NSDictionary *responseJSON = [FIRInstallationsItem dictionaryFromJSONData:data error:outError];
+  if (!responseJSON) {
+    return nil;
+  }
+
+  NSString *refreshToken = [FIRInstallationsItem validStringOrNilForKey:@"refreshToken"
+                                                               fromDict:responseJSON];
+  if (refreshToken == nil) {
+    FIRInstallationsItemSetErrorToPointer(
+        [FIRInstallationsErrorUtil FIDRegestrationErrorWithResponseMissingField:@"refreshToken"],
+        outError);
+    return nil;
+  }
+
+  NSDictionary *authTokenDict = responseJSON[@"authToken"];
+  if (![authTokenDict isKindOfClass:[NSDictionary class]]) {
+    FIRInstallationsItemSetErrorToPointer(
+        [FIRInstallationsErrorUtil FIDRegestrationErrorWithResponseMissingField:@"authToken"],
+        outError);
+    return nil;
+  }
+
+  FIRInstallationsStoredAuthToken *authToken =
+      [FIRInstallationsItem authTokenWithJSONDict:authTokenDict date:date error:outError];
+  if (authToken == nil) {
+    return nil;
+  }
+
+  FIRInstallationsItem *installation =
+      [[FIRInstallationsItem alloc] initWithAppID:self.appID firebaseAppName:self.firebaseAppName];
+  installation.firebaseInstallationID = self.firebaseInstallationID;
+  installation.refreshToken = refreshToken;
+  installation.authToken = authToken;
+  installation.registrationStatus = FIRInstallationStatusRegistered;
+
+  return installation;
+}
+
+#pragma mark - Auth token
+
++ (nullable FIRInstallationsStoredAuthToken *)authTokenWithGenerateTokenAPIJSONData:(NSData *)data
+                                                                               date:(NSDate *)date
+                                                                              error:(NSError **)
+                                                                                        outError {
+  NSDictionary *dict = [self dictionaryFromJSONData:data error:outError];
+  if (!dict) {
+    return nil;
+  }
+
+  return [self authTokenWithJSONDict:dict date:date error:outError];
+}
+
++ (nullable FIRInstallationsStoredAuthToken *)authTokenWithJSONDict:
+                                                  (NSDictionary<NSString *, id> *)dict
+                                                               date:(NSDate *)date
+                                                              error:(NSError **)outError {
+  NSString *token = [self validStringOrNilForKey:@"token" fromDict:dict];
+  if (token == nil) {
+    FIRInstallationsItemSetErrorToPointer(
+        [FIRInstallationsErrorUtil FIDRegestrationErrorWithResponseMissingField:@"authToken.token"],
+        outError);
+    return nil;
+  }
+
+  NSString *expiresInString = [self validStringOrNilForKey:@"expiresIn" fromDict:dict];
+  if (expiresInString == nil) {
+    FIRInstallationsItemSetErrorToPointer(
+        [FIRInstallationsErrorUtil
+            FIDRegestrationErrorWithResponseMissingField:@"authToken.expiresIn"],
+        outError);
+    return nil;
+  }
+
+  // The response should contain the string in format like "604800s".
+  // The server should never response with anything else except seconds.
+  // Just drop the last character and parse a number from string.
+  NSString *expiresInSeconds = [expiresInString substringToIndex:expiresInString.length - 1];
+  NSTimeInterval expiresIn = [expiresInSeconds doubleValue];
+  NSDate *experationDate = [date dateByAddingTimeInterval:expiresIn];
+
+  FIRInstallationsStoredAuthToken *authToken = [[FIRInstallationsStoredAuthToken alloc] init];
+  authToken.status = FIRInstallationsAuthTokenStatusTokenReceived;
+  authToken.token = token;
+  authToken.expirationDate = experationDate;
+
+  return authToken;
+}
+
+#pragma mark - JSON
+
++ (nullable NSDictionary<NSString *, id> *)dictionaryFromJSONData:(NSData *)data
+                                                            error:(NSError **)outError {
+  NSError *error;
+  NSDictionary *responseJSON = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
+
+  if (![responseJSON isKindOfClass:[NSDictionary class]]) {
+    FIRInstallationsItemSetErrorToPointer([FIRInstallationsErrorUtil JSONSerializationError:error],
+                                          outError);
+    return nil;
+  }
+
+  return responseJSON;
+}
+
++ (NSString *)validStringOrNilForKey:(NSString *)key fromDict:(NSDictionary *)dict {
+  NSString *string = dict[key];
+  if ([string isKindOfClass:[NSString class]] && string.length > 0) {
+    return string;
+  }
+  return nil;
+}
+
+@end

+ 42 - 0
FirebaseInstallations/Source/Library/InstallationsIDController/FIRInstallationsIDController.h

@@ -0,0 +1,42 @@
+/*
+ * 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>
+
+NS_ASSUME_NONNULL_BEGIN
+
+@class FBLPromise<ValueType>;
+@class FIRInstallationsItem;
+
+/**
+ * The class is responsible for managing FID for a given `FIRApp`.
+ */
+@interface FIRInstallationsIDController : NSObject
+
+- (instancetype)initWithGoogleAppID:(NSString *)appID
+                            appName:(NSString *)appName
+                             APIKey:(NSString *)APIKey
+                          projectID:(NSString *)projectID;
+
+- (FBLPromise<FIRInstallationsItem *> *)getInstallationItem;
+
+- (FBLPromise<FIRInstallationsItem *> *)getAuthTokenForcingRefresh:(BOOL)forceRefresh;
+
+- (FBLPromise<NSNull *> *)deleteInstallation;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 323 - 0
FirebaseInstallations/Source/Library/InstallationsIDController/FIRInstallationsIDController.m

@@ -0,0 +1,323 @@
+/*
+ * 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 "FIRInstallationsIDController.h"
+
+#if __has_include(<FBLPromises/FBLPromises.h>)
+#import <FBLPromises/FBLPromises.h>
+#else
+#import "FBLPromises.h"
+#endif
+
+#import "FIRInstallationsAPIService.h"
+#import "FIRInstallationsErrorUtil.h"
+#import "FIRInstallationsIIDStore.h"
+#import "FIRInstallationsItem.h"
+#import "FIRInstallationsSingleOperationPromiseCache.h"
+#import "FIRInstallationsStore.h"
+#import "FIRInstallationsStoredAuthToken.h"
+#import "FIRSecureStorage.h"
+
+const NSNotificationName FIRInstallationIDDidChangeNotification =
+    @"FIRInstallationIDDidChangeNotification";
+NSTimeInterval const kFIRInstallationsTokenExpirationThreshold = 60 * 60;  // 1 hour.
+
+@interface FIRInstallationsIDController ()
+@property(nonatomic, readonly) NSString *appID;
+@property(nonatomic, readonly) NSString *appName;
+
+@property(nonatomic, readonly) FIRInstallationsStore *installationsStore;
+@property(nonatomic, readonly) FIRInstallationsIIDStore *IIDStore;
+
+@property(nonatomic, readonly) FIRInstallationsAPIService *APIService;
+
+@property(nonatomic, readonly) FIRInstallationsSingleOperationPromiseCache<FIRInstallationsItem *>
+    *getInstallationPromiseCache;
+@property(nonatomic, readonly)
+    FIRInstallationsSingleOperationPromiseCache<FIRInstallationsItem *> *authTokenPromiseCache;
+@property(nonatomic, readonly) FIRInstallationsSingleOperationPromiseCache<FIRInstallationsItem *>
+    *authTokenForcingRefreshPromiseCache;
+@property(nonatomic, readonly)
+    FIRInstallationsSingleOperationPromiseCache<NSNull *> *deleteInstallationPromiseCache;
+@end
+
+@implementation FIRInstallationsIDController
+
+- (instancetype)initWithGoogleAppID:(NSString *)appID
+                            appName:(NSString *)appName
+                             APIKey:(NSString *)APIKey
+                          projectID:(NSString *)projectID {
+  FIRSecureStorage *secureStorage = [[FIRSecureStorage alloc] init];
+  FIRInstallationsStore *installationsStore =
+      [[FIRInstallationsStore alloc] initWithSecureStorage:secureStorage accessGroup:nil];
+  FIRInstallationsAPIService *apiService =
+      [[FIRInstallationsAPIService alloc] initWithAPIKey:APIKey projectID:projectID];
+  FIRInstallationsIIDStore *IIDStore = [[FIRInstallationsIIDStore alloc] init];
+
+  return [self initWithGoogleAppID:appID
+                           appName:appName
+                installationsStore:installationsStore
+                        APIService:apiService
+                          IIDStore:IIDStore];
+}
+
+/// The initializer is supposed to be used by tests to inject `installationsStore`.
+- (instancetype)initWithGoogleAppID:(NSString *)appID
+                            appName:(NSString *)appName
+                 installationsStore:(FIRInstallationsStore *)installationsStore
+                         APIService:(FIRInstallationsAPIService *)APIService
+                           IIDStore:(FIRInstallationsIIDStore *)IIDStore {
+  self = [super init];
+  if (self) {
+    _appID = appID;
+    _appName = appName;
+    _installationsStore = installationsStore;
+    _APIService = APIService;
+    _IIDStore = IIDStore;
+
+    __weak FIRInstallationsIDController *weakSelf = self;
+
+    _getInstallationPromiseCache = [[FIRInstallationsSingleOperationPromiseCache alloc]
+        initWithNewOperationHandler:^FBLPromise *_Nonnull {
+          FIRInstallationsIDController *strongSelf = weakSelf;
+          return [strongSelf createGetInstallationItemPromise];
+        }];
+
+    _authTokenPromiseCache = [[FIRInstallationsSingleOperationPromiseCache alloc]
+        initWithNewOperationHandler:^FBLPromise *_Nonnull {
+          FIRInstallationsIDController *strongSelf = weakSelf;
+          return [strongSelf installationWithValidAuthTokenForcingRefresh:NO];
+        }];
+
+    _authTokenForcingRefreshPromiseCache = [[FIRInstallationsSingleOperationPromiseCache alloc]
+        initWithNewOperationHandler:^FBLPromise *_Nonnull {
+          FIRInstallationsIDController *strongSelf = weakSelf;
+          return [strongSelf installationWithValidAuthTokenForcingRefresh:YES];
+        }];
+
+    _deleteInstallationPromiseCache = [[FIRInstallationsSingleOperationPromiseCache alloc]
+        initWithNewOperationHandler:^FBLPromise *_Nonnull {
+          FIRInstallationsIDController *strongSelf = weakSelf;
+          return [strongSelf createDeleteInstallationPromise];
+        }];
+  }
+  return self;
+}
+
+#pragma mark - Get Installation.
+
+- (FBLPromise<FIRInstallationsItem *> *)getInstallationItem {
+  return [self.getInstallationPromiseCache getExistingPendingOrCreateNewPromise];
+}
+
+- (FBLPromise<FIRInstallationsItem *> *)createGetInstallationItemPromise {
+  FBLPromise<FIRInstallationsItem *> *installationItemPromise =
+      [self getStoredInstallation].recover(^id(NSError *error) {
+        return [self createAndSaveFID];
+      });
+
+  // Initiate registration process on success if needed, but return the installation without waiting
+  // for it.
+  installationItemPromise.then(^id(FIRInstallationsItem *installation) {
+    [self getAuthTokenForcingRefresh:NO];
+    return nil;
+  });
+
+  return installationItemPromise;
+}
+
+- (FBLPromise<FIRInstallationsItem *> *)getStoredInstallation {
+  return [self.installationsStore installationForAppID:self.appID appName:self.appName].validate(
+      ^BOOL(FIRInstallationsItem *installation) {
+        BOOL isValid = NO;
+        switch (installation.registrationStatus) {
+          case FIRInstallationStatusUnregistered:
+          case FIRInstallationStatusRegistered:
+            isValid = YES;
+            break;
+
+          case FIRInstallationStatusUnknown:
+            isValid = NO;
+            break;
+        }
+
+        return isValid;
+      });
+}
+
+- (FBLPromise<FIRInstallationsItem *> *)createAndSaveFID {
+  return [self migrateOrGenerateFID]
+      .then(^FBLPromise<FIRInstallationsItem *> *(NSString *FID) {
+        return [self createAndSaveInstallationWithFID:FID];
+      })
+      .then(^FIRInstallationsItem *(FIRInstallationsItem *installation) {
+        // TODO: Consider passing additional info like FIRApp ID or maybe even FIRInstallationsItem
+        [[NSNotificationCenter defaultCenter]
+            postNotificationName:FIRInstallationIDDidChangeNotification
+                          object:nil];
+        return installation;
+      });
+}
+
+- (FBLPromise<FIRInstallationsItem *> *)createAndSaveInstallationWithFID:(NSString *)FID {
+  FIRInstallationsItem *installation = [[FIRInstallationsItem alloc] initWithAppID:self.appID
+                                                                   firebaseAppName:self.appName];
+  installation.firebaseInstallationID = FID;
+  installation.registrationStatus = FIRInstallationStatusUnregistered;
+
+  return [self.installationsStore saveInstallation:installation].then(^id(NSNull *result) {
+    return installation;
+  });
+}
+
+- (FBLPromise<NSString *> *)migrateOrGenerateFID {
+  return [self.IIDStore existingIID].recover(^NSString *(NSError *error) {
+    return [FIRInstallationsItem generateFID];
+  });
+}
+
+#pragma mark - FID registration
+
+- (FBLPromise<FIRInstallationsItem *> *)registerInstallationIfNeeded:
+    (FIRInstallationsItem *)installation {
+  switch (installation.registrationStatus) {
+    case FIRInstallationStatusRegistered:
+      // Already registered. Do nothing.
+      return [FBLPromise resolvedWith:installation];
+
+    case FIRInstallationStatusUnknown:
+    case FIRInstallationStatusUnregistered:
+      // Registration required. Proceed.
+      break;
+  }
+
+  return [self.APIService registerInstallation:installation]
+      .then(^id(FIRInstallationsItem *registredInstallation) {
+        // Expected successful result: @[FIRInstallationsItem *registredInstallation, NSNull]
+        return [FBLPromise all:@[
+          registredInstallation, [self.installationsStore saveInstallation:registredInstallation]
+        ]];
+      })
+      .then(^FIRInstallationsItem *(NSArray *result) {
+        return result.firstObject;
+      });
+}
+
+#pragma mark - Auth Token
+
+- (FBLPromise<FIRInstallationsItem *> *)getAuthTokenForcingRefresh:(BOOL)forceRefresh {
+  if (forceRefresh) {
+    return [self.authTokenForcingRefreshPromiseCache getExistingPendingOrCreateNewPromise];
+  } else {
+    return [self.authTokenPromiseCache getExistingPendingOrCreateNewPromise];
+  }
+}
+
+- (FBLPromise<FIRInstallationsItem *> *)installationWithValidAuthTokenForcingRefresh:
+    (BOOL)forceRefresh {
+  return [self getInstallationItem]
+      .then(^FBLPromise<FIRInstallationsItem *> *(FIRInstallationsItem *installstion) {
+        return [self registerInstallationIfNeeded:installstion];
+      })
+      .then(^id(FIRInstallationsItem *registeredInstallation) {
+        BOOL isTokenExpiredOrExpiresSoon =
+            [registeredInstallation.authToken.expirationDate timeIntervalSinceDate:[NSDate date]] <
+            kFIRInstallationsTokenExpirationThreshold;
+        if (forceRefresh || isTokenExpiredOrExpiresSoon) {
+          return [self refreshAuthTokenForInstallation:registeredInstallation];
+        } else {
+          return registeredInstallation;
+        }
+      })
+      .catch(^void(NSError *error){
+          // TODO: Handle the errors.
+      });
+}
+
+- (FBLPromise<FIRInstallationsItem *> *)refreshAuthTokenForInstallation:
+    (FIRInstallationsItem *)installation {
+  return [FBLPromise attempts:1
+      delay:1
+      condition:^BOOL(NSInteger remainingAttempts, NSError *_Nonnull error) {
+        return [FIRInstallationsErrorUtil isAPIError:error withHTTPCode:500];
+      }
+      retry:^id _Nullable {
+        return [self.APIService refreshAuthTokenForInstallation:installation];
+      }];
+}
+
+#pragma mark - Delete FID
+
+- (FBLPromise<NSNull *> *)deleteInstallation {
+  return [self.deleteInstallationPromiseCache getExistingPendingOrCreateNewPromise];
+}
+
+- (FBLPromise<NSNull *> *)createDeleteInstallationPromise {
+  // Check for ongoing requests first, if there is no a request, then check local storage for
+  // existing installation.
+  FBLPromise<FIRInstallationsItem *> *currentInstallationPromise =
+      [self mostRecentInstallationOperation] ?: [self getStoredInstallation];
+
+  return currentInstallationPromise
+      .then(^id(FIRInstallationsItem *installation) {
+        return [self sendDeleteInstallationRequestIfNeeded:installation];
+      })
+      .then(^id(FIRInstallationsItem *installation) {
+        // Remove the installation from the local storage.
+        return [self.installationsStore removeInstallationForAppID:installation.appID
+                                                           appName:installation.firebaseAppName];
+      })
+      .then(^NSNull *(NSNull *result) {
+        [[NSNotificationCenter defaultCenter]
+            postNotificationName:FIRInstallationIDDidChangeNotification
+                          object:nil];
+        return result;
+      });
+}
+
+- (FBLPromise<FIRInstallationsItem *> *)sendDeleteInstallationRequestIfNeeded:
+    (FIRInstallationsItem *)installation {
+  switch (installation.registrationStatus) {
+    case FIRInstallationStatusUnknown:
+    case FIRInstallationStatusUnregistered:
+      // The installation is not registered, so it is safe to be deleted as is, so return early.
+      return [FBLPromise resolvedWith:installation];
+      break;
+
+    case FIRInstallationStatusRegistered:
+      // Proceed to de-register the installation on the server.
+      break;
+  }
+
+  return [self.APIService deleteInstallation:installation].recover(^id(NSError *APIError) {
+    if ([FIRInstallationsErrorUtil isAPIError:APIError withHTTPCode:404]) {
+      // The installation was not found on the server.
+      // Return success.
+      return installation;
+    } else {
+      // Re-throw the error otherwise.
+      return APIError;
+    }
+  });
+}
+
+- (nullable FBLPromise<FIRInstallationsItem *> *)mostRecentInstallationOperation {
+  return [self.authTokenForcingRefreshPromiseCache getExistingPendingPromise]
+             ?: [self.authTokenPromiseCache getExistingPendingPromise]
+                    ?: [self.getInstallationPromiseCache getExistingPendingPromise];
+}
+
+@end

+ 58 - 0
FirebaseInstallations/Source/Library/InstallationsIDController/FIRInstallationsSingleOperationPromiseCache.h

@@ -0,0 +1,58 @@
+/*
+ * 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>
+
+@class FBLPromise<ValueType>;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * The class makes sure the a single operation (represented by a promise) is performed at a time. If
+ * there is an ongoing operation, then its existing corresponding promise will be returned instead
+ * of starting a new operation.
+ */
+@interface FIRInstallationsSingleOperationPromiseCache<__covariant ResultType> : NSObject
+
+- (instancetype)init NS_UNAVAILABLE;
+
+/**
+ * The designated initializer.
+ * @param newOperationHandler The block that must return a new promise representing the
+ * single-at-a-time operation. The promise should be fulfilled when the operation is completed. The
+ * factory block will be used to create a new promise when needed.
+ */
+- (instancetype)initWithNewOperationHandler:
+    (FBLPromise<ResultType> *_Nonnull (^)(void))newOperationHandler NS_DESIGNATED_INITIALIZER;
+
+/**
+ * Creates a new promise or returns an existing pending one.
+ * @return Returns and existing pending promise if exists. If the pending promise does not exist
+ * then a new one will be created using the `factory` block passed in the initializer. Once the
+ * pending promise gets resolved, it is removed, so calling the method again will lead to creating
+ * and caching another promise.
+ */
+- (FBLPromise<ResultType> *)getExistingPendingOrCreateNewPromise;
+
+/**
+ * Returns an existing pending promise or `nil`.
+ * @return Returns an existing pending promise if there is one or `nil` otherwise.
+ */
+- (nullable FBLPromise<ResultType> *)getExistingPendingPromise;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 67 - 0
FirebaseInstallations/Source/Library/InstallationsIDController/FIRInstallationsSingleOperationPromiseCache.m

@@ -0,0 +1,67 @@
+/*
+ * 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 "FIRInstallationsSingleOperationPromiseCache.h"
+
+#if __has_include(<FBLPromises/FBLPromises.h>)
+#import <FBLPromises/FBLPromises.h>
+#else
+#import "FBLPromises.h"
+#endif
+
+@interface FIRInstallationsSingleOperationPromiseCache <ResultType>()
+@property(nonatomic, readonly) FBLPromise *_Nonnull (^newOperationHandler)(void);
+@property(atomic, nullable) FBLPromise *pendingPromise;
+@end
+
+@implementation FIRInstallationsSingleOperationPromiseCache
+
+- (instancetype)initWithNewOperationHandler:
+    (FBLPromise<id> *_Nonnull (^)(void))newOperationHandler {
+  if (newOperationHandler == nil) {
+    [NSException raise:NSInvalidArgumentException
+                format:@"`newOperationHandler` must not be `nil`."];
+  }
+
+  self = [super init];
+  if (self) {
+    _newOperationHandler = [newOperationHandler copy];
+  }
+  return self;
+}
+
+- (FBLPromise *)getExistingPendingOrCreateNewPromise {
+  if (!self.pendingPromise) {
+    self.pendingPromise = self.newOperationHandler();
+
+    self.pendingPromise
+        .then(^id(id result) {
+          self.pendingPromise = nil;
+          return nil;
+        })
+        .catch(^void(NSError *error) {
+          self.pendingPromise = nil;
+        });
+  }
+
+  return self.pendingPromise;
+}
+
+- (nullable FBLPromise *)getExistingPendingPromise {
+  return self.pendingPromise;
+}
+
+@end

+ 35 - 0
FirebaseInstallations/Source/Library/InstallationsIDController/FIRInstallationsStatus.h

@@ -0,0 +1,35 @@
+/*
+ * 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>
+
+/**
+ * The enum represent possible states of the installation ID.
+ *
+ * WARNING: The enum is stored to Keychain as a part of `FIRInstallationsStoredItem`. Modification
+ * of it can lead to incompatibility with previous version. Any modification must be evaluated and,
+ * if it is really needed, the `storageVersion` must be bumped and proper migration code added.
+ */
+typedef NS_ENUM(NSInteger, FIRInstallationsStatus) {
+  /** Represents either an initial status when a FIRInstallationsItem instance was created but not
+   * stored to Keychain or an undefined status (e.g. when the status failed to deserialize).
+   */
+  FIRInstallationStatusUnknown,
+  /// The Firebase Installation has not yet been registered with FIS.
+  FIRInstallationStatusUnregistered,
+  /// The Firebase Installation has successfully been registered with FIS.
+  FIRInstallationStatusRegistered,
+};

+ 71 - 0
FirebaseInstallations/Source/Library/InstallationsStore/FIRInstallationsStore.h

@@ -0,0 +1,71 @@
+/*
+ * 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>
+
+@class FBLPromise<ValueType>;
+@class FIRInstallationsItem;
+@class FIRSecureStorage;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/// The user defaults suite name used to store data.
+extern NSString *const kFIRInstallationsStoreUserDefaultsID;
+
+/// The class is responsible for storing and accessing the installations data.
+@interface FIRInstallationsStore : NSObject
+
+/**
+ * The default initializer.
+ * @param storage The secure storage to save installations data.
+ * @param accessGroup The Keychain Access Group to store and request the installations data.
+ */
+- (instancetype)initWithSecureStorage:(FIRSecureStorage *)storage
+                          accessGroup:(nullable NSString *)accessGroup;
+
+/**
+ * Retrieves existing installation ID if there is.
+ * @param appID The Firebase(Google) Application ID.
+ * @param appName The Firebase Application Name.
+ *
+ * @return Returns a `FBLPromise` instance. The promise is resolved with a FIRInstallationsItem
+ * instance if there is a valid installation stored for `appID` and `appName`. The promise is
+ * rejected with a specific error when the installation has not been found or with another possible
+ * error.
+ */
+- (FBLPromise<FIRInstallationsItem *> *)installationForAppID:(NSString *)appID
+                                                     appName:(NSString *)appName;
+
+/**
+ * Saves the given installation.
+ *
+ * @param installationItem The installation data.
+ * @return Returns a promise that is resolved with `[NSNull null]` on success.
+ */
+- (FBLPromise<NSNull *> *)saveInstallation:(FIRInstallationsItem *)installationItem;
+
+/**
+ * Removes installation data for the given app parameters.
+ * @param appID The Firebase(Google) Application ID.
+ * @param appName The Firebase Application Name.
+ *
+ * @return Returns a promise that is resolved with `[NSNull null]` on success.
+ */
+- (FBLPromise<NSNull *> *)removeInstallationForAppID:(NSString *)appID appName:(NSString *)appName;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 131 - 0
FirebaseInstallations/Source/Library/InstallationsStore/FIRInstallationsStore.m

@@ -0,0 +1,131 @@
+/*
+ * 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 "FIRInstallationsStore.h"
+
+#import <GoogleUtilities/GULUserDefaults.h>
+
+#if __has_include(<FBLPromises/FBLPromises.h>)
+#import <FBLPromises/FBLPromises.h>
+#else
+#import "FBLPromises.h"
+#endif
+
+#import "FIRInstallationsErrorUtil.h"
+#import "FIRInstallationsItem.h"
+#import "FIRInstallationsStoredItem.h"
+#import "FIRSecureStorage.h"
+
+NSString *const kFIRInstallationsStoreUserDefaultsID = @"com.firebase.FIRInstallations";
+
+@interface FIRInstallationsStore ()
+@property(nonatomic, readonly) FIRSecureStorage *secureStorage;
+@property(nonatomic, readonly, nullable) NSString *accessGroup;
+@property(nonatomic, readonly) dispatch_queue_t queue;
+@end
+
+@implementation FIRInstallationsStore
+
+- (instancetype)initWithSecureStorage:(FIRSecureStorage *)storage
+                          accessGroup:(NSString *)accessGroup {
+  self = [super init];
+  if (self) {
+    _secureStorage = storage;
+    _accessGroup = [accessGroup copy];
+    _queue = dispatch_queue_create("com.firebase.FIRInstallationsStore", DISPATCH_QUEUE_SERIAL);
+  }
+  return self;
+}
+
+- (FBLPromise<FIRInstallationsItem *> *)installationForAppID:(NSString *)appID
+                                                     appName:(NSString *)appName {
+  NSString *itemID = [FIRInstallationsItem identifierWithAppID:appID appName:appName];
+  return [self installationExistsForAppID:appID appName:appName]
+      .then(^id(id result) {
+        return [self.secureStorage getObjectForKey:itemID
+                                       objectClass:[FIRInstallationsStoredItem class]
+                                       accessGroup:self.accessGroup];
+      })
+      .then(^id(FIRInstallationsStoredItem *_Nullable storedItem) {
+        if (storedItem == nil) {
+          return [FIRInstallationsErrorUtil installationItemNotFoundForAppID:appID appName:appName];
+        }
+
+        FIRInstallationsItem *item = [[FIRInstallationsItem alloc] initWithAppID:appID
+                                                                 firebaseAppName:appName];
+        [item updateWithStoredItem:storedItem];
+        return item;
+      });
+}
+
+- (FBLPromise<NSNull *> *)saveInstallation:(FIRInstallationsItem *)installationItem {
+  FIRInstallationsStoredItem *storedItem = [installationItem storedItem];
+  NSString *identifier = [installationItem identifier];
+
+  return
+      [self.secureStorage setObject:storedItem forKey:identifier accessGroup:self.accessGroup].then(
+          ^id(id result) {
+            return [self setInstallationExists:YES forItemWithIdentifier:identifier];
+          });
+}
+
+- (FBLPromise<NSNull *> *)removeInstallationForAppID:(NSString *)appID appName:(NSString *)appName {
+  NSString *identifier = [FIRInstallationsItem identifierWithAppID:appID appName:appName];
+  return [self.secureStorage removeObjectForKey:identifier accessGroup:self.accessGroup].then(
+      ^id(id result) {
+        return [self setInstallationExists:NO forItemWithIdentifier:identifier];
+      });
+}
+
+#pragma mark - User defaults
+
+- (FBLPromise<NSNull *> *)installationExistsForAppID:(NSString *)appID appName:(NSString *)appName {
+  NSString *identifier = [FIRInstallationsItem identifierWithAppID:appID appName:appName];
+  return [FBLPromise onQueue:self.queue
+                          do:^id _Nullable {
+                            return [[self userDefaults] objectForKey:identifier] != nil
+                                       ? [NSNull null]
+                                       : [FIRInstallationsErrorUtil
+                                             installationItemNotFoundForAppID:appID
+                                                                      appName:appName];
+                          }];
+}
+
+- (FBLPromise<NSNull *> *)setInstallationExists:(BOOL)exists
+                          forItemWithIdentifier:(NSString *)identifier {
+  return [FBLPromise onQueue:self.queue
+                          do:^id _Nullable {
+                            if (exists) {
+                              [[self userDefaults] setBool:YES forKey:identifier];
+                            } else {
+                              [[self userDefaults] removeObjectForKey:identifier];
+                            }
+
+                            return [NSNull null];
+                          }];
+}
+
+- (GULUserDefaults *)userDefaults {
+  static GULUserDefaults *userDefaults;
+  static dispatch_once_t onceToken;
+  dispatch_once(&onceToken, ^{
+    userDefaults = [[GULUserDefaults alloc] initWithSuiteName:kFIRInstallationsStoreUserDefaultsID];
+  });
+
+  return userDefaults;
+}
+
+@end

+ 58 - 0
FirebaseInstallations/Source/Library/InstallationsStore/FIRInstallationsStoredAuthToken.h

@@ -0,0 +1,58 @@
+/*
+ * 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>
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * The enum represent possible states of the installation auth token.
+ *
+ * WARNING: The enum is stored to Keychain as a part of `FIRInstallationsStoredAuthToken`.
+ * Modification of it can lead to incompatibility with previous version. Any modification must be
+ * evaluated and, if it is really needed, the `storageVersion` must be bumped and proper migration
+ * code added.
+ */
+typedef NS_ENUM(NSInteger, FIRInstallationsAuthTokenStatus) {
+  /// An initial status or an undefined value.
+  FIRInstallationsAuthTokenStatusUnknown,
+  /// The auth token has been received from the server.
+  FIRInstallationsAuthTokenStatusTokenReceived
+};
+
+/**
+ * This class serializes and deserializes the installation data into/from `NSData` to be stored in
+ * Keychain. This class is primarily used by `FIRInstallationsStore`. It is also used on the logic
+ * level as a data object (see `FIRInstallationsItem.authToken`).
+ *
+ * WARNING: Modification of the class properties can lead to incompatibility with the stored data
+ * encoded by the previous class versions. Any modification must be evaluated and, if it is really
+ * needed, the `storageVersion` must be bumped and proper migration code added.
+ */
+@interface FIRInstallationsStoredAuthToken : NSObject <NSSecureCoding, NSCopying>
+@property FIRInstallationsAuthTokenStatus status;
+
+/// The token that can be used to authorize requests to Firebase backend.
+@property(nullable, copy) NSString *token;
+/// The date when the auth token expires.
+@property(nullable, copy) NSDate *expirationDate;
+
+/// The version of local storage.
+@property(nonatomic) NSInteger storageVersion;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 69 - 0
FirebaseInstallations/Source/Library/InstallationsStore/FIRInstallationsStoredAuthToken.m

@@ -0,0 +1,69 @@
+/*
+ * 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 "FIRInstallationsStoredAuthToken.h"
+
+NSString *const kFIRInstallationsStoredAuthTokenStatusKey = @"status";
+NSString *const kFIRInstallationsStoredAuthTokenTokenKey = @"token";
+NSString *const kFIRInstallationsStoredAuthTokenExpirationDateKey = @"expirationDate";
+NSString *const kFIRInstallationsStoredAuthTokenStorageVersionKey = @"storageVersion";
+
+NSInteger const kFIRInstallationsStoredAuthTokenStorageVersion = 1;
+
+@implementation FIRInstallationsStoredAuthToken
+
+- (nonnull id)copyWithZone:(nullable NSZone *)zone {
+  FIRInstallationsStoredAuthToken *clone = [[FIRInstallationsStoredAuthToken alloc] init];
+  clone.status = self.status;
+  clone.token = [self.token copy];
+  clone.expirationDate = self.expirationDate;
+  clone.storageVersion = self.storageVersion;
+  return clone;
+}
+
+- (void)encodeWithCoder:(nonnull NSCoder *)aCoder {
+  [aCoder encodeInteger:self.status forKey:kFIRInstallationsStoredAuthTokenStatusKey];
+  [aCoder encodeObject:self.token forKey:kFIRInstallationsStoredAuthTokenTokenKey];
+  [aCoder encodeObject:self.expirationDate
+                forKey:kFIRInstallationsStoredAuthTokenExpirationDateKey];
+  [aCoder encodeInteger:kFIRInstallationsStoredAuthTokenStorageVersion
+                 forKey:kFIRInstallationsStoredAuthTokenStorageVersionKey];
+}
+
+- (nullable instancetype)initWithCoder:(nonnull NSCoder *)aDecoder {
+  NSInteger storageVersion =
+      [aDecoder decodeIntegerForKey:kFIRInstallationsStoredAuthTokenStorageVersionKey];
+  if (storageVersion != kFIRInstallationsStoredAuthTokenStorageVersion) {
+    // TODO: Log a warning about the future version of the storage to a console.
+    // This is the first version, so we cannot do any migration yet.
+  }
+
+  FIRInstallationsStoredAuthToken *object = [[FIRInstallationsStoredAuthToken alloc] init];
+  object.status = [aDecoder decodeIntegerForKey:kFIRInstallationsStoredAuthTokenStatusKey];
+  object.token = [aDecoder decodeObjectOfClass:[NSString class]
+                                        forKey:kFIRInstallationsStoredAuthTokenTokenKey];
+  object.expirationDate =
+      [aDecoder decodeObjectOfClass:[NSDate class]
+                             forKey:kFIRInstallationsStoredAuthTokenExpirationDateKey];
+
+  return object;
+}
+
++ (BOOL)supportsSecureCoding {
+  return YES;
+}
+
+@end

+ 47 - 0
FirebaseInstallations/Source/Library/InstallationsStore/FIRInstallationsStoredItem.h

@@ -0,0 +1,47 @@
+/*
+ * 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>
+
+#import "FIRInstallationsStatus.h"
+
+@class FIRInstallationsStoredAuthToken;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * The class is supposed to be used by `FIRInstallationsStore` only. It is required to
+ * serialize/deserialize the installation data into/from `NSData` to be stored in Keychain.
+ *
+ * WARNING: Modification of the class properties can lead to incompatibility with the stored data
+ * encoded by the previous class versions. Any modification must be evaluated and, if it is really
+ * needed, the `storageVersion` must be bumped and proper migration code added.
+ */
+@interface FIRInstallationsStoredItem : NSObject <NSSecureCoding>
+
+///  A stable identifier that uniquely identifies the app instance.
+@property(nonatomic, copy, nullable) NSString *firebaseInstallationID;
+/// The `refreshToken` is used to authorize the auth token requests.
+@property(nonatomic, copy, nullable) NSString *refreshToken;
+
+@property(nonatomic, nullable) FIRInstallationsStoredAuthToken *authToken;
+@property(nonatomic) FIRInstallationsStatus registrationStatus;
+
+/// The version of local storage.
+@property(nonatomic) NSInteger storageVersion;
+@end
+
+NS_ASSUME_NONNULL_END

+ 67 - 0
FirebaseInstallations/Source/Library/InstallationsStore/FIRInstallationsStoredItem.m

@@ -0,0 +1,67 @@
+/*
+ * 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 "FIRInstallationsStoredItem.h"
+#import "FIRInstallationsStoredAuthToken.h"
+
+NSString *const kFIRInstallationsStoredItemFirebaseInstallationIDKey = @"firebaseInstallationID";
+NSString *const kFIRInstallationsStoredItemRefreshTokenKey = @"refreshToken";
+NSString *const kFIRInstallationsStoredItemAuthTokenKey = @"authToken";
+NSString *const kFIRInstallationsStoredItemRegistrationStatusKey = @"registrationStatus";
+NSString *const kFIRInstallationsStoredItemStorageVersionKey = @"storageVersion";
+
+NSInteger const kFIRInstallationsStoredItemStorageVersion = 1;
+
+@implementation FIRInstallationsStoredItem
+
+- (void)encodeWithCoder:(nonnull NSCoder *)aCoder {
+  [aCoder encodeObject:self.firebaseInstallationID
+                forKey:kFIRInstallationsStoredItemFirebaseInstallationIDKey];
+  [aCoder encodeObject:self.refreshToken forKey:kFIRInstallationsStoredItemRefreshTokenKey];
+  [aCoder encodeObject:self.authToken forKey:kFIRInstallationsStoredItemAuthTokenKey];
+  [aCoder encodeInteger:self.registrationStatus
+                 forKey:kFIRInstallationsStoredItemRegistrationStatusKey];
+  [aCoder encodeInteger:self.storageVersion forKey:kFIRInstallationsStoredItemStorageVersionKey];
+}
+
+- (nullable instancetype)initWithCoder:(nonnull NSCoder *)aDecoder {
+  NSInteger storageVersion =
+      [aDecoder decodeIntegerForKey:kFIRInstallationsStoredItemStorageVersionKey];
+  if (storageVersion != kFIRInstallationsStoredItemStorageVersion) {
+    // TODO: Log a warning about the future version of the storage to a console.
+    // This is the first version, so we cannot do any migration yet.
+  }
+
+  FIRInstallationsStoredItem *item = [[FIRInstallationsStoredItem alloc] init];
+  item.firebaseInstallationID =
+      [aDecoder decodeObjectOfClass:[NSString class]
+                             forKey:kFIRInstallationsStoredItemFirebaseInstallationIDKey];
+  item.refreshToken = [aDecoder decodeObjectOfClass:[NSString class]
+                                             forKey:kFIRInstallationsStoredItemRefreshTokenKey];
+  item.authToken = [aDecoder decodeObjectOfClass:[FIRInstallationsStoredAuthToken class]
+                                          forKey:kFIRInstallationsStoredItemAuthTokenKey];
+  item.registrationStatus =
+      [aDecoder decodeIntegerForKey:kFIRInstallationsStoredItemRegistrationStatusKey];
+  item.storageVersion = [aDecoder decodeIntegerForKey:kFIRInstallationsStoredItemStorageVersionKey];
+
+  return item;
+}
+
++ (BOOL)supportsSecureCoding {
+  return YES;
+}
+
+@end

+ 115 - 0
FirebaseInstallations/Source/Library/Public/FIRInstallations.h

@@ -0,0 +1,115 @@
+/*
+ * 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>
+
+#import <FirebaseInstallations/FIRInstallationsAuthTokenResult.h>
+
+@class FIRApp;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** A notification with this name is sent each time an installation is created or deleted. */
+FOUNDATION_EXPORT const NSNotificationName FIRInstallationIDDidChangeNotification;
+
+/**
+ * An installation ID handler block.
+ * @param identifier The installation ID string if exists or `nil` otherwise.
+ * @param error The error when `identifier == nil` or `nil` otherwise.
+ */
+typedef void (^FIRInstallationsIDHandler)(NSString *__nullable identifier,
+                                          NSError *__nullable error)
+    NS_SWIFT_NAME(InstallationsIDHandler);
+
+/**
+ * An authorization token handler block.
+ * @param tokenResult An instance of `InstallationsAuthTokenResult` in case of success or `nil`
+ * otherwise.
+ * @param error The error when `tokenResult == nil` or `nil` otherwise.
+ */
+typedef void (^FIRInstallationsTokenHandler)(
+    FIRInstallationsAuthTokenResult *__nullable tokenResult, NSError *__nullable error)
+    NS_SWIFT_NAME(InstallationsTokenHandler);
+
+/**
+ * The class provides API for Firebase Installations.
+ * Each configured `FirebaseApp` has a corresponding single instance of `Installations`.
+ * An instance of the class provides access to the installation info for the `FirebaseApp` as well
+ * as the ability to delete it. A Firebase Installation is unique by `FirebaseApp.name` and
+ * `FirebaseApp.options.googleAppID` .
+ */
+NS_SWIFT_NAME(Installations)
+@interface FIRInstallations : NSObject
+
+/**
+ * Returns a default instance of `Installations`.
+ * @return Returns an instance of `Installations` for `FirebaseApp.defaultApp(). Throws an exception
+ * if the default app is not configured yet.
+ */
++ (FIRInstallations *)installations;
+
+/**
+ * Returns an instance of `Installations` for an application.
+ * @param application A configured `FirebaseApp` instance.
+ * @return Returns an instance of `Installations` corresponding to the passed application.
+ */
++ (FIRInstallations *)installationsWithApp:(FIRApp *)application NS_SWIFT_NAME(installations(app:));
+
+/**
+ * The method creates or retrieves an installation ID. The installation ID is a stable identifier
+ * that uniquely identifies the app instance. NOTE: If the application already has an existing
+ * FirebaseInstanceID then the InstanceID identifier will be used.
+ * @param completion A completion handler which is invoked when the operation completes. See
+ * `InstallationsIDHandler` for additional details.
+ */
+- (void)installationIDWithCompletion:(FIRInstallationsIDHandler)completion;
+
+/**
+ * Retrieves (locally if it exists or from the server) a valid authorization token. An existing
+ * token may be invalidated or expired, so it is recommended to fetch the auth token before each
+ * server request. The method does the same as `Installations.authTokenForcingRefresh(:,
+ * completion:)` with forcing refresh `NO`.
+ * @param completion A completion handler which is invoked when the operation completes. See
+ * `InstallationsTokenHandler` for additional details.
+ */
+- (void)authTokenWithCompletion:(FIRInstallationsTokenHandler)completion;
+
+/**
+ * Retrieves (locally or from the server depending on `forceRefresh` value) a valid authorization
+ * token. An existing token may be invalidated or expire, so it is recommended to fetch the auth
+ * token before each server request. This method should be used with `forceRefresh == YES` when e.g.
+ * a request with the previously fetched auth token failed with "Not Authorized" error.
+ * @param forceRefresh If `YES` then the locally cached auth token will be ignored and a new one
+ * will be requested from the server. If `NO`, then the locally cached auth token will be returned
+ * if exists and has not expired yet.
+ * @param completion  A completion handler which is invoked when the operation completes. See
+ * `InstallationsTokenHandler` for additional details.
+ */
+- (void)authTokenForcingRefresh:(BOOL)forceRefresh
+                     completion:(FIRInstallationsTokenHandler)completion;
+
+/**
+ * Deletes all the installation data including the unique indentifier, auth tokens and
+ * all related data on the server side. A network connection is required for the method to
+ * succeed. If fails, the existing installation data remains untouched.
+ * @param completion A completion handler which is invoked when the operation completes. `error ==
+ * nil` indicates success.
+ */
+- (void)deleteWithCompletion:(void (^)(NSError *__nullable error))completion;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 33 - 0
FirebaseInstallations/Source/Library/Public/FIRInstallationsAuthTokenResult.h

@@ -0,0 +1,33 @@
+/*
+ * 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>
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** The class represents a result of the auth token request. */
+NS_SWIFT_NAME(InstallationsAuthTokenResult)
+@interface FIRInstallationsAuthTokenResult : NSObject
+
+/** The authorization token string. */
+@property(nonatomic, readonly) NSString *authToken;
+
+/** The auth token experation date. */
+@property(nonatomic, readonly) NSDate *expirationDate;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 34 - 0
FirebaseInstallations/Source/Library/Public/FIRInstallationsErrors.h

@@ -0,0 +1,34 @@
+/*
+ * 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>
+
+extern NSString *const kFirebaseInstallationsErrorDomain;
+
+typedef NS_ENUM(NSUInteger, FIRInstallationsErrorCode) {
+  /** Unknown error. See `userInfo` for details. */
+  FIRInstallationsErrorCodeUnknown = 0,
+
+  /** Keychain error. See `userInfo` for details. */
+  FIRInstallationsErrorCodeKeychain = 1,
+
+  /** Server unreachable. A network error or server is unavailable. See `userInfo` for details. */
+  FIRInstallationsErrorCodeServerUnreachable = 2,
+
+  /** FirebaseApp configuration issues e.g. invalid GMP-App-ID, etc. See `userInfo` for details. */
+  FIRInstallationsErrorCodeInvalidConfiguration = 3,
+
+} NS_SWIFT_NAME(InstallationsErrorCode);

+ 19 - 0
FirebaseInstallations/Source/Library/Public/FIRInstallationsVersion.h

@@ -0,0 +1,19 @@
+/*
+ * 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>
+
+FOUNDATION_EXPORT const char *const FIRInstallationsVersionStr;

+ 3 - 0
FirebaseInstallations/Source/Tests/Fixture/APIGenerateTokenResponseInvalidRefreshToken.json

@@ -0,0 +1,3 @@
+{
+  "error": "invalid_refresh_token"
+}

+ 4 - 0
FirebaseInstallations/Source/Tests/Fixture/APIGenerateTokenResponseSuccess.json

@@ -0,0 +1,4 @@
+{
+  "token": "aaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbb.cccccccccccccccccccccccc",
+  "expiresIn": "3987465s"
+}

+ 8 - 0
FirebaseInstallations/Source/Tests/Fixture/APIRegisterInstallationResponseSuccess.json

@@ -0,0 +1,8 @@
+{
+    "name": "projects/project-id/installations/eapzYQai_g8flVQyfKoGs7",
+    "refreshToken": "aaaaaaabbbbbbbbcccccccccdddddddd00000000",
+    "authToken": {
+      "token": "aaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbb.cccccccccccccccccccccccc",
+      "expiresIn": "604800s"
+    }
+  }

+ 195 - 0
FirebaseInstallations/Source/Tests/Integration/FIRInstallationsIntegrationTests.m

@@ -0,0 +1,195 @@
+/*
+ * 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 <XCTest/XCTest.h>
+
+#import <FirebaseCore/FIRAppInternal.h>
+#import <FirebaseCore/FIROptionsInternal.h>
+
+#import "FBLPromise+Testing.h"
+#import "FIRInstallations+Tests.h"
+#import "FIRInstallationsItem+Tests.h"
+
+#import <FirebaseInstallations/FIRInstallations.h>
+#import <FirebaseInstallations/FIRInstallationsAuthTokenResult.h>
+
+@interface FIRInstallationsIntegrationTests : XCTestCase
+@property(nonatomic) FIRInstallations *installations;
+@end
+
+@implementation FIRInstallationsIntegrationTests
+
+- (void)setUp {
+  static dispatch_once_t onceToken;
+  dispatch_once(&onceToken, ^{
+    [FIRApp configure];
+  });
+
+  self.installations = [FIRInstallations installationsWithApp:[FIRApp defaultApp]];
+}
+
+- (void)tearDown {
+  // Delete the installation.
+  [self.installations deleteWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNil(error);
+  }];
+
+  // Wait for any pending background job to be completed.
+  FBLWaitForPromisesWithTimeout(10);
+}
+
+// TODO: Enable the test once Travis configured.
+// Need to configure the GoogleService-Info.plist copying from the encrypted archive.
+// So far, let's run the tests locally.
+- (void)disabled_testGetFID {
+  NSString *FID1 = [self getFID];
+  NSString *FID2 = [self getFID];
+
+  XCTAssertEqualObjects(FID1, FID2);
+}
+
+- (void)disabled_testAuthToken {
+  XCTestExpectation *authTokenExpectation =
+      [self expectationWithDescription:@"authTokenExpectation"];
+
+  [self.installations
+      authTokenWithCompletion:^(FIRInstallationsAuthTokenResult *_Nullable tokenResult,
+                                NSError *_Nullable error) {
+        XCTAssertNil(error);
+        XCTAssertNotNil(tokenResult);
+        XCTAssertGreaterThanOrEqual(tokenResult.authToken.length, 10);
+        XCTAssertGreaterThanOrEqual([tokenResult.expirationDate timeIntervalSinceNow], 50 * 60);
+
+        [authTokenExpectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ authTokenExpectation ] timeout:2];
+}
+
+- (void)disabled_testDeleteInstallation {
+  NSString *FIDBefore = [self getFID];
+  FIRInstallationsAuthTokenResult *authTokenBefore = [self getAuthToken];
+
+  XCTestExpectation *deleteExpectation = [self expectationWithDescription:@"Delete Installation"];
+  [self.installations deleteWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNil(error);
+    [deleteExpectation fulfill];
+  }];
+  [self waitForExpectations:@[ deleteExpectation ] timeout:2];
+
+  NSString *FIDAfter = [self getFID];
+  FIRInstallationsAuthTokenResult *authTokenAfter = [self getAuthToken];
+
+  XCTAssertNotEqualObjects(FIDBefore, FIDAfter);
+  XCTAssertNotEqualObjects(authTokenBefore.authToken, authTokenAfter.authToken);
+  XCTAssertNotEqualObjects(authTokenBefore.expirationDate, authTokenAfter.expirationDate);
+}
+
+// TODO: Configure the tests to run on macOS without requesting the keychain password.
+#if !TARGET_OS_OSX
+- (void)testInstallationsWithApp {
+  [self assertInstallationsWithAppNamed:@"testInstallationsWithApp1"];
+  [self assertInstallationsWithAppNamed:@"testInstallationsWithApp2"];
+
+  // Wait for finishing all background operations.
+  FBLWaitForPromisesWithTimeout(10);
+
+  [FIRApp resetApps];
+}
+
+- (void)testDefaultAppInstallation {
+  XCTAssertNotNil(self.installations);
+  XCTAssertEqualObjects(self.installations.appOptions.googleAppID,
+                        [FIRApp defaultApp].options.googleAppID);
+  XCTAssertEqualObjects(self.installations.appName, [FIRApp defaultApp].name);
+
+  // Wait for finishing all background operations.
+  FBLWaitForPromisesWithTimeout(10);
+
+  [FIRApp resetApps];
+}
+
+#endif  // !TARGET_OS_OSX
+
+#pragma mark - Helpers
+
+- (NSString *)getFID {
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:[NSString stringWithFormat:@"FID %@", self.name]];
+
+  __block NSString *retreivedID;
+  [self.installations
+      installationIDWithCompletion:^(NSString *_Nullable identifier, NSError *_Nullable error) {
+        XCTAssertNotNil(identifier);
+        XCTAssertNil(error);
+        XCTAssertEqual(identifier.length, 22);
+
+        retreivedID = identifier;
+
+        [expectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ expectation ] timeout:2];
+
+  return retreivedID;
+}
+
+- (FIRInstallationsAuthTokenResult *)getAuthToken {
+  XCTestExpectation *authTokenExpectation =
+      [self expectationWithDescription:@"authTokenExpectation"];
+
+  __block FIRInstallationsAuthTokenResult *retreivedTokenResult;
+  [self.installations
+      authTokenWithCompletion:^(FIRInstallationsAuthTokenResult *_Nullable tokenResult,
+                                NSError *_Nullable error) {
+        XCTAssertNil(error);
+        XCTAssertNotNil(tokenResult);
+        XCTAssertGreaterThanOrEqual(tokenResult.authToken.length, 10);
+        XCTAssertGreaterThanOrEqual([tokenResult.expirationDate timeIntervalSinceNow], 50 * 60);
+
+        retreivedTokenResult = tokenResult;
+
+        [authTokenExpectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ authTokenExpectation ] timeout:2];
+
+  return retreivedTokenResult;
+}
+
+- (FIRInstallations *)assertInstallationsWithAppNamed:(NSString *)appName {
+  FIRApp *app = [self createAndConfigureAppWithName:appName];
+  FIRInstallations *installations = [FIRInstallations installationsWithApp:app];
+
+  XCTAssertNotNil(installations);
+  XCTAssertEqualObjects(installations.appOptions.googleAppID, app.options.googleAppID);
+  XCTAssertEqualObjects(installations.appName, app.name);
+
+  return installations;
+}
+
+#pragma mark - Helpers
+
+- (FIRApp *)createAndConfigureAppWithName:(NSString *)name {
+  FIROptions *options =
+      [[FIROptions alloc] initWithGoogleAppID:@"1:100000000000:ios:aaaaaaaaaaaaaaaaaaaaaaaa"
+                                  GCMSenderID:@"valid_sender_id"];
+  [FIRApp configureWithName:name options:options];
+
+  return [FIRApp appNamed:name];
+}
+
+@end

+ 28 - 0
FirebaseInstallations/Source/Tests/Resources/GoogleService-Info.plist

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>API_KEY</key>
+	<string>correct_api_key</string>
+	<key>TRACKING_ID</key>
+	<string>correct_tracking_id</string>
+	<key>CLIENT_ID</key>
+	<string>correct_client_id</string>
+	<key>REVERSED_CLIENT_ID</key>
+	<string>correct_reversed_client_id</string>
+	<key>GOOGLE_APP_ID</key>
+	<string>1:123:ios:123abc</string>
+	<key>GCM_SENDER_ID</key>
+	<string>correct_gcm_sender_id</string>
+	<key>PLIST_VERSION</key>
+	<string>1</string>
+	<key>BUNDLE_ID</key>
+	<string>com.google.FirebaseSDKTests</string>
+	<key>PROJECT_ID</key>
+	<string>abc-xyz-123</string>
+	<key>DATABASE_URL</key>
+	<string>https://abc-xyz-123.firebaseio.com</string>
+	<key>STORAGE_BUCKET</key>
+	<string>project-id-123.storage.firebase.com</string>
+</dict>
+</plist>

+ 480 - 0
FirebaseInstallations/Source/Tests/Unit/FIRInstallationsAPIServiceTests.m

@@ -0,0 +1,480 @@
+/*
+ * 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 <XCTest/XCTest.h>
+
+#import <OCMock/OCMock.h>
+#import "FBLPromise+Testing.h"
+#import "FIRInstallationsItem+Tests.h"
+
+#import "FIRInstallationsAPIService.h"
+#import "FIRInstallationsErrorUtil.h"
+#import "FIRInstallationsStoredAuthToken.h"
+#import "FIRInstallationsVersion.h"
+
+typedef FBLPromise * (^FIRInstallationsAPIServiceTask)(void);
+
+@interface FIRInstallationsAPIService (Tests)
+- (instancetype)initWithURLSession:(NSURLSession *)URLSession
+                            APIKey:(NSString *)APIKey
+                         projectID:(NSString *)projectID;
+@end
+
+@interface FIRInstallationsAPIServiceTests : XCTestCase
+@property(nonatomic) FIRInstallationsAPIService *service;
+@property(nonatomic) id mockURLSession;
+@property(nonatomic) NSString *APIKey;
+@property(nonatomic) NSString *projectID;
+@end
+
+@implementation FIRInstallationsAPIServiceTests
+
+- (void)setUp {
+  self.APIKey = @"api-key";
+  self.projectID = @"project-id";
+  self.mockURLSession = OCMClassMock([NSURLSession class]);
+  self.service = [[FIRInstallationsAPIService alloc] initWithURLSession:self.mockURLSession
+                                                                 APIKey:self.APIKey
+                                                              projectID:self.projectID];
+}
+
+- (void)tearDown {
+  self.service = nil;
+  self.mockURLSession = nil;
+  self.projectID = nil;
+  self.APIKey = nil;
+}
+
+- (void)testRegisterInstallationSuccess {
+  FIRInstallationsItem *installation = [[FIRInstallationsItem alloc] initWithAppID:@"app-id"
+                                                                   firebaseAppName:@"name"];
+  installation.firebaseInstallationID = [FIRInstallationsItem generateFID];
+
+  // 1. Stub URL session:
+
+  // 1.1. URL request validation.
+  id URLRequestValidation = [OCMArg checkWithBlock:^BOOL(NSURLRequest *request) {
+    XCTAssertEqualObjects(request.HTTPMethod, @"POST");
+    XCTAssertEqualObjects(
+        request.URL.absoluteString,
+        @"https://firebaseinstallations.googleapis.com/v1/projects/project-id/installations/");
+    XCTAssertEqualObjects([request valueForHTTPHeaderField:@"Content-Type"], @"application/json");
+    XCTAssertEqualObjects([request valueForHTTPHeaderField:@"X-Goog-Api-Key"], self.APIKey);
+
+    NSError *error;
+    NSDictionary *body = [NSJSONSerialization JSONObjectWithData:request.HTTPBody
+                                                         options:0
+                                                           error:&error];
+    XCTAssertNotNil(body, @"Error: %@", error);
+
+    XCTAssertEqualObjects(body[@"fid"], installation.firebaseInstallationID);
+    XCTAssertEqualObjects(body[@"authVersion"], @"FIS_v2");
+    XCTAssertEqualObjects(body[@"appId"], installation.appID);
+
+    XCTAssertEqualObjects(body[@"sdkVersion"], [self SDKVersion]);
+
+    return YES;
+  }];
+
+  // 1.2. Capture completion to call it later.
+  __block void (^taskCompletion)(NSData *, NSURLResponse *, NSError *);
+  id completionArg = [OCMArg checkWithBlock:^BOOL(id obj) {
+    taskCompletion = obj;
+    return YES;
+  }];
+
+  // 1.3. Create a data task mock.
+  id mockDataTask = OCMClassMock([NSURLSessionDataTask class]);
+  OCMExpect([(NSURLSessionDataTask *)mockDataTask resume]);
+
+  // 1.4. Expect `dataTaskWithRequest` to be called.
+  OCMExpect([self.mockURLSession dataTaskWithRequest:URLRequestValidation
+                                   completionHandler:completionArg])
+      .andReturn(mockDataTask);
+
+  // 2. Call
+  FBLPromise<FIRInstallationsItem *> *promise = [self.service registerInstallation:installation];
+
+  // 3. Wait for `[NSURLSession dataTaskWithRequest...]` to be called
+  OCMVerifyAllWithDelay(self.mockURLSession, 0.5);
+
+  // 4. Wait for the data task `resume` to be called.
+  OCMVerifyAllWithDelay(mockDataTask, 0.5);
+
+  // 5. Call the data task completion.
+  NSData *successResponseData =
+      [self loadFixtureNamed:@"APIRegisterInstallationResponseSuccess.json"];
+  taskCompletion(successResponseData, [self responseWithStatusCode:201], nil);
+
+  // 6. Check result.
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  XCTAssertNil(promise.error);
+  XCTAssertNotNil(promise.value);
+
+  XCTAssertNotEqual(promise.value, installation);
+  XCTAssertEqualObjects(promise.value.appID, installation.appID);
+  XCTAssertEqualObjects(promise.value.firebaseAppName, installation.firebaseAppName);
+  XCTAssertEqualObjects(promise.value.firebaseInstallationID, installation.firebaseInstallationID);
+  XCTAssertEqualObjects(promise.value.refreshToken, @"aaaaaaabbbbbbbbcccccccccdddddddd00000000");
+  XCTAssertEqualObjects(promise.value.authToken.token,
+                        @"aaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbb.cccccccccccccccccccccccc");
+  [self assertDate:promise.value.authToken.expirationDate
+      isApproximatelyEqualCurrentPlusTimeInterval:604800];
+}
+
+// TODO: More tests for Register Installation API
+
+- (void)testRefreshAuthTokenSuccess {
+  FIRInstallationsItem *installation = [FIRInstallationsItem createRegisteredInstallationItem];
+  installation.firebaseInstallationID = @"qwertyuiopasdfghjklzxcvbnm";
+
+  // 1. Stub URL session:
+
+  // 1.1. URL request validation.
+  id URLRequestValidation = [self refreshTokenRequestValidationArgWithInstallation:installation];
+
+  // 1.2. Capture completion to call it later.
+  __block void (^taskCompletion)(NSData *, NSURLResponse *, NSError *);
+  id completionArg = [OCMArg checkWithBlock:^BOOL(id obj) {
+    taskCompletion = obj;
+    return YES;
+  }];
+
+  // 1.3. Create a data task mock.
+  id mockDataTask = OCMClassMock([NSURLSessionDataTask class]);
+  OCMExpect([(NSURLSessionDataTask *)mockDataTask resume]);
+
+  // 1.4. Expect `dataTaskWithRequest` to be called.
+  OCMExpect([self.mockURLSession dataTaskWithRequest:URLRequestValidation
+                                   completionHandler:completionArg])
+      .andReturn(mockDataTask);
+
+  // 1.5. Prepare server response data.
+  NSData *successResponseData = [self loadFixtureNamed:@"APIGenerateTokenResponseSuccess.json"];
+
+  // 2. Call
+  FBLPromise<FIRInstallationsItem *> *promise =
+      [self.service refreshAuthTokenForInstallation:installation];
+
+  // 3. Wait for `[NSURLSession dataTaskWithRequest...]` to be called
+  OCMVerifyAllWithDelay(self.mockURLSession, 0.5);
+
+  // 4. Wait for the data task `resume` to be called.
+  OCMVerifyAllWithDelay(mockDataTask, 0.5);
+
+  // 5. Call the data task completion.
+  taskCompletion(successResponseData, [self responseWithStatusCode:200], nil);
+
+  // 6. Check result.
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  XCTAssertNil(promise.error);
+  XCTAssertNotNil(promise.value);
+
+  XCTAssertNotEqual(promise.value, installation);
+  XCTAssertEqualObjects(promise.value.appID, installation.appID);
+  XCTAssertEqualObjects(promise.value.firebaseAppName, installation.firebaseAppName);
+  XCTAssertEqualObjects(promise.value.firebaseInstallationID, installation.firebaseInstallationID);
+  XCTAssertEqualObjects(promise.value.refreshToken, installation.refreshToken);
+  XCTAssertEqualObjects(promise.value.authToken.token,
+                        @"aaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbb.cccccccccccccccccccccccc");
+  [self assertDate:promise.value.authToken.expirationDate
+      isApproximatelyEqualCurrentPlusTimeInterval:3987465];
+}
+
+- (void)testRefreshAuthTokenAPIError {
+  FIRInstallationsItem *installation = [FIRInstallationsItem createRegisteredInstallationItem];
+  installation.firebaseInstallationID = @"qwertyuiopasdfghjklzxcvbnm";
+
+  // 1. Stub URL session:
+
+  // 1.1. URL request validation.
+  id URLRequestValidation = [self refreshTokenRequestValidationArgWithInstallation:installation];
+
+  // 1.2. Capture completion to call it later.
+  __block void (^taskCompletion)(NSData *, NSURLResponse *, NSError *);
+  id completionArg = [OCMArg checkWithBlock:^BOOL(id obj) {
+    taskCompletion = obj;
+    return YES;
+  }];
+
+  // 1.3. Create a data task mock.
+  id mockDataTask = OCMClassMock([NSURLSessionDataTask class]);
+  OCMExpect([(NSURLSessionDataTask *)mockDataTask resume]);
+
+  // 1.4. Expect `dataTaskWithRequest` to be called.
+  OCMExpect([self.mockURLSession dataTaskWithRequest:URLRequestValidation
+                                   completionHandler:completionArg])
+      .andReturn(mockDataTask);
+
+  // 1.5. Prepare server response data.
+  NSData *errorResponseData =
+      [self loadFixtureNamed:@"APIGenerateTokenResponseInvalidRefreshToken.json"];
+
+  // 2. Call
+  FBLPromise<FIRInstallationsItem *> *promise =
+      [self.service refreshAuthTokenForInstallation:installation];
+
+  // 3. Wait for `[NSURLSession dataTaskWithRequest...]` to be called
+  OCMVerifyAllWithDelay(self.mockURLSession, 0.5);
+
+  // 4. Wait for the data task `resume` to be called.
+  OCMVerifyAllWithDelay(mockDataTask, 0.5);
+
+  // 5. Call the data task completion.
+  taskCompletion(errorResponseData, [self responseWithStatusCode:401], nil);
+
+  // 6. Check result.
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  XCTAssertTrue([FIRInstallationsErrorUtil isAPIError:promise.error withHTTPCode:401]);
+  XCTAssertNil(promise.value);
+}
+
+- (void)testRefreshAuthTokenDataNil {
+  FIRInstallationsItem *installation = [FIRInstallationsItem createRegisteredInstallationItem];
+  installation.firebaseInstallationID = @"qwertyuiopasdfghjklzxcvbnm";
+
+  // 1. Stub URL session:
+
+  // 1.1. URL request validation.
+  id URLRequestValidation = [self refreshTokenRequestValidationArgWithInstallation:installation];
+
+  // 1.2. Capture completion to call it later.
+  __block void (^taskCompletion)(NSData *, NSURLResponse *, NSError *);
+  id completionArg = [OCMArg checkWithBlock:^BOOL(id obj) {
+    taskCompletion = obj;
+    return YES;
+  }];
+
+  // 1.3. Create a data task mock.
+  id mockDataTask = OCMClassMock([NSURLSessionDataTask class]);
+  OCMExpect([(NSURLSessionDataTask *)mockDataTask resume]);
+
+  // 1.4. Expect `dataTaskWithRequest` to be called.
+  OCMExpect([self.mockURLSession dataTaskWithRequest:URLRequestValidation
+                                   completionHandler:completionArg])
+      .andReturn(mockDataTask);
+
+  // 2. Call
+  FBLPromise<FIRInstallationsItem *> *promise =
+      [self.service refreshAuthTokenForInstallation:installation];
+
+  // 3. Wait for `[NSURLSession dataTaskWithRequest...]` to be called
+  OCMVerifyAllWithDelay(self.mockURLSession, 0.5);
+
+  // 4. Wait for the data task `resume` to be called.
+  OCMVerifyAllWithDelay(mockDataTask, 0.5);
+
+  // 5. Call the data task completion.
+  // HTTP 200 but no data (a potential server failure).
+  taskCompletion(nil, [self responseWithStatusCode:200], nil);
+
+  // 6. Check result.
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  XCTAssertEqualObjects(promise.error.userInfo[NSLocalizedFailureReasonErrorKey],
+                        @"Failed to serialize JSON data.");
+  XCTAssertNil(promise.value);
+}
+
+- (void)testDeleteInstallationSuccess {
+  FIRInstallationsItem *installation = [FIRInstallationsItem createRegisteredInstallationItem];
+
+  // 1. Stub URL session:
+
+  // 1.1. URL request validation.
+  id URLRequestValidation = [self deleteInstallationRequestValidationWithInstallation:installation];
+
+  // 1.2. Capture completion to call it later.
+  __block void (^taskCompletion)(NSData *, NSURLResponse *, NSError *);
+  id completionArg = [OCMArg checkWithBlock:^BOOL(id obj) {
+    taskCompletion = obj;
+    return YES;
+  }];
+
+  // 1.3. Create a data task mock.
+  id mockDataTask = OCMClassMock([NSURLSessionDataTask class]);
+  OCMExpect([(NSURLSessionDataTask *)mockDataTask resume]);
+
+  // 1.4. Expect `dataTaskWithRequest` to be called.
+  OCMExpect([self.mockURLSession dataTaskWithRequest:URLRequestValidation
+                                   completionHandler:completionArg])
+      .andReturn(mockDataTask);
+
+  // 2. Call
+  FBLPromise<FIRInstallationsItem *> *promise = [self.service deleteInstallation:installation];
+
+  // 3. Wait for `[NSURLSession dataTaskWithRequest...]` to be called
+  OCMVerifyAllWithDelay(self.mockURLSession, 0.5);
+
+  // 4. Wait for the data task `resume` to be called.
+  OCMVerifyAllWithDelay(mockDataTask, 0.5);
+
+  // 5. Call the data task completion.
+  // HTTP 200 but no data (a potential server failure).
+  NSData *successResponseData = [@"{}" dataUsingEncoding:NSUTF8StringEncoding];
+  taskCompletion(successResponseData, [self responseWithStatusCode:200], nil);
+
+  // 6. Check result.
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  XCTAssertNil(promise.error);
+  XCTAssertEqual(promise.value, installation);
+}
+
+- (void)testDeleteInstallationErrorNotFound {
+  FIRInstallationsItem *installation = [FIRInstallationsItem createRegisteredInstallationItem];
+
+  // 1. Stub URL session:
+
+  // 1.1. URL request validation.
+  id URLRequestValidation = [self deleteInstallationRequestValidationWithInstallation:installation];
+
+  // 1.2. Capture completion to call it later.
+  __block void (^taskCompletion)(NSData *, NSURLResponse *, NSError *);
+  id completionArg = [OCMArg checkWithBlock:^BOOL(id obj) {
+    taskCompletion = obj;
+    return YES;
+  }];
+
+  // 1.3. Create a data task mock.
+  id mockDataTask = OCMClassMock([NSURLSessionDataTask class]);
+  OCMExpect([(NSURLSessionDataTask *)mockDataTask resume]);
+
+  // 1.4. Expect `dataTaskWithRequest` to be called.
+  OCMExpect([self.mockURLSession dataTaskWithRequest:URLRequestValidation
+                                   completionHandler:completionArg])
+      .andReturn(mockDataTask);
+
+  // 2. Call
+  FBLPromise<FIRInstallationsItem *> *promise = [self.service deleteInstallation:installation];
+
+  // 3. Wait for `[NSURLSession dataTaskWithRequest...]` to be called
+  OCMVerifyAllWithDelay(self.mockURLSession, 0.5);
+
+  // 4. Wait for the data task `resume` to be called.
+  OCMVerifyAllWithDelay(mockDataTask, 0.5);
+
+  // 5. Call the data task completion.
+  // HTTP 200 but no data (a potential server failure).
+  taskCompletion(nil, [self responseWithStatusCode:404], nil);
+
+  // 6. Check result.
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  XCTAssertTrue([FIRInstallationsErrorUtil isAPIError:promise.error withHTTPCode:404]);
+  XCTAssertNil(promise.value);
+}
+
+#pragma mark - Helpers
+
+- (NSData *)loadFixtureNamed:(NSString *)fileName {
+  NSURL *fileURL = [[NSBundle bundleForClass:[self class]] URLForResource:fileName
+                                                            withExtension:nil];
+  XCTAssertNotNil(fileURL);
+
+  NSError *error;
+  NSData *data = [NSData dataWithContentsOfURL:fileURL options:0 error:&error];
+  XCTAssertNotNil(data, @"File name: %@ Error: %@", fileName, error);
+
+  return data;
+}
+
+- (NSURLResponse *)responseWithStatusCode:(NSUInteger)statusCode {
+  NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:[NSURL fileURLWithPath:@"/"]
+                                                            statusCode:statusCode
+                                                           HTTPVersion:nil
+                                                          headerFields:nil];
+  return response;
+}
+
+- (void)assertDate:(NSDate *)date
+    isApproximatelyEqualCurrentPlusTimeInterval:(NSTimeInterval)timeInterval {
+  NSDate *expectedDate = [NSDate dateWithTimeIntervalSinceNow:timeInterval];
+
+  NSTimeInterval precision = 10;
+  XCTAssert(ABS([date timeIntervalSinceDate:expectedDate]) <= precision,
+            @"date: %@ is not equal to expected %@ with precision %f - %@", date, expectedDate,
+            precision, self.name);
+}
+
+- (id)refreshTokenRequestValidationArgWithInstallation:(FIRInstallationsItem *)installation {
+  return [OCMArg checkWithBlock:^BOOL(NSURLRequest *request) {
+    XCTAssertEqualObjects(request.HTTPMethod, @"POST");
+    XCTAssertEqualObjects(request.URL.absoluteString,
+                          @"https://firebaseinstallations.googleapis.com/v1/projects/project-id/"
+                          @"installations/qwertyuiopasdfghjklzxcvbnm/authTokens:generate");
+    XCTAssertEqualObjects([request valueForHTTPHeaderField:@"Content-Type"], @"application/json",
+                          @"%@", self.name);
+    XCTAssertEqualObjects([request valueForHTTPHeaderField:@"X-Goog-Api-Key"], self.APIKey, @"%@",
+                          self.name);
+    NSString *expectedAuthHeader =
+        [NSString stringWithFormat:@"FIS_v2 %@", installation.refreshToken];
+    XCTAssertEqualObjects(request.allHTTPHeaderFields[@"Authorization"], expectedAuthHeader, @"%@",
+                          self.name);
+
+    NSError *error;
+    NSDictionary *body = [NSJSONSerialization JSONObjectWithData:request.HTTPBody
+                                                         options:0
+                                                           error:&error];
+    XCTAssertNotNil(body, @"Error: %@, test: %@", error, self.name);
+
+    XCTAssertEqualObjects(body,
+                          @{@"installation" : @{@"sdkVersion" : [self SDKVersion]}}, @"%@",
+                          self.name);
+
+    return YES;
+  }];
+}
+
+- (id)deleteInstallationRequestValidationWithInstallation:(FIRInstallationsItem *)installation {
+  return [OCMArg checkWithBlock:^BOOL(NSURLRequest *request) {
+    XCTAssert([request isKindOfClass:[NSURLRequest class]], @"Unexpected class: %@",
+              [request class]);
+    XCTAssertEqualObjects(request.HTTPMethod, @"DELETE");
+    NSString *expectedURL = [NSString
+        stringWithFormat:
+            @"https://firebaseinstallations.googleapis.com/v1/projects/%@/installations/%@/",
+            self.projectID, installation.firebaseInstallationID];
+    XCTAssertEqualObjects(request.URL.absoluteString, expectedURL);
+    XCTAssertEqualObjects(request.allHTTPHeaderFields[@"Content-Type"], @"application/json");
+    XCTAssertEqualObjects(request.allHTTPHeaderFields[@"X-Goog-Api-Key"], self.APIKey);
+
+    NSString *expectedAuthHeader =
+        [NSString stringWithFormat:@"FIS_v2 %@", installation.refreshToken];
+    XCTAssertEqualObjects(request.allHTTPHeaderFields[@"Authorization"], expectedAuthHeader, @"%@",
+                          self.name);
+
+    NSError *error;
+    NSDictionary *JSONBody = [NSJSONSerialization JSONObjectWithData:request.HTTPBody
+                                                             options:0
+                                                               error:&error];
+    XCTAssertNotNil(JSONBody, @"Error: %@", error);
+    XCTAssertEqualObjects(JSONBody, @{});
+
+    return YES;
+  }];
+}
+
+#pragma mark - Helpers
+
+- (NSString *)SDKVersion {
+  return [NSString stringWithUTF8String:FIRInstallationsVersionStr];
+}
+
+@end

+ 111 - 0
FirebaseInstallations/Source/Tests/Unit/FIRInstallationsHTTPErrorTests.m

@@ -0,0 +1,111 @@
+/*
+ * 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 <XCTest/XCTest.h>
+#import "FIRInstallationsHTTPError.h"
+#import "FIRKeyedArchivingUtils.h"
+
+@interface FIRInstallationsHTTPErrorTests : XCTestCase
+
+@end
+
+@implementation FIRInstallationsHTTPErrorTests
+
+- (void)testInit {
+  NSHTTPURLResponse *HTTPResponse = [self createHTTPResponse];
+  NSData *responseData = [self createResponseData];
+  FIRInstallationsHTTPError *error =
+      [[FIRInstallationsHTTPError alloc] initWithHTTPResponse:HTTPResponse data:responseData];
+
+  XCTAssertNotNil(error);
+  XCTAssertEqualObjects(error.HTTPResponse, HTTPResponse);
+  XCTAssertEqualObjects(error.data, responseData);
+}
+
+- (void)testUserInfoContainsResponseData {
+  NSHTTPURLResponse *HTTPResponse = [self createHTTPResponse];
+  NSData *responseData = [self createResponseData];
+  FIRInstallationsHTTPError *error =
+      [[FIRInstallationsHTTPError alloc] initWithHTTPResponse:HTTPResponse data:responseData];
+
+  NSString *failureReason = error.userInfo[NSLocalizedFailureReasonErrorKey];
+  XCTAssertNotNil(failureReason);
+
+  // Validate HTTPResponse content.
+  XCTAssertTrue([failureReason containsString:HTTPResponse.URL.absoluteString]);
+  XCTAssertTrue([failureReason containsString:@(HTTPResponse.statusCode).stringValue]);
+  XCTAssertTrue([failureReason containsString:@(HTTPResponse.statusCode).stringValue]);
+  XCTAssertTrue([failureReason containsString:@"header1"]);
+  XCTAssertTrue([failureReason containsString:@"value1"]);
+
+  // Validate response data content.
+  XCTAssertTrue([failureReason containsString:@"invalid request"]);
+  XCTAssertTrue([failureReason containsString:@"Invalid parameters"]);
+}
+
+- (NSHTTPURLResponse *)createHTTPResponse {
+  return [[NSHTTPURLResponse alloc] initWithURL:[NSURL URLWithString:@"https://example.com"]
+                                     statusCode:403
+                                    HTTPVersion:@"1.1"
+                                   headerFields:@{@"header1" : @"value1"}];
+}
+
+- (NSData *)createResponseData {
+  NSDictionary *response = @{@"invalid request" : @"Invalid parameters"};
+  NSData *responseData = [NSJSONSerialization dataWithJSONObject:response options:0 error:nil];
+  XCTAssertNotNil(responseData);
+  return responseData;
+}
+
+- (void)testCopying {
+  NSHTTPURLResponse *HTTPResponse = [self createHTTPResponse];
+  NSData *responseData = [self createResponseData];
+  FIRInstallationsHTTPError *error =
+      [[FIRInstallationsHTTPError alloc] initWithHTTPResponse:HTTPResponse data:responseData];
+
+  FIRInstallationsHTTPError *clone = [error copy];
+
+  XCTAssertEqualObjects(error, clone);
+  XCTAssertEqualObjects(error.HTTPResponse, clone.HTTPResponse);
+  XCTAssertEqualObjects(error.data, clone.data);
+}
+
+- (void)testCoding {
+  NSHTTPURLResponse *HTTPResponse = [self createHTTPResponse];
+  NSData *responseData = [self createResponseData];
+  FIRInstallationsHTTPError *error =
+      [[FIRInstallationsHTTPError alloc] initWithHTTPResponse:HTTPResponse data:responseData];
+
+  NSError *codingError;
+  NSData *archive = [FIRKeyedArchivingUtils archivedDataWithRootObject:error error:&codingError];
+  XCTAssertNotNil(archive, @"Error: %@", codingError);
+
+  FIRInstallationsHTTPError *unarchivedError =
+      [FIRKeyedArchivingUtils unarchivedObjectOfClass:[FIRInstallationsHTTPError class]
+                                             fromData:archive
+                                                error:&codingError];
+  XCTAssertNotNil(unarchivedError, @"Error: %@", codingError);
+
+  // The error will not be equal because two different instances of NSHTTPURLResponse with the same
+  // content are not equal. Let's check the content below.
+  XCTAssertEqual(error.HTTPResponse.statusCode, unarchivedError.HTTPResponse.statusCode);
+  XCTAssertEqualObjects(error.HTTPResponse.allHeaderFields,
+                        unarchivedError.HTTPResponse.allHeaderFields);
+  XCTAssertEqualObjects(error.HTTPResponse.URL, unarchivedError.HTTPResponse.URL);
+  XCTAssertEqualObjects(error.data, unarchivedError.data);
+}
+
+@end

+ 783 - 0
FirebaseInstallations/Source/Tests/Unit/FIRInstallationsIDControllerTests.m

@@ -0,0 +1,783 @@
+/*
+ * 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 <XCTest/XCTest.h>
+
+#import <OCMock/OCMock.h>
+#import "FBLPromise+Testing.h"
+#import "FIRInstallationsErrorUtil+Tests.h"
+#import "FIRInstallationsItem+Tests.h"
+
+#import "FIRInstallations.h"
+#import "FIRInstallationsAPIService.h"
+#import "FIRInstallationsErrorUtil.h"
+#import "FIRInstallationsIDController.h"
+#import "FIRInstallationsIIDStore.h"
+#import "FIRInstallationsStore.h"
+#import "FIRInstallationsStoredAuthToken.h"
+
+@interface FIRInstallationsIDController (Tests)
+- (instancetype)initWithGoogleAppID:(NSString *)appID
+                            appName:(NSString *)appName
+                 installationsStore:(FIRInstallationsStore *)installationsStore
+                         APIService:(FIRInstallationsAPIService *)APIService
+                           IIDStore:(FIRInstallationsIIDStore *)IIDStore;
+@end
+
+@interface FIRInstallationsIDControllerTests : XCTestCase
+@property(nonatomic) FIRInstallationsIDController *controller;
+@property(nonatomic) id mockInstallationsStore;
+@property(nonatomic) id mockAPIService;
+@property(nonatomic) id mockIIDStore;
+@property(nonatomic) NSString *appID;
+@property(nonatomic) NSString *appName;
+@end
+
+@implementation FIRInstallationsIDControllerTests
+
+- (void)setUp {
+  self.appID = @"appID";
+  self.appName = @"appName";
+  self.mockInstallationsStore = OCMClassMock([FIRInstallationsStore class]);
+  self.mockAPIService = OCMClassMock([FIRInstallationsAPIService class]);
+  self.mockIIDStore = OCMClassMock([FIRInstallationsIIDStore class]);
+
+  self.controller =
+      [[FIRInstallationsIDController alloc] initWithGoogleAppID:self.appID
+                                                        appName:self.appName
+                                             installationsStore:self.mockInstallationsStore
+                                                     APIService:self.mockAPIService
+                                                       IIDStore:self.mockIIDStore];
+}
+
+- (void)tearDown {
+  self.controller = nil;
+  self.mockIIDStore = nil;
+  self.mockAPIService = nil;
+  self.mockInstallationsStore = nil;
+  self.appID = nil;
+  self.appName = nil;
+}
+
+#pragma mark - Get Installation
+
+- (void)testGetInstallationItem_WhenFIDExists_ThenItIsReturned {
+  FIRInstallationsItem *storedInstallations =
+      [FIRInstallationsItem createRegisteredInstallationItem];
+  OCMExpect([self.mockInstallationsStore installationForAppID:self.appID appName:self.appName])
+      .andReturn([FBLPromise resolvedWith:storedInstallations]);
+
+  // Don't expect FIRInstallationIDDidChangeNotification to be sent.
+  XCTestExpectation *notificationExpectation =
+      [self installationIDDidChangeNotificationExpectation];
+  notificationExpectation.inverted = YES;
+
+  FBLPromise<FIRInstallationsItem *> *promise = [self.controller getInstallationItem];
+  XCTAssert(FBLWaitForPromisesWithTimeout(0.5));
+
+  XCTAssertNil(promise.error);
+  XCTAssertEqual(promise.value, storedInstallations);
+
+  OCMVerifyAll(self.mockInstallationsStore);
+  [self waitForExpectations:@[ notificationExpectation ] timeout:0.5];
+}
+
+- (void)testGetInstallationItem_WhenNoFIDAndNoIID_ThenFIDIsCreatedAndRegistered {
+  // 1. Stub store get installation.
+  [self expectInstallationsStoreGetInstallationNotFound];
+
+  // 2. Stub store save installation.
+  __block FIRInstallationsItem *createdInstallation;
+
+  OCMExpect([self.mockInstallationsStore
+                saveInstallation:[OCMArg checkWithBlock:^BOOL(FIRInstallationsItem *obj) {
+                  [self assertValidCreatedInstallation:obj];
+
+                  createdInstallation = obj;
+                  return YES;
+                }]])
+      .andReturn([FBLPromise resolvedWith:[NSNull null]]);
+
+  // 3. Stub API register installation.
+  // 3.1. Verify installation to be registered.
+  id registerInstallationValidation = [OCMArg checkWithBlock:^BOOL(FIRInstallationsItem *obj) {
+    [self assertValidCreatedInstallation:obj];
+    XCTAssertEqual(obj.firebaseInstallationID.length, 22);
+    return YES;
+  }];
+
+  // 3.2. Expect for `registerInstallation` to be called.
+  FBLPromise<FIRInstallationsItem *> *registerPromise = [FBLPromise pendingPromise];
+  OCMExpect([self.mockAPIService registerInstallation:registerInstallationValidation])
+      .andReturn(registerPromise);
+
+  // 4. Expect IIDStore to be checked for existing IID.
+  FBLPromise *rejectedPromise = [FBLPromise pendingPromise];
+  [rejectedPromise reject:[FIRInstallationsErrorUtil keychainErrorWithFunction:@"" status:-1]];
+  OCMExpect([self.mockIIDStore existingIID]).andReturn(rejectedPromise);
+
+  // 5. Call get installation and check.
+  FBLPromise<FIRInstallationsItem *> *getInstallationPromise =
+      [self.controller getInstallationItem];
+
+  // 5.1. Wait for the stored item to be read and saved.
+  OCMVerifyAllWithDelay(self.mockInstallationsStore, 0.5);
+
+  // 5.2. Wait for `registerInstallation` to be called.
+  OCMVerifyAllWithDelay(self.mockAPIService, 0.5);
+
+  // 5.3. Expect for the registered installation to be saved.
+  FIRInstallationsItem *registeredInstallation = [FIRInstallationsItem
+      createRegisteredInstallationItemWithAppID:createdInstallation.appID
+                                        appName:createdInstallation.firebaseAppName];
+
+  OCMExpect([self.mockInstallationsStore
+                saveInstallation:[OCMArg checkWithBlock:^BOOL(FIRInstallationsItem *obj) {
+                  XCTAssertEqual(registeredInstallation, obj);
+                  return YES;
+                }]])
+      .andReturn([FBLPromise resolvedWith:[NSNull null]]);
+
+  // 5.5. Resolve `registerPromise` to simulate finished registration.
+  [registerPromise fulfill:registeredInstallation];
+
+  // 5.4. Wait for the task to complete.
+  XCTAssert(FBLWaitForPromisesWithTimeout(0.5));
+
+  XCTAssertNil(getInstallationPromise.error);
+  // We expect the initially created installation to be returned - must not wait for registration to
+  // complete here.
+  XCTAssertEqual(getInstallationPromise.value, createdInstallation);
+
+  // 5.5. Verify registered installation was saved.
+  OCMVerifyAll(self.mockInstallationsStore);
+  OCMVerifyAll(self.mockIIDStore);
+}
+
+- (void)testGetInstallationItem_WhenThereIsIIDAndNoFID_ThenIIDIsUsedAsFID {
+  // 1. Stub store get installation.
+  [self expectInstallationsStoreGetInstallationNotFound];
+
+  // 2. Expect IIDStore to be checked for existing IID.
+  NSString *existingIID = @"existing-iid";
+  OCMExpect([self.mockIIDStore existingIID]).andReturn([FBLPromise resolvedWith:existingIID]);
+
+  // 3. Stub store save installation.
+  __block FIRInstallationsItem *createdInstallation;
+
+  OCMExpect([self.mockInstallationsStore
+                saveInstallation:[OCMArg checkWithBlock:^BOOL(FIRInstallationsItem *obj) {
+                  [self assertValidCreatedInstallation:obj];
+                  XCTAssertEqualObjects(existingIID, obj.firebaseInstallationID);
+                  createdInstallation = obj;
+                  return YES;
+                }]])
+      .andReturn([FBLPromise resolvedWith:[NSNull null]]);
+
+  // 4. Stub API register installation.
+  // 4.1. Verify installation to be registered.
+  id registerInstallationValidation = [OCMArg checkWithBlock:^BOOL(FIRInstallationsItem *obj) {
+    [self assertValidCreatedInstallation:obj];
+    XCTAssertEqualObjects(existingIID, obj.firebaseInstallationID);
+    return YES;
+  }];
+
+  // 4.2. Expect for `registerInstallation` to be called.
+  FBLPromise<FIRInstallationsItem *> *registerPromise = [FBLPromise pendingPromise];
+  OCMExpect([self.mockAPIService registerInstallation:registerInstallationValidation])
+      .andReturn(registerPromise);
+
+  // 5. Call get installation and check.
+  FBLPromise<FIRInstallationsItem *> *getInstallationPromise =
+      [self.controller getInstallationItem];
+
+  // 5.1. Wait for the stored item to be read and saved.
+  OCMVerifyAllWithDelay(self.mockInstallationsStore, 0.5);
+
+  // 5.2. Wait for `registerInstallation` to be called.
+  OCMVerifyAllWithDelay(self.mockAPIService, 0.5);
+
+  // 5.3. Expect for the registered installation to be saved.
+  FIRInstallationsItem *registeredInstallation = [FIRInstallationsItem
+      createRegisteredInstallationItemWithAppID:createdInstallation.appID
+                                        appName:createdInstallation.firebaseAppName];
+
+  OCMExpect([self.mockInstallationsStore
+                saveInstallation:[OCMArg checkWithBlock:^BOOL(FIRInstallationsItem *obj) {
+                  XCTAssertEqual(registeredInstallation, obj);
+                  return YES;
+                }]])
+      .andReturn([FBLPromise resolvedWith:[NSNull null]]);
+
+  // 5.5. Resolve `registerPromise` to simulate finished registration.
+  [registerPromise fulfill:registeredInstallation];
+
+  // 5.4. Wait for the task to complete.
+  XCTAssert(FBLWaitForPromisesWithTimeout(0.5));
+
+  XCTAssertNil(getInstallationPromise.error);
+  // We expect the initially created installation to be returned - must not wait for registration to
+  // complete here.
+  XCTAssertEqual(getInstallationPromise.value, createdInstallation);
+
+  // 5.5. Verify registered installation was saved.
+  OCMVerifyAll(self.mockInstallationsStore);
+  OCMVerifyAll(self.mockIIDStore);
+}
+
+- (void)testGetInstallationItem_WhenCalledSeveralTimes_OnlyOneOperationIsPerformed {
+  // 1. Expect the installation to be requested from the store only once.
+  FIRInstallationsItem *storedInstallation1 =
+      [FIRInstallationsItem createRegisteredInstallationItem];
+  FBLPromise<FIRInstallationsItem *> *pendingStorePromise = [FBLPromise pendingPromise];
+  OCMExpect([self.mockInstallationsStore installationForAppID:self.appID appName:self.appName])
+      .andReturn(pendingStorePromise);
+
+  // 3. Request installation n times
+  NSInteger requestCount = 10;
+  NSMutableArray *instllationPromises = [NSMutableArray arrayWithCapacity:requestCount];
+  for (NSInteger i = 0; i < requestCount; i++) {
+    [instllationPromises addObject:[self.controller getInstallationItem]];
+  }
+
+  // 4. Resolve store promise.
+  [pendingStorePromise fulfill:storedInstallation1];
+
+  // 5. Wait for operation to be completed and check.
+  XCTAssert(FBLWaitForPromisesWithTimeout(0.5));
+
+  for (FBLPromise<FIRInstallationsItem *> *installationPromise in instllationPromises) {
+    XCTAssertNil(installationPromise.error);
+    XCTAssertEqual(installationPromise.value, storedInstallation1);
+  }
+
+  OCMVerifyAll(self.mockInstallationsStore);
+  OCMVerifyAll(self.mockAPIService);
+
+  // 6. Check that a new request is performed once prevoius finished.
+  FIRInstallationsItem *storedInstallation2 =
+      [FIRInstallationsItem createRegisteredInstallationItem];
+  OCMExpect([self.mockInstallationsStore installationForAppID:self.appID appName:self.appName])
+      .andReturn([FBLPromise resolvedWith:storedInstallation2]);
+
+  FBLPromise<FIRInstallationsItem *> *installationPromise = [self.controller getInstallationItem];
+  XCTAssert(FBLWaitForPromisesWithTimeout(0.5));
+
+  XCTAssertNil(installationPromise.error);
+  XCTAssertEqual(installationPromise.value, storedInstallation2);
+
+  OCMVerifyAll(self.mockInstallationsStore);
+  OCMVerifyAll(self.mockAPIService);
+}
+
+#pragma mark - Get Auth Token
+
+- (void)testGetAuthToken_WhenValidInstallationExists_ThenItIsReturned {
+  // 1. Expect installation to be requested from the store.
+  FIRInstallationsItem *storedInstallation =
+      [FIRInstallationsItem createRegisteredInstallationItem];
+  OCMExpect([self.mockInstallationsStore installationForAppID:self.appID appName:self.appName])
+      .andReturn([FBLPromise resolvedWith:storedInstallation]);
+
+  // 2. Request auth token.
+  FBLPromise<FIRInstallationsItem *> *promise = [self.controller getAuthTokenForcingRefresh:NO];
+
+  // 3. Wait for the promise to resolve.
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  // 4. Check.
+  OCMVerifyAll(self.mockInstallationsStore);
+  OCMVerifyAll(self.mockAPIService);
+
+  XCTAssertNil(promise.error);
+  XCTAssertNotNil(promise.value);
+
+  XCTAssertEqualObjects(promise.value.authToken.token, storedInstallation.authToken.token);
+  XCTAssertEqualObjects(promise.value.authToken.expirationDate,
+                        storedInstallation.authToken.expirationDate);
+}
+
+- (void)testGetAuthToken_WhenValidInstallationWithExpiredTokenExists_ThenTokenRequested {
+  // 1.1. Expect installation to be requested from the store.
+  FIRInstallationsItem *storedInstallation =
+      [FIRInstallationsItem createRegisteredInstallationItem];
+  storedInstallation.authToken.expirationDate = [NSDate dateWithTimeIntervalSinceNow:60 * 60 - 1];
+  OCMExpect([self.mockInstallationsStore installationForAppID:self.appID appName:self.appName])
+      .andReturn([FBLPromise resolvedWith:storedInstallation]);
+
+  // 1.2. Expect API request.
+  FIRInstallationsItem *responseInstallation =
+      [FIRInstallationsItem createRegisteredInstallationItem];
+  responseInstallation.authToken.token =
+      [responseInstallation.authToken.token stringByAppendingString:@"_new"];
+  OCMExpect([self.mockAPIService refreshAuthTokenForInstallation:storedInstallation])
+      .andReturn([FBLPromise resolvedWith:responseInstallation]);
+
+  // 2. Request auth token.
+  FBLPromise<FIRInstallationsItem *> *promise = [self.controller getAuthTokenForcingRefresh:NO];
+
+  // 3. Wait for the promise to resolve.
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  // 4. Check.
+  OCMVerifyAll(self.mockInstallationsStore);
+  OCMVerifyAll(self.mockAPIService);
+
+  XCTAssertNil(promise.error);
+  XCTAssertNotNil(promise.value);
+
+  XCTAssertEqualObjects(promise.value.authToken.token, responseInstallation.authToken.token);
+  XCTAssertEqualObjects(promise.value.authToken.expirationDate,
+                        responseInstallation.authToken.expirationDate);
+}
+
+- (void)testGetAuthTokenForcingRefresh_WhenValidInstallationExists_ThenTokenRequested {
+  // 1.1. Expect installation to be requested from the store.
+  FIRInstallationsItem *storedInstallation =
+      [FIRInstallationsItem createRegisteredInstallationItem];
+  OCMExpect([self.mockInstallationsStore installationForAppID:self.appID appName:self.appName])
+      .andReturn([FBLPromise resolvedWith:storedInstallation]);
+
+  // 1.2. Expect API request.
+  FIRInstallationsItem *responseInstallation =
+      [FIRInstallationsItem createRegisteredInstallationItem];
+  responseInstallation.authToken.token =
+      [responseInstallation.authToken.token stringByAppendingString:@"_new"];
+  OCMExpect([self.mockAPIService refreshAuthTokenForInstallation:storedInstallation])
+      .andReturn([FBLPromise resolvedWith:responseInstallation]);
+
+  // 2. Request auth token.
+  FBLPromise<FIRInstallationsItem *> *promise = [self.controller getAuthTokenForcingRefresh:YES];
+
+  // 3. Wait for the promise to resolve.
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  // 4. Check.
+  OCMVerifyAll(self.mockInstallationsStore);
+  OCMVerifyAll(self.mockAPIService);
+
+  XCTAssertNil(promise.error);
+  XCTAssertNotNil(promise.value);
+
+  XCTAssertEqualObjects(promise.value.authToken.token, responseInstallation.authToken.token);
+  XCTAssertEqualObjects(promise.value.authToken.expirationDate,
+                        responseInstallation.authToken.expirationDate);
+}
+
+- (void)testGetAuthToken_WhenServerResponseIsInternalError_ThenRetriesOnceAndSucceeds {
+  // 1.1. Expect installation to be requested from the store.
+  FIRInstallationsItem *storedInstallation =
+      [FIRInstallationsItem createRegisteredInstallationItem];
+  OCMExpect([self.mockInstallationsStore installationForAppID:self.appID appName:self.appName])
+      .andReturn([FBLPromise resolvedWith:storedInstallation]);
+
+  // 1.2. Expect API request called twice.
+  // 1.2.1. Fail 1st.
+  NSError *error500 = [FIRInstallationsErrorUtil APIErrorWithHTTPCode:500];
+  FBLPromise *rejectedPromise = [FBLPromise pendingPromise];
+  [rejectedPromise reject:error500];
+  OCMExpect([self.mockAPIService refreshAuthTokenForInstallation:storedInstallation])
+      .andReturn(rejectedPromise);
+
+  // 2. Request auth token.
+  FBLPromise<FIRInstallationsItem *> *promise = [self.controller getAuthTokenForcingRefresh:YES];
+
+  // 3. Wait for the operation to complete.
+  // 3.1. Wait for the 1st request to fail.
+  OCMVerifyAllWithDelay(self.mockAPIService, 0.5);
+
+  // 3.2. Expect another request and succeed.
+  FIRInstallationsItem *responseInstallation =
+      [FIRInstallationsItem createRegisteredInstallationItem];
+  responseInstallation.authToken.token =
+      [responseInstallation.authToken.token stringByAppendingString:@"_new"];
+  OCMExpect([self.mockAPIService refreshAuthTokenForInstallation:storedInstallation])
+      .andReturn([FBLPromise resolvedWith:responseInstallation]);
+
+  // 3.3. Wait for the promise to resolve.
+  XCTAssert(FBLWaitForPromisesWithTimeout(2));
+
+  // 4. Check.
+  OCMVerifyAll(self.mockInstallationsStore);
+  OCMVerifyAll(self.mockAPIService);
+
+  XCTAssertNil(promise.error);
+  XCTAssertNotNil(promise.value);
+
+  XCTAssertEqualObjects(promise.value.authToken.token, responseInstallation.authToken.token);
+  XCTAssertEqualObjects(promise.value.authToken.expirationDate,
+                        responseInstallation.authToken.expirationDate);
+}
+
+- (void)testGetAuthToken_WhenServerResponseIsInternalError_ThenRetriesOnceAndFails {
+  // 1.1. Expect installation to be requested from the store.
+  FIRInstallationsItem *storedInstallation =
+      [FIRInstallationsItem createRegisteredInstallationItem];
+  OCMExpect([self.mockInstallationsStore installationForAppID:self.appID appName:self.appName])
+      .andReturn([FBLPromise resolvedWith:storedInstallation]);
+
+  // 1.2. Expect API request called twice.
+  NSError *error500 = [FIRInstallationsErrorUtil APIErrorWithHTTPCode:500];
+  FBLPromise *rejectedPromise = [FBLPromise pendingPromise];
+  [rejectedPromise reject:error500];
+
+  OCMExpect([self.mockAPIService refreshAuthTokenForInstallation:storedInstallation])
+      .andReturn(rejectedPromise);
+  OCMExpect([self.mockAPIService refreshAuthTokenForInstallation:storedInstallation])
+      .andReturn(rejectedPromise);
+
+  // 2. Request auth token.
+  FBLPromise<FIRInstallationsItem *> *promise = [self.controller getAuthTokenForcingRefresh:YES];
+
+  // 3. Wait for the promise to resolve.
+  XCTAssert(FBLWaitForPromisesWithTimeout(2));
+
+  // 4. Check.
+  OCMVerifyAll(self.mockInstallationsStore);
+  OCMVerifyAll(self.mockAPIService);
+
+  XCTAssertEqualObjects(promise.error, error500);
+  XCTAssertNil(promise.value);
+}
+
+- (void)testGetAuthToken_WhenCalledSeveralTimes_OnlyOneOperationIsPerformed {
+  // 1. Expect installation to be requested from the store.
+  FIRInstallationsItem *storedInstallation =
+      [FIRInstallationsItem createRegisteredInstallationItem];
+
+  FBLPromise *storagePendingPromise = [FBLPromise pendingPromise];
+  // Expect the instalation to be requested only once.
+  OCMExpect([self.mockInstallationsStore installationForAppID:self.appID appName:self.appName])
+      .andReturn(storagePendingPromise);
+
+  // 2. Request auth token n times.
+  NSInteger requestCount = 10;
+  NSMutableArray *authTokenPromises = [NSMutableArray arrayWithCapacity:requestCount];
+  for (NSInteger i = 0; i < requestCount; i++) {
+    [authTokenPromises addObject:[self.controller getAuthTokenForcingRefresh:NO]];
+  }
+
+  // 3. Finish the storage request.
+  [storagePendingPromise fulfill:storedInstallation];
+
+  // 4. Wait for the promise to resolve.
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  // 5. Check.
+  OCMVerifyAll(self.mockInstallationsStore);
+
+  for (FBLPromise<FIRInstallationsItem *> *authPromise in authTokenPromises) {
+    XCTAssertNil(authPromise.error);
+    XCTAssertNotNil(authPromise.value);
+
+    XCTAssertEqualObjects(authPromise.value.authToken.token, storedInstallation.authToken.token);
+    XCTAssertEqualObjects(authPromise.value.authToken.expirationDate,
+                          storedInstallation.authToken.expirationDate);
+  }
+}
+
+- (void)testGetAuthTokenForceRefresh_WhenCalledSeveralTimes_OnlyOneOperationIsPerformed {
+  // 1.1. Expect installation to be requested from the store.
+  FIRInstallationsItem *storedInstallation =
+      [FIRInstallationsItem createRegisteredInstallationItem];
+  OCMExpect([self.mockInstallationsStore installationForAppID:self.appID appName:self.appName])
+      .andReturn([FBLPromise resolvedWith:storedInstallation]);
+
+  // 1.2. Expect API request.
+  FIRInstallationsItem *responseInstallation =
+      [FIRInstallationsItem createRegisteredInstallationItem];
+  responseInstallation.authToken.token =
+      [responseInstallation.authToken.token stringByAppendingString:@"_new"];
+  FBLPromise *pendingAPIPromise = [FBLPromise pendingPromise];
+  OCMExpect([self.mockAPIService refreshAuthTokenForInstallation:storedInstallation])
+      .andReturn(pendingAPIPromise);
+
+  // 2. Request auth token n times.
+  NSInteger requestCount = 10;
+  NSMutableArray *authTokenPromises = [NSMutableArray arrayWithCapacity:requestCount];
+  for (NSInteger i = 0; i < requestCount; i++) {
+    [authTokenPromises addObject:[self.controller getAuthTokenForcingRefresh:YES]];
+  }
+
+  // 3. Finish the API request.
+  [pendingAPIPromise fulfill:responseInstallation];
+
+  // 4. Wait for the promise to resolve.
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  // 5. Check.
+  OCMVerifyAll(self.mockInstallationsStore);
+
+  for (FBLPromise<FIRInstallationsItem *> *authPromise in authTokenPromises) {
+    XCTAssertNil(authPromise.error);
+    XCTAssertNotNil(authPromise.value);
+
+    XCTAssertEqualObjects(authPromise.value.authToken.token, responseInstallation.authToken.token);
+    XCTAssertEqualObjects(authPromise.value.authToken.expirationDate,
+                          responseInstallation.authToken.expirationDate);
+  }
+}
+
+#pragma mark - FID Deletion
+
+- (void)testDeleteRegisteredInstallation {
+  // 1. Expect installation to be requested from the store.
+  FIRInstallationsItem *installation = [FIRInstallationsItem createRegisteredInstallationItem];
+  OCMExpect([self.mockInstallationsStore installationForAppID:installation.appID
+                                                      appName:installation.firebaseAppName])
+      .andReturn([FBLPromise resolvedWith:installation]);
+
+  // 2. Expect API request to delete installation.
+  OCMExpect([self.mockAPIService deleteInstallation:installation])
+      .andReturn([FBLPromise resolvedWith:installation]);
+
+  // 3. Expect the installation to be removed from the storage.
+  OCMExpect([self.mockInstallationsStore removeInstallationForAppID:installation.appID
+                                                            appName:installation.firebaseAppName])
+      .andReturn([FBLPromise resolvedWith:[NSNull null]]);
+
+  // 4. Expect FIRInstallationIDDidChangeNotification to be sent.
+  XCTestExpectation *notificationExpectation =
+      [self installationIDDidChangeNotificationExpectation];
+
+  // 5. Call delete installation.
+  FBLPromise<NSNull *> *promise = [self.controller deleteInstallation];
+
+  // 6. Wait for operations to complete and check.
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  XCTAssertNil(promise.error);
+  XCTAssertTrue(promise.isFulfilled);
+  [self waitForExpectations:@[ notificationExpectation ] timeout:0.5];
+}
+
+- (void)testDeleteUnregisteredInstallation {
+  // 1. Expect installation to be requested from the store.
+  FIRInstallationsItem *installation = [FIRInstallationsItem createValidInstallationItem];
+  OCMExpect([self.mockInstallationsStore installationForAppID:installation.appID
+                                                      appName:installation.firebaseAppName])
+      .andReturn([FBLPromise resolvedWith:installation]);
+
+  // 2. Don't expect API request to delete installation.
+  OCMReject([self.mockAPIService deleteInstallation:[OCMArg any]]);
+
+  // 3. Expect the installation to be removed from the storage.
+  OCMExpect([self.mockInstallationsStore removeInstallationForAppID:installation.appID
+                                                            appName:installation.firebaseAppName])
+      .andReturn([FBLPromise resolvedWith:[NSNull null]]);
+
+  // 4. Expect FIRInstallationIDDidChangeNotification to be sent.
+  XCTestExpectation *notificationExpectation =
+      [self installationIDDidChangeNotificationExpectation];
+
+  // 5. Call delete installation.
+  FBLPromise<NSNull *> *promise = [self.controller deleteInstallation];
+
+  // 6. Wait for operations to complete and check.
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  XCTAssertNil(promise.error);
+  XCTAssertTrue(promise.isFulfilled);
+  [self waitForExpectations:@[ notificationExpectation ] timeout:0.5];
+}
+
+- (void)testDeleteRegisteredInstallation_WhenAPIRequestFails_ThenFailsAndInstallationIsNotRemoved {
+  // 1. Expect installation to be requested from the store.
+  FIRInstallationsItem *installation = [FIRInstallationsItem createRegisteredInstallationItem];
+  OCMExpect([self.mockInstallationsStore installationForAppID:installation.appID
+                                                      appName:installation.firebaseAppName])
+      .andReturn([FBLPromise resolvedWith:installation]);
+
+  // 2. Expect API request to delete installation.
+  FBLPromise *rejectedAPIPromise = [FBLPromise pendingPromise];
+  NSError *error500 = [FIRInstallationsErrorUtil APIErrorWithHTTPCode:500];
+  [rejectedAPIPromise reject:error500];
+  OCMExpect([self.mockAPIService deleteInstallation:installation]).andReturn(rejectedAPIPromise);
+
+  // 3. Don't expect the installation to be removed from the storage.
+  OCMReject([self.mockInstallationsStore removeInstallationForAppID:[OCMArg any]
+                                                            appName:[OCMArg any]]);
+
+  // 4. Don't expect FIRInstallationIDDidChangeNotification to be sent.
+  XCTestExpectation *notificationExpectation =
+      [self installationIDDidChangeNotificationExpectation];
+  notificationExpectation.inverted = YES;
+
+  // 5. Call delete installation.
+  FBLPromise<NSNull *> *promise = [self.controller deleteInstallation];
+
+  // 6. Wait for operations to complete and check.
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  XCTAssertEqualObjects(promise.error, error500);
+  XCTAssertTrue(promise.isRejected);
+  [self waitForExpectations:@[ notificationExpectation ] timeout:0.5];
+}
+
+- (void)testDeleteRegisteredInstallation_WhenAPIFailsWithNotFound_ThenInstallationIsRemoved {
+  // 1. Expect installation to be requested from the store.
+  FIRInstallationsItem *installation = [FIRInstallationsItem createRegisteredInstallationItem];
+  OCMExpect([self.mockInstallationsStore installationForAppID:installation.appID
+                                                      appName:installation.firebaseAppName])
+      .andReturn([FBLPromise resolvedWith:installation]);
+
+  // 2. Expect API request to delete installation.
+  FBLPromise *rejectedAPIPromise = [FBLPromise pendingPromise];
+  [rejectedAPIPromise reject:[FIRInstallationsErrorUtil APIErrorWithHTTPCode:404]];
+  OCMExpect([self.mockAPIService deleteInstallation:installation]).andReturn(rejectedAPIPromise);
+
+  // 3. Expect the installation to be removed from the storage.
+  OCMExpect([self.mockInstallationsStore removeInstallationForAppID:installation.appID
+                                                            appName:installation.firebaseAppName])
+      .andReturn([FBLPromise resolvedWith:[NSNull null]]);
+
+  // 4. Expect FIRInstallationIDDidChangeNotification to be sent.
+  XCTestExpectation *notificationExpectation =
+      [self installationIDDidChangeNotificationExpectation];
+
+  // 5. Call delete installation.
+  FBLPromise<NSNull *> *promise = [self.controller deleteInstallation];
+
+  // 6. Wait for operations to complete and check.
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  XCTAssertNil(promise.error);
+  XCTAssertTrue(promise.isFulfilled);
+  [self waitForExpectations:@[ notificationExpectation ] timeout:0.5];
+}
+
+- (void)testDeleteInstallation_WhenThereIsOngoingAuthTokenRequest_ThenUsesItsResult {
+  // 1. Stub mocks for auth token request.
+
+  // 1.1. Expect installation to be requested from the store.
+  FIRInstallationsItem *storedInstallation =
+      [FIRInstallationsItem createRegisteredInstallationItem];
+  OCMExpect([self.mockInstallationsStore installationForAppID:self.appID appName:self.appName])
+      .andReturn([FBLPromise resolvedWith:storedInstallation]);
+
+  // 1.2. Expect API request.
+  FIRInstallationsItem *responseInstallation =
+      [FIRInstallationsItem createRegisteredInstallationItem];
+  responseInstallation.authToken.token =
+      [responseInstallation.authToken.token stringByAppendingString:@"_new"];
+  FBLPromise *pendingAuthTokenAPIPromise = [FBLPromise pendingPromise];
+  OCMExpect([self.mockAPIService refreshAuthTokenForInstallation:storedInstallation])
+      .andReturn(pendingAuthTokenAPIPromise);
+
+  // 2. Send auth token request.
+  [self.controller getAuthTokenForcingRefresh:YES];
+
+  OCMVerifyAllWithDelay(self.mockInstallationsStore, 0.5);
+  OCMVerifyAllWithDelay(self.mockAPIService, 0.5);
+
+  // 3. Delete installation.
+
+  // 3.1. Don't expect installation to be requested from the store.
+  OCMReject([self.mockInstallationsStore installationForAppID:[OCMArg any] appName:[OCMArg any]]);
+
+  // 3.2. Expect API request to delete the UPDATED installation.
+  OCMExpect([self.mockAPIService deleteInstallation:responseInstallation])
+      .andReturn([FBLPromise resolvedWith:responseInstallation]);
+
+  // 3.3. Expect the UPDATED installation to be removed from the storage.
+  OCMExpect([self.mockInstallationsStore
+                removeInstallationForAppID:responseInstallation.appID
+                                   appName:responseInstallation.firebaseAppName])
+      .andReturn([FBLPromise resolvedWith:[NSNull null]]);
+
+  // 3.4. Call delete installation.
+  FBLPromise<NSNull *> *deletePromise = [self.controller deleteInstallation];
+
+  // 4. Fulfill auth token promise to proceed.
+  [pendingAuthTokenAPIPromise fulfill:responseInstallation];
+
+  // 5. Wait for operations to complete and check the result.
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  XCTAssertNil(deletePromise.error);
+  XCTAssertTrue(deletePromise.isFulfilled);
+}
+
+// TODO: Test a single delete installation request at a time.
+
+#pragma mark - Notifications
+
+- (void)testFIDDidChangeNotificationIsSentWhenFIDCreated {
+  // 1. Stub - no installation.
+  // 1.2. FID store.
+  [self expectInstallationsStoreGetInstallationNotFound];
+
+  OCMStub([self.mockInstallationsStore saveInstallation:[OCMArg any]])
+      .andReturn([FBLPromise resolvedWith:[NSNull null]]);
+
+  // 1.3. IID store.
+  FBLPromise *rejectedPromise = [FBLPromise pendingPromise];
+  [rejectedPromise reject:[FIRInstallationsErrorUtil keychainErrorWithFunction:@"" status:-1]];
+  OCMExpect([self.mockIIDStore existingIID]).andReturn(rejectedPromise);
+
+  // 1.4. API Service.
+  OCMExpect([self.mockAPIService registerInstallation:[OCMArg any]])
+      .andReturn([FBLPromise resolvedWith:[FIRInstallationsItem createRegisteredInstallationItem]]);
+
+  // 2. Expect FIRInstallationIDDidChangeNotification to be sent.
+  XCTestExpectation *notificationExpectation =
+      [self installationIDDidChangeNotificationExpectation];
+
+  // 3. Request FID.
+  FBLPromise *promise = [self.controller getInstallationItem];
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  // 4. Check.
+  XCTAssertNil(promise.error);
+  XCTAssertNotNil(promise.value);
+  [self waitForExpectations:@[ notificationExpectation ] timeout:0.5];
+
+  OCMVerifyAll(self.mockInstallationsStore);
+  OCMVerifyAll(self.mockIIDStore);
+  OCMVerifyAll(self.mockAPIService);
+}
+
+#pragma mark - Helpers
+
+- (void)expectInstallationsStoreGetInstallationNotFound {
+  NSError *notFoundError =
+      [FIRInstallationsErrorUtil installationItemNotFoundForAppID:self.appID appName:self.appName];
+  FBLPromise *installationNotFoundPromise = [FBLPromise pendingPromise];
+  [installationNotFoundPromise reject:notFoundError];
+
+  OCMExpect([self.mockInstallationsStore installationForAppID:self.appID appName:self.appName])
+      .andReturn(installationNotFoundPromise);
+}
+
+- (void)assertValidCreatedInstallation:(FIRInstallationsItem *)installation {
+  XCTAssertEqualObjects([installation class], [FIRInstallationsItem class]);
+  XCTAssertEqualObjects(installation.appID, self.appID);
+  XCTAssertEqualObjects(installation.firebaseAppName, self.appName);
+  XCTAssertEqual(installation.registrationStatus, FIRInstallationStatusUnregistered);
+  XCTAssertNotNil(installation.firebaseInstallationID);
+}
+
+- (XCTestExpectation *)installationIDDidChangeNotificationExpectation {
+  XCTestExpectation *notificationExpectation =
+      [self expectationForNotification:FIRInstallationIDDidChangeNotification
+                                object:nil
+                               handler:^BOOL(NSNotification *_Nonnull notification) {
+                                 return YES;
+                               }];
+  return notificationExpectation;
+}
+
+@end

+ 116 - 0
FirebaseInstallations/Source/Tests/Unit/FIRInstallationsIIDStoreTests.m

@@ -0,0 +1,116 @@
+/*
+ * 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 <XCTest/XCTest.h>
+
+#import <FirebaseInstanceID/FirebaseInstanceID.h>
+#import "FBLPromise+Testing.h"
+#import "FIRTestKeychain.h"
+
+#import "FIRInstallationsIIDStore.h"
+
+@interface FIRInstanceID (Tests)
++ (FIRInstanceID *)instanceIDForTests;
+@end
+
+@interface FIRInstallationsIIDStoreTests : XCTestCase
+@property(nonatomic) FIRInstanceID *instanceID;
+@property(nonatomic) FIRInstallationsIIDStore *IIDStore;
+#if TARGET_OS_OSX
+@property(nonatomic) FIRTestKeychain *privateKeychain;
+#endif  // TARGET_OSX
+@end
+
+@implementation FIRInstallationsIIDStoreTests
+
+- (void)setUp {
+  self.instanceID = [FIRInstanceID instanceIDForTests];
+  self.IIDStore = [[FIRInstallationsIIDStore alloc] init];
+
+#if TARGET_OS_OSX
+  self.privateKeychain = [[FIRTestKeychain alloc] init];
+  self.IIDStore.keychainRef = self.privateKeychain.testKeychainRef;
+#endif  // TARGET_OSX
+}
+
+- (void)tearDown {
+  self.instanceID = nil;
+#if TARGET_OS_OSX
+  self.privateKeychain = nil;
+#endif  // TARGET_OSX
+}
+
+// TODO: Configure the tests to run on macOS without requesting the keychain password.
+#if !TARGET_OS_OSX
+- (void)testExistingIIDSuccess {
+  NSString *existingIID = [self readExistingIID];
+
+  FBLPromise<NSString *> *IIDPromise = [self.IIDStore existingIID];
+
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  XCTAssertNil(IIDPromise.error);
+  XCTAssertEqualObjects(IIDPromise.value, existingIID);
+  NSLog(@"Existing IID: %@", IIDPromise.value);
+}
+
+- (void)testDeleteExistingIID {
+  // 1. Generate IID.
+  NSString *existingIID1 = [self readExistingIID];
+
+  // 2. Delete IID.
+  FBLPromise<NSNull *> *deletePromise = [self.IIDStore deleteExistingIID];
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  XCTAssertNil(deletePromise.error);
+  XCTAssertTrue(deletePromise.isFulfilled);
+
+  // 3. Check there is no IID.
+  FBLPromise<NSString *> *IIDPromise = [self.IIDStore existingIID];
+  FBLWaitForPromisesWithTimeout(0.5);
+
+  XCTAssertNotNil(IIDPromise.error);
+  XCTAssertTrue(IIDPromise.isRejected);
+
+  // 4. Re-instantiate IID instance to reset its in-memory cache.
+  self.instanceID = [FIRInstanceID instanceIDForTests];
+
+  // 5. Generate a new IID and check it is different.
+  NSString *existingIID2 = [self readExistingIID];
+  XCTAssertNotEqualObjects(existingIID1, existingIID2);
+}
+
+#endif  // !TARGET_OSX
+
+#pragma mark - Helpers
+
+- (NSString *)readExistingIID {
+  __block NSString *existingIID;
+
+  XCTestExpectation *IIDExpectation = [self expectationWithDescription:@"IIDExpectation"];
+  [self.instanceID getIDWithHandler:^(NSString *_Nullable identity, NSError *_Nullable error) {
+    XCTAssertNil(error);
+    XCTAssertNotNil(identity);
+    existingIID = identity;
+    [IIDExpectation fulfill];
+  }];
+
+  [self waitForExpectations:@[ IIDExpectation ] timeout:20];
+
+  return existingIID;
+}
+
+@end

+ 59 - 0
FirebaseInstallations/Source/Tests/Unit/FIRInstallationsItemTests.m

@@ -0,0 +1,59 @@
+/*
+ * 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 <XCTest/XCTest.h>
+#import "FIRInstallationsItem.h"
+#import "FIRInstallationsStoredItem.h"
+
+@interface FIRInstallationsItemTests : XCTestCase
+
+@end
+
+// TODO: Add more tests.
+@implementation FIRInstallationsItemTests
+
+- (void)testInstallationsItemInit {
+  NSString *appID = @"appID";
+  NSString *name = @"name";
+  FIRInstallationsItem *item = [[FIRInstallationsItem alloc] initWithAppID:appID
+                                                           firebaseAppName:name];
+
+  XCTAssertEqualObjects(item.appID, appID);
+  XCTAssertEqualObjects(item.firebaseAppName, name);
+}
+
+- (void)testItemUpdateWithStoredItem {
+  // TODO: Implement.
+}
+
+- (void)testGenerateFID {
+  NSString *FID1 = [FIRInstallationsItem generateFID];
+  [self assertValidFID:FID1];
+
+  NSString *FID2 = [FIRInstallationsItem generateFID];
+  XCTAssertEqual(FID2.length, 22);
+  [self assertValidFID:FID2];
+
+  XCTAssertNotEqualObjects(FID1, FID2);
+}
+
+- (void)assertValidFID:(NSString *)FID {
+  XCTAssertEqual(FID.length, 22);
+  XCTAssertFalse([FID containsString:@"/"]);
+  XCTAssertFalse([FID containsString:@"+"]);
+}
+
+@end

+ 247 - 0
FirebaseInstallations/Source/Tests/Unit/FIRInstallationsStoreTests.m

@@ -0,0 +1,247 @@
+/*
+ * 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 <XCTest/XCTest.h>
+
+#import <OCMock/OCMock.h>
+
+#import <GoogleUtilities/GULUserDefaults.h>
+#import "FBLPromise+Testing.h"
+#import "FIRInstallationsErrorUtil.h"
+#import "FIRInstallationsItem+Tests.h"
+#import "FIRInstallationsItem.h"
+#import "FIRInstallationsStore.h"
+#import "FIRInstallationsStoredItem.h"
+#import "FIRSecureStorage.h"
+
+@interface FIRInstallationsStoreTests : XCTestCase
+@property(nonatomic) NSString *accessGroup;
+@property(nonatomic) FIRInstallationsStore *store;
+@property(nonatomic) id mockSecureStorage;
+@property(nonatomic) GULUserDefaults *userDefaults;
+@end
+
+@implementation FIRInstallationsStoreTests
+
+- (void)setUp {
+  self.accessGroup = @"accessGroup";
+  self.mockSecureStorage = OCMClassMock([FIRSecureStorage class]);
+  self.store = [[FIRInstallationsStore alloc] initWithSecureStorage:self.mockSecureStorage
+                                                        accessGroup:self.accessGroup];
+
+  // TODO: Replace real user defaults by an injected mock or a test specific user defaults instance
+  // with a specific suite name.
+  self.userDefaults =
+      [[GULUserDefaults alloc] initWithSuiteName:kFIRInstallationsStoreUserDefaultsID];
+}
+
+- (void)tearDown {
+  self.userDefaults = nil;
+  self.store = nil;
+  self.mockSecureStorage = nil;
+  [self.mockSecureStorage stopMocking];
+}
+
+- (void)testInstallationID_WhenNoUserDefaultsItem_ThenNotFound {
+  NSString *appID = @"123";
+  NSString *appName = @"name";
+  NSString *itemID = [self itemIDWithAppID:appID appName:appName];
+
+  [self.userDefaults removeObjectForKey:itemID];
+
+  // Check with empty keychain.
+  OCMReject([self.mockSecureStorage getObjectForKey:[OCMArg any]
+                                        objectClass:[OCMArg any]
+                                        accessGroup:[OCMArg any]]);
+
+  [self assertInstallationIDNotFoundForAppID:appID appName:appName];
+  OCMVerifyAll(self.mockSecureStorage);
+}
+
+- (void)testInstallationID_WhenThereIsUserDefaultsAndKeychain_ThenReturnsItem {
+  NSString *appID = @"123";
+  NSString *appName = @"name";
+  NSString *itemID = [self itemIDWithAppID:appID appName:appName];
+
+  [self.userDefaults setObject:@(YES) forKey:itemID];
+
+  FIRInstallationsStoredItem *storedItem = [self createValidStoredItem];
+
+  OCMExpect([self.mockSecureStorage getObjectForKey:itemID
+                                        objectClass:[FIRInstallationsStoredItem class]
+                                        accessGroup:self.accessGroup])
+      .andReturn([FBLPromise resolvedWith:storedItem]);
+
+  FBLPromise<FIRInstallationsItem *> *itemPromise = [self.store installationForAppID:appID
+                                                                             appName:appName];
+  XCTAssert(FBLWaitForPromisesWithTimeout(0.5));
+
+  XCTAssertTrue(itemPromise.isFulfilled);
+  XCTAssertNil(itemPromise.error);
+  XCTAssertNotNil(itemPromise.value);
+
+  FIRInstallationsItem *item = itemPromise.value;
+  XCTAssertEqualObjects(item.appID, appID);
+  XCTAssertEqualObjects(item.firebaseAppName, appName);
+  [self assertStoredItem:storedItem correspondsToItem:item];
+
+  OCMVerifyAll(self.mockSecureStorage);
+}
+
+- (void)testInstallationID_WhenThereIsUserDefaultsAndNoKeychain_ThenNotFound {
+  NSString *appID = @"123";
+  NSString *appName = @"name";
+  NSString *itemID = [self itemIDWithAppID:appID appName:appName];
+
+  [self.userDefaults setObject:@(YES) forKey:itemID];
+
+  OCMExpect([self.mockSecureStorage getObjectForKey:itemID
+                                        objectClass:[FIRInstallationsStoredItem class]
+                                        accessGroup:self.accessGroup])
+      .andReturn([FBLPromise resolvedWith:nil]);
+
+  FBLPromise<FIRInstallationsItem *> *itemPromise = [self.store installationForAppID:appID
+                                                                             appName:appName];
+  XCTAssert(FBLWaitForPromisesWithTimeout(0.5));
+
+  XCTAssertNotNil(itemPromise.error);
+  XCTAssertEqualObjects(itemPromise.error,
+                        [FIRInstallationsErrorUtil installationItemNotFoundForAppID:appID
+                                                                            appName:appName]);
+  XCTAssertNil(itemPromise.value);
+
+  OCMVerifyAll(self.mockSecureStorage);
+}
+
+- (void)testSaveInstallationWhenKeychainSucceds {
+  FIRInstallationsItem *item = [FIRInstallationsItem createValidInstallationItem];
+  NSString *itemID = [item identifier];
+  // Reset user defaults key.
+  [self.userDefaults removeObjectForKey:itemID];
+
+  id storedItemArg = [OCMArg checkWithBlock:^BOOL(FIRInstallationsStoredItem *obj) {
+    XCTAssertEqualObjects([obj class], [FIRInstallationsStoredItem class]);
+    [self assertStoredItem:obj correspondsToItem:item];
+    return YES;
+  }];
+  OCMExpect([self.mockSecureStorage setObject:storedItemArg
+                                       forKey:itemID
+                                  accessGroup:self.accessGroup])
+      .andReturn([FBLPromise resolvedWith:[NSNull null]]);
+
+  FBLPromise<NSNull *> *promise = [self.store saveInstallation:item];
+  XCTAssert(FBLWaitForPromisesWithTimeout(0.5));
+
+  XCTAssertNil(promise.error);
+  XCTAssertTrue(promise.isFulfilled);
+
+  OCMVerifyAll(self.mockSecureStorage);
+
+  // Check the user defaults key updated.
+  XCTAssertNotNil([self.userDefaults objectForKey:itemID]);
+}
+
+- (void)testSaveInstallationWhenKeychainFails {
+  FIRInstallationsItem *item = [FIRInstallationsItem createValidInstallationItem];
+  NSString *itemID = [item identifier];
+  // Reset user defaults key.
+  [self.userDefaults removeObjectForKey:itemID];
+
+  NSError *keychainError = [FIRInstallationsErrorUtil keychainErrorWithFunction:@"Get" status:-1];
+  FBLPromise *rejectedPromise = [FBLPromise pendingPromise];
+  [rejectedPromise reject:keychainError];
+
+  id storedItemArg = [OCMArg checkWithBlock:^BOOL(FIRInstallationsStoredItem *obj) {
+    XCTAssertEqualObjects([obj class], [FIRInstallationsStoredItem class]);
+    [self assertStoredItem:obj correspondsToItem:item];
+    return YES;
+  }];
+  OCMExpect([self.mockSecureStorage setObject:storedItemArg
+                                       forKey:itemID
+                                  accessGroup:self.accessGroup])
+      .andReturn(rejectedPromise);
+
+  FBLPromise<NSNull *> *promise = [self.store saveInstallation:item];
+  XCTAssert(FBLWaitForPromisesWithTimeout(0.5));
+
+  XCTAssertTrue(promise.isRejected);
+  XCTAssertEqualObjects(promise.error, keychainError);
+
+  OCMVerifyAll(self.mockSecureStorage);
+
+  // Check the user defaults key wasn't updated.
+  XCTAssertNil([self.userDefaults objectForKey:itemID]);
+}
+
+- (void)testRemoveInstallation {
+  NSString *appID = @"123";
+  NSString *appName = @"name";
+  NSString *itemID = [self itemIDWithAppID:appID appName:appName];
+
+  [self.userDefaults setObject:@(YES) forKey:itemID];
+
+  OCMExpect([self.mockSecureStorage removeObjectForKey:itemID accessGroup:self.accessGroup])
+      .andReturn([FBLPromise resolvedWith:[NSNull null]]);
+
+  FBLPromise<NSNull *> *promise = [self.store removeInstallationForAppID:appID appName:appName];
+  XCTAssert(FBLWaitForPromisesWithTimeout(0.5));
+
+  XCTAssertTrue(promise.isFulfilled);
+  XCTAssertNil(promise.error);
+
+  OCMVerifyAll(self.mockSecureStorage);
+
+  XCTAssertNil([self.userDefaults objectForKey:itemID]);
+}
+
+#pragma mark - Common
+
+- (void)assertInstallationIDNotFoundForAppID:(NSString *)appID appName:(NSString *)appName {
+  FBLPromise<FIRInstallationsItem *> *itemPromise = [self.store installationForAppID:appID
+                                                                             appName:appName];
+
+  XCTAssert(FBLWaitForPromisesWithTimeout(0.5));
+
+  XCTAssertTrue(itemPromise.isRejected, @"%@", self.name);
+  XCTAssertEqualObjects(itemPromise.error,
+                        [FIRInstallationsErrorUtil installationItemNotFoundForAppID:appID
+                                                                            appName:appName],
+                        @"%@", self.name);
+}
+
+#pragma mark - Helpers
+
+- (NSString *)itemIDWithAppID:(NSString *)appID appName:(NSString *)appName {
+  return [FIRInstallationsItem identifierWithAppID:appID appName:appName];
+}
+
+- (FIRInstallationsStoredItem *)createValidStoredItem {
+  FIRInstallationsStoredItem *storedItem = [[FIRInstallationsStoredItem alloc] init];
+
+  storedItem.firebaseInstallationID = @"firebaseInstallationID";
+  storedItem.refreshToken = @"refreshToken";
+
+  return storedItem;
+}
+
+- (void)assertStoredItem:(FIRInstallationsStoredItem *)storedItem
+       correspondsToItem:(FIRInstallationsItem *)item {
+  XCTAssertEqualObjects(item.refreshToken, storedItem.refreshToken);
+  XCTAssertEqualObjects(item.firebaseInstallationID, storedItem.firebaseInstallationID);
+  XCTAssertEqual(item.registrationStatus, storedItem.registrationStatus);
+}
+
+@end

+ 50 - 0
FirebaseInstallations/Source/Tests/Unit/FIRInstallationsStoredAuthTokenTests.m

@@ -0,0 +1,50 @@
+/*
+ * 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 <XCTest/XCTest.h>
+
+#import "FIRKeyedArchivingUtils.h"
+
+#import "FIRInstallationsStoredAuthToken.h"
+
+@interface FIRInstallationsStoredAuthTokenTests : XCTestCase
+
+@end
+
+@implementation FIRInstallationsStoredAuthTokenTests
+
+- (void)testTokenArchivingUnarchiving {
+  FIRInstallationsStoredAuthToken *token = [[FIRInstallationsStoredAuthToken alloc] init];
+  token.token = @"auth-token";
+  token.expirationDate = [NSDate dateWithTimeIntervalSinceNow:12345];
+  token.status = FIRInstallationsAuthTokenStatusTokenReceived;
+
+  NSError *error;
+  NSData *archivedToken = [FIRKeyedArchivingUtils archivedDataWithRootObject:token error:&error];
+  XCTAssertNotNil(archivedToken, @"Error: %@", error);
+
+  FIRInstallationsStoredAuthToken *unarchivedToken =
+      [FIRKeyedArchivingUtils unarchivedObjectOfClass:[FIRInstallationsStoredAuthToken class]
+                                             fromData:archivedToken
+                                                error:&error];
+  XCTAssertNotNil(unarchivedToken, @"Error: %@", error);
+
+  XCTAssertEqualObjects(token.token, unarchivedToken.token);
+  XCTAssertEqualObjects(token.expirationDate, unarchivedToken.expirationDate);
+  XCTAssertEqual(token.status, unarchivedToken.status);
+}
+
+@end

+ 59 - 0
FirebaseInstallations/Source/Tests/Unit/FIRInstallationsStoredItemTests.m

@@ -0,0 +1,59 @@
+/*
+ * 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 <XCTest/XCTest.h>
+
+#import "FIRKeyedArchivingUtils.h"
+
+#import "FIRInstallationsStoredAuthToken.h"
+#import "FIRInstallationsStoredItem.h"
+
+@interface FIRInstallationsStoredItemTests : XCTestCase
+
+@end
+
+@implementation FIRInstallationsStoredItemTests
+
+- (void)testItemArchivingUnarchiving {
+  FIRInstallationsStoredAuthToken *authToken = [[FIRInstallationsStoredAuthToken alloc] init];
+  authToken.token = @"auth-token";
+  authToken.expirationDate = [NSDate dateWithTimeIntervalSinceNow:12345];
+  authToken.status = FIRInstallationsAuthTokenStatusTokenReceived;
+
+  FIRInstallationsStoredItem *item = [[FIRInstallationsStoredItem alloc] init];
+  item.firebaseInstallationID = @"inst-id";
+  item.refreshToken = @"refresh-token";
+  item.authToken = authToken;
+  item.registrationStatus = FIRInstallationStatusRegistered;
+
+  NSError *error;
+  NSData *archivedItem = [FIRKeyedArchivingUtils archivedDataWithRootObject:item error:&error];
+  XCTAssertNotNil(archivedItem, @"Error: %@", error);
+
+  FIRInstallationsStoredItem *unarchivedItem =
+      [FIRKeyedArchivingUtils unarchivedObjectOfClass:[FIRInstallationsStoredItem class]
+                                             fromData:archivedItem
+                                                error:&error];
+  XCTAssertNotNil(unarchivedItem, @"Error: %@", error);
+
+  XCTAssertEqualObjects(unarchivedItem.firebaseInstallationID, item.firebaseInstallationID);
+  XCTAssertEqualObjects(unarchivedItem.refreshToken, item.refreshToken);
+  XCTAssertEqualObjects(unarchivedItem.authToken.token, item.authToken.token);
+  XCTAssertEqualObjects(unarchivedItem.authToken.expirationDate, item.authToken.expirationDate);
+  XCTAssertEqual(unarchivedItem.registrationStatus, item.registrationStatus);
+}
+
+@end

+ 236 - 0
FirebaseInstallations/Source/Tests/Unit/FIRInstallationsTests.m

@@ -0,0 +1,236 @@
+/*
+ * 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 <XCTest/XCTest.h>
+
+#import <OCMock/OCMock.h>
+
+#import <FirebaseCore/FIRAppInternal.h>
+#import <FirebaseCore/FIROptionsInternal.h>
+#import <FirebaseCore/FirebaseCore.h>
+#import "FBLPromise+Testing.h"
+#import "FIRInstallations+Tests.h"
+#import "FIRInstallationsErrorUtil+Tests.h"
+#import "FIRInstallationsItem+Tests.h"
+
+#import "FIRInstallations.h"
+#import "FIRInstallationsAuthTokenResultInternal.h"
+#import "FIRInstallationsErrorUtil.h"
+#import "FIRInstallationsHTTPError.h"
+#import "FIRInstallationsIDController.h"
+#import "FIRInstallationsStoredAuthToken.h"
+
+@interface FIRInstallationsTests : XCTestCase
+@property(nonatomic) FIRInstallations *installations;
+@property(nonatomic) id mockIDController;
+@property(nonatomic) FIROptions *appOptions;
+@end
+
+@implementation FIRInstallationsTests
+
+- (void)setUp {
+  [super setUp];
+
+  self.appOptions = [[FIROptions alloc] initWithGoogleAppID:@"GoogleAppID"
+                                                GCMSenderID:@"GCMSenderID"];
+  self.mockIDController = OCMClassMock([FIRInstallationsIDController class]);
+  self.installations = [[FIRInstallations alloc] initWithAppOptions:self.appOptions
+                                                            appName:@"appName"
+                                          installationsIDController:self.mockIDController
+                                                  prefetchAuthToken:NO];
+}
+
+- (void)tearDown {
+  self.installations = nil;
+  self.mockIDController = nil;
+  [super tearDown];
+}
+
+- (void)testDefaultInstallationWhenNoDefaultAppThenIsNil {
+  XCTAssertThrows([FIRInstallations installations]);
+}
+
+- (void)testInstallationIDSuccess {
+  // Stub get installation.
+  FIRInstallationsItem *installation = [FIRInstallationsItem createValidInstallationItem];
+  OCMExpect([self.mockIDController getInstallationItem])
+      .andReturn([FBLPromise resolvedWith:installation]);
+
+  XCTestExpectation *idExpectation = [self expectationWithDescription:@"InstallationIDSuccess"];
+  [self.installations
+      installationIDWithCompletion:^(NSString *_Nullable identifier, NSError *_Nullable error) {
+        XCTAssertNil(error);
+        XCTAssertNotNil(identifier);
+        XCTAssertEqualObjects(identifier, installation.firebaseInstallationID);
+
+        [idExpectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ idExpectation ] timeout:0.5];
+
+  OCMVerifyAll(self.mockIDController);
+}
+
+- (void)testInstallationIDError {
+  // Stub get installation.
+  FBLPromise *errorPromise = [FBLPromise pendingPromise];
+  NSError *privateError = [NSError errorWithDomain:@"TestsError" code:-1 userInfo:nil];
+  [errorPromise reject:privateError];
+
+  OCMExpect([self.mockIDController getInstallationItem]).andReturn(errorPromise);
+
+  XCTestExpectation *idExpectation = [self expectationWithDescription:@"InstallationIDSuccess"];
+  [self.installations
+      installationIDWithCompletion:^(NSString *_Nullable identifier, NSError *_Nullable error) {
+        XCTAssertNil(identifier);
+        XCTAssertNotNil(error);
+
+        XCTAssertEqualObjects(error.domain, kFirebaseInstallationsErrorDomain);
+        XCTAssertEqualObjects(error.userInfo[NSUnderlyingErrorKey], errorPromise.error);
+
+        [idExpectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ idExpectation ] timeout:0.5];
+
+  OCMVerifyAll(self.mockIDController);
+}
+
+- (void)testAuthTokenSuccess {
+  FIRInstallationsItem *installationWithToken =
+      [FIRInstallationsItem createRegisteredInstallationItemWithAppID:self.appOptions.googleAppID
+                                                              appName:@"appName"];
+  installationWithToken.authToken.token = @"token";
+  installationWithToken.authToken.expirationDate = [NSDate dateWithTimeIntervalSinceNow:1000];
+  OCMExpect([self.mockIDController getAuthTokenForcingRefresh:NO])
+      .andReturn([FBLPromise resolvedWith:installationWithToken]);
+
+  XCTestExpectation *tokenExpectation = [self expectationWithDescription:@"AuthTokenSuccess"];
+  [self.installations
+      authTokenWithCompletion:^(FIRInstallationsAuthTokenResult *_Nullable tokenResult,
+                                NSError *_Nullable error) {
+        XCTAssertNotNil(tokenResult);
+        XCTAssertGreaterThan(tokenResult.authToken.length, 0);
+        XCTAssertTrue([tokenResult.expirationDate laterDate:[NSDate date]]);
+        XCTAssertNil(error);
+
+        [tokenExpectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ tokenExpectation ] timeout:0.5];
+
+  OCMVerifyAll(self.mockIDController);
+}
+
+- (void)testAuthTokenError {
+  FBLPromise *errorPromise = [FBLPromise pendingPromise];
+  [errorPromise reject:[FIRInstallationsErrorUtil APIErrorWithHTTPCode:500]];
+  OCMExpect([self.mockIDController getAuthTokenForcingRefresh:NO]).andReturn(errorPromise);
+
+  XCTestExpectation *tokenExpectation = [self expectationWithDescription:@"AuthTokenSuccess"];
+  [self.installations
+      authTokenWithCompletion:^(FIRInstallationsAuthTokenResult *_Nullable tokenResult,
+                                NSError *_Nullable error) {
+        XCTAssertNil(tokenResult);
+        XCTAssertEqualObjects(error, errorPromise.error);
+
+        [tokenExpectation fulfill];
+      }];
+
+  [self waitForExpectations:@[ tokenExpectation ] timeout:0.5];
+
+  OCMVerifyAll(self.mockIDController);
+}
+
+- (void)testAuthTokenForcingRefreshSuccess {
+  FIRInstallationsItem *installationWithToken =
+      [FIRInstallationsItem createRegisteredInstallationItemWithAppID:self.appOptions.googleAppID
+                                                              appName:@"appName"];
+  installationWithToken.authToken.token = @"token";
+  installationWithToken.authToken.expirationDate = [NSDate dateWithTimeIntervalSinceNow:1000];
+  OCMExpect([self.mockIDController getAuthTokenForcingRefresh:YES])
+      .andReturn([FBLPromise resolvedWith:installationWithToken]);
+
+  XCTestExpectation *tokenExpectation = [self expectationWithDescription:@"AuthTokenSuccess"];
+  [self.installations
+      authTokenForcingRefresh:YES
+                   completion:^(FIRInstallationsAuthTokenResult *_Nullable tokenResult,
+                                NSError *_Nullable error) {
+                     XCTAssertNil(error);
+                     XCTAssertNotNil(tokenResult);
+                     XCTAssertEqualObjects(tokenResult.authToken,
+                                           installationWithToken.authToken.token);
+                     XCTAssertEqualObjects(tokenResult.expirationDate,
+                                           installationWithToken.authToken.expirationDate);
+                     [tokenExpectation fulfill];
+                   }];
+
+  [self waitForExpectations:@[ tokenExpectation ] timeout:0.5];
+
+  OCMVerifyAll(self.mockIDController);
+}
+
+- (void)testAuthTokenForcingRefreshError {
+  FBLPromise *errorPromise = [FBLPromise pendingPromise];
+  [errorPromise reject:[FIRInstallationsErrorUtil APIErrorWithHTTPCode:500]];
+  OCMExpect([self.mockIDController getAuthTokenForcingRefresh:YES]).andReturn(errorPromise);
+
+  XCTestExpectation *tokenExpectation = [self expectationWithDescription:@"AuthTokenSuccess"];
+  [self.installations
+      authTokenForcingRefresh:YES
+                   completion:^(FIRInstallationsAuthTokenResult *_Nullable tokenResult,
+                                NSError *_Nullable error) {
+                     XCTAssertNil(tokenResult);
+                     XCTAssertEqualObjects(error, errorPromise.error);
+
+                     [tokenExpectation fulfill];
+                   }];
+
+  [self waitForExpectations:@[ tokenExpectation ] timeout:0.5];
+
+  OCMVerifyAll(self.mockIDController);
+}
+
+- (void)testDeleteSuccess {
+  OCMExpect([self.mockIDController deleteInstallation])
+      .andReturn([FBLPromise resolvedWith:[NSNull null]]);
+
+  XCTestExpectation *deleteExpectation = [self expectationWithDescription:@"DeleteSuccess"];
+  [self.installations deleteWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNil(error);
+    [deleteExpectation fulfill];
+  }];
+
+  [self waitForExpectations:@[ deleteExpectation ] timeout:0.5];
+}
+
+- (void)testDeleteError {
+  FBLPromise *errorPromise = [FBLPromise pendingPromise];
+  [errorPromise reject:[FIRInstallationsErrorUtil APIErrorWithHTTPCode:500]];
+  OCMExpect([self.mockIDController deleteInstallation]).andReturn(errorPromise);
+
+  XCTestExpectation *deleteExpectation = [self expectationWithDescription:@"DeleteSuccess"];
+  [self.installations deleteWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNotNil(error);
+    // TODO: Verify the error content.
+
+    [deleteExpectation fulfill];
+  }];
+
+  [self waitForExpectations:@[ deleteExpectation ] timeout:0.5];
+}
+
+@end

+ 201 - 0
FirebaseInstallations/Source/Tests/Unit/FIRSecureStorageTests.m

@@ -0,0 +1,201 @@
+/*
+ * 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 <XCTest/XCTest.h>
+
+#import <OCMock/OCMock.h>
+#import "FBLPromise+Testing.h"
+#import "FIRTestKeychain.h"
+
+#import "FIRSecureStorage.h"
+
+@interface FIRSecureStorage (Tests)
+- (instancetype)initWithService:(NSString *)service cache:(NSCache *)cache;
+- (void)resetInMemoryCache;
+@end
+
+@interface FIRSecureStorageTests : XCTestCase
+@property(nonatomic, strong) FIRSecureStorage *storage;
+@property(nonatomic, strong) NSCache *cache;
+@property(nonatomic, strong) id mockCache;
+
+#if TARGET_OS_OSX
+@property(nonatomic) FIRTestKeychain *privateKeychain;
+#endif  // TARGET_OSX
+
+@end
+
+@implementation FIRSecureStorageTests
+
+- (void)setUp {
+  self.cache = [[NSCache alloc] init];
+  self.mockCache = OCMPartialMock(self.cache);
+  self.storage = [[FIRSecureStorage alloc] initWithService:@"com.tests.FIRSecureStorageTests"
+                                                     cache:self.mockCache];
+
+#if TARGET_OS_OSX
+  self.privateKeychain = [[FIRTestKeychain alloc] init];
+  self.storage.keychainRef = self.privateKeychain.testKeychainRef;
+#endif  // TARGET_OSX
+}
+
+- (void)tearDown {
+  self.storage = nil;
+  self.mockCache = nil;
+  self.cache = nil;
+
+#if TARGET_OS_OSX
+  self.privateKeychain = nil;
+#endif  // TARGET_OSX
+}
+
+- (void)testSetGetObjectForKey {
+  // 1. Write and read object initially.
+  [self assertSuccessWriteObject:@[ @1, @2 ] forKey:@"test-key1"];
+  [self assertSuccessReadObject:@[ @1, @2 ]
+                         forKey:@"test-key1"
+                          class:[NSArray class]
+                  existsInCache:YES];
+
+  //  // 2. Override existing object.
+  [self assertSuccessWriteObject:@{@"key" : @"value"} forKey:@"test-key1"];
+  [self assertSuccessReadObject:@{@"key" : @"value"}
+                         forKey:@"test-key1"
+                          class:[NSDictionary class]
+                  existsInCache:YES];
+
+  // 3. Read existing object which is not present in in-memory cache.
+  [self.cache removeAllObjects];
+  [self assertSuccessReadObject:@{@"key" : @"value"}
+                         forKey:@"test-key1"
+                          class:[NSDictionary class]
+                  existsInCache:NO];
+
+  // 4. Write and read an object for another key.
+  [self assertSuccessWriteObject:@{@"key" : @"value"} forKey:@"test-key2"];
+  [self assertSuccessReadObject:@{@"key" : @"value"}
+                         forKey:@"test-key2"
+                          class:[NSDictionary class]
+                  existsInCache:YES];
+}
+
+- (void)testGetNonExistingObject {
+  [self assertNonExistingObjectForKey:[NSUUID UUID].UUIDString class:[NSArray class]];
+}
+
+- (void)testGetExistingObjectClassMismatch {
+  NSString *key = [NSUUID UUID].UUIDString;
+
+  // Write.
+  [self assertSuccessWriteObject:@[ @8 ] forKey:key];
+
+  // Read.
+  // Skip in-memory cache because the error is relevant only for Keychain.
+  OCMExpect([self.mockCache objectForKey:key]).andReturn(nil);
+
+  FBLPromise<id<NSSecureCoding>> *getPromise = [self.storage getObjectForKey:key
+                                                                 objectClass:[NSString class]
+                                                                 accessGroup:nil];
+
+  XCTAssert(FBLWaitForPromisesWithTimeout(1));
+  XCTAssertNil(getPromise.value);
+  XCTAssertNotNil(getPromise.error);
+  // TODO: Test for particular error.
+
+  OCMVerifyAll(self.mockCache);
+}
+
+- (void)testRemoveExistingObject {
+  NSString *key = @"testRemoveExistingObject";
+  // Store the object.
+  [self assertSuccessWriteObject:@[ @5 ] forKey:(NSString *)key];
+
+  // Remove object.
+  [self assertRemoveObjectForKey:key];
+
+  // Check if object is still stored.
+  [self assertNonExistingObjectForKey:key class:[NSArray class]];
+}
+
+- (void)testRemoveNonExistingObject {
+  NSString *key = [NSUUID UUID].UUIDString;
+  [self assertRemoveObjectForKey:key];
+  [self assertNonExistingObjectForKey:key class:[NSArray class]];
+}
+
+#pragma mark - Common
+
+- (void)assertSuccessWriteObject:(id<NSSecureCoding>)object forKey:(NSString *)key {
+  OCMExpect([self.mockCache setObject:object forKey:key]).andForwardToRealObject();
+
+  FBLPromise<NSNull *> *setPromise = [self.storage setObject:object forKey:key accessGroup:nil];
+
+  XCTAssert(FBLWaitForPromisesWithTimeout(1));
+  XCTAssertNil(setPromise.error, @"%@", self.name);
+
+  OCMVerify(self.mockCache);
+
+  // Check in-memory cache.
+  XCTAssertEqualObjects([self.cache objectForKey:key], object);
+}
+
+- (void)assertSuccessReadObject:(id<NSSecureCoding>)object
+                         forKey:(NSString *)key
+                          class:(Class)class
+                  existsInCache:(BOOL)existisInCache {
+  OCMExpect([self.mockCache objectForKey:key]).andForwardToRealObject();
+
+  if (!existisInCache) {
+    OCMExpect([self.mockCache setObject:object forKey:key]).andForwardToRealObject();
+  }
+
+  FBLPromise<id<NSSecureCoding>> *getPromise =
+      [self.storage getObjectForKey:key objectClass:class accessGroup:nil];
+
+  XCTAssert(FBLWaitForPromisesWithTimeout(1), @"%@", self.name);
+  XCTAssertEqualObjects(getPromise.value, object, @"%@", self.name);
+  XCTAssertNil(getPromise.error, @"%@", self.name);
+
+  OCMVerifyAll(self.mockCache);
+
+  // Check in-memory cache.
+  XCTAssertEqualObjects([self.cache objectForKey:key], object, @"%@", self.name);
+}
+
+- (void)assertNonExistingObjectForKey:(NSString *)key class:(Class)class {
+  OCMExpect([self.mockCache objectForKey:key]).andForwardToRealObject();
+
+  FBLPromise<id<NSSecureCoding>> *promise =
+      [self.storage getObjectForKey:key objectClass:class accessGroup:nil];
+
+  XCTAssert(FBLWaitForPromisesWithTimeout(1));
+  XCTAssertNil(promise.error, @"%@", self.name);
+  XCTAssertNil(promise.value, @"%@", self.name);
+
+  OCMVerifyAll(self.mockCache);
+}
+
+- (void)assertRemoveObjectForKey:(NSString *)key {
+  OCMExpect([self.mockCache removeObjectForKey:key]).andForwardToRealObject();
+
+  FBLPromise<NSNull *> *removePromise = [self.storage removeObjectForKey:key accessGroup:nil];
+  XCTAssert(FBLWaitForPromisesWithTimeout(1));
+  XCTAssertNil(removePromise.error);
+
+  OCMVerifyAll(self.mockCache);
+}
+
+@end

+ 34 - 0
FirebaseInstallations/Source/Tests/Utils/FIRInstallations+Tests.h

@@ -0,0 +1,34 @@
+/*
+ * 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 <FirebaseInstallations/FIRInstallations.h>
+#import <Foundation/Foundation.h>
+
+@class FIRInstallationsIDController;
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FIRInstallations (Tests)
+@property(nonatomic, readwrite, strong) FIROptions *appOptions;
+@property(nonatomic, readwrite, strong) NSString *appName;
+
+- (instancetype)initWithAppOptions:(FIROptions *)appOptions
+                           appName:(NSString *)appName
+         installationsIDController:(FIRInstallationsIDController *)installationsIDController
+                 prefetchAuthToken:(BOOL)prefetchAuthToken;
+
+@end
+NS_ASSUME_NONNULL_END

+ 27 - 0
FirebaseInstallations/Source/Tests/Utils/FIRInstallationsErrorUtil+Tests.h

@@ -0,0 +1,27 @@
+/*
+ * 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 "FIRInstallationsErrorUtil.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FIRInstallationsErrorUtil (Tests)
+
++ (NSError *)APIErrorWithHTTPCode:(NSInteger)HTTPCode;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 32 - 0
FirebaseInstallations/Source/Tests/Utils/FIRInstallationsErrorUtil+Tests.m

@@ -0,0 +1,32 @@
+/*
+ * 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 "FIRInstallationsErrorUtil+Tests.h"
+
+#import "FIRInstallationsHTTPError.h"
+
+@implementation FIRInstallationsErrorUtil (Tests)
+
++ (NSError *)APIErrorWithHTTPCode:(NSInteger)HTTPCode {
+  NSHTTPURLResponse *response =
+      [[NSHTTPURLResponse alloc] initWithURL:[NSURL URLWithString:@"http://localhost"]
+                                  statusCode:HTTPCode
+                                 HTTPVersion:@"1.1"
+                                headerFields:@{}];
+  return [[FIRInstallationsHTTPError alloc] initWithHTTPResponse:response data:nil];
+}
+
+@end

+ 31 - 0
FirebaseInstallations/Source/Tests/Utils/FIRInstallationsItem+Tests.h

@@ -0,0 +1,31 @@
+/*
+ * 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 "FIRInstallationsItem.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FIRInstallationsItem (Tests)
+
++ (FIRInstallationsItem *)createValidInstallationItem;
++ (FIRInstallationsItem *)createRegisteredInstallationItem;
+
++ (FIRInstallationsItem *)createRegisteredInstallationItemWithAppID:(NSString *)appID
+                                                            appName:(NSString *)appName;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 58 - 0
FirebaseInstallations/Source/Tests/Utils/FIRInstallationsItem+Tests.m

@@ -0,0 +1,58 @@
+/*
+ * 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 "FIRInstallationsItem+Tests.h"
+#import "FIRInstallationsStoredAuthToken.h"
+
+@implementation FIRInstallationsItem (Tests)
+
++ (FIRInstallationsItem *)createValidInstallationItem {
+  FIRInstallationsItem *item = [[FIRInstallationsItem alloc] initWithAppID:@"appID"
+                                                           firebaseAppName:@"appName"];
+  item.firebaseInstallationID = @"firebaseInstallationID";
+  item.refreshToken = @"refreshToken";
+  item.registrationStatus = FIRInstallationStatusUnregistered;
+
+  return item;
+}
+
++ (FIRInstallationsItem *)createRegisteredInstallationItem {
+  FIRInstallationsItem *item = [self createRegisteredInstallationItemWithAppID:@"appID"
+                                                                       appName:@"appName"];
+  item.firebaseInstallationID = @"firebaseInstallationID";
+  item.refreshToken = @"refreshToken";
+  item.registrationStatus = FIRInstallationStatusRegistered;
+
+  return item;
+}
+
++ (FIRInstallationsItem *)createRegisteredInstallationItemWithAppID:(NSString *)appID
+                                                            appName:(NSString *)appName {
+  FIRInstallationsItem *item = [[FIRInstallationsItem alloc] initWithAppID:appID
+                                                           firebaseAppName:appName];
+  item.firebaseInstallationID = [FIRInstallationsItem generateFID];
+  item.refreshToken = @"refreshToken";
+  item.registrationStatus = FIRInstallationStatusRegistered;
+
+  FIRInstallationsStoredAuthToken *authToken = [[FIRInstallationsStoredAuthToken alloc] init];
+  authToken.token = @"auth-token";
+  authToken.expirationDate = [NSDate dateWithTimeIntervalSinceNow:2 * 60 * 60];
+  item.authToken = authToken;
+
+  return item;
+}
+
+@end

+ 30 - 0
FirebaseInstallations/Source/Tests/Utils/FIRKeyedArchivingUtils.h

@@ -0,0 +1,30 @@
+/*
+ * 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>
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FIRKeyedArchivingUtils : NSObject
+
++ (nullable NSData *)archivedDataWithRootObject:(id)object error:(NSError **)outError;
++ (nullable id)unarchivedObjectOfClass:(Class)class
+                              fromData:(NSData *)data
+                                 error:(NSError **)outError;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 79 - 0
FirebaseInstallations/Source/Tests/Utils/FIRKeyedArchivingUtils.m

@@ -0,0 +1,79 @@
+/*
+ * 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 "FIRKeyedArchivingUtils.h"
+
+@implementation FIRKeyedArchivingUtils
+
++ (nullable NSData *)archivedDataWithRootObject:(id)object error:(NSError **)outError {
+  NSData *archivedData;
+  if (@available(macOS 10.13, iOS 11.0, tvOS 11.0, *)) {
+    archivedData = [NSKeyedArchiver archivedDataWithRootObject:object
+                                         requiringSecureCoding:YES
+                                                         error:outError];
+  } else {
+    @try {
+      NSMutableData *data = [NSMutableData data];
+      NSKeyedArchiver *archiver = [[NSKeyedArchiver alloc] initForWritingWithMutableData:data];
+      archiver.requiresSecureCoding = YES;
+
+      [archiver encodeObject:object forKey:NSKeyedArchiveRootObjectKey];
+      [archiver finishEncoding];
+
+      archivedData = [data copy];
+    } @catch (NSException *exception) {
+      if (outError) {
+        NSString *failureReason = [NSString stringWithFormat:@"Exception: %@", exception];
+        *outError = [NSError errorWithDomain:@"FIRKeyedArchivingUtils"
+                                        code:-1
+                                    userInfo:@{
+                                      NSLocalizedFailureReasonErrorKey : failureReason,
+                                    }];
+      }
+    }
+  }
+
+  return archivedData;
+}
+
++ (nullable id)unarchivedObjectOfClass:(Class)class
+                              fromData:(NSData *)data
+                                 error:(NSError **)outError {
+  id object;
+  if (@available(macOS 10.13, iOS 11.0, tvOS 11.0, *)) {
+    object = [NSKeyedUnarchiver unarchivedObjectOfClass:class fromData:data error:outError];
+  } else {
+    @try {
+      NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:data];
+      unarchiver.requiresSecureCoding = YES;
+
+      object = [unarchiver decodeObjectOfClass:class forKey:NSKeyedArchiveRootObjectKey];
+    } @catch (NSException *exception) {
+      if (outError) {
+        NSString *failureReason = [NSString stringWithFormat:@"Exception: %@", exception];
+        *outError = [NSError errorWithDomain:@"FIRKeyedArchivingUtils"
+                                        code:-1
+                                    userInfo:@{
+                                      NSLocalizedFailureReasonErrorKey : failureReason,
+                                    }];
+      }
+    }
+  }
+
+  return object;
+}
+
+@end

+ 33 - 0
FirebaseInstallations/Source/Tests/Utils/FIRTestKeychain.h

@@ -0,0 +1,33 @@
+/*
+ * 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>
+#import <Security/Security.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+#if TARGET_OS_OSX
+
+@interface FIRTestKeychain : NSObject
+
+- (nullable instancetype)init;
+
+@property(nonatomic, readonly, nullable) SecKeychainRef testKeychainRef;
+
+@end
+
+#endif  // TARGET_OSX
+
+NS_ASSUME_NONNULL_END

+ 64 - 0
FirebaseInstallations/Source/Tests/Utils/FIRTestKeychain.m

@@ -0,0 +1,64 @@
+/*
+ * 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.
+ */
+
+#if TARGET_OS_OSX
+
+#import "FIRTestKeychain.h"
+
+#import <XCTest/XCTest.h>
+
+@implementation FIRTestKeychain
+
+- (nullable instancetype)init {
+  self = [super init];
+  if (self) {
+    SecKeychainRef privateKeychain;
+    NSString *keychainPath =
+        [NSTemporaryDirectory() stringByAppendingPathComponent:@"FIRTestKeychain"];
+    if ([[NSFileManager defaultManager] fileExistsAtPath:keychainPath]) {
+      NSError *error;
+      if (![[NSFileManager defaultManager] removeItemAtPath:keychainPath error:&error]) {
+        NSLog(@"Failed to delete existing test keychain: %@", error);
+        return nil;
+      }
+    }
+    OSStatus result = SecKeychainCreate([keychainPath cStringUsingEncoding:NSUTF8StringEncoding], 0,
+                                        "1", false, nil, &privateKeychain);
+    if (result != errSecSuccess) {
+      NSError *error = [NSError errorWithDomain:NSOSStatusErrorDomain code:result userInfo:nil];
+      NSLog(@"SecKeychainCreate error: %@", error);
+      return nil;
+    }
+    _testKeychainRef = privateKeychain;
+  }
+  return self;
+}
+
+- (void)dealloc {
+  if (self.testKeychainRef) {
+    OSStatus result = SecKeychainDelete(self.testKeychainRef);
+    if (result != errSecSuccess) {
+      NSError *error = [NSError errorWithDomain:NSOSStatusErrorDomain code:result userInfo:nil];
+      NSLog(@"SecKeychainCreate error: %@", error);
+    }
+
+    CFRelease(self.testKeychainRef);
+  }
+}
+
+@end
+
+#endif  // TARGET_OSX

+ 4 - 0
scripts/if_changed.sh

@@ -129,6 +129,10 @@ else
       check_changes '^(Firebase/Core|Firebase/Storage|Example/Storage|GoogleUtilities|FirebaseStorage.podspec)'
       ;;
 
+    Installations-*)
+      check_changes '^(Firebase/Core|GoogleUtilities|FirebaseInstallations|FirebaseInstallations.podspec)'
+      ;;
+
     *)
       echo "Unknown project-method combo" 1>&2
       echo "  PROJECT=$PROJECT" 1>&2