はじめに
僕が個人開発しているAndroidアプリに『メモアプリ』というメモ帳アプリがあります。基本的にはオフラインで使えるシンプルなメモ帳アプリなのですが、機種変時のデータ移行を楽にしたいという要望もあり、Firebaseを使ってユーザデータのバックアップ作成/復元ができるようになっています。
FirebaseのAuth, Firestore, Storageを使うことで比較的容易にこの機能を組み込むことができましたが、それでも実装量はそれなりにあるため、本記事では、この機能の実装例を簡単にではありますが紹介します。
1. AndroidプロジェクトにFirebaseを追加する
以下の公式ドキュメントを参考にAndroidプロジェクトにFirebaseを追加します。
Android プロジェクトに Firebase を追加する
また、Firebaseの認証にGoogleログインを使いますので、以下の公式ドキュメントを参考にGoogleログインを統合します。
Android で Google ログインを使用して認証する
次に、Firebase Console上でCloud FirestoreとCloud Storageの初期設定を以下の公式ドキュメントを参考に済ませてください。
CloudFirestoreを使ってみる
AndroidでCloud Storageを使ってみる
2. Firebaseのセキュリティルール
Firebase Console上で以下のセキュリティルールを設定します。
Firestore
usersコレクション直下にFirebase Authで払い出されたユーザのUIDを持つドキュメントに対して、そのユーザのみがRead/Writeできる権限を設定します。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
}
}
Storage
usersフォルダ直下にFirebase Authで払い出されたユーザ専用のフォルダを作り、そのフォルダに対してのみユーザ自身がRead/Writeできる権限を設定します。
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /users/{userId}/{allPaths=**} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
}
}
3. クライアントの実装
主な処理の流れは以下の通りです。なお、アプリ仕様はサインインしなくても通常機能は使えるようになっており、バックアップ機能を有効にしたときに初めてサインインする仕様になっています。
バックアップ作成時
- バックアップボタンが押される
- 未サインインの場合はサインイン処理
- ユーザファイルをzipファイルに固める
- Storageにzipファイルをアップロード
- Firestoreにユーザデータを格納
- 成功/エラーを返す
バックアップ復元時
- リストアボタンが押される
- 未サインインの場合はサインイン処理
- Firestoreからユーザデータの取得
- Storageからユーザファイルをダウンロード
- zip解凍しローカルストレージに保存
- 成功/エラーを返す
バックアップ作成時
Firebase認証
まずは、サインイン済みかどうか判定し、未サインインならば、Googleアカウントでサインインしてもらう処理を書きます。
companion object {
private const val REQUEST_CODE_SIGN_IN_WITH_MAKE_BACKUP = 1
}
private lateinit var auth: FirebaseAuth
private lateinit var googleSignInClient: GoogleSignInClient
private val isSignedIn: Boolean
get() = auth.currentUser != null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Firebase Authの初期化
auth = FirebaseAuth.getInstance()
// Google SignInの初期化
val googleSignInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken(getString(R.string.default_web_client_id))
.requestEmail()
.build()
googleSignInClient = GoogleSignIn.getClient(this, googleSignInOptions)
// バックアップボタンをクリック
backupButton.setOnClickListener {
if (!isSignedIn) {
// 未サインインならGoogleログインするダイアログを表示
val signInIntent = googleSignInClient.signInIntent
startActivityForResult(signInIntent, REQUEST_CODE_SIGN_IN_WITH_MAKE_BACKUP)
}
else {
// バックアップ作成
makeBackup()
}
}
}
private fun makeBackup() {
// TODO: バックアップ作成
}
Googleログイン処理が完了するとonActivityResult
メソッドが呼ばれます。Firebase認証後、auth.currentUser
で認証ユーザ情報(ここではユーザID:uid
)を取得できます。
なお、RxJavaを使っているところがありますが、本筋ではないため、説明は割愛します。
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_CODE_SIGN_IN_WITH_MAKE_BACKUP) {
val task = GoogleSignIn.getSignedInAccountFromIntent(data)
try {
// Google Sign In was successful, authenticate with Firebase
val account = task.getResult(ApiException::class.java)
firebaseAuthWithGoogle(account!!)
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe { /*progressView.show()*/ }
.doFinally { /*progressView.dismiss()*/ }
.subscribe(object : SingleObserver<FirebaseUser> {
override fun onSubscribe(d: Disposable) {
}
override fun onSuccess(user: FirebaseUser) {
if (requestCode == REQUEST_CODE_SIGN_IN_WITH_MAKE_BACKUP) {
// バックアップ作成
makeBackup()
}
}
override fun onError(error: Throwable) {
// TODO: Error Handling
}
})
}
catch (e: ApiException) {
// TODO: Error Handling
}
}
}
// Firebase認証
private fun firebaseAuthWithGoogle(account: GoogleSignInAccount): Single<FirebaseUser> {
return Single.create { emitter ->
val credential = GoogleAuthProvider.getCredential(account.idToken, null)
auth.signInWithCredential(credential).addOnCompleteListener(this) { task ->
if (task.isSuccessful) {
val user = auth.currentUser
emitter.onSuccess(user!!)
}
else {
emitter.onError(Error("認証エラー"))
}
}
}
}
バックアップデータの作成
バックアップデータを作成します。最初にユーザの画像ファイルなどをStorageにアップロードした後、Firestoreにユーザデータを格納しています。両方の処理が成功したときのみバックアップ成功とし、それ以外はエラーを返します。
private fun makeBackup() {
auth.currentUser?.let { user ->
val backupManager = BackupManager(this@SettingsActivity)
backupManager.backupToStorage(user)
.flatMap { backupManager.backupToDatabase(user) }
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe { /*progressView.show()*/ }
.doFinally { /*progressView.dismiss()*/ }
.subscribe(object : Observer<Boolean> {
override fun onSubscribe(d: Disposable) {
}
override fun onNext(t: Boolean) {
}
override fun onComplete() {
// TODO: Success Handling
}
override fun onError(error: Throwable) {
// TODO: Error Handling
}
})
}
}
BackupManagerクラスは以下の通りです。Storageにusers/{userId}/backup.zip
としてファイルアップロードします。あくまでバックアップ用途のため、個別にファイル取得をすることはないので、クライアント側でzipファイルに固めてしまっています。ここでは、Zip4jを使ってzipファイルを作成しています。
Firestoreにはusers/{userId}
ドキュメントにタイムスタンプとユーザデータを格納しています。
class BackupManager(private val context: Context) {
// Storageにファイルをアップロード
fun backupToStorage(user: FirebaseUser): Observable<FirebaseUser> {
return Observable.create { emitter ->
val storage = FirebaseStorage.getInstance()
val file = archive()
val ref = storage.reference.child("users/${user.uid}/backup.zip")
ref.putFile(Uri.fromFile(file))
.addOnSuccessListener {
emitter.onNext(user)
emitter.onComplete()
}
.addOnFailureListener { exception ->
emitter.onError(Error("アップロード失敗"))
}
}
}
// Firestoreにデータを格納
fun backupToDatabase(user: FirebaseUser): Observable<Boolean> {
return Observable.create { emitter ->
val db = FirebaseFirestore.getInstance()
val data = hashMapOf(
"storedAt" to Timestamp(Date()), // 最終バックアップ時刻
// TODO: 保存したいユーザデータを追記
)
db.collection("users").document(user.uid).set(data)
.addOnSuccessListener {
emitter.onNext(true)
emitter.onComplete()
}
.addOnFailureListener { exception ->
emitter.onError(Error("データベースエラー"))
}
}
}
private fun archive(): File {
val userFile1: File = /* ユーザの画像ファイルなどのFileオブジェクト */
val userFile2: File = /* ユーザの画像ファイルなどのFileオブジェクト */
// ユーザデータのFileオブジェクトをzipファイルに固める
val zipFile = ZipFile("path/to/backup.zip")
zipFile.addFiles(listOf(userFile1, userFile2))
return zipFile.file
}
}
バックアップ復元時
Firebase認証
バックアップ復元時も作成時と同様です。先ほどのコードに以下のように追加します。
companion object {
private const val REQUEST_CODE_SIGN_IN_WITH_MAKE_BACKUP = 1
private const val REQUEST_CODE_SIGN_IN_WITH_RESTORE_BACKUP = 2
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
// リストアボタンをクリック
restoreButton.setOnClickListener {
if (!isSignedIn) {
// 未サインインならGoogleログインするダイアログを表示
val signInIntent = googleSignInClient.signInIntent
startActivityForResult(signInIntent, REQUEST_CODE_SIGN_IN_WITH_RESTORE_BACKUP)
}
else {
// バックアップ復元
restoreBackup()
}
}
}
private fun restoreBackup() {
// TODO: バックアップ復元
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_CODE_SIGN_IN_WITH_MAKE_BACKUP ||
requestCode == REQUEST_CODE_SIGN_IN_WITH_RESTORE_BACKUP) {
val task = GoogleSignIn.getSignedInAccountFromIntent(data)
try {
// Google Sign In was successful, authenticate with Firebase
val account = task.getResult(ApiException::class.java)
firebaseAuthWithGoogle(account!!)
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe { /*progressView.show()*/ }
.doFinally { /*progressView.dismiss()*/ }
.subscribe(object : SingleObserver<FirebaseUser> {
override fun onSubscribe(d: Disposable) {
}
override fun onSuccess(user: FirebaseUser) {
if (requestCode == REQUEST_CODE_SIGN_IN_WITH_MAKE_BACKUP) {
// バックアップ作成
makeBackup()
}
else if (requestCode == REQUEST_CODE_SIGN_IN_WITH_RESTORE_BACKUP) {
// バックアップ復元
restoreBackup()
}
}
override fun onError(error: Throwable) {
// TODO: Error Handling
}
})
}
catch (e: ApiException) {
// TODO: Error Handling
}
}
}
バックアップデータから復元
バックアップデータから復元します。Firestoreからユーザデータをロードした後、Storageからユーザの画像ファイルなどをダウンロードします。両方の処理が成功したときのみバックアップ成功とし、それ以外はエラーを返します。
private fun restoreBackup() {
auth.currentUser?.let { user ->
val backupManager = BackupManager(this@SettingsActivity)
backupManager.restoreFromDatabase(user)
.flatMap { backupManager.restoreFromStorage(user) }
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe { /*progressView.show()*/ }
.doFinally { /*progressView.dismiss()*/ }
.subscribe(object : Observer<Boolean> {
override fun onSubscribe(d: Disposable) {
}
override fun onNext(t: Boolean) {
}
override fun onComplete() {
// TODO: Success Handling
}
override fun onError(error: Throwable) {
// TODO: Error Handling
}
})
}
}
}
先ほどのBackupManagerクラスに以下のコードを追加します。バックアップ作成時と逆の処理を書いていく感じです。
class BackupManager(private val context: Context) {
...
fun restoreFromDatabase(user: FirebaseUser): Observable<FirebaseUser> {
return Observable.create { emitter ->
val db = FirebaseFirestore.getInstance()
db.collection("users").document(user.uid).get(Source.SERVER) // オフラインキャッシュを利用しない
.addOnSuccessListener { document ->
if (document != null && document.data != null) {
val data: Map<String, Any> = document.data!!
// TODO: ユーザデータをローカルDBに保存
emitter.onNext(user)
emitter.onComplete()
}
else {
emitter.onError(Error("バックアップデータがありません"))
}
}
.addOnFailureListener { exception ->
emitter.onError(Error("データベースエラー"))
}
}
}
fun restoreFromStorage(user: FirebaseUser): Observable<Boolean> {
return Observable.create { emitter ->
val tmpFile = File("path/to/backup.zip")
val storage = FirebaseStorage.getInstance()
val ref = storage.reference.child("users/${user.uid}/backup.zip")
ref.getFile(tmpFile)
.addOnSuccessListener {
try {
unarchive(tmpFile)
emitter.onNext(true)
emitter.onComplete()
}
catch (e: Exception) {
emitter.onError(Error("復元に失敗"))
}
}
.addOnFailureListener { exception ->
emitter.onError(Error("復元に失敗"))
}
}
}
private fun unarchive(file: File) {
// TODO: ローカルストレージに展開
ZipFile(file).extractAll("path/to/")
}
}
まとめ
FirebaseのAuth, Firestore, Storageを使い、ユーザデータのバックアップを作成する例を紹介しました。サンプルコードのままでは、データが平文で保存されるため、実際は暗号化して保存するなどした方が良いかもしれません。
参考
- Android プロジェクトに Firebase を追加する
https://firebase.google.com/docs/android/setup?authuser=1 - RxJava
https://github.com/ReactiveX/RxJava - Zip4j
https://github.com/srikanth-lingala/zip4j