データを圧縮してバックアップするようにしたメモ

公開 2019-05-01 / 最終更新 2021-05-05 12:07
カテゴリー Androidアプリ

今回は、画像をバックアップする際に、Zip圧縮して送信するようにしました。そっちのほうがシステム的にも便利だし、転送量を削減できるので。(というか、JPEGはもともと圧縮された画像なので、あまり転送量は減らないと思うが…)

システム的に便利というのは、バックアップに利用しているサービス「Firebase Storage」にフォルダー内に存在するファイルの一覧を取得する方法が存在しないため、削除したり取得したりするにはファイル一覧を別の場所に保存しておく必要があります。そうなると、実際にあるファイルとデータベース上の一覧で矛盾が生じ、削除しきれなかったり存在しないファイルを参照しようとしてしまう可能性がありました。

しかし、Zipに圧縮してしまえば、**ファイル名が1つでいいため、確実に削除できます。**ダウンロード・アップロードする際もまたしかり。

というわけで、コードを書いてみました。いつもどおり別のサイト様を参考にして。ちょっと情報が古そうですが、まあ動けば問題なし。

バックアップ(圧縮)する際の関数

fun sendBackup() {
        try {
            val db = FirebaseFirestore.getInstance()
            val storage = FirebaseStorage.getInstance("gs://mydatauploader.appspot.com/")
            val auth = FirebaseAuth.getInstance().currentUser

            if (progressDialog != null) progressDialog!!.setMessage(PROGRESS_STRING + "バックアップされた画像データを削除しています・・・")

            storage.reference.child("users/${auth!!.uid}/PictureData.zip").delete()
                    .addOnCompleteListener {
                        if (progressDialog != null) progressDialog!!.setMessage(PROGRESS_STRING + "提出物データを送信しています・・・")
                        val map: Map<String, String>
                        val mapper = ObjectMapper()
                        map = mapper.readValue(realmToJson(Realm.getDefaultInstance(), context!!).toString(), object : TypeReference<LinkedHashMap<String, Any>>() {})
                        db.collection("users")
                                .document(auth.uid)
                                .set(map)
                                .addOnSuccessListener {
                                    val result = Realm.getDefaultInstance().where<Schedule>().notEqualTo("picPath", "")
                                            .findAll().filter { File(it.picPath).exists() }
                                    val picturePaths = mutableListOf<String>()
                                    result.forEach {
                                        picturePaths.add(it.picPath)
                                    }
                                    if (picturePaths.isEmpty()) {
                                        if (progressDialog != null) progressDialog!!.dismiss()
                                        toast("保存が完了しました。")
                                    } else {
                                        if (progressDialog != null) progressDialog!!.setMessage(PROGRESS_STRING + "画像データを圧縮しています・・・")

                                        Thread {
                                            val outputStream = ZipOutputStream(FileOutputStream("${context!!.externalCacheDir!!.path}/PictureData.zip"))
                                            File("${context!!.externalCacheDir!!.path}/PictureData.zip").deleteOnExit()
                                            runOnUiThread {
                                                if (progressDialog != null) progressDialog!!.apply {
                                                    isIndeterminate = false
                                                    max = picturePaths.size
                                                }
                                            }
                                            picturePaths.forEach {
                                                val inputStream = FileInputStream(it)
                                                val buffer = ByteArray(10240 * 4)

                                                val zipEntry = ZipEntry(it.split("/").last())

                                                outputStream.putNextEntry(zipEntry)

                                                var len: Int
                                                do {
                                                    len = inputStream.read(buffer)
                                                    if (len == -1) break
                                                    outputStream.write(buffer, 0, len)
                                                } while (true)

                                                inputStream.close()
                                                outputStream.closeEntry()

                                                if (progressDialog != null) progressDialog!!.progress++
                                            }
                                            outputStream.close()

                                            runOnUiThread {
                                                if (progressDialog != null) progressDialog!!.setMessage(PROGRESS_STRING + "画像データを送信しています・・・")
                                            }
                                            storage.reference.child("users/${auth.uid}/PictureData.zip")
                                                    .putFile(Uri.fromFile(File("${context!!.externalCacheDir!!.path}/PictureData.zip")))
                                                    .addOnSuccessListener {
                                                        File("${context!!.externalCacheDir!!.path}/PictureData.zip").delete()
                                                        runOnUiThread {
                                                            if (progressDialog != null) progressDialog!!.dismiss()
                                                            toast("保存が完了しました。")
                                                        }
                                                    }
                                                    .addOnProgressListener {
                                                        runOnUiThread {
                                                            if (progressDialog != null) {
                                                                progressDialog!!.apply {
                                                                    progress = (it.bytesTransferred / 1024).toInt()
                                                                    max = (it.totalByteCount / 1024).toInt()
                                                                }
                                                            }
                                                        }
                                                    }
                                        }.start()
                                    }
                                }
                                .addOnFailureListener {
                                    toast("提出物データの保存に失敗しました。(${it.message})")
                                }
                    }
        } catch (e: Exception) {
            e.printStackTrace()
            if (progressDialog != null) {
                progressDialog!!.dismiss()
                progressDialog = null
            }
            context!!.toast("バックアップに失敗しました。${e.message}")
        }
    }

