From 9a3cc5af821fa216b6f76bd179d165c50576fe9e Mon Sep 17 00:00:00 2001 From: Dominik Dzienia Date: Tue, 5 May 2020 18:35:10 +0200 Subject: [PATCH] Adds support for preferences directory, list of preferences to import, additional check on imported preferences metadata --- app/build.gradle | 3 + app/src/main/AndroidManifest.xml | 1 + .../dependencyInjection/ActivitiesModule.kt | 2 + .../dependencyInjection/PreferencesModule.kt | 2 + .../versionChecker/VersionCheckerUtils.kt | 8 + .../general/maintenance/ImportExportPrefs.kt | 126 ++++------ .../maintenance/PrefFileListProvider.kt | 219 ++++++++++++++++++ .../activities/PrefImportListActivity.kt | 135 +++++++++++ .../maintenance/formats/ClassicPrefsFormat.kt | 17 +- .../formats/EncryptedPrefsFormat.kt | 62 +++-- .../maintenance/formats/PrefsFormat.kt | 31 ++- .../maintenance_importlist_activity.xml | 24 ++ .../layout/maintenance_importlist_item.xml | 169 ++++++++++++++ app/src/main/res/values/colors.xml | 4 + app/src/main/res/values/strings.xml | 10 + .../VersionCheckerUtilsKtTest.kt | 36 +++ 16 files changed, 737 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/PrefFileListProvider.kt create mode 100644 app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/activities/PrefImportListActivity.kt create mode 100644 app/src/main/res/layout/maintenance_importlist_activity.xml create mode 100644 app/src/main/res/layout/maintenance_importlist_item.xml diff --git a/app/build.gradle b/app/build.gradle index f767cfc823..593efb2304 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,6 +31,7 @@ ext { retrofit2Version = '2.8.1' okhttp3Version = '4.6.0' coroutinesVersion = '1.3.5' + activityVersion = '1.2.0-alpha03' } @@ -264,6 +265,8 @@ dependencies { implementation 'androidx.gridlayout:gridlayout:1.0.0' implementation 'androidx.percentlayout:percentlayout:1.0.0' implementation "androidx.preference:preference-ktx:1.1.1" + implementation "androidx.activity:activity:${activityVersion}" + implementation "androidx.activity:activity-ktx:${activityVersion}" implementation 'com.google.android.material:material:1.1.0' implementation 'com.wdullaer:materialdatetimepicker:4.2.3' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index caf37d462d..2bd4527cf6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -82,6 +82,7 @@ + diff --git a/app/src/main/java/info/nightscout/androidaps/dependencyInjection/ActivitiesModule.kt b/app/src/main/java/info/nightscout/androidaps/dependencyInjection/ActivitiesModule.kt index e7411ad56b..2f6d515b0e 100644 --- a/app/src/main/java/info/nightscout/androidaps/dependencyInjection/ActivitiesModule.kt +++ b/app/src/main/java/info/nightscout/androidaps/dependencyInjection/ActivitiesModule.kt @@ -6,6 +6,7 @@ import info.nightscout.androidaps.MainActivity import info.nightscout.androidaps.activities.* import info.nightscout.androidaps.historyBrowser.HistoryBrowseActivity import info.nightscout.androidaps.plugins.general.maintenance.activities.LogSettingActivity +import info.nightscout.androidaps.plugins.general.maintenance.activities.PrefImportListActivity import info.nightscout.androidaps.plugins.general.overview.activities.QuickWizardListActivity import info.nightscout.androidaps.plugins.general.smsCommunicator.activities.SmsCommunicatorOtpActivity import info.nightscout.androidaps.plugins.pump.common.dialog.RileyLinkBLEScanActivity @@ -48,4 +49,5 @@ abstract class ActivitiesModule { @ContributesAndroidInjector abstract fun contributesStatsActivity(): StatsActivity @ContributesAndroidInjector abstract fun contributesSurveyActivity(): SurveyActivity @ContributesAndroidInjector abstract fun contributesTDDStatsActivity(): TDDStatsActivity + @ContributesAndroidInjector abstract fun contributesPrefImportListActivity(): PrefImportListActivity } \ No newline at end of file diff --git a/app/src/main/java/info/nightscout/androidaps/dependencyInjection/PreferencesModule.kt b/app/src/main/java/info/nightscout/androidaps/dependencyInjection/PreferencesModule.kt index fa2df68ef0..39fb959c15 100644 --- a/app/src/main/java/info/nightscout/androidaps/dependencyInjection/PreferencesModule.kt +++ b/app/src/main/java/info/nightscout/androidaps/dependencyInjection/PreferencesModule.kt @@ -3,6 +3,7 @@ package info.nightscout.androidaps.dependencyInjection import dagger.Module import dagger.android.ContributesAndroidInjector import info.nightscout.androidaps.plugins.general.maintenance.ImportExportPrefs +import info.nightscout.androidaps.plugins.general.maintenance.PrefFileListProvider import info.nightscout.androidaps.plugins.general.maintenance.formats.ClassicPrefsFormat import info.nightscout.androidaps.plugins.general.maintenance.formats.EncryptedPrefsFormat import info.nightscout.androidaps.utils.CryptoUtil @@ -15,4 +16,5 @@ abstract class PreferencesModule { @ContributesAndroidInjector abstract fun importExportPrefsInjector(): ImportExportPrefs @ContributesAndroidInjector abstract fun encryptedPrefsFormatInjector(): EncryptedPrefsFormat @ContributesAndroidInjector abstract fun classicPrefsFormatInjector(): ClassicPrefsFormat + @ContributesAndroidInjector abstract fun prefImportListProviderInjector(): PrefFileListProvider } \ No newline at end of file diff --git a/app/src/main/java/info/nightscout/androidaps/plugins/constraints/versionChecker/VersionCheckerUtils.kt b/app/src/main/java/info/nightscout/androidaps/plugins/constraints/versionChecker/VersionCheckerUtils.kt index d1ce90fc72..636d623bd0 100644 --- a/app/src/main/java/info/nightscout/androidaps/plugins/constraints/versionChecker/VersionCheckerUtils.kt +++ b/app/src/main/java/info/nightscout/androidaps/plugins/constraints/versionChecker/VersionCheckerUtils.kt @@ -116,6 +116,14 @@ class VersionCheckerUtils @Inject constructor( private fun String?.toNumberList() = this?.numericVersionPart().takeIf { !it.isNullOrBlank() }?.split(".")?.map { it.toInt() } + fun versionDigits(versionString: String?): IntArray { + val digits = mutableListOf() + versionString?.numericVersionPart().toNumberList()?.let { + digits.addAll(it.take(4)) + } + return digits.toIntArray() + } + fun findVersion(file: String?): String? { val regex = "(.*)version(.*)\"(((\\d+)\\.)+(\\d+))\"(.*)".toRegex() return file?.lines()?.filter { regex.matches(it) }?.mapNotNull { regex.matchEntire(it)?.groupValues?.getOrNull(3) }?.firstOrNull() diff --git a/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/ImportExportPrefs.kt b/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/ImportExportPrefs.kt index 1d72212fb3..b859a1dee5 100644 --- a/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/ImportExportPrefs.kt +++ b/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/ImportExportPrefs.kt @@ -6,12 +6,12 @@ import android.bluetooth.BluetoothAdapter import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.os.Build -import android.os.Environment import android.provider.Settings +import androidx.activity.invoke import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity import info.nightscout.androidaps.BuildConfig import info.nightscout.androidaps.R import info.nightscout.androidaps.activities.PreferencesActivity @@ -22,9 +22,9 @@ import info.nightscout.androidaps.plugins.bus.RxBusWrapper import info.nightscout.androidaps.plugins.general.maintenance.formats.* import info.nightscout.androidaps.plugins.general.smsCommunicator.otp.OneTimePassword import info.nightscout.androidaps.utils.DateUtil +import info.nightscout.androidaps.utils.ToastUtils import info.nightscout.androidaps.utils.alertDialogs.OKDialog import info.nightscout.androidaps.utils.alertDialogs.OKDialog.show -import info.nightscout.androidaps.utils.ToastUtils import info.nightscout.androidaps.utils.alertDialogs.PrefImportSummaryDialog import info.nightscout.androidaps.utils.alertDialogs.TwoMessagesAlertDialog import info.nightscout.androidaps.utils.alertDialogs.WarningDialog @@ -32,8 +32,6 @@ import info.nightscout.androidaps.utils.buildHelper.BuildHelper import info.nightscout.androidaps.utils.protection.PasswordCheck import info.nightscout.androidaps.utils.resources.ResourceHelper import info.nightscout.androidaps.utils.sharedPreferences.SP -import org.joda.time.DateTime -import org.joda.time.Days import java.io.File import java.io.FileNotFoundException import java.io.IOException @@ -51,37 +49,25 @@ private val PERMISSIONS_STORAGE = arrayOf( Manifest.permission.WRITE_EXTERNAL_STORAGE ) -private const val IMPORT_AGE_NOT_YET_OLD_DAYS = 60 - @Singleton class ImportExportPrefs @Inject constructor( private var log: AAPSLogger, private val resourceHelper: ResourceHelper, private val sp: SP, private val buildHelper: BuildHelper, - private val otp: OneTimePassword, private val rxBus: RxBusWrapper, private val passwordCheck: PasswordCheck, private val classicPrefsFormat: ClassicPrefsFormat, - private val encryptedPrefsFormat: EncryptedPrefsFormat + private val encryptedPrefsFormat: EncryptedPrefsFormat, + private val prefFileList: PrefFileListProvider ) { val TAG = LTag.CORE - private val path = File(Environment.getExternalStorageDirectory().toString()) - - private val file = File(path, resourceHelper.gs(R.string.app_name) + "Preferences") - private val encFile = File(path, resourceHelper.gs(R.string.app_name) + "Preferences.json") - - fun prefsImportFile(): File { - return if (encFile.exists()) encFile else file - } - fun prefsFileExists(): Boolean { - return encFile.exists() || file.exists() + return prefFileList.listPreferenceFiles().size > 0 } - fun exportSharedPreferences(f: Fragment) { f.activity?.let { exportSharedPreferences(it) } } @@ -135,9 +121,6 @@ class ImportExportPrefs @Inject constructor( return name } - private fun getCurrentDeviceModelString() = - Build.MANUFACTURER + " " + Build.MODEL + " (" + Build.DEVICE + ")" - private fun prefsEncryptionIsDisabled() = buildHelper.isEngineeringMode() && !sp.getBoolean(resourceHelper.gs(R.string.key_maintenance_encrypt_exported_prefs), true) @@ -173,36 +156,41 @@ class ImportExportPrefs @Inject constructor( return true } - private fun askToConfirmExport(activity: Activity, then: ((password: String) -> Unit)) { + private fun askToConfirmExport(activity: Activity, fileToExport: File, then: ((password: String) -> Unit)) { if (!prefsEncryptionIsDisabled() && !assureMasterPasswordSet(activity, R.string.nav_export)) return TwoMessagesAlertDialog.showAlert(activity, resourceHelper.gs(R.string.nav_export), - resourceHelper.gs(R.string.export_to) + " " + encFile + " ?", + resourceHelper.gs(R.string.export_to) + " " + fileToExport + " ?", resourceHelper.gs(R.string.password_preferences_encrypt_prompt), { askForMasterPassIfNeeded(activity, R.string.preferences_export_canceled, then) - }, null, R.drawable.ic_header_export) + }, null, R.drawable.ic_header_export) } - private fun askToConfirmImport(activity: Activity, fileToImport: File, then: ((password: String) -> Unit)) { + private fun askToConfirmImport(activity: Activity, fileToImport: PrefsFile, then: ((password: String) -> Unit)) { - if (encFile.exists()) { + if (fileToImport.handler == PrefsFormatsHandler.ENCRYPTED) { if (!assureMasterPasswordSet(activity, R.string.nav_import)) return TwoMessagesAlertDialog.showAlert(activity, resourceHelper.gs(R.string.nav_import), - resourceHelper.gs(R.string.import_from) + " " + fileToImport + " ?", + resourceHelper.gs(R.string.import_from) + " " + fileToImport.file + " ?", resourceHelper.gs(R.string.password_preferences_decrypt_prompt), { askForMasterPass(activity, R.string.preferences_import_canceled, then) }, null, R.drawable.ic_header_import) } else { OKDialog.showConfirmation(activity, resourceHelper.gs(R.string.nav_import), - resourceHelper.gs(R.string.import_from) + " " + fileToImport + " ?", + resourceHelper.gs(R.string.import_from) + " " + fileToImport.file + " ?", Runnable { then("") }) } } private fun exportSharedPreferences(activity: Activity) { - askToConfirmExport(activity) { password -> + + prefFileList.ensureExportDirExists() + val legacyFile = prefFileList.legacyFile() + val newFile = prefFileList.newExportFile() + + askToConfirmExport(activity, newFile) { password -> try { val entries: MutableMap = mutableMapOf() for ((key, value) in sp.getAll()) { @@ -211,12 +199,14 @@ class ImportExportPrefs @Inject constructor( val prefs = Prefs(entries, prepareMetadata(activity)) - classicPrefsFormat.savePreferences(file, prefs) - encryptedPrefsFormat.savePreferences(encFile, prefs, password) + if (BuildConfig.DEBUG && buildHelper.isEngineeringMode()) { + classicPrefsFormat.savePreferences(legacyFile, prefs) + } + encryptedPrefsFormat.savePreferences(newFile, prefs, password) ToastUtils.okToast(activity, resourceHelper.gs(R.string.exported)) } catch (e: FileNotFoundException) { - ToastUtils.errorToast(activity, resourceHelper.gs(R.string.filenotfound) + " " + encFile) + ToastUtils.errorToast(activity, resourceHelper.gs(R.string.filenotfound) + " " + newFile) log.error(TAG, "Unhandled exception", e) } catch (e: IOException) { ToastUtils.errorToast(activity, e.message) @@ -226,21 +216,38 @@ class ImportExportPrefs @Inject constructor( } fun importSharedPreferences(fragment: Fragment) { - fragment.activity?.let { importSharedPreferences(it) } + fragment.activity?.let { fragmentAct -> + val callForPrefFile = fragmentAct.prepareCall(PrefsFileContract()) { + it?.let { + importSharedPreferences(fragmentAct, it) + } + } + callForPrefFile.invoke() + } } - fun importSharedPreferences(activity: Activity) { + fun importSharedPreferences(activity: FragmentActivity) { + val callForPrefFile = activity.prepareCall(PrefsFileContract()) { + it?.let { + importSharedPreferences(activity, it) + } + } + callForPrefFile.invoke() + } - val importFile = prefsImportFile() + private fun importSharedPreferences(activity: Activity, importFile: PrefsFile) { askToConfirmImport(activity, importFile) { password -> - val format: PrefsFormat = if (encFile.exists()) encryptedPrefsFormat else classicPrefsFormat + val format: PrefsFormat = when (importFile.handler) { + PrefsFormatsHandler.CLASSIC -> classicPrefsFormat + PrefsFormatsHandler.ENCRYPTED -> encryptedPrefsFormat + } try { - val prefs = format.loadPreferences(importFile, password) - prefs.metadata = checkMetadata(prefs.metadata) + val prefs = format.loadPreferences(importFile.file, password) + prefs.metadata = prefFileList.checkMetadata(prefs.metadata) // import is OK when we do not have errors (warnings are allowed) val importOk = checkIfImportIsOk(prefs) @@ -276,45 +283,6 @@ class ImportExportPrefs @Inject constructor( } } - // check metadata for known issues, change their status and add info with explanations - private fun checkMetadata(metadata: Map): Map { - val meta = metadata.toMutableMap() - - meta[PrefsMetadataKey.AAPS_FLAVOUR]?.let { flavour -> - val flavourOfPrefs = flavour.value - if (flavour.value != BuildConfig.FLAVOR) { - flavour.status = PrefsStatus.WARN - flavour.info = resourceHelper.gs(R.string.metadata_warning_different_flavour, flavourOfPrefs, BuildConfig.FLAVOR) - } - } - - meta[PrefsMetadataKey.DEVICE_MODEL]?.let { model -> - if (model.value != getCurrentDeviceModelString()) { - model.status = PrefsStatus.WARN - model.info = resourceHelper.gs(R.string.metadata_warning_different_device) - } - } - - meta[PrefsMetadataKey.CREATED_AT]?.let { createdAt -> - try { - val date1 = DateTime.parse(createdAt.value); - val date2 = DateTime.now() - - val daysOld = Days.daysBetween(date1.toLocalDate(), date2.toLocalDate()).getDays() - - if (daysOld > IMPORT_AGE_NOT_YET_OLD_DAYS) { - createdAt.status = PrefsStatus.WARN - createdAt.info = resourceHelper.gs(R.string.metadata_warning_old_export, daysOld.toString()) - } - } catch (e: Exception) { - createdAt.status = PrefsStatus.WARN - createdAt.info = resourceHelper.gs(R.string.metadata_warning_date_format) - } - } - - return meta - } - private fun checkIfImportIsOk(prefs: Prefs): Boolean { var importOk = true diff --git a/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/PrefFileListProvider.kt b/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/PrefFileListProvider.kt new file mode 100644 index 0000000000..52910dfff1 --- /dev/null +++ b/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/PrefFileListProvider.kt @@ -0,0 +1,219 @@ +package info.nightscout.androidaps.plugins.general.maintenance + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Environment +import android.os.Parcelable +import androidx.activity.result.contract.ActivityResultContract +import info.nightscout.androidaps.BuildConfig +import info.nightscout.androidaps.R +import info.nightscout.androidaps.logging.AAPSLogger +import info.nightscout.androidaps.plugins.constraints.versionChecker.VersionCheckerUtils +import info.nightscout.androidaps.plugins.general.maintenance.activities.PrefImportListActivity +import info.nightscout.androidaps.plugins.general.maintenance.formats.* +import info.nightscout.androidaps.utils.resources.ResourceHelper +import info.nightscout.androidaps.utils.sharedPreferences.SP +import info.nightscout.androidaps.utils.storage.Storage +import kotlinx.android.parcel.Parcelize +import kotlinx.android.parcel.RawValue +import org.joda.time.DateTime +import org.joda.time.Days +import org.joda.time.Hours +import org.joda.time.LocalDateTime +import org.joda.time.format.DateTimeFormat +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +enum class PrefsImportDir { + ROOT_DIR, + AAPS_DIR +} + +@Parcelize +data class PrefsFile( + val file: File, + val baseDir: File, + val dirKind: PrefsImportDir, + val handler: PrefsFormatsHandler, + + // metadata here is used only for list display + val metadata: @RawValue Map +) : Parcelable + +class PrefsFileContract : ActivityResultContract() { + + companion object { + const val OUTPUT_PARAM = "prefs_file" + } + + override fun parseResult(resultCode: Int, intent: Intent?): PrefsFile? { + return when (resultCode) { + Activity.RESULT_OK -> intent?.getParcelableExtra(OUTPUT_PARAM) + else -> null + } + } + + override fun createIntent(context: Context, input: Void?): Intent { + return Intent(context, PrefImportListActivity::class.java) + } +} + +fun getCurrentDeviceModelString() = + Build.MANUFACTURER + " " + Build.MODEL + " (" + Build.DEVICE + ")" + +@Singleton +class PrefFileListProvider @Inject constructor( + private val resourceHelper: ResourceHelper, + private val classicPrefsFormat: ClassicPrefsFormat, + private val encryptedPrefsFormat: EncryptedPrefsFormat, + private val storage: Storage, + private val versionCheckerUtils: VersionCheckerUtils +) { + + companion object { + private val path = File(Environment.getExternalStorageDirectory().toString()) + private val aapsPath = File(path, "AAPS" + File.separator + "preferences") + private const val IMPORT_AGE_NOT_YET_OLD_DAYS = 60 + } + + /** + * This function tries to list possible preference files from main SDCard root dir and AAPS/preferences dir + * and tries to do quick assessment for preferences format plausibility. + * It does NOT load full metadata or is 100% accurate - it tries to do QUICK detection, based on: + * - file name and extension + * - predicted file contents + */ + fun listPreferenceFiles(loadMetadata: Boolean = false): MutableList { + val prefFiles = mutableListOf() + + // searching rood dir for legacy files + path.walk().maxDepth(1).filter { it.isFile && (it.name.endsWith(".json") || it.name.contains("Preferences")) }.forEach { + val contents = storage.getFileContents(it) + val detectedNew = encryptedPrefsFormat.isPreferencesFile(it, contents) + val detectedOld = !detectedNew && classicPrefsFormat.isPreferencesFile(it, contents) + if (detectedNew || detectedOld) { + val formatHandler = if (detectedNew) PrefsFormatsHandler.ENCRYPTED else PrefsFormatsHandler.CLASSIC + prefFiles.add(PrefsFile(it, path, PrefsImportDir.ROOT_DIR, formatHandler, metadataFor(loadMetadata, formatHandler, contents))) + } + } + + // searching dedicated dir, only for new JSON format + aapsPath.walk().filter { it.isFile && it.name.endsWith(".json") }.forEach { + val contents = storage.getFileContents(it) + if (encryptedPrefsFormat.isPreferencesFile(it, contents)) { + prefFiles.add(PrefsFile(it, aapsPath, PrefsImportDir.AAPS_DIR, PrefsFormatsHandler.ENCRYPTED, metadataFor(loadMetadata, PrefsFormatsHandler.ENCRYPTED, contents))) + } + } + + // we sort only if we have metadata to be used for that + if (loadMetadata) { + prefFiles.sortWith( + compareByDescending { it.handler } + .thenBy { it.metadata[PrefsMetadataKey.AAPS_FLAVOUR]?.status } + .thenByDescending { it.metadata[PrefsMetadataKey.CREATED_AT]?.value } + ) + } + + return prefFiles + } + + private fun metadataFor(loadMetadata: Boolean, formatHandler: PrefsFormatsHandler, contents: String): PrefMetadataMap { + if (!loadMetadata) { + return mapOf() + } + return checkMetadata(when (formatHandler) { + PrefsFormatsHandler.CLASSIC -> classicPrefsFormat.loadMetadata(contents) + PrefsFormatsHandler.ENCRYPTED -> encryptedPrefsFormat.loadMetadata(contents) + }) + } + + fun legacyFile(): File { + return File(path, resourceHelper.gs(R.string.app_name) + "Preferences") + } + + fun ensureExportDirExists() { + if (!aapsPath.exists()) { + aapsPath.mkdirs() + } + } + + fun newExportFile(): File { + val timeLocal = LocalDateTime.now().toString(DateTimeFormat.forPattern("yyyy-MM-dd'_'HHmmss")) + return File(aapsPath, timeLocal + "_" + BuildConfig.FLAVOR + ".json") + } + + // check metadata for known issues, change their status and add info with explanations + fun checkMetadata(metadata: Map): Map { + val meta = metadata.toMutableMap() + + meta[PrefsMetadataKey.AAPS_FLAVOUR]?.let { flavour -> + val flavourOfPrefs = flavour.value + if (flavour.value != BuildConfig.FLAVOR) { + flavour.status = PrefsStatus.WARN + flavour.info = resourceHelper.gs(R.string.metadata_warning_different_flavour, flavourOfPrefs, BuildConfig.FLAVOR) + } + } + + meta[PrefsMetadataKey.DEVICE_MODEL]?.let { model -> + if (model.value != getCurrentDeviceModelString()) { + model.status = PrefsStatus.WARN + model.info = resourceHelper.gs(R.string.metadata_warning_different_device) + } + } + + meta[PrefsMetadataKey.CREATED_AT]?.let { createdAt -> + try { + val date1 = DateTime.parse(createdAt.value); + val date2 = DateTime.now() + + val daysOld = Days.daysBetween(date1.toLocalDate(), date2.toLocalDate()).getDays() + + if (daysOld > IMPORT_AGE_NOT_YET_OLD_DAYS) { + createdAt.status = PrefsStatus.WARN + createdAt.info = resourceHelper.gs(R.string.metadata_warning_old_export, daysOld.toString()) + } + } catch (e: Exception) { + createdAt.status = PrefsStatus.WARN + createdAt.info = resourceHelper.gs(R.string.metadata_warning_date_format) + } + } + + meta[PrefsMetadataKey.AAPS_VERSION]?.let { version -> + val currentAppVer = versionCheckerUtils.versionDigits(BuildConfig.VERSION_NAME) + val metadataVer = versionCheckerUtils.versionDigits(version.value) + + if ((currentAppVer.size >= 2) && (metadataVer.size >= 2) && (Math.abs(currentAppVer[1] - metadataVer[1]) > 1)) { + version.status = PrefsStatus.WARN + version.info = resourceHelper.gs(R.string.metadata_warning_different_version) + } + + if ((currentAppVer.isNotEmpty()) && (metadataVer.isNotEmpty()) && (currentAppVer[0] != metadataVer[0])) { + version.status = PrefsStatus.WARN + version.info = resourceHelper.gs(R.string.metadata_urgent_different_version) + } + } + + return meta + } + + fun formatExportedAgo(utcTime: String): String { + val refTime = DateTime.now() + val itTime = DateTime.parse(utcTime) + val days = Days.daysBetween(itTime, refTime).days + val hours = Hours.hoursBetween(itTime, refTime).hours + + return if (hours == 0) { + resourceHelper.gs(R.string.exported_less_than_hour_ago) + } else if ((hours < 24) && (hours > 0)) { + resourceHelper.gs(R.string.exported_ago, resourceHelper.gq(R.plurals.objective_hours, hours, hours)) + } else if ((days < IMPORT_AGE_NOT_YET_OLD_DAYS) && (days > 0)) { + resourceHelper.gs(R.string.exported_ago, resourceHelper.gq(R.plurals.objective_days, days, days)) + } else { + resourceHelper.gs(R.string.exported_at, utcTime.substring(0, 10)) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/activities/PrefImportListActivity.kt b/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/activities/PrefImportListActivity.kt new file mode 100644 index 0000000000..af19414794 --- /dev/null +++ b/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/activities/PrefImportListActivity.kt @@ -0,0 +1,135 @@ +package info.nightscout.androidaps.plugins.general.maintenance.activities + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import dagger.android.support.DaggerAppCompatActivity +import info.nightscout.androidaps.R +import info.nightscout.androidaps.plugins.general.maintenance.PrefFileListProvider +import info.nightscout.androidaps.plugins.general.maintenance.PrefsFile +import info.nightscout.androidaps.plugins.general.maintenance.PrefsFileContract +import info.nightscout.androidaps.plugins.general.maintenance.formats.PrefsFormatsHandler +import info.nightscout.androidaps.plugins.general.maintenance.formats.PrefsMetadataKey +import info.nightscout.androidaps.plugins.general.maintenance.formats.PrefsStatus +import info.nightscout.androidaps.utils.LocaleHelper +import info.nightscout.androidaps.utils.resources.ResourceHelper +import kotlinx.android.synthetic.main.maintenance_importlist_activity.* +import javax.inject.Inject + +class PrefImportListActivity : DaggerAppCompatActivity() { + + @Inject lateinit var resourceHelper: ResourceHelper + @Inject lateinit var prefFileListProvider: PrefFileListProvider + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setTheme(R.style.AppTheme) + setContentView(R.layout.maintenance_importlist_activity) + + title = resourceHelper.gs(R.string.preferences_import_list_title) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayShowHomeEnabled(true) + supportActionBar?.setDisplayShowTitleEnabled(true) + + importlist_recyclerview.layoutManager = LinearLayoutManager(this) + importlist_recyclerview.adapter = RecyclerViewAdapter(prefFileListProvider.listPreferenceFiles(loadMetadata = true)) + } + + inner class RecyclerViewAdapter internal constructor(private var prefFileList: List) : RecyclerView.Adapter() { + + inner class PrefFileViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + var fileName: TextView = itemView.findViewById(R.id.filelist_name) + var fileDir: TextView = itemView.findViewById(R.id.filelist_dir) + var metaDateTime: TextView = itemView.findViewById(R.id.meta_date_time) + var metaDeviceName: TextView = itemView.findViewById(R.id.meta_device_name) + var metaAppVersion: TextView = itemView.findViewById(R.id.meta_app_version) + var metaVariantFormat: TextView = itemView.findViewById(R.id.meta_variant_format) + + var metalineName: View = itemView.findViewById(R.id.metaline_name) + var metaDateTimeIcon: View = itemView.findViewById(R.id.meta_date_time_icon) + + init { + itemView.isClickable = true + itemView.setOnClickListener { v: View -> + val prefFile = fileName.tag as PrefsFile + val i = Intent() + + i.putExtra(PrefsFileContract.OUTPUT_PARAM, prefFile) + setResult(Activity.RESULT_OK, i) + finish() + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PrefFileViewHolder { + val v = LayoutInflater.from(parent.context).inflate(R.layout.maintenance_importlist_item, parent, false) + return PrefFileViewHolder(v) + } + + override fun getItemCount(): Int { + return prefFileList.size + } + + override fun onBindViewHolder(holder: PrefFileViewHolder, position: Int) { + val prefFile = prefFileList[position] + holder.fileName.text = prefFile.file.name + holder.fileName.tag = prefFile + + holder.fileDir.text = resourceHelper.gs(R.string.in_directory, prefFile.file.parentFile.absolutePath) + + val visible = if (prefFile.handler == PrefsFormatsHandler.CLASSIC) View.GONE else View.VISIBLE + holder.metalineName.visibility = visible + holder.metaDateTimeIcon.visibility = visible + holder.metaAppVersion.visibility = visible + + if (prefFile.handler == PrefsFormatsHandler.CLASSIC) { + holder.metaVariantFormat.text = resourceHelper.gs(R.string.metadata_format_old) + holder.metaVariantFormat.setTextColor(resourceHelper.gc(R.color.metadataTextWarning)) + holder.metaDateTime.text = " " + } else { + + prefFile.metadata[PrefsMetadataKey.AAPS_FLAVOUR]?.let { + holder.metaVariantFormat.text = it.value + val color = if (it.status == PrefsStatus.OK) R.color.metadataOk else R.color.metadataTextWarning + holder.metaVariantFormat.setTextColor(resourceHelper.gc(color)) + } + + prefFile.metadata[PrefsMetadataKey.CREATED_AT]?.let { + holder.metaDateTime.text = prefFileListProvider.formatExportedAgo(it.value) + } + + prefFile.metadata[PrefsMetadataKey.AAPS_VERSION]?.let { + holder.metaAppVersion.text = it.value + val color = if (it.status == PrefsStatus.OK) R.color.metadataOk else R.color.metadataTextWarning + holder.metaAppVersion.setTextColor(resourceHelper.gc(color)) + } + + prefFile.metadata[PrefsMetadataKey.DEVICE_NAME]?.let { + holder.metaDeviceName.text = it.value + } + + } + + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + finish() + return true + } + return false + } + + public override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(LocaleHelper.wrap(newBase)) + } +} \ No newline at end of file diff --git a/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/formats/ClassicPrefsFormat.kt b/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/formats/ClassicPrefsFormat.kt index 4689f549a6..40b1ceada4 100644 --- a/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/formats/ClassicPrefsFormat.kt +++ b/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/formats/ClassicPrefsFormat.kt @@ -1,5 +1,6 @@ package info.nightscout.androidaps.plugins.general.maintenance.formats +import info.nightscout.androidaps.Constants import info.nightscout.androidaps.R import info.nightscout.androidaps.utils.resources.ResourceHelper import info.nightscout.androidaps.utils.storage.Storage @@ -19,6 +20,11 @@ class ClassicPrefsFormat @Inject constructor( val FORMAT_KEY = "aaps_old" } + override fun isPreferencesFile(file: File, preloadedContents: String?): Boolean { + val contents = preloadedContents ?: storage.getFileContents(file) + return contents.contains("units::" + Constants.MGDL) || contents.contains("units::" + Constants.MMOL) + } + override fun savePreferences(file: File, prefs: Prefs, masterPassword: String?) { try { val contents = prefs.values.entries.joinToString("\n") { entry -> @@ -35,7 +41,6 @@ class ClassicPrefsFormat @Inject constructor( override fun loadPreferences(file: File, masterPassword: String?): Prefs { var lineParts: Array val entries: MutableMap = mutableMapOf() - val metadata: MutableMap = mutableMapOf() try { val rawLines = storage.getFileContents(file).split("\n") @@ -46,9 +51,7 @@ class ClassicPrefsFormat @Inject constructor( } } - metadata[PrefsMetadataKey.FILE_FORMAT] = PrefMetadata(FORMAT_KEY, PrefsStatus.WARN, resourceHelper.gs(R.string.metadata_warning_outdated_format)) - - return Prefs(entries, metadata) + return Prefs(entries, loadMetadata()) } catch (e: FileNotFoundException) { throw PrefFileNotFoundError(file.absolutePath) @@ -57,4 +60,10 @@ class ClassicPrefsFormat @Inject constructor( } } + override fun loadMetadata(contents: String?): PrefMetadataMap { + val metadata: MutableMap = mutableMapOf() + metadata[PrefsMetadataKey.FILE_FORMAT] = PrefMetadata(FORMAT_KEY, PrefsStatus.WARN, resourceHelper.gs(R.string.metadata_warning_outdated_format)) + return metadata + } + } \ No newline at end of file diff --git a/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/formats/EncryptedPrefsFormat.kt b/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/formats/EncryptedPrefsFormat.kt index 463b5a31cb..ec88c07bec 100644 --- a/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/formats/EncryptedPrefsFormat.kt +++ b/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/formats/EncryptedPrefsFormat.kt @@ -27,6 +27,16 @@ class EncryptedPrefsFormat @Inject constructor( val FORMAT_KEY_NOENC = "aaps_structured" private val KEY_CONSCIENCE = "if you remove/change this, please make sure you know the consequences!" + private val FORMAT_TEST_REGEX = Regex("(\\\"format\\\"\\s*\\:\\s*\\\"aaps_[^\"]*\\\")") + } + + override fun isPreferencesFile(file: File, preloadedContents: String?): Boolean { + return if (file.absolutePath.endsWith(".json")) { + val contents = preloadedContents ?: storage.getFileContents(file) + FORMAT_TEST_REGEX.containsMatchIn(contents) + } else { + false + } } override fun savePreferences(file: File, prefs: Prefs, masterPassword: String?) { @@ -97,7 +107,6 @@ class EncryptedPrefsFormat @Inject constructor( override fun loadPreferences(file: File, masterPassword: String?): Prefs { val entries: MutableMap = mutableMapOf() - val metadata: MutableMap = mutableMapOf() val issues = LinkedList() try { @@ -105,25 +114,11 @@ class EncryptedPrefsFormat @Inject constructor( val fileContents = jsonBody.replace(Regex("(?is)(\\\"file_hash\\\"\\s*\\:\\s*\\\")([^\"]*)(\\\")"), "$1--to-be-calculated--$3") val calculatedFileHash = cryptoUtil.hmac256(fileContents, KEY_CONSCIENCE) val container = JSONObject(jsonBody) + val metadata: MutableMap = loadMetadata(container) - if (container.has(PrefsMetadataKey.FILE_FORMAT.key) && container.has("security") && container.has("content") && container.has("metadata")) { + if (container.has(PrefsMetadataKey.FILE_FORMAT.key) && container.has("security") && container.has("content")) { val fileFormat = container.getString(PrefsMetadataKey.FILE_FORMAT.key) - - if ((fileFormat != FORMAT_KEY_ENC) && (fileFormat != FORMAT_KEY_NOENC)) { - throw PrefFormatError("Unsupported file format: " + fileFormat) - } - - val meta = container.getJSONObject("metadata") val security = container.getJSONObject("security") - - metadata[PrefsMetadataKey.FILE_FORMAT] = PrefMetadata(fileFormat, PrefsStatus.OK) - for (key in meta.keys()) { - val metaKey = PrefsMetadataKey.fromKey(key) - if (metaKey != null) { - metadata[metaKey] = PrefMetadata(meta.getString(key), PrefsStatus.OK) - } - } - val encrypted = fileFormat == FORMAT_KEY_ENC var secure: PrefsStatus = PrefsStatus.OK var decryptedOk = false @@ -208,8 +203,6 @@ class EncryptedPrefsFormat @Inject constructor( } metadata[PrefsMetadataKey.ENCRYPTION] = PrefMetadata(encryptionDescStr, secure, issuesStr) - } else { - metadata[PrefsMetadataKey.FILE_FORMAT] = PrefMetadata(resourceHelper.gs(R.string.prefdecrypt_wrong_json), PrefsStatus.ERROR) } return Prefs(entries, metadata) @@ -223,4 +216,35 @@ class EncryptedPrefsFormat @Inject constructor( } } + override fun loadMetadata(contents: String?): PrefMetadataMap { + contents?.let { + val container = JSONObject(contents) + return loadMetadata(container) + } + return mutableMapOf() + } + + private fun loadMetadata(container: JSONObject): MutableMap { + val metadata: MutableMap = mutableMapOf() + if (container.has(PrefsMetadataKey.FILE_FORMAT.key) && container.has("security") && container.has("content") && container.has("metadata")) { + val fileFormat = container.getString(PrefsMetadataKey.FILE_FORMAT.key) + if ((fileFormat != FORMAT_KEY_ENC) && (fileFormat != FORMAT_KEY_NOENC)) { + metadata[PrefsMetadataKey.FILE_FORMAT] = PrefMetadata(resourceHelper.gs(R.string.metadata_format_other), PrefsStatus.ERROR) + } else { + val meta = container.getJSONObject("metadata") + metadata[PrefsMetadataKey.FILE_FORMAT] = PrefMetadata(fileFormat, PrefsStatus.OK) + for (key in meta.keys()) { + val metaKey = PrefsMetadataKey.fromKey(key) + if (metaKey != null) { + metadata[metaKey] = PrefMetadata(meta.getString(key), PrefsStatus.OK) + } + } + } + } else { + metadata[PrefsMetadataKey.FILE_FORMAT] = PrefMetadata(resourceHelper.gs(R.string.prefdecrypt_wrong_json), PrefsStatus.ERROR) + } + + return metadata; + } + } \ No newline at end of file diff --git a/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/formats/PrefsFormat.kt b/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/formats/PrefsFormat.kt index a47add0dd7..d0d3935023 100644 --- a/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/formats/PrefsFormat.kt +++ b/app/src/main/java/info/nightscout/androidaps/plugins/general/maintenance/formats/PrefsFormat.kt @@ -1,12 +1,14 @@ package info.nightscout.androidaps.plugins.general.maintenance.formats import android.content.Context +import android.os.Parcelable import androidx.annotation.DrawableRes import androidx.annotation.StringRes import info.nightscout.androidaps.R +import kotlinx.android.parcel.Parcelize import java.io.File -enum class PrefsMetadataKey(val key: String, @DrawableRes val icon:Int, @StringRes val label:Int) { +enum class PrefsMetadataKey(val key: String, @DrawableRes val icon: Int, @StringRes val label: Int) { FILE_FORMAT("format", R.drawable.ic_meta_format, R.string.metadata_label_format), CREATED_AT("created_at", R.drawable.ic_meta_date, R.string.metadata_label_created_at), @@ -33,16 +35,15 @@ enum class PrefsMetadataKey(val key: String, @DrawableRes val icon:Int, @StringR } } - } - fun formatForDisplay(context: Context, value:String): String { + fun formatForDisplay(context: Context, value: String): String { return when (this) { FILE_FORMAT -> when (value) { - ClassicPrefsFormat.FORMAT_KEY -> context.getString(R.string.metadata_format_old) - EncryptedPrefsFormat.FORMAT_KEY_ENC -> context.getString(R.string.metadata_format_new) + ClassicPrefsFormat.FORMAT_KEY -> context.getString(R.string.metadata_format_old) + EncryptedPrefsFormat.FORMAT_KEY_ENC -> context.getString(R.string.metadata_format_new) EncryptedPrefsFormat.FORMAT_KEY_NOENC -> context.getString(R.string.metadata_format_debug) - else -> context.getString(R.string.metadata_format_other) + else -> context.getString(R.string.metadata_format_other) } CREATED_AT -> value.replace("T", " ").replace("Z", " (UTC)") else -> value @@ -51,16 +52,21 @@ enum class PrefsMetadataKey(val key: String, @DrawableRes val icon:Int, @StringR } -data class PrefMetadata(var value : String, var status : PrefsStatus, var info : String? = null) +@Parcelize +data class PrefMetadata(var value: String, var status: PrefsStatus, var info: String? = null) : Parcelable -data class Prefs(val values : Map, var metadata : Map) +typealias PrefMetadataMap = Map + +data class Prefs(val values: Map, var metadata: PrefMetadataMap) interface PrefsFormat { fun savePreferences(file: File, prefs: Prefs, masterPassword: String? = null) - fun loadPreferences(file: File, masterPassword: String? = null) : Prefs + fun loadPreferences(file: File, masterPassword: String? = null): Prefs + fun loadMetadata(contents: String? = null): PrefMetadataMap + fun isPreferencesFile(file: File, preloadedContents: String? = null): Boolean } -enum class PrefsStatus(@DrawableRes val icon:Int) { +enum class PrefsStatus(@DrawableRes val icon: Int) { OK(R.drawable.ic_meta_ok), WARN(R.drawable.ic_meta_warning), ERROR(R.drawable.ic_meta_error), @@ -68,6 +74,11 @@ enum class PrefsStatus(@DrawableRes val icon:Int) { DISABLED(R.drawable.ic_meta_error) } +enum class PrefsFormatsHandler { + CLASSIC, + ENCRYPTED +} + class PrefFileNotFoundError(message: String) : Exception(message) class PrefIOError(message: String) : Exception(message) class PrefFormatError(message: String) : Exception(message) diff --git a/app/src/main/res/layout/maintenance_importlist_activity.xml b/app/src/main/res/layout/maintenance_importlist_activity.xml new file mode 100644 index 0000000000..0bdd43b927 --- /dev/null +++ b/app/src/main/res/layout/maintenance_importlist_activity.xml @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/maintenance_importlist_item.xml b/app/src/main/res/layout/maintenance_importlist_item.xml new file mode 100644 index 0000000000..8c2cfc0557 --- /dev/null +++ b/app/src/main/res/layout/maintenance_importlist_item.xml @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index c1873d786f..de49a0aee5 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -90,7 +90,11 @@ #FF8C00 #03A9F4 + #77dd77 #FF8C00 #FF5555 + #FFFFFF + #BBBBBB + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e09f297a7b..c87bff5c35 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -257,6 +257,8 @@ Export canceled! Preferences were NOT exported! Import canceled! Preferences were NOT imported! + Select file to import + Please check preferences before importing: Preferences cannot be imported! Preferences should not be imported! @@ -270,6 +272,8 @@ You are using the outdated legacy format from old versions of AAPS, which is not secure! Only use it as a last resort, if you do not have an export in current, JSON format. Imported preferences are already %1$s days old! Maybe you have more up-to-date preferences or you choose the wrong file? Remember to export preferences regularly! Invalid date-time format! + Preferences from different minor version of application. It is OK if you are importing after upgrade, but check after import if preferences are still correct! + Preferences from different major version of application. Major versions differ significantly and may have incompatible preferences! Make sure after import that preferences are still correct! File format Created at @@ -297,6 +301,12 @@ Missing encryption configuration, settings format is invalid! Unsupported or not specified encryption algorithm! + exported today + exported %1$s ago + exported at %1$s + exported less than hour ago + in directory: %1$s + DanaR Connecting Connected diff --git a/app/src/test/java/info/nightscout/androidaps/plugins/constraints/versionChecker/VersionCheckerUtilsKtTest.kt b/app/src/test/java/info/nightscout/androidaps/plugins/constraints/versionChecker/VersionCheckerUtilsKtTest.kt index 17a9c171d0..db693198bb 100644 --- a/app/src/test/java/info/nightscout/androidaps/plugins/constraints/versionChecker/VersionCheckerUtilsKtTest.kt +++ b/app/src/test/java/info/nightscout/androidaps/plugins/constraints/versionChecker/VersionCheckerUtilsKtTest.kt @@ -6,6 +6,7 @@ import info.nightscout.androidaps.logging.AAPSLogger import info.nightscout.androidaps.plugins.bus.RxBusWrapper import info.nightscout.androidaps.utils.resources.ResourceHelper import info.nightscout.androidaps.utils.sharedPreferences.SP +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -25,6 +26,41 @@ class VersionCheckerUtilsKtTest : TestBase() { versionCheckerUtils = VersionCheckerUtils(aapsLogger, sp, resourceHelper, rxBus, context) } + @Test + fun `should handle invalid version`() { + assertArrayEquals(intArrayOf(), versionCheckerUtils.versionDigits("definitely not version string")) + } + + @Test + fun `should handle empty version`() { + assertArrayEquals(intArrayOf(), versionCheckerUtils.versionDigits("")) + } + + @Test + fun `should parse 2 digit version`() { + assertArrayEquals(intArrayOf(0, 999), versionCheckerUtils.versionDigits("0.999-beta")) + } + + @Test + fun `should parse 3 digit version`() { + assertArrayEquals(intArrayOf(6, 83, 93), versionCheckerUtils.versionDigits("6.83.93")) + } + + @Test + fun `should parse 4 digit version`() { + assertArrayEquals(intArrayOf(42, 7, 13, 101), versionCheckerUtils.versionDigits("42.7.13.101")) + } + + @Test + fun `should parse 4 digit version with extra`() { + assertArrayEquals(intArrayOf(1, 2, 3, 4), versionCheckerUtils.versionDigits("1.2.3.4-RC5")) + } + + @Test + fun `should parse version but only 4 digits are taken`() { + assertArrayEquals(intArrayOf(67, 8, 31, 5), versionCheckerUtils.versionDigits("67.8.31.5.153.4.2")) + } + /* @Test fun `should keep 2 digit version`() {