firebase_storageでPermission denied対応

firebase_storageの扱い方で数時間を溶かしたので、同じような人が出ない為にまとめました。

TL; DL

よくない例

FirebaseStorage _storage = FirebaseStorage(app: myapp);
final ref = _storage.ref().child(dirname).child(filename);

よい例1

FirebaseStorage(app: myapp);
final ref = FirebaseStorage.instance.ref().child(dirname).child(filename);

よい例2

FirebaseStorage(app: myapp);
final _storage = FirebaseStorage.instance;
final ref = _storage.ref().child(dirname).child(filename);

firebase_storageを使う

FlutterはFirebaseと親和性が高く、Firebaseのあれこれが簡単にできます。
FirebaseStorageへのアクセスも公式からパッケージが準備されているので、それをpubspecに記載して、コードを書くだけ。
(build.gradleを修正する必要はありません)
かんたん!!easy!!

と思っているとハマりました。

firebase_storageのインストール

pubfirebase_storageと検索をかけると出てきます。

pub.dev

Usage To use this plugin, add firebase_storage as a dependency in your pubspec.yaml file.

簡潔すぎる気もしますが、firebase_storageを使うための準備は実際これだけ。

dependencies:
  flutter:
    sdk: flutter
  firebase_core:
  firebase_auth:
  firebase_storage: # <= added!

Firebaseを使うためにはfirebase_coreが必要なのと、Storageへのアクセス権限を限定したい場合はfirebase_authも必要です。
追記したらflutter packages get

ConsoleでStorageの権限を確認

Firebase Console「開発」メニュー内にある「Storage」を選択、「ルール」タブを表示します。
デフォルトはこんな感じ。

service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read, write: if request.auth != null;
    }
  }
}

認証済みだったら誰でも読み書きできるよ、という設定。
疎通確認が目的なのでこのまま進めます。

Permission deniedにぶつかる

とりあえずこんな感じでコードを書いてみます。

import 'dart:io' show File;
import 'package:intl/intl.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_storage/firebase_storage.dart';

class StorageApi {
  // myappはFirebaseAppのインスタンス
  FirebaseStorage _storage = FirebaseStorage(app: myapp);

  Future<String> uploadImage(File file) async {
    final timestamp = DateFormat('yyyyMMddHHmmss').format(DateTime.now());
    final ref = _storage.ref().child('images').child(timestamp);
    final uploadTask = ref.putFile(file, StorageMetadata(contentType: 'image/jpeg'));
    final snapshot = await uploadTask.onComplete;
    if (snapshot.error == null) {
      return await snapshot.ref.getDownloadURL();
    } else {
      print(snapshot.error);
      return null;
    }
  }
}

FirebaseにログインしたユーザでuploadImageを叩いてみると、Permission denied
こんな感じのエラーが続きます。

E/StorageUtil(25832): error getting token java.util.concurrent.ExecutionException: com.google.firebase.internal.api.FirebaseNoSignedInUserException: Please sign in before trying to get a token.
W/NetworkRequest(25832): no auth token for request
E/StorageException(25832): StorageException has occurred.
E/StorageException(25832): User does not have permission to access this object.
E/StorageException(25832):  Code: -13021 HttpResult: 403
E/StorageException(25832): The server has terminated the upload session

ログインはしてるはず、と思ってcurrentUserをprint。

final currentUser = await FirebaseAuth.instance.currentUser();
print(currentUser);

返ってきます。ログイン済。

他に原因があるのかと思って、Storageのルールを全許可にすると、アップロードできます。
ということは、エラーメッセージ通り認証がうまくいっていないということ。

………ん?🤔

対処法

調べても出てこない!
同様に悩んでいる人は多いのですが、ルールを全許可にすればよいよ、という回答があるばかり。
違う、やりたいことはそうじゃない。

調べ続けて出てきたのがこのIssue

In fact looking into some of the related issues of the one you mentioned I managed to find a workaround which is simply access the storage with FirebaseStorage.instance

また、このIssueも。

Not Works

final FirebaseStorage storage = FirebaseStorage(app: app, storageBucket: '...');

Works

final FirebaseStorage storage = FirebaseStorage.instance;

つまり、こういうことか…!

class StorageApi {
  // FirebaseStorageはここではinitializeするだけ
  StorageApi() {
    FirebaseStorage(app: myapp);
  }

  Future<String> uploadImage(File file) async {
    final timestamp = DateFormat('yyyyMMddHHmmss').format(DateTime.now());
    // `FirebaseStorage.instance`にアクセスする
    // 変数に保持したい場合は`FirebaseStorage.instance`を入れる
    final ref = FirebaseStorage.instance.ref().child('images').child(timestamp);
    final uploadTask = ref.putFile(file, StorageMetadata(contentType: 'image/jpeg'));
    final snapshot = await uploadTask.onComplete;
    if (snapshot.error == null) {
      return await snapshot.ref.getDownloadURL();
    } else {
      print(snapshot.error);
      return null;
    }
  }
}

どういうこと?

FirebaseStorageはシングルトンなので、一度initializeすると常に同じオブジェクトが返ってきます。

class FirebaseStorage {
  FirebaseStorage({this.app, this.storageBucket}) {
    if (_initialized) return;
    channel.setMethodCallHandler((MethodCall call) async {
      _methodStreamController.add(call);
    });
    _initialized = true;
  }
  // ...(略)
  static bool _initialized = false;
  static FirebaseStorage _instance = FirebaseStorage();
  static FirebaseStorage get instance => _instance;
  // ...(略)
}

が、FirebaseStorage(app: myapp)で返ってくるインスタンスFirebaseStorage.instance(FirebaseStorage())で返ってくるインスタンスは違っていて、使うべきは後者だった、ということでした。

FirebaseAuth.instanceでアクセスするのが布石だったな…という反省。
お疲れ様でした。