ちょっと強引な感じもなくはない?

リストア(解凍)する際の関数

fun restore(dbTask: Task<DocumentSnapshot>, auth: FirebaseUser) {
        val mapData = dbTask.result?.data
        val mapper = ObjectMapper()
        val jsonData = mapper.writeValueAsString(mapData)
        val realm = Realm.getDefaultInstance()

        val result = jsonToRealm(realm, JSONObject(jsonData), context!!)
        when (result) {
            null -> {
                Functions.showSnackbar(root_restore, "JSONファイルの解析に失敗しました。(このアプリのバックアップデータではありません)")
            }
            false -> {
                context!!.alertDeviceDefault("古いバックアップデータのバージョンです。アプリのバージョンを下げてリストアしてください。\n(アプリ内のデータはアップデート時にマイグレーションされます。)\n\n詳しくは更新履歴をご覧ください。", "エラー") {
                    yesButton { }
                    negativeButton("更新履歴へ") {
                        startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://sites.google.com/site/chikachuploader/upd-submission")))
                    }
                }
            }
            true -> {
                progressDialog = ProgressDialog(context, android.R.style.Theme_DeviceDefault_Dialog_NoActionBar).apply {
                    setTitle("リストア中")
                    setMessage(RESTORE_PROGRESS_STRING)
                    max = 1
                    setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
                    setCancelable(false)
                    show()
                }
                fun completeRestoring() {
                    progressDialog!!.dismiss()
                    fun showRestartDialog() {
                        context!!.alertDeviceDefault("再起動します。", "リストア完了") {
                            yesButton {
                                val intent = Intent(context, MainActivity::class.java)
                                intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
                                startActivity(intent)
                            }
                            setCancelable(false)
                        }
                    }
                    context!!.alertDeviceDefault("リストアが完了しました。自動同期の設定をしますか?", "リストア完了") {
                        yesButton {
                            if (defaultSharedPreferences.getString("checktime_key", "not-set") == "not-set") {
                                alert("自動同期する時刻を設定してください。(針を動かすと設定されます。)") {
                                    customView {
                                        timePicker {
                                            setIs24HourView(true)
                                            setOnTimeChangedListener { view, hourOfDay, minute ->
                                                val time = "$hourOfDay:${if (minute < 10) "0$minute" else minute.toString()}"
                                                defaultSharedPreferences.edit().putString("checktime_key", time).apply()
                                            }
                                        }
                                    }
                                    yesButton {
                                        defaultSharedPreferences.edit().putBoolean("automatic_backup", true).apply()
                                        context.reSetupNotifications(realm, true)
                                        showRestartDialog()
                                    }
                                    isCancelable = false
                                }.show()
                            } else {
                                defaultSharedPreferences.edit().putBoolean("automatic_backup", true).apply()
                                showRestartDialog()
                            }
                        }
                        cancelButton {
                            showRestartDialog()
                        }
                        setCancelable(false)
                    }
                }
                val storage = FirebaseStorage.getInstance()
                val ref = storage.reference.child("users/${auth.uid}/PictureData.zip")
                val file = File.createTempFile("PictureData", "zip", context!!.externalCacheDir)
                file.deleteOnExit()
                ref.getFile(file)
                        .addOnProgressListener {
                            progressDialog!!.max = if (it.totalByteCount >= 1024L) (it.totalByteCount / 1024).toInt()
                            else 1
                            progressDialog!!.progress = (it.bytesTransferred / 1024).toInt()
                        }
                        .addOnSuccessListener {
                            progressDialog!!.setMessage("画像データを解凍しています・・・")

                            //非同期でZipから解凍
                            Thread {
                                val inputStream = ZipInputStream(FileInputStream(file))

                                do {
                                    val entry = inputStream.nextEntry ?: break
                                    val newFile = File(entry.name)
                                    val outputStream = BufferedOutputStream(FileOutputStream(
                                            "${Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)}/SubmissionPictures/${newFile.name}")
                                    )
                                    val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
                                    do {
                                        val len = inputStream.read(buffer)
                                        if (len == -1) break
                                        outputStream.write(buffer, 0, len)
                                    } while (true)
                                    inputStream.closeEntry()
                                    outputStream.close()
                                } while (true)
                                runOnUiThread {
                                    completeRestoring()
                                }
                            }.start()
                        }
                        .addOnFailureListener {
                            if (JSONObject(it.message).getInt("code") == 404) {
                                completeRestoring()
                            } else {
                                toast("画像データのダウンロードに失敗しました。(${it::class.java.name} : ${it.message})")
                            }
                        }
                file.delete()
            }
        }
    }

新端末でログインすると、以前の端末で「自動的に同期する」のチェックが外れるようにもした。

見出しのとおり。いろいろ新端末でログインされたことを検知する方法はあると思うが、直接検知できるようなメソッドは提供されていない。今回は最後にログインした端末のIMEIをデータベースに保存し、毎日機能の時刻になったときにデータベースと一致すれば自動バックアップ、一致しなければ自動同期のチェックを外して何もしない、というような動作をするようにした。

IMEIを取得するためには「READ_PHONE_STATE」という権限が必要。このアップデートで電話権限が追加されたのはこのため。

まずはパーミッションチェック。

fun checkPermission() {
        if (ContextCompat.checkSelfPermission(this,
                        READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED || ContextCompat.checkSelfPermission(this,
                        WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_DENIED || ContextCompat.checkSelfPermission(this,
                        READ_PHONE_STATE) == PackageManager.PERMISSION_DENIED) {
            requestRWExternalStorage()
        }
    }

    fun requestRWExternalStorage() {
        alertDeviceDefault("この機能を利用するには、以下の機能を許可する必要があります。\n\nストレージ:画像をバックアップまたはリストアするため\n" +
                "電話:以前の端末で自動的に自動同期をオフにするために、端末のIMEIを取得するため", "権限が必要です") {
            yesButton { ActivityCompat.requestPermissions(this@BackupActivity, arrayOf(READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE, READ_PHONE_STATE), REQUEST_PERMISSION) }
            cancelButton { finish() }
            onCancelled { finish() }
        }
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode == REQUEST_PERMISSION) {
            if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
                //許可された
            } else {
                if (ActivityCompat.shouldShowRequestPermissionRationale(this,
                                READ_EXTERNAL_STORAGE) || ActivityCompat.shouldShowRequestPermissionRationale(this,
                                WRITE_EXTERNAL_STORAGE) || ActivityCompat.shouldShowRequestPermissionRationale(this,
                                READ_PHONE_STATE)) {
                    toast("一部または全ての権限が許可されていないため、この機能を利用できません。")
                    finish()
                } else {
                    alertDeviceDefault("この機能を利用するには、設定画面から許可する必要があります。「設定画面を開く」→「許可」(または「権限」)→「ストレージ」、「電話」の順でタップしてください。", "権限がありません") {
                        positiveButton("設定画面を開く") {
                            val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
                            val uri = Uri.fromParts("package", packageName, null)
                            intent.data = uri
                            startActivity(intent)
                            finish()
                        }
                        cancelButton { finish() }
                        onCancelled { finish() }
                    }
                }
            }
        }
    }

謎にパーミッションチェックに凝っています。これだけで46行ある。

で、次はログインが完了したタイミングでの処理ですね。

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        if (requestCode == RC_SIGN_IN) {
//            val responce = IdpResponse.fromResultIntent(data)

            if (resultCode == AppCompatActivity.RESULT_OK) {

                val pd = ProgressDialog(context).apply {
                    setProgressStyle(ProgressDialog.STYLE_SPINNER)
                    setTitle("しばらくお待ちください...")
                    setMessage("データを取得しています。")
                    setCancelable(false)
                    show()
                }
                val auth = FirebaseAuth.getInstance().currentUser
                val db = FirebaseFirestore.getInstance()
                val deviceId: String
                //念の為パーミッション確認
                if (context!!.checkSelfPermission(READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) {
                    deviceId = context!!.telephonyManager.deviceId
                } else {
                    context!!.alertDeviceDefault("権限が許可されていません。もう一度アクティビティ(画面)を開き直してください。", "エラー") {
                        yesButton { pd.dismiss() }
                        onCancelled { pd.dismiss() }
                    }
                    //パーミッションが許可されていなかったらログアウトしてやり直させる。
                    FirebaseAuth.getInstance().signOut()
                    return
                }
                context!!.toast("ログインしました。")
                updateUI()
                //ここでIMEIをデータベースに送信
                db.collection("users").document(auth!!.uid).collection("deviceid").
                        document("deviceid").set(mapOf("device_id" to deviceId))
                        .addOnFailureListener {
                            toast("製造番号の送信に失敗しました。以前の端末で正常に自動同期のチェックが外れない可能性があります。")
                        }
                //毎回毎回チェックするのもあれなので、一度ログインしたら内部データベースに保存される。
                context!!.defaultSharedPreferences.edit().putString("imei", deviceId).apply()
                db.collection("users").document(auth.uid).get()
                        .addOnCompleteListener { dbTask ->
                            if (dbTask.isSuccessful) {
                                if (dbTask.result?.exists()!!) {
                                    context!!.alertDeviceDefault("バックアップしたデータがすでに存在します。リストアしますか?\n" +
                                            "\n" +
                                            "※端末内の全データが削除されます。", "リストア") {
                                        yesButton {
                                            restore(dbTask, auth)
                                        }
                                        cancelButton {}
                                        setCancelable(false)
                                    }
                                } else {
                                    sendBackup()
                                }
                            } else {
                                context!!.toast("データの取得に失敗しました。")
                            }
                            pd.dismiss()
                        }

            }
        }
    }

ここからは自動同期を実行するタイミング。ここでサーバーと一致するか確認している。

//自動同期
    //自動同期がONか確認
    if (context.defaultSharedPreferences.getBoolean("automatic_backup", false)) {
            //ログイン済みか確認
            if (FirebaseAuth.getInstance().currentUser != null) {
                val db = FirebaseFirestore.getInstance()
                val auth = FirebaseAuth.getInstance().currentUser
                //FirestoreからIMEI取得
                db.collection("users").document(auth!!.uid).collection("deviceid").
                        document("deviceid").get()
                        .addOnSuccessListener {
                            //成功すれば内部データベースと比較
                            //一致すれば保存、
                            if (context.defaultSharedPreferences.getString("imei", "") == it.data!!["device_id"]) {
                                val notificationId = 7285
                                val backupNotification = Notification.Builder(context, "backup")
                                        .setContentTitle("保存中")
                                        .setContentText("アイテムをバックアップして保存しています。")
                                        .setAutoCancel(false)
                                        .setSmallIcon(R.drawable.icon)
                                        .setProgress(0, 0, true)
                                        .build().apply {
                                            flags = Notification.FLAG_NO_CLEAR
                                        }

                                nm.notify(notificationId, backupNotification)
                                val map: Map<String, String>
                                val mapper = ObjectMapper()
                                try {
                                    map = mapper.readValue(Functions.realmToJson(Realm.getDefaultInstance(), context).toString(),
                                            object : TypeReference<LinkedHashMap<String, Any>>() {})
                                    db.collection("users")
                                            .document(auth.uid)
                                            .set(map)
                                            .addOnCompleteListener {
                                                nm.cancel(notificationId)
                                                if (it.isSuccessful) {
                                                    val completeNotification = Notification.Builder(context, "backup")
                                                            .setAutoCancel(true)
                                                            .setContentTitle("保存完了")
                                                            .setContentText("バックアップが完了しました。")
                                                            .setSmallIcon(R.drawable.icon)
                                                            .build()
                                                    nm.notify(UUID.randomUUID().hashCode(), completeNotification)
                                                } else {
                                                    val completeNotification = Notification.Builder(context, "backup")
                                                            .setAutoCancel(true)
                                                            .setContentTitle("保存失敗")
                                                            .setContentText("何らかの理由でバックアップに失敗しました。")
                                                            .setSmallIcon(R.drawable.icon)
                                                            .build()
                                                    nm.notify(UUID.randomUUID().hashCode(), completeNotification)
                                                }

                                            }
                                } catch (e: Exception) {
                                    e.printStackTrace()
                                }
                            } else {
                                //一致しなければ自動同期のチェックを外す
                                context.defaultSharedPreferences.edit().putBoolean("automatic_backup", false).apply()
                            }
                        }
                        .addOnFailureListener {
                            val completeNotification = Notification.Builder(context, "backup")
                                    .setAutoCancel(true)
                                    .setContentTitle("保存失敗")
                                    .setContentText("データベースのIMEIの取得に失敗しました。")
                                    .setSmallIcon(R.drawable.icon)
                                    .build()
                            nm.notify(UUID.randomUUID().hashCode(), completeNotification)
                        }
            }
        }

alertDeviceDefaultという関数について

デバイス依存のテーマでダイアログを出したかったのですが、ankoはテーマの変更に対応していないため、自分で作ってやりました。anko(記述を簡略化するライブラリ)からパクったというか参考にしたというか。。。GitHubに載せときました。

まとめ

無駄にバックアップ機能に力を入れていますが、全部ロマンのためにやっています。ユーザー確認してみたら、自分以外いませんでした。つまり、全く使われていません。

そのへんのアプリと比較してもユーザビリティが違います。是非ダウンロードお願いします。

Google Play で手に入れよう

へーこういうのをいかがでしたかブログっていうんだ−しらなかったー

タグ:
imei
バックアップ
圧縮
コメントする

※コメントシステムの詳細はこちらを御覧ください。

コメント本文

※reCAPTCHAによるボット判定を行っているため、送信に少々時間がかかる場合があります。ご了承ください。

送信しました。

確認

コメントを削除しますか?

返信

返信を入力

arrow_upward