Add timeout properties to FirebaseStorage (#532)

diff --git a/packages/firebase_storage/android/src/main/java/io/flutter/plugins/firebase/storage/FirebaseStoragePlugin.java b/packages/firebase_storage/android/src/main/java/io/flutter/plugins/firebase/storage/FirebaseStoragePlugin.java
index 46318d9..06296fd 100755
--- a/packages/firebase_storage/android/src/main/java/io/flutter/plugins/firebase/storage/FirebaseStoragePlugin.java
+++ b/packages/firebase_storage/android/src/main/java/io/flutter/plugins/firebase/storage/FirebaseStoragePlugin.java
@@ -53,6 +53,24 @@
     }
 
     switch (call.method) {
+      case "FirebaseStorage#getMaxDownloadRetryTime":
+        result.success(firebaseStorage.getMaxDownloadRetryTimeMillis());
+        break;
+      case "FirebaseStorage#getMaxUploadRetryTime":
+        result.success(firebaseStorage.getMaxUploadRetryTimeMillis());
+        break;
+      case "FirebaseStorage#getMaxOperationRetryTime":
+        result.success(firebaseStorage.getMaxOperationRetryTimeMillis());
+        break;
+      case "FirebaseStorage#setMaxDownloadRetryTime":
+        setMaxDownloadRetryTimeMillis(call, result);
+        break;
+      case "FirebaseStorage#setMaxUploadRetryTime":
+        setMaxUploadRetryTimeMillis(call, result);
+        break;
+      case "FirebaseStorage#setMaxOperationRetryTime":
+        setMaxOperationTimeMillis(call, result);
+        break;
       case "StorageReference#putFile":
         putFile(call, result);
         break;
@@ -92,6 +110,24 @@
     }
   }
 
