Rewrite BackupManager

This commit is contained in:
Sad Ellie 2024-01-19 00:20:53 +03:00
parent d9454d8d01
commit 8d46084b51
19 changed files with 224 additions and 801 deletions

View File

@ -1,6 +1,6 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2023 Elshan Agaev
* Copyright (c) 2023-2024 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,19 +17,25 @@
*/
plugins {
id("com.google.devtools.ksp")
id("unitto.library")
id("unitto.android.library.jacoco")
id("unitto.android.hilt")
id("unitto.room")
id("unitto.android.library.jacoco")
}
android.namespace = "com.sadellie.unitto.data.backup"
android {
room {
val schemaLocation = "$projectDir/schemas"
schemaDirectory(schemaLocation)
println("Exported Database schema to $schemaLocation")
}
}
dependencies {
implementation(libs.androidx.datastore.datastore.preferences)
implementation(libs.com.squareup.moshi.moshi.kotlin)
implementation(libs.com.github.sadellie.themmo.core)
ksp(libs.com.squareup.moshi.moshi.kotlin.codegen)
implementation(project(":data:database"))
implementation(project(":data:model"))

View File

@ -1,6 +1,6 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2023 Elshan Agaev
* Copyright (c) 2023-2024 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -18,22 +18,8 @@
package com.sadellie.unitto.data.backup
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.sadellie.unitto.data.database.CalculatorHistoryEntity
import com.sadellie.unitto.data.database.TimeZoneEntity
import com.sadellie.unitto.data.database.UnitsEntity
import com.sadellie.unitto.data.userprefs.PrefsKeys
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
@ -43,130 +29,5 @@ import org.junit.runner.RunWith
*/
@RunWith(AndroidJUnit4::class)
class BackupManagerTest {
private lateinit var dataStore: DataStore<Preferences>
private lateinit var backupManager: BackupManager
@Before
fun setup() {
// Inserting dummy data as app data (db and prefs)
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
dataStore = PreferenceDataStoreFactory.create(
corruptionHandler = null,
produceFile = { appContext.preferencesDataStoreFile("test") }
)
backupManager = BackupManager(
mContext = appContext,
dataStore = dataStore,
unitsDao = FakeUnitsDao,
timeZoneDao = FakeTimeZoneDao,
calculatorHistoryDao = FakeCalculatorHistoryDao
)
runBlocking {
dataStore.edit {
it[PrefsKeys.THEMING_MODE] = fakeUserData.themingMode
it[PrefsKeys.ENABLE_DYNAMIC_THEME] = fakeUserData.enableDynamicTheme
it[PrefsKeys.ENABLE_AMOLED_THEME] = fakeUserData.enableAmoledTheme
it[PrefsKeys.CUSTOM_COLOR] = fakeUserData.customColor
it[PrefsKeys.MONET_MODE] = fakeUserData.monetMode
it[PrefsKeys.STARTING_SCREEN] = fakeUserData.startingScreen
it[PrefsKeys.ENABLE_TOOLS_EXPERIMENT] = fakeUserData.enableToolsExperiment
it[PrefsKeys.SYSTEM_FONT] = fakeUserData.systemFont
it[PrefsKeys.LAST_READ_CHANGELOG] = fakeUserData.lastReadChangelog
it[PrefsKeys.ENABLE_VIBRATIONS] = fakeUserData.enableVibrations
it[PrefsKeys.MIDDLE_ZERO] = fakeUserData.middleZero
it[PrefsKeys.AC_BUTTON] = fakeUserData.acButton
it[PrefsKeys.RPN_MODE] = fakeUserData.rpnMode
it[PrefsKeys.DIGITS_PRECISION] = fakeUserData.precision
it[PrefsKeys.SEPARATOR] = fakeUserData.separator
it[PrefsKeys.OUTPUT_FORMAT] = fakeUserData.outputFormat
it[PrefsKeys.RADIAN_MODE] = fakeUserData.radianMode
it[PrefsKeys.PARTIAL_HISTORY_VIEW] = fakeUserData.partialHistoryView
it[PrefsKeys.LATEST_LEFT_SIDE] = fakeUserData.latestLeftSide
it[PrefsKeys.LATEST_RIGHT_SIDE] = fakeUserData.latestRightSide
it[PrefsKeys.SHOWN_UNIT_GROUPS] = fakeUserData.shownUnitGroups
it[PrefsKeys.UNIT_CONVERTER_FAVORITES_ONLY] = fakeUserData.unitConverterFavoritesOnly
it[PrefsKeys.UNIT_CONVERTER_FORMAT_TIME] = fakeUserData.unitConverterFormatTime
it[PrefsKeys.UNIT_CONVERTER_SORTING] = fakeUserData.unitConverterSorting
}
}
}
@Test
fun testBackup() = runBlocking{
// Backup manager also saves the file to disk, but it is not tested here.
// There is probably no need to test if the data in app is valid since moshi will throw an
// exceptions if the data in app is invalid. For example, if unitConverterSorting is set to
// "Qwerty" (this sorting doesn't exist) there will be an exception.
val actualUserData = backupManager.userDataFromApp()
val expectedUserData = fakeUserData
assertEquals(expectedUserData, actualUserData)
}
@Test
fun testRestoreDatastore() = runBlocking{
// Backup manager also saves the file to disk, but it is not tested here.
// There is probably no need to test if the data in app is valid since moshi will throw an
// exceptions if the data in app is invalid. For example, if unitConverterSorting is set to
// "Qwerty" (this sorting doesn't exist) there will be an exception.
backupManager.updateDatastore(fakeUserData)
assertEquals(backupManager.userDataFromApp(), fakeUserData)
}
@Test
fun testRestoreCalculatorHistoryTable() = runBlocking {
// Data for import
val fakeUserData2 = fakeUserData.copy(
calculatorHistoryTable = listOf(
CalculatorHistoryEntity(
timestamp = System.currentTimeMillis(),
expression = "69+420",
result = "444"
)
)
)
backupManager.updateCalculatorHistoryTable(fakeUserData2)
assertEquals(FakeCalculatorHistoryDao.getAllDescending().first(), fakeUserData2.calculatorHistoryTable)
}
@Test
fun testRestoreUnitsTable() = runBlocking {
// Data for import
val fakeUserData2 = fakeUserData.copy(
unitsTable = listOf(
UnitsEntity(
unitId = "UnitId3",
isFavorite = false,
pairedUnitId = "Pair4",
frequency = 123
)
)
)
backupManager.updateUnitsTable(fakeUserData2)
assertEquals(FakeUnitsDao.getAllFlow().first(), fakeUserData2.unitsTable)
}
@Test
fun testRestoreTimeZonesTable() = runBlocking {
// Data for import
val fakeUserData2 = fakeUserData.copy(
timeZoneTable = listOf(
TimeZoneEntity(
id = "id3",
position = 123,
label = "label456"
)
)
)
backupManager.updateTimeZonesTable(fakeUserData2)
assertEquals(FakeTimeZoneDao.getFavorites().first(), fakeUserData2.timeZoneTable)
}
// TODO Write tests. Currently testing manually.
}

View File

@ -1,46 +0,0 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2023 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.data.backup
import com.sadellie.unitto.data.database.CalculatorHistoryDao
import com.sadellie.unitto.data.database.CalculatorHistoryEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
var calculatorHistory: List<CalculatorHistoryEntity> = listOf(
CalculatorHistoryEntity(
timestamp = System.currentTimeMillis(),
expression = "2+2",
result = "4"
)
)
object FakeCalculatorHistoryDao: CalculatorHistoryDao {
override fun getAllDescending(): Flow<List<CalculatorHistoryEntity>> = flow {
emit(calculatorHistory)
}
override suspend fun insert(vararg historyEntity: CalculatorHistoryEntity) {
calculatorHistory += historyEntity
}
override suspend fun clear() {
calculatorHistory = emptyList()
}
}

View File

@ -1,56 +0,0 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2023 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.data.backup
import com.sadellie.unitto.data.database.TimeZoneDao
import com.sadellie.unitto.data.database.TimeZoneEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
var timeZones = listOf(
TimeZoneEntity(
id = "id1",
position = 9,
label = "label"
),
TimeZoneEntity(
id = "id2",
position = 9,
label = "label"
)
)
object FakeTimeZoneDao: TimeZoneDao {
override fun getFavorites(): Flow<List<TimeZoneEntity>> = flow {
emit(timeZones)
}
override fun getMaxPosition(): Int = 0
override suspend fun insert(vararg timeZoneEntity: TimeZoneEntity) {
timeZones += timeZoneEntity
}
override suspend fun removeFromFavorites(id: String) {}
override suspend fun updateLabel(id: String, label: String) {}
override suspend fun updateDragged(id: String, oldPosition: Int, newPosition: Int) {}
override suspend fun moveDown(currentPosition: Int, targetPosition: Int) {}
override suspend fun moveUp(currentPosition: Int, targetPosition: Int) {}
override suspend fun clear() {
timeZones = emptyList()
}
}

View File

@ -1,55 +0,0 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2023 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.data.backup
import com.sadellie.unitto.data.database.UnitsDao
import com.sadellie.unitto.data.database.UnitsEntity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
var units = listOf(
UnitsEntity(
unitId = "UnitId1",
isFavorite = false,
pairedUnitId = "Pair",
frequency = 9
),
UnitsEntity(
unitId = "UnitId2",
isFavorite = false,
pairedUnitId = "Pair",
frequency = 9
)
)
object FakeUnitsDao : UnitsDao {
override fun getAllFlow(): Flow<List<UnitsEntity>> {
return flow {
emit(units)
}
}
override suspend fun insertUnit(unit: UnitsEntity) {
units += unit
}
override suspend fun getById(unitId: String): UnitsEntity? = null
override suspend fun clear() {
units = emptyList()
}
}

View File

@ -1,49 +0,0 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2023 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.data.backup
internal val fakeUserData = UserData(
themingMode = "AUTO",
enableDynamicTheme = false,
enableAmoledTheme = false,
customColor = 777L,
monetMode = "TonalSpot",
startingScreen = "calculator_route",
enableToolsExperiment = false,
systemFont = false,
lastReadChangelog = "33",
enableVibrations = false,
middleZero = false,
acButton = false,
rpnMode = false,
precision = 11,
separator = 1,
outputFormat = 1,
radianMode = false,
partialHistoryView = false,
latestLeftSide = "kilometer",
latestRightSide = "mile",
shownUnitGroups = "LENGTH",
unitConverterFavoritesOnly = false,
unitConverterFormatTime = false,
unitConverterSorting = "USAGE",
calculatorHistoryTable = calculatorHistory,
unitsTable = units,
timeZoneTable = timeZones
)

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Unitto is a unit converter for Android
~ Copyright (c) 2023 Elshan Agaev
~ Copyright (c) 2023-2024 Elshan Agaev
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
@ -17,16 +17,5 @@
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities=".BackupManager"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_path"/>
</provider>
</application>
<manifest>
</manifest>

View File

@ -1,6 +1,6 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2023 Elshan Agaev
* Copyright (c) 2023-2024 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -18,187 +18,137 @@
package com.sadellie.unitto.data.backup
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import com.sadellie.unitto.data.database.CalculatorHistoryDao
import com.sadellie.unitto.data.database.TimeZoneDao
import com.sadellie.unitto.data.database.UnitsDao
import com.sadellie.unitto.data.userprefs.PrefsKeys
import com.sadellie.unitto.data.userprefs.getAcButton
import com.sadellie.unitto.data.userprefs.getCustomColor
import com.sadellie.unitto.data.userprefs.getDigitsPrecision
import com.sadellie.unitto.data.userprefs.getEnableAmoledTheme
import com.sadellie.unitto.data.userprefs.getEnableDynamicTheme
import com.sadellie.unitto.data.userprefs.getEnableToolsExperiment
import com.sadellie.unitto.data.userprefs.getEnableVibrations
import com.sadellie.unitto.data.userprefs.getLastReadChangelog
import com.sadellie.unitto.data.userprefs.getLatestLeftSide
import com.sadellie.unitto.data.userprefs.getLatestRightSide
import com.sadellie.unitto.data.userprefs.getMiddleZero
import com.sadellie.unitto.data.userprefs.getMonetMode
import com.sadellie.unitto.data.userprefs.getOutputFormat
import com.sadellie.unitto.data.userprefs.getPartialHistoryView
import com.sadellie.unitto.data.userprefs.getRadianMode
import com.sadellie.unitto.data.userprefs.getRpnMode
import com.sadellie.unitto.data.userprefs.getSeparator
import com.sadellie.unitto.data.userprefs.getShownUnitGroups
import com.sadellie.unitto.data.userprefs.getStartingScreen
import com.sadellie.unitto.data.userprefs.getSystemFont
import com.sadellie.unitto.data.userprefs.getThemingMode
import com.sadellie.unitto.data.userprefs.getUnitConverterFavoritesOnly
import com.sadellie.unitto.data.userprefs.getUnitConverterFormatTime
import com.sadellie.unitto.data.userprefs.getUnitConverterSorting
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import dagger.hilt.android.qualifiers.ApplicationContext
import com.sadellie.unitto.data.database.DATABASE_NAME
import com.sadellie.unitto.data.database.UnittoDatabase
import com.sadellie.unitto.data.database.checkpoint
import com.sadellie.unitto.data.userprefs.USER_PREFERENCES
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import javax.inject.Inject
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
@OptIn(ExperimentalStdlibApi::class)
class BackupManager @Inject constructor(
@ApplicationContext private val mContext: Context,
private val dataStore: DataStore<Preferences>,
private val calculatorHistoryDao: CalculatorHistoryDao,
private val unitsDao: UnitsDao,
private val timeZoneDao: TimeZoneDao,
) {
private val moshi: Moshi = Moshi.Builder()
.add(UserDataTableAdapter())
.build()
private val jsonAdapter: JsonAdapter<UserData> = moshi.adapter<UserData>()
private val auth = "com.sadellie.unitto.BackupManager"
class BackupManager {
suspend fun backup(
context: Context,
backupFileUri: Uri,
database: UnittoDatabase,
) = withContext(Dispatchers.IO) {
context
.applicationContext
.contentResolver
.openOutputStream(backupFileUri)
?.use { backupFileOutputStream ->
ZipOutputStream(backupFileOutputStream.buffered())
.use { zipOutputStream ->
// Datastore
context
.datastoreFile
.writeToZip(zipOutputStream, DATASTORE_FILE_NAME)
suspend fun backup(): Uri = withContext(Dispatchers.IO) {
val userData = userDataFromApp()
// save to disk
val tempFile = File.createTempFile("backup", ".unitto", mContext.cacheDir)
tempFile.writeText(jsonAdapter.toJson(userData))
return@withContext FileProvider.getUriForFile(mContext, auth, tempFile)
}
suspend fun restore(uri: Uri) = withContext(Dispatchers.IO) {
val jsonContent = StringBuilder()
mContext.contentResolver.openInputStream(uri)?.use { inputStream ->
BufferedReader(InputStreamReader(inputStream)).use { reader ->
var line: String? = reader.readLine()
while (line != null) {
jsonContent.append(line)
line = reader.readLine()
// Database
database.checkpoint()
database
.file
.writeToZip(zipOutputStream, DATABASE_FILE_NAME)
}
}
}
// Return error
val userData: UserData = jsonAdapter.fromJson(jsonContent.toString())
?: return@withContext IllegalArgumentException("Can't parse: $jsonContent")
// Apply tables
updateCalculatorHistoryTable(userData)
updateUnitsTable(userData)
updateTimeZonesTable(userData)
// Apply datastore prefs
// Datastore settings are restored at the end, because it will trigger recomposition of the
// entire app composable and all jobs in ViewModels will be canceled
updateDatastore(userData)
suspend fun restore(
context: Context,
backupFileUri: Uri,
database: UnittoDatabase,
) = withContext(Dispatchers.IO) {
context
.applicationContext
.contentResolver
.openInputStream(backupFileUri)
?.use { backupFileInputStream ->
ZipInputStream(backupFileInputStream).use { zipInputStream ->
var entry = zipInputStream.nextEntry
while (entry != null) {
when (entry.name) {
DATASTORE_FILE_NAME -> {
context
.datastoreFile
.writeFromZip(zipInputStream)
}
internal suspend fun userDataFromApp(): UserData {
val data = dataStore.data.first()
val calculatorHistoryTable = calculatorHistoryDao.getAllDescending().first()
val unitsTableData = unitsDao.getAllFlow().first()
val timeZoneTableData = timeZoneDao.getFavorites().first()
return UserData(
themingMode = data.getThemingMode().name,
enableDynamicTheme = data.getEnableDynamicTheme(),
enableAmoledTheme = data.getEnableAmoledTheme(),
customColor = data.getCustomColor(),
monetMode = data.getMonetMode().name,
startingScreen = data.getStartingScreen(),
enableToolsExperiment = data.getEnableToolsExperiment(),
systemFont = data.getSystemFont(),
lastReadChangelog = data.getLastReadChangelog(),
enableVibrations = data.getEnableVibrations(),
middleZero = data.getMiddleZero(),
acButton = data.getAcButton(),
rpnMode = data.getRpnMode(),
precision = data.getDigitsPrecision(),
separator = data.getSeparator(),
outputFormat = data.getOutputFormat(),
radianMode = data.getRadianMode(),
partialHistoryView = data.getPartialHistoryView(),
latestLeftSide = data.getLatestLeftSide(),
latestRightSide = data.getLatestRightSide(),
shownUnitGroups = data.getShownUnitGroups().joinToString(","),
unitConverterFavoritesOnly = data.getUnitConverterFavoritesOnly(),
unitConverterFormatTime = data.getUnitConverterFormatTime(),
unitConverterSorting = data.getUnitConverterSorting().name,
calculatorHistoryTable = calculatorHistoryTable,
unitsTable = unitsTableData,
timeZoneTable = timeZoneTableData,
)
}
internal suspend fun updateDatastore(userData: UserData) {
dataStore.edit { it.clear() }
dataStore.edit {
it[PrefsKeys.THEMING_MODE] = userData.themingMode
it[PrefsKeys.ENABLE_DYNAMIC_THEME] = userData.enableDynamicTheme
it[PrefsKeys.ENABLE_AMOLED_THEME] = userData.enableAmoledTheme
it[PrefsKeys.CUSTOM_COLOR] = userData.customColor
it[PrefsKeys.MONET_MODE] = userData.monetMode
it[PrefsKeys.STARTING_SCREEN] = userData.startingScreen
it[PrefsKeys.ENABLE_TOOLS_EXPERIMENT] = userData.enableToolsExperiment
it[PrefsKeys.SYSTEM_FONT] = userData.systemFont
it[PrefsKeys.LAST_READ_CHANGELOG] = userData.lastReadChangelog
it[PrefsKeys.ENABLE_VIBRATIONS] = userData.enableVibrations
it[PrefsKeys.MIDDLE_ZERO] = userData.middleZero
it[PrefsKeys.AC_BUTTON] = userData.acButton
it[PrefsKeys.RPN_MODE] = userData.rpnMode
// FORMATTER
it[PrefsKeys.DIGITS_PRECISION] = userData.precision
it[PrefsKeys.SEPARATOR] = userData.separator
it[PrefsKeys.OUTPUT_FORMAT] = userData.outputFormat
// CALCULATOR
it[PrefsKeys.RADIAN_MODE] = userData.radianMode
it[PrefsKeys.PARTIAL_HISTORY_VIEW] = userData.partialHistoryView
// UNIT CONVERTER
it[PrefsKeys.LATEST_LEFT_SIDE] = userData.latestLeftSide
it[PrefsKeys.LATEST_RIGHT_SIDE] = userData.latestRightSide
it[PrefsKeys.SHOWN_UNIT_GROUPS] = userData.shownUnitGroups
it[PrefsKeys.UNIT_CONVERTER_FAVORITES_ONLY] = userData.unitConverterFavoritesOnly
it[PrefsKeys.UNIT_CONVERTER_FORMAT_TIME] = userData.unitConverterFormatTime
it[PrefsKeys.UNIT_CONVERTER_SORTING] = userData.unitConverterSorting
DATABASE_FILE_NAME -> {
database.checkpoint()
database.close()
database
.file
.writeFromZip(zipInputStream)
}
}
internal suspend fun updateCalculatorHistoryTable(userData: UserData) {
calculatorHistoryDao.clear()
userData.calculatorHistoryTable.forEach { calculatorHistoryDao.insert(it) }
entry = zipInputStream.nextEntry
}
internal suspend fun updateUnitsTable(userData: UserData) {
unitsDao.clear()
userData.unitsTable.forEach { unitsDao.insertUnit(it) }
}
} ?: return@withContext // Don't restart activity if the file is not found
internal suspend fun updateTimeZonesTable(userData: UserData) {
timeZoneDao.clear()
userData.timeZoneTable.forEach { timeZoneDao.insert(it) }
context.restartActivity()
}
}
private val Context.datastoreFile: File
get() = this
.filesDir
.addChild(DATASTORE_FOLDER_NAME)
.addChild(DATASTORE_FILE_NAME)
private fun Context.restartActivity() {
val componentName: ComponentName = this
.packageManager
.getLaunchIntentForPackage(this.packageName)
?.component
?: throw Exception("BackupManager was unable to find component for this context")
val restartIntent: Intent = Intent.makeRestartActivityTask(componentName)
this.startActivity(restartIntent)
Runtime.getRuntime().exit(0)
}
private val UnittoDatabase.file: File
get() = File(this.openHelper.writableDatabase.path!!) // Will be caught on higher level if null
/**
* Will write content of this [File] into given [ZipOutputStream].
*
* @receiver Source [File].
* @param zipOutputStream Target [ZipOutputStream].
* @param name Name of the file ([ZipEntry]) that will be created in the archive [ZipOutputStream].
*/
private fun File.writeToZip(zipOutputStream: ZipOutputStream, name: String) = this
.inputStream()
.buffered()
.use { inputStream ->
// Using explicit names only to reduce overhead. Don't use this.name,
zipOutputStream.putNextEntry(ZipEntry(name))
inputStream.copyTo(zipOutputStream)
}
/**
* Will write content from given [ZipInputStream] into [File].
*
* @receiver Target [File] that will be filled with content from [ZipInputStream].
* @param zipInputStream Source [ZipInputStream].
*/
private fun File.writeFromZip(zipInputStream: ZipInputStream) = this
.outputStream()
.buffered()
.use { outputStream ->
zipInputStream.copyTo(outputStream)
}
private fun File.addChild(child: String): File = File(this, child)
private const val DATASTORE_FILE_NAME = "$USER_PREFERENCES.preferences_pb"
private const val DATASTORE_FOLDER_NAME = "datastore"
private const val DATABASE_FILE_NAME = "$DATABASE_NAME.db"

View File

@ -1,61 +0,0 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2023 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.data.backup
import com.sadellie.unitto.data.database.CalculatorHistoryEntity
import com.sadellie.unitto.data.database.TimeZoneEntity
import com.sadellie.unitto.data.database.UnitsEntity
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
// Don't move to model module. This uses entity classes from database module
@JsonClass(generateAdapter = true)
internal data class UserData(
@Json(name = "themingMode") val themingMode: String,
@Json(name = "enableDynamicTheme") val enableDynamicTheme: Boolean,
@Json(name = "enableAmoledTheme") val enableAmoledTheme: Boolean,
@Json(name = "customColor") val customColor: Long,
@Json(name = "monetMode") val monetMode: String,
@Json(name = "startingScreen") val startingScreen: String,
@Json(name = "enableToolsExperiment") val enableToolsExperiment: Boolean,
@Json(name = "systemFont") val systemFont: Boolean,
@Json(name = "lastReadChangelog") val lastReadChangelog: String,
@Json(name = "enableVibrations") val enableVibrations: Boolean,
@Json(name = "middleZero") val middleZero: Boolean,
@Json(name = "acButton") val acButton: Boolean,
@Json(name = "rpnMode") val rpnMode: Boolean,
@Json(name = "precision") val precision: Int,
@Json(name = "separator") val separator: Int,
@Json(name = "outputFormat") val outputFormat: Int,
@Json(name = "radianMode") val radianMode: Boolean,
@Json(name = "partialHistoryView") val partialHistoryView: Boolean,
@Json(name = "latestLeftSide") val latestLeftSide: String,
@Json(name = "latestRightSide") val latestRightSide: String,
@Json(name = "shownUnitGroups") val shownUnitGroups: String,
@Json(name = "unitConverterFavoritesOnly") val unitConverterFavoritesOnly: Boolean,
@Json(name = "unitConverterFormatTime") val unitConverterFormatTime: Boolean,
@Json(name = "unitConverterSorting") val unitConverterSorting: String,
@Json(name = "calculatorHistoryTable") val calculatorHistoryTable: List<CalculatorHistoryEntity>,
@Json(name = "unitsTable") val unitsTable: List<UnitsEntity>,
@Json(name = "timeZoneTable") val timeZoneTable: List<TimeZoneEntity>,
)

View File

@ -1,81 +0,0 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2023 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.data.backup
import com.sadellie.unitto.data.database.CalculatorHistoryEntity
import com.sadellie.unitto.data.database.TimeZoneEntity
import com.sadellie.unitto.data.database.UnitsEntity
import com.squareup.moshi.FromJson
import com.squareup.moshi.ToJson
// Have to use this wrapper since entity classes are in database module
@Suppress("UNUSED")
internal class UserDataTableAdapter {
@ToJson
fun toJson(calculatorHistoryEntity: CalculatorHistoryEntity): UserDataCalculatorHistory =
UserDataCalculatorHistory(
entityId = calculatorHistoryEntity.entityId,
timestamp = calculatorHistoryEntity.timestamp,
expression = calculatorHistoryEntity.expression,
result = calculatorHistoryEntity.result
)
@FromJson
fun fromJson(userDataCalculatorHistory: UserDataCalculatorHistory): CalculatorHistoryEntity =
CalculatorHistoryEntity(
entityId = userDataCalculatorHistory.entityId,
timestamp = userDataCalculatorHistory.timestamp,
expression = userDataCalculatorHistory.expression,
result = userDataCalculatorHistory.result
)
@ToJson
fun toJson(unitsEntity: UnitsEntity): UserDataUnit =
UserDataUnit(
unitId = unitsEntity.unitId,
isFavorite = unitsEntity.isFavorite,
pairedUnitId = unitsEntity.pairedUnitId,
frequency = unitsEntity.frequency
)
@FromJson
fun fromJson(userDataUnit: UserDataUnit): UnitsEntity =
UnitsEntity(
unitId = userDataUnit.unitId,
isFavorite = userDataUnit.isFavorite,
pairedUnitId = userDataUnit.pairedUnitId,
frequency = userDataUnit.frequency
)
@ToJson
fun toJson(timeZoneEntity: TimeZoneEntity): UserDataTimezone =
UserDataTimezone(
id = timeZoneEntity.id,
position = timeZoneEntity.position,
label = timeZoneEntity.label
)
@FromJson
fun fromJson(userDataTimezone: UserDataTimezone): TimeZoneEntity =
TimeZoneEntity(
id = userDataTimezone.id,
position = userDataTimezone.position,
label = userDataTimezone.label
)
}

View File

@ -1,28 +0,0 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2023 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.data.backup
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class UserDataTimezone(
val id: String,
val position: Int,
val label: String,
)

View File

@ -1,29 +0,0 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2023 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.data.backup
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class UserDataUnit(
val unitId: String,
val isFavorite: Boolean,
val pairedUnitId: String?,
val frequency: Int,
)

View File

@ -1,6 +1,6 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2023 Elshan Agaev
* Copyright (c) 2024 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -16,14 +16,17 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.sadellie.unitto.data.backup
package com.sadellie.unitto.data.database
import com.squareup.moshi.JsonClass
import androidx.room.Dao
import androidx.room.RawQuery
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
@JsonClass(generateAdapter = true)
internal data class UserDataCalculatorHistory(
val entityId: Int,
val timestamp: Long,
val expression: String,
val result: String,
)
@Dao
interface RawDao {
@RawQuery
suspend fun execute(query: SupportSQLiteQuery): Int
suspend fun walCheckpoint() = execute(SimpleSQLiteQuery("PRAGMA wal_checkpoint(FULL)"))
}

View File

@ -1,6 +1,6 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2022-2023 Elshan Agaev
* Copyright (c) 2022-2024 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -46,4 +46,7 @@ abstract class UnittoDatabase : RoomDatabase() {
abstract fun calculatorHistoryDao(): CalculatorHistoryDao
abstract fun timeZoneDao(): TimeZoneDao
abstract fun currencyRatesDao(): CurrencyRatesDao
internal abstract fun rawDao(): RawDao
}
suspend fun UnittoDatabase.checkpoint() = this.rawDao().walCheckpoint()

View File

@ -1,6 +1,6 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2022-2023 Elshan Agaev
* Copyright (c) 2022-2024 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -27,6 +27,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
const val DATABASE_NAME = "unitto_database"
/**
* Module for database. Used to access same instance of database
*
@ -34,6 +36,10 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
class UnittoDatabaseModule {
@Provides
fun provideRawDao(unittoDatabase: UnittoDatabase): RawDao {
return unittoDatabase.rawDao()
}
@Provides
fun provideUnitsDao(unittoDatabase: UnittoDatabase): UnitsDao {
@ -73,7 +79,7 @@ class UnittoDatabaseModule {
return Room.databaseBuilder(
appContext.applicationContext,
UnittoDatabase::class.java,
"unitto_database"
DATABASE_NAME
).build()
}
}

View File

@ -1,6 +1,6 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2022-2023 Elshan Agaev
* Copyright (c) 2022-2024 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -34,7 +34,7 @@ import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
// DON'T TOUCH!!!
private const val USER_PREFERENCES = "settings"
const val USER_PREFERENCES = "settings"
/**
* This module is for DataStore dependency injection

View File

@ -1,6 +1,6 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2022-2023 Elshan Agaev
* Copyright (c) 2022-2024 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -20,13 +20,11 @@ package com.sadellie.unitto.feature.settings
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
@ -93,6 +91,8 @@ import com.sadellie.unitto.feature.settings.navigation.formattingRoute
import com.sadellie.unitto.feature.settings.navigation.startingScreenRoute
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
@Composable
internal fun SettingsRoute(
@ -101,21 +101,13 @@ internal fun SettingsRoute(
navControllerAction: (String) -> Unit,
) {
val mContext = LocalContext.current
val errorLabel = stringResource(R.string.error_label)
val uiState: SettingsUIState = viewModel.uiState.collectAsStateWithLifecycle().value
val backupFileUri: Uri? = viewModel.backupFileUri.collectAsStateWithLifecycle(initialValue = null).value
val showErrorToast: Boolean = viewModel.showErrorToast.collectAsStateWithLifecycle(initialValue = false).value
// Share backup file when it's emitted
LaunchedEffect(backupFileUri) {
if (backupFileUri == null) return@LaunchedEffect
mContext.share(backupFileUri)
}
val uiState: SettingsUIState = viewModel.uiState
.collectAsStateWithLifecycle().value
val showErrorToast: Boolean = viewModel.showErrorToast
.collectAsStateWithLifecycle(initialValue = false).value
LaunchedEffect(showErrorToast) {
if (showErrorToast) Toast.makeText(mContext, errorLabel, Toast.LENGTH_SHORT).show()
if (showErrorToast) showToast(mContext, mContext.resources.getString(R.string.error_label))
}
when (uiState) {
@ -142,15 +134,24 @@ private fun SettingsScreen(
updateLastReadChangelog: (String) -> Unit,
updateVibrations: (Boolean) -> Unit,
clearCache: () -> Unit,
backup: () -> Unit,
restore: (Uri) -> Unit = {},
backup: (Context, Uri) -> Unit,
restore: (Context, Uri) -> Unit,
) {
val mContext = LocalContext.current
var showMenu by remember { mutableStateOf(false) }
// Pass picked file uri to BackupManager
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { pickedUri ->
if (pickedUri != null) restore(pickedUri)
val restoreLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { pickedUri ->
if (pickedUri != null) restore(mContext, pickedUri)
}
// Pass picked file uri to BackupManager
val backupLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument(backupMimeType)
) { pickedUri ->
if (pickedUri != null) backup(mContext, pickedUri)
}
BackHandler(uiState.backupInProgress) {}
@ -168,11 +169,17 @@ private fun SettingsScreen(
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
onClick = { showMenu = false; backup() },
onClick = {
showMenu = false
backupLauncher.launchSafely(backupFileName())
},
text = { Text(stringResource(R.string.settings_back_up)) }
)
DropdownMenuItem(
onClick = { showMenu = false; launcher.launchPicker() },
onClick = {
showMenu = false
restoreLauncher.launchSafely(arrayOf(backupMimeType))
},
text = { Text(stringResource(R.string.settings_restore)) }
)
}
@ -297,25 +304,20 @@ private fun SettingsScreen(
}
}
private fun Context.share(uri: Uri) {
val shareIntent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_STREAM, uri)
type = backupMimeType
}
startActivity(shareIntent)
}
private fun ManagedActivityResultLauncher<Array<String>, Uri?>.launchPicker() {
private fun <T> ActivityResultLauncher<T>.launchSafely(input: T) {
try {
launch(arrayOf(backupMimeType))
this.launch(input)
} catch (e: ActivityNotFoundException) {
Log.e("SettingsScreen", "launchPicker: ActivityNotFoundException")
Log.e("SettingsScreen", "launchSafely: ActivityNotFoundException")
}
}
private const val backupMimeType = "application/octet-stream"
private fun backupFileName(): String {
val formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss")
return "${ZonedDateTime.now().format(formatter)}.zip"
}
private const val backupMimeType = "application/zip"
@Preview
@Composable
@ -348,7 +350,14 @@ private fun PreviewSettingsScreen() {
clearCache = {
uiState = uiState.copy(cacheSize = 0)
},
backup = {
backup = { _, _ ->
corScope.launch {
uiState = uiState.copy(backupInProgress = true)
delay(2000)
uiState = uiState.copy(backupInProgress = false)
}
},
restore = { _, _ ->
corScope.launch {
uiState = uiState.copy(backupInProgress = true)
delay(2000)

View File

@ -1,6 +1,6 @@
/*
* Unitto is a unit converter for Android
* Copyright (c) 2022-2023 Elshan Agaev
* Copyright (c) 2022-2024 Elshan Agaev
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -18,6 +18,7 @@
package com.sadellie.unitto.feature.settings
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
@ -26,6 +27,7 @@ import com.sadellie.unitto.core.base.BuildConfig
import com.sadellie.unitto.data.backup.BackupManager
import com.sadellie.unitto.data.common.stateIn
import com.sadellie.unitto.data.database.CurrencyRatesDao
import com.sadellie.unitto.data.database.UnittoDatabase
import com.sadellie.unitto.data.model.repository.UserPreferencesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
@ -42,11 +44,8 @@ import javax.inject.Inject
internal class SettingsViewModel @Inject constructor(
private val userPrefsRepository: UserPreferencesRepository,
private val currencyRatesDao: CurrencyRatesDao,
private val backupManager: BackupManager
private val database: UnittoDatabase
) : ViewModel() {
private val _backupFileUri = MutableSharedFlow<Uri?>()
val backupFileUri = _backupFileUri.asSharedFlow()
private val _showErrorToast = MutableSharedFlow<Boolean>()
val showErrorToast = _showErrorToast.asSharedFlow()
@ -67,14 +66,15 @@ internal class SettingsViewModel @Inject constructor(
}
.stateIn(viewModelScope, SettingsUIState.Loading)
fun backup() {
fun backup(
context: Context,
uri: Uri,
) {
backupJob?.cancel()
backupJob = viewModelScope.launch(Dispatchers.IO) {
_backupInProgress.update { true }
try {
val backupFileUri = backupManager.backup()
_backupFileUri.emit(backupFileUri) // Emit to trigger file share intent
_showErrorToast.emit(false)
BackupManager().backup(context, uri, database)
} catch (e: Exception) {
_showErrorToast.emit(true)
Log.e(TAG, "$e")
@ -83,13 +83,15 @@ internal class SettingsViewModel @Inject constructor(
}
}
fun restore(uri: Uri) {
fun restore(
context: Context,
uri: Uri,
) {
backupJob?.cancel()
backupJob = viewModelScope.launch(Dispatchers.IO) {
_backupInProgress.update { true }
try {
backupManager.restore(uri)
_showErrorToast.emit(false)
BackupManager().restore(context, uri, database)
} catch (e: Exception) {
_showErrorToast.emit(true)
Log.e(TAG, "$e")

View File

@ -76,7 +76,6 @@ com-google-dagger-android-hilt-android = { group = "com.google.dagger", name = "
com-google-dagger-dagger-android-processor = { group = "com.google.dagger", name = "dagger-android-processor", version.ref = "comGoogleDagger" }
com-google-dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "comGoogleDagger" }
com-squareup-moshi-moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "comSquareupMoshiMoshiKotlin" }
com-squareup-moshi-moshi-kotlin-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "comSquareupMoshiMoshiKotlin" }
com-squareup-retrofit2-converter-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "comSquareupRetrofit2ConverterMoshi" }
junit-junit = { group = "junit", name = "junit", version.ref = "junitJunit" }
org-burnoutcrew-composereorderable-reorderable = { group = "org.burnoutcrew.composereorderable", name = "reorderable", version.ref = "orgBurnoutcrewComposereorderableReorderable" }