2021年7月21日 星期三

Android kotlin 備份及還原 app 的 sqlite database

因為在 Android Q (API 29) 後有 getExternalStoragePublicDirectory is deprecated 的狀況,所以最後終於東拼西湊出可以複製。

下列程式是用在 Fragment,如果是Android Q以上,會有檔案選取頁面選擇要儲存備份的檔案位置和自訂檔名,在還原時也可以自行選擇還原檔案。其他較低的Android 則在備份時直接以指定檔名儲存到 Download 資料夾,還原時也從 Download 資料夾讀取指定檔名。

下方式完整有關的程式碼

app.db 是 app 儲存 database 的檔名

backup.db 是儲存到 Download 資料夾的備份檔案檔名

使用在 Fragment Class 中

class FragmentMain : Fragment() {
作為 dialog 建立時使用的 View
    private var mView: View? = null


在 class 開始先定義 ActivityResultLaunchar<String!>

寫入的 Launchar,因為 onActivityResult() deprecated ,改成用 registerForActivityResult()

裡面的 it 是 android.net.Uri ,可以直接使用在 openOutputStream(Uri) 和 openInputStream(Uri)。

但是上方兩個 Open...Stream() 產生的 outputStream 和 InputStream 就不一定都可以直接使用在 copyTo()

context?.getDatabasePath(String) 可以直接取得 java.io.File,可以直接以 inputStream() 轉成 FileInputStream,在 copyTo 作為讀取沒問題,有 InputStream.copyTo(OutputStream, buffersize)。

在還原時寫入 app 的 database檔案也可以用 context?.getDatabasePath(String)?.outputStream() 取得 FileOutputStream ,直接用在 copyTo(),

因為 FileOutputStream 是由 OutputStream 擴充而來, FileInputStream 是由 InputStream 擴充的。

setContentFilterItems() 是重新將還原的資料庫內容讀取到 RecyclerView 的資料內,並更新 RecyclerView()

// 寫入的 Launchar,採用 CreateDocument()
private var
mGetContent = registerForActivityResult(
            
ActivityResultContracts.CreateDocument()){ netUri: Uri! ->
val resolver = context?.contentResolver
resolver?.openOutputStream(netUri!!).use { inputStream: InputStream? ->
val inputStream = context?.getDatabasePath(
            "app.db")?.inputStream()
inputStream?.copyTo(inputStream!!, DEFAULT_BUFFER_SIZE)
}
}

private var mGetContentOpenDoc = registerForActivityResult(
            (
ActivityResultContracts.OpenDocument())){ netUri:Uri! ->
val resolver = context?.contentResolver
resolver?.openInputStream(netUri!!).use { inputStream: InputStream? ->

    val outputStream = context?.getDatabasePath(
              
"app.db")?.outputStream()
inputStream?.copyTo(outputStream!!, DEFAULT_BUFFER_SIZE)
setContentFilterItems()
}
}

把Fragment 的 View 存放到 mView,要用在產生 AlertDialog

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

mView = view
}

處理備份和還原的主要函式

isBackup 決定是備份還是還原

fileName 是預設儲存備份檔案的檔名

上半部是無法使用 getExternalStoragePublicDirectory() 的時候執行

測試到 API 22 LOLLIPOP_MR1 也就是 Android 5.1 都還是可以正常運作

在使用內容是 OpenDocument() 的 ActivityResultLaunchar: mGetContentOpenDoc 時,launch() 要輸入允許讀取的檔案的MIME type,這裡是使用代表 Binary Data 的 application/octet-stream ,可以以陣列同時允許讀取多種類型檔案,MIME type 可以到 freeformatter.com 的 MIME Types ListsMozilla 的 MIME類別 查。

下半部是舊的 API 使用

Enviroment.getExternalStoragePublicDirectory() 和 context?.getDatabasePath(String) 取得 java.io.File,

可以直接用在 File.copyTo(File,overwrite:Boolean, buffersize),可是在還原時,copyTo 好像會延遲寫入,導致接下來的 setContentFilterItems() 更新 RecyclerView 時還不是新的資料,所以改成把 FIle 轉成 FileInptstream ,使用和前方 FileInputstream 的同樣方式,才能即時顯示新的資料。

private fun backupRestoreDB(isBackup:Boolean,fileName:String){
//輸出 database 檔案位置到log
Log.d("backdb", context?.getDatabasePath(
            
FilterDbHelper.DATABASE_NAME).toString())

    if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {

if (isBackup) {
            // CreateDocument 的 ActivityResultLauncher 是傳入預設檔名
mGetContent.launch(fileName)
} else {
mGetContentOpenDoc.launch(
                   arrayOf
("application/octet-stream"))
}
} else {
var dlPath = Environment.getExternalStoragePublicDirectory(
                        Environment.DIRECTORY_DOWNLOADS)
var dbFFile = context?.getDatabasePath("app.db")
var newFile = File(dlPath.toString()+"/"+fileName)
if(isBackup) {
var cFilte = dbFFile?.copyTo(
                            
newFile, true, DEFAULT_BUFFER_SIZE)
val builder = AlertDialog.Builder(context)
builder.setMessage(newFile.absolutePath).setNeutralButton(
                
R.string.yes, null).show()
}else{
if(newFile.exists()) {

var cFilte = newFile.inputStream().copyTo(
                    dbFFile!!.outputStream(), DEFAULT_BUFFER_SIZE)
Snackbar.make(
                    mView
!!, "restored", Snackbar.LENGTH_LONG).setAction(
                        "Action"
, null).show()
setContentFilterItems()
} else {
Snackbar.make(mView!!, resources.getString(
                    
R.string.file_not_exist,newFile.absolutePath), Snackbar.LENGTH_LONG).setAction(
                        "Action"
, null).show()
}
}
}
}

上方 R.string.file_not_exist 在 string.xml 中

<string name="file_not_exist">%s not exist.</string>


因為還原時可能會誤按還原按鈕,所以執行還原前,先顯示詢問是否確定要還原。


private fun showRestoreComfirmDialog(){
val dialogClickListener =
DialogInterface.OnClickListener { dialog, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
backupRestoreDB(false, "backup.db")
}
DialogInterface.BUTTON_NEGATIVE -> {
}
}
}

val builder = AlertDialog.Builder(context)
builder.setMessage(R.string.confirm_restore_db).setPositiveButton(
            R.string.yes, dialogClickListener)
.setNegativeButton(R.string.cancel, dialogClickListener).show()
}
上方使用到 string.xml 的內容
<string name="yes">Yes</string>
<string name="cancel">Cancel</string>

使用方式

執行備份

    backupRestoreDB(true,resources.getString("backup.db"))

執行還原前先詢問是否要還原

    showRestoreComfirmDialog()

Class結束

}

參考資料:

https://blog.csdn.net/jingzz1/article/details/107338872


https://www.baeldung.com/kotlin/read-file


https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.io/java.io.-input-stream/copy-to.html


https://stackoverflow.com/questions/35528409/write-a-large-inputstream-to-file-in-kotlin/35529070


https://developer.mozilla.org/zh-TW/docs/Web/HTTP/Basics_of_HTTP/MIME_types


https://www.freeformatter.com/mime-types-list.html


https://stackoverflow.com/questions/56468539/getexternalstoragepublicdirectory-deprecated-in-android-q


沒有留言:

張貼留言