+  private void setMaxDownloadRetryTimeMillis(MethodCall call, Result result) {
+    Number time = call.argument("time");
+    firebaseStorage.setMaxDownloadRetryTimeMillis(time.longValue());
+    result.success(null);
+  }
+
+  private void setMaxUploadRetryTimeMillis(MethodCall call, Result result) {
+    Number time = call.argument("time");
+    firebaseStorage.setMaxUploadRetryTimeMillis(time.longValue());
+    result.success(null);
+  }
+
+  private void setMaxOperationTimeMillis(MethodCall call, Result result) {
+    Number time = call.argument("time");
+    firebaseStorage.setMaxOperationRetryTimeMillis(time.longValue());
+    result.success(null);
+  }
+
   private void getMetadata(MethodCall call, final Result result) {
     String path = call.argument("path");
     StorageReference ref = firebaseStorage.getReference().child(path);
diff --git a/packages/firebase_storage/ios/Classes/FirebaseStoragePlugin.m b/packages/firebase_storage/ios/Classes/FirebaseStoragePlugin.m
index 1e20e59..7a889a2 100644
--- a/packages/firebase_storage/ios/Classes/FirebaseStoragePlugin.m
+++ b/packages/firebase_storage/ios/Classes/FirebaseStoragePlugin.m
@@ -19,6 +19,8 @@
 @end
 
 @implementation FLTFirebaseStoragePlugin {
+  NSMutableDictionary<NSString * /* app name */,
+                      NSMutableDictionary<NSString * /* bucket */, FIRStorage *> *> *_storageMap;
   FIRStorage *storage;
 }
 
@@ -36,24 +38,26 @@
     if (![FIRApp defaultApp]) {
       [FIRApp configure];
     }
+    _storageMap = [[NSMutableDictionary alloc] init];
   }
   return self;
 }
 
 - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result {
-  NSString *appName = call.arguments[@"app"];
-  NSString *storageBucket = call.arguments[@"bucket"];
-  if ([appName isEqual:[NSNull null]] && [storageBucket isEqual:[NSNull null]]) {
-    storage = [FIRStorage storage];
-  } else if ([appName isEqual:[NSNull null]]) {
-    storage = [FIRStorage storageWithURL:storageBucket];
-  } else if ([storageBucket isEqual:[NSNull null]]) {
-    storage = [FIRStorage storageForApp:[FIRApp appNamed:appName]];
-  } else {
-    storage = [FIRStorage storageForApp:[FIRApp appNamed:appName] URL:storageBucket];
-  }
-
-  if ([@"StorageReference#putFile" isEqualToString:call.method]) {
+  storage = [self getStorage:call result:result];
+  if ([@"FirebaseStorage#getMaxDownloadRetryTime" isEqualToString:call.method]) {
+    result(@((int64_t)(storage.maxDownloadRetryTime * 1000.0)));
+  } else if ([@"FirebaseStorage#getMaxUploadRetryTime" isEqualToString:call.method]) {
+    result(@((int64_t)(storage.maxUploadRetryTime * 1000.0)));
+  } else if ([@"FirebaseStorage#getMaxOperationRetryTime" isEqualToString:call.method]) {
+    result(@((int64_t)(storage.maxOperationRetryTime * 1000.0)));
+  } else if ([@"FirebaseStorage#setMaxDownloadRetryTime" isEqualToString:call.method]) {
+    [self setMaxDownloadRetryTime:call result:result];
+  } else if ([@"FirebaseStorage#setMaxUploadRetryTime" isEqualToString:call.method]) {
+    [self setMaxUploadRetryTime:call result:result];
+  } else if ([@"FirebaseStorage#setMaxOperationRetryTime" isEqualToString:call.method]) {
+    [self setMaxOperationRetryTime:call result:result];
+  } else if ([@"StorageReference#putFile" isEqualToString:call.method]) {
     [self putFile:call result:result];
   } else if ([@"StorageReference#putData" isEqualToString:call.method]) {
     [self putData:call result:result];
@@ -80,6 +84,83 @@
   }
 }
 
+// Returns a [FIRStorage] instance which is a singleton given a fixed app and bucket.
+// This is to be consistent with the Android API so that repated calls to getters/setters
+// affect the right [FIRStorage] instance.
+- (FIRStorage *)getStorage:(FlutterMethodCall *)call result:(FlutterResult)result {
+  NSString *appName = call.arguments[@"app"];
+  NSString *bucketUrl = call.arguments[@"bucket"];
+  FIRApp *app;
+
+  if ([appName isEqual:[NSNull null]]) {
+    app = [FIRApp defaultApp];
+  } else {
+    app = [FIRApp appNamed:appName];
+  }
+
+  if ([bucketUrl isEqual:[NSNull null]]) {
+    if (app.options.storageBucket) {
+      bucketUrl = [app.options.storageBucket isEqualToString:@""]
+                      ? @""
+                      : [@"gs://" stringByAppendingString:app.options.storageBucket];
+    } else {
+      bucketUrl = nil;
+    }
+  }
+
+  NSURL *url = [NSURL URLWithString:bucketUrl];
+  if (!url) {
+    @try {
+      // Call storage constructor to raise proper exception.
+      storage = [FIRStorage storageForApp:app URL:bucketUrl];
+    } @catch (NSException *exception) {
+      result([FlutterError errorWithCode:@"storage_error"
+                                 message:[exception name]
+                                 details:[exception reason]]);
+    }
+  }
+
+  NSMutableDictionary *bucketMap = _storageMap[app.name];
+  if (!bucketMap) {
+    bucketMap = [NSMutableDictionary dictionaryWithCapacity:1];
+    _storageMap[app.name] = bucketMap;
+  }
+
+  NSString *bucketName = [url host];
+  FIRStorage *storage = bucketMap[bucketName];
+  if (!storage) {
+    // Raises an exception if bucketUrl is invalid.
+    @try {
+      storage = [FIRStorage storageForApp:app URL:bucketUrl];
+    } @catch (NSException *exception) {
+      result([FlutterError errorWithCode:@"storage_error"
+                                 message:[exception name]
+                                 details:[exception reason]]);
+    }
+    bucketMap[bucketName] = storage;
+  }
+
+  return storage;
+}
+
+- (void)setMaxDownloadRetryTime:(FlutterMethodCall *)call result:(FlutterResult)result {
+  NSNumber *time = call.arguments[@"time"];
+  storage.maxDownloadRetryTime = [time longLongValue] / 1000.0;
+  result(nil);
+}
+
+- (void)setMaxUploadRetryTime:(FlutterMethodCall *)call result:(FlutterResult)result {
+  NSNumber *time = call.arguments[@"time"];
+  storage.maxUploadRetryTime = [time longLongValue] / 1000.0;
+  result(nil);
+}
+
+- (void)setMaxOperationRetryTime:(FlutterMethodCall *)call result:(FlutterResult)result {
+  NSNumber *time = call.arguments[@"time"];
+  storage.maxOperationRetryTime = [time longLongValue] / 1000.0;
+  result(nil);
+}
+
 - (void)putFile:(FlutterMethodCall *)call result:(FlutterResult)result {
   NSData *data = [NSData dataWithContentsOfFile:call.arguments[@"filename"]];
   [self put:data call:call result:result];
diff --git a/packages/firebase_storage/lib/firebase_storage.dart b/packages/firebase_storage/lib/firebase_storage.dart
index 82fca5f..726debc 100755
--- a/packages/firebase_storage/lib/firebase_storage.dart
+++ b/packages/firebase_storage/lib/firebase_storage.dart
@@ -17,7 +17,11 @@
 
   /// Returns the [FirebaseStorage] instance, initialized with a custom
   /// [FirebaseApp] if [app] is specified and a custom Google Cloud Storage
-  /// bucket if [storageBucket] is specified.
+  /// bucket if [storageBucket] is specified. Otherwise the instance will be
+  /// initialized with the default [FirebaseApp].
+  ///
+  /// The [FirebaseStorage] instance is a singleton for fixed [app] and
+  /// [storageBucket].
   ///
   /// The [storageBucket] argument is the gs:// url to the custom Firebase
   /// Storage Bucket.
@@ -44,6 +48,57 @@
   /// Creates a new [StorageReference] initialized at the root
   /// Firebase Storage location.
   StorageReference ref() => new StorageReference._(const <String>[], this);
+
+  Future<int> getMaxDownloadRetryTimeMillis() async {
+    return await channel.invokeMethod(
+        "FirebaseStorage#getMaxDownloadRetryTime", <String, dynamic>{
+      'app': app?.name,
+      'bucket': storageBucket,
+    });
+  }
+
+  Future<int> getMaxUploadRetryTimeMillis() async {
+    return await channel.invokeMethod(
+        "FirebaseStorage#getMaxUploadRetryTime", <String, dynamic>{
+      'app': app?.name,
+      'bucket': storageBucket,
+    });
+  }
+
+  Future<int> getMaxOperationRetryTimeMillis() async {
+    return await channel.invokeMethod(
+        "FirebaseStorage#getMaxOperationRetryTime", <String, dynamic>{
+      'app': app?.name,
+      'bucket': storageBucket,
+    });
+  }
+
+  Future<void> setMaxDownloadRetryTimeMillis(int time) {
+    return channel.invokeMethod(
+        "FirebaseStorage#setMaxDownloadRetryTime", <String, dynamic>{
+      'app': app?.name,
+      'bucket': storageBucket,
+      'time': time,
+    });
+  }
+
+  Future<void> setMaxUploadRetryTimeMillis(int time) {
+    return channel.invokeMethod(
+        "FirebaseStorage#setMaxUploadRetryTime", <String, dynamic>{
+      'app': app?.name,
+      'bucket': storageBucket,
+      'time': time,
+    });
+  }
+
+  Future<void> setMaxOperationRetryTimeMillis(int time) {
+    return channel.invokeMethod(
+        "FirebaseStorage#setMaxOperationRetryTime", <String, dynamic>{
+      'app': app?.name,
+      'bucket': storageBucket,
+      'time': time,
+    });
+  }
 }
 
 class StorageReference {
diff --git a/packages/firebase_storage/test/firebase_storage_test.dart b/packages/firebase_storage/test/firebase_storage_test.dart
index 1697821..667352e 100644
--- a/packages/firebase_storage/test/firebase_storage_test.dart
+++ b/packages/firebase_storage/test/firebase_storage_test.dart
@@ -19,6 +19,174 @@
     final FirebaseStorage storage =
         new FirebaseStorage(app: app, storageBucket: storageBucket);
 
+    group('getMaxDownloadRetryTimeMillis', () {
+      final List<MethodCall> log = <MethodCall>[];
+
+      setUp(() {
+        FirebaseStorage.channel
+            .setMockMethodCallHandler((MethodCall methodCall) async {
+          log.add(methodCall);
+          return 1000;
+        });
+      });
+
+      test('invokes correct method', () async {
+        await storage.getMaxDownloadRetryTimeMillis();
+
+        expect(log, <Matcher>[
+          isMethodCall(
+            'FirebaseStorage#getMaxDownloadRetryTime',
+            arguments: <String, String>{
+              'app': 'testApp',
+              'bucket': 'gs://fake-storage-bucket-url.com',
+            },
+          ),
+        ]);
+      });
+
+      test('returns correct result', () async {
+        expect(await storage.getMaxDownloadRetryTimeMillis(), 1000);
+      });
+    });
+
+    group('getMaxUploadRetryTimeMillis', () {
+      final List<MethodCall> log = <MethodCall>[];
+
+      setUp(() {
+        FirebaseStorage.channel
+            .setMockMethodCallHandler((MethodCall methodCall) async {
+          log.add(methodCall);
+          return 2000;
+        });
+      });
+
+      test('invokes correct method', () async {
+        await storage.getMaxUploadRetryTimeMillis();
+
+        expect(log, <Matcher>[
+          isMethodCall(
+            'FirebaseStorage#getMaxUploadRetryTime',
+            arguments: <String, String>{
+              'app': 'testApp',
+              'bucket': 'gs://fake-storage-bucket-url.com',
+            },
+          ),
+        ]);
+      });
+
+      test('returns correct result', () async {
+        expect(await storage.getMaxUploadRetryTimeMillis(), 2000);
+      });
+    });
+
+    group('getMaxOperationRetryTimeMillis', () {
+      final List<MethodCall> log = <MethodCall>[];
+
+      setUp(() {
+        FirebaseStorage.channel
+            .setMockMethodCallHandler((MethodCall methodCall) async {
+          log.add(methodCall);
+          return 3000;
+        });
+      });
+
+      test('invokes correct method', () async {
+        await storage.getMaxOperationRetryTimeMillis();
+
+        expect(log, <Matcher>[
+          isMethodCall(
+            'FirebaseStorage#getMaxOperationRetryTime',
+            arguments: <String, String>{
+              'app': 'testApp',
+              'bucket': 'gs://fake-storage-bucket-url.com',
+            },
+          ),
+        ]);
+      });
+
+      test('returns correct result', () async {
+        expect(await storage.getMaxOperationRetryTimeMillis(), 3000);
+      });
+    });
+
+    group('setMaxDownloadRetryTimeMillis', () {
+      final List<MethodCall> log = <MethodCall>[];
+
+      setUp(() {
+        FirebaseStorage.channel
+            .setMockMethodCallHandler((MethodCall methodCall) async {
+          log.add(methodCall);
+        });
+      });
+
+      test('invokes correct method', () async {
+        await storage.setMaxDownloadRetryTimeMillis(1000);
+
+        expect(log, <Matcher>[
+          isMethodCall(
+            'FirebaseStorage#setMaxDownloadRetryTime',
+            arguments: <String, dynamic>{
+              'app': 'testApp',
+              'bucket': 'gs://fake-storage-bucket-url.com',
+              'time': 1000,
+            },
+          ),
+        ]);
+      });
+    });
+
+    group('setMaxUploadRetryTimeMillis', () {
+      final List<MethodCall> log = <MethodCall>[];
+
+      setUp(() {
+        FirebaseStorage.channel
+            .setMockMethodCallHandler((MethodCall methodCall) async {
+          log.add(methodCall);
+        });
+      });
+
+      test('invokes correct method', () async {
+        await storage.setMaxUploadRetryTimeMillis(2000);
+
+        expect(log, <Matcher>[
+          isMethodCall(
+            'FirebaseStorage#setMaxUploadRetryTime',
+            arguments: <String, dynamic>{
+              'app': 'testApp',
+              'bucket': 'gs://fake-storage-bucket-url.com',
+              'time': 2000,
+            },
+          ),
+        ]);
+      });
+    });
+
+    group('setMaxOperationRetryTimeMillis', () {
+      final List<MethodCall> log = <MethodCall>[];
+
+      setUp(() {
+        FirebaseStorage.channel
+            .setMockMethodCallHandler((MethodCall methodCall) async {
+          log.add(methodCall);
+        });
+      });
+
+      test('invokes correct method', () async {
+        await storage.setMaxOperationRetryTimeMillis(3000);
+
+        expect(log, <Matcher>[
+          isMethodCall(
+            'FirebaseStorage#setMaxOperationRetryTime',
+            arguments: <String, dynamic>{
+              'app': 'testApp',
+              'bucket': 'gs://fake-storage-bucket-url.com',
+              'time': 3000,
+            },
+          ),
+        ]);
+      });
+    });
+
     group('StorageReference', () {
       group('getData', () {
         final List<MethodCall> log = <MethodCall>[];