diff --git a/BackupApp/build.gradle b/BackupApp/build.gradle index 508f9b9..fbcf364 100644 --- a/BackupApp/build.gradle +++ b/BackupApp/build.gradle @@ -24,8 +24,8 @@ android { applicationId "org.secuso.privacyfriendlybackup" minSdkVersion 21 targetSdkVersion 34 - versionCode 6 - versionName "1.3.2" + versionCode 7 + versionName "1.3.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -82,7 +82,7 @@ android { androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) } lint { - abortOnError false + lintConfig = file("lint.xml") } namespace 'org.secuso.privacyfriendlybackup' } diff --git a/BackupApp/lint.xml b/BackupApp/lint.xml new file mode 100644 index 0000000..e9ad894 --- /dev/null +++ b/BackupApp/lint.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/data/exporter/DataExporter.kt b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/data/exporter/DataExporter.kt index a2862bd..b69d1eb 100644 --- a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/data/exporter/DataExporter.kt +++ b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/data/exporter/DataExporter.kt @@ -7,25 +7,68 @@ import android.util.JsonWriter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.secuso.privacyfriendlybackup.data.BackupDataStorageRepository +import java.io.BufferedWriter import java.io.IOException import java.io.OutputStreamWriter +import java.text.FieldPosition +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream class DataExporter { companion object { - suspend fun exportData(context: Context, uri: Uri, data: BackupDataStorageRepository.BackupData) : Boolean{ + suspend fun exportDataZip(context: Context, uri: Uri, data: Set): Boolean { return withContext(Dispatchers.IO) { try { - ParcelFileDescriptor.AutoCloseOutputStream( - context.contentResolver.openFileDescriptor(uri, "w") - ).use { out -> - JsonWriter(OutputStreamWriter(out, Charsets.UTF_8)).use { writer -> - writer.setIndent(" ") - writer.beginObject() - writeData(writer, data) - writer.endObject() + ZipOutputStream(ParcelFileDescriptor.AutoCloseOutputStream(context.contentResolver.openFileDescriptor(uri, "w"))).apply { + setLevel(5) + setComment("PFA Backup Export") + }.use { zipOut -> + data.forEach { backupData -> + val zipEntry = ZipEntry(getSingleExportFileName(backupData, backupData.encrypted)) + zipOut.putNextEntry(zipEntry) + + val osw = OutputStreamWriter(zipOut, Charsets.UTF_8) + val bw = BufferedWriter(osw) + val jw = JsonWriter(bw) + jw.let { writer -> + writer.setIndent(" ") + writer.beginObject() + writeData(writer, backupData) + writer.endObject() + } + jw.flush() + bw.flush() + osw.flush() + + zipOut.closeEntry() } } - } catch (e : IOException) { + } catch (e: IOException) { + e.printStackTrace() + return@withContext false + } + return@withContext true + } + } + + suspend fun exportData(context: Context, uri: Uri, data: BackupDataStorageRepository.BackupData): Boolean { + return withContext(Dispatchers.IO) { + try { + JsonWriter( + OutputStreamWriter( + ParcelFileDescriptor.AutoCloseOutputStream(context.contentResolver.openFileDescriptor(uri, "w")), + Charsets.UTF_8 + ) + ).use { writer -> + writer.setIndent(" ") + writer.beginObject() + writeData(writer, data) + writer.endObject() + } + } catch (e: IOException) { return@withContext false } return@withContext true @@ -39,5 +82,40 @@ class DataExporter { writer.name("encrypted").value(metaData.encrypted) writer.name("data").value(String(metaData.data!!)) } + + /** + * Filename for export containing multiple backups (.zip) + */ + fun getMultipleExportFileName(): String { + val sb = StringBuffer() + sb.append("PfaBackup_") + val sdf = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.getDefault()) + sdf.format(Calendar.getInstance().time, sb, FieldPosition(SimpleDateFormat.DATE_FIELD)) + sb.append(".zip") + return sb.toString() + } + + /** + * Filename for single Backup export (.backup) + */ + fun getSingleExportFileName(backupMetaData: BackupDataStorageRepository.BackupData, encrypted: Boolean): String { + return if (backupMetaData.encrypted && encrypted) { + getEncryptedFilename(backupMetaData.filename) + } else { + getUnencryptedFilename(backupMetaData.filename) + } + } + + + private fun getUnencryptedFilename(filename: String) = + filename.replace("_encrypted.backup", ".backup") + + private fun getEncryptedFilename(filename: String): String { + return if (filename.contains("_encrypted.backup", true)) { + filename + } else { + filename.replace(".backup", "_encrypted.backup", true) + } + } } } \ No newline at end of file diff --git a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/data/importer/DataImporter.kt b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/data/importer/DataImporter.kt index 046291f..420275e 100644 --- a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/data/importer/DataImporter.kt +++ b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/data/importer/DataImporter.kt @@ -15,15 +15,66 @@ import org.secuso.privacyfriendlybackup.data.BackupDataStorageRepository import org.secuso.privacyfriendlybackup.data.room.model.enums.StorageType import java.io.IOException import java.io.InputStreamReader -import java.util.* +import java.util.Date +import java.util.LinkedList +import java.util.zip.ZipInputStream class DataImporter { companion object { - suspend fun importData(context: Context, uri: Uri) : Pair { + suspend fun importDataZip(context: Context, uri: Uri): List>? { return withContext(IO) { - var backupData : BackupDataStorageRepository.BackupData? = null + val descriptor = context.contentResolver.openFileDescriptor(uri, "r") + val inStream = ParcelFileDescriptor.AutoCloseInputStream(descriptor) + val zipInStream = ZipInputStream(inStream) + val result: MutableList> = LinkedList() + + try { + zipInStream.use { zipInputStream -> + generateSequence { zipInputStream.nextEntry } + .filterNot { it.isDirectory } + .forEach { _ -> + var backupData: BackupDataStorageRepository.BackupData? = null + val isr = InputStreamReader(zipInputStream, Charsets.UTF_8) + val jr = JsonReader(isr) + jr.let { reader -> + reader.beginObject() + backupData = readData(reader) + reader.endObject() + } + if (backupData != null) { + + if (!backupData!!.encrypted) { + // short validation check if the json is valid + if (!isValidJSON(String(backupData!!.data!!))) { + result.add(false to null) + } + } + + result.add( + BackupDataStorageRepository.getInstance(context).storeFile( + context, + backupData!! + ) + ) + } else { + result.add(false to null) + } + } + } + } catch (e: MalformedJsonException) { + return@withContext null + } catch (e: IOException) { + return@withContext null + } + return@withContext result + } + } + + suspend fun importData(context: Context, uri: Uri): Pair { + return withContext(IO) { + var backupData: BackupDataStorageRepository.BackupData? = null val descriptor = context.contentResolver.openFileDescriptor(uri, "r") val inStream = ParcelFileDescriptor.AutoCloseInputStream(descriptor) @@ -40,13 +91,13 @@ class DataImporter { return@withContext false to null } - val result : Pair + val result: Pair - if(backupData != null) { + if (backupData != null) { - if(!backupData!!.encrypted) { + if (!backupData!!.encrypted) { // short validation check if the json is valid - if(!isValidJSON(String(backupData!!.data!!))) { + if (!isValidJSON(String(backupData!!.data!!))) { return@withContext false to null } } @@ -75,17 +126,17 @@ class DataImporter { } } - private fun readData(reader: JsonReader) : BackupDataStorageRepository.BackupData? { - var filename : String? = null - var packageName : String? = null - var timestamp : Long? = null - var encrypted : Boolean? = null - var data : String? = null + private fun readData(reader: JsonReader): BackupDataStorageRepository.BackupData? { + var filename: String? = null + var packageName: String? = null + var timestamp: Long? = null + var encrypted: Boolean? = null + var data: String? = null while (reader.hasNext()) { val nextName = reader.nextName() - when(nextName) { + when (nextName) { "filename" -> filename = reader.nextString() "packageName" -> packageName = reader.nextString() "timestamp" -> timestamp = reader.nextLong() @@ -95,11 +146,12 @@ class DataImporter { } } - if(TextUtils.isEmpty(filename) + if (TextUtils.isEmpty(filename) || TextUtils.isEmpty(packageName) || TextUtils.isEmpty(data) || timestamp == null - || encrypted == null) { + || encrypted == null + ) { return null } diff --git a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/backup/BackupOverviewFragment.kt b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/backup/BackupOverviewFragment.kt index 107fc63..d591546 100644 --- a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/backup/BackupOverviewFragment.kt +++ b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/backup/BackupOverviewFragment.kt @@ -1,17 +1,33 @@ package org.secuso.privacyfriendlybackup.ui.backup -import android.animation.* +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.AnimatorSet +import android.animation.ArgbEvaluator +import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.content.Intent +import android.content.res.ColorStateList import android.graphics.Color import android.os.Bundle import android.util.Log -import android.view.* +import android.view.Gravity +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewAnimationUtils +import android.view.ViewGroup import android.widget.CheckBox +import android.widget.TextView +import android.widget.Toast import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat +import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.observe import androidx.preference.PreferenceManager @@ -25,15 +41,17 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.secuso.privacyfriendlybackup.R import org.secuso.privacyfriendlybackup.data.BackupDataStorageRepository +import org.secuso.privacyfriendlybackup.data.exporter.DataExporter import org.secuso.privacyfriendlybackup.databinding.FragmentBackupOverviewBinding import org.secuso.privacyfriendlybackup.preference.PreferenceKeys.DIALOG_SKIP_IMPORT_START import org.secuso.privacyfriendlybackup.ui.common.BaseFragment import org.secuso.privacyfriendlybackup.ui.common.DisplayMenuItemActivity import org.secuso.privacyfriendlybackup.ui.common.Mode -import org.secuso.privacyfriendlybackup.ui.inspection.DataInspectionActivity import org.secuso.privacyfriendlybackup.ui.importbackup.ImportBackupActivity +import org.secuso.privacyfriendlybackup.ui.inspection.DataInspectionActivity import org.secuso.privacyfriendlybackup.ui.main.MainActivity.Companion.BACKUP_ID import org.secuso.privacyfriendlybackup.ui.main.MainActivity.Companion.FILTER +import java.io.FileNotFoundException class BackupOverviewFragment : BaseFragment(), @@ -42,6 +60,7 @@ class BackupOverviewFragment : BaseFragment(), companion object { const val TAG = "PFA BackupFragment" + const val REQUEST_CODE_CREATE_DOCUMENT: Int = 251 fun newInstance() = BackupOverviewFragment() @@ -50,16 +69,18 @@ class BackupOverviewFragment : BaseFragment(), private var currentDeleteCount: Int = 0 private lateinit var viewModel: BackupOverviewViewModel - private lateinit var adapter : FilterableBackupAdapter + private lateinit var adapter: FilterableBackupAdapter private lateinit var binding: FragmentBackupOverviewBinding private var toolbarDeleteIcon: MenuItem? = null private var searchIcon: MenuItem? = null private var selectAllIcon: MenuItem? = null - private var oldMode : Mode = Mode.NORMAL + private var oldMode: Mode = Mode.NORMAL + private var exportEncrypted: Boolean = true + private var lastToastTime: Long = 0 - var predefinedFilter : String? = null - var highlightSpecificBackup : Long = -1L + var predefinedFilter: String? = null + var highlightSpecificBackup: Long = -1L override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -74,7 +95,7 @@ class BackupOverviewFragment : BaseFragment(), savedInstanceState ?: return val savedOldMode = savedInstanceState.getString("oldMode") - oldMode = if(savedOldMode != null) Mode.valueOf(savedOldMode) else oldMode + oldMode = if (savedOldMode != null) Mode.valueOf(savedOldMode) else oldMode currentDeleteCount = savedInstanceState.getInt("currentDeleteCount") } @@ -110,6 +131,7 @@ class BackupOverviewFragment : BaseFragment(), false ) } + isLargeTablet() -> { GridLayoutManager( context, @@ -118,25 +140,82 @@ class BackupOverviewFragment : BaseFragment(), false ) } + else -> { LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) } } binding.fab.setOnClickListener { - if(Mode.DELETE.isActiveIn(viewModel.getCurrentMode())) { + if (Mode.DELETE.isActiveIn(viewModel.getCurrentMode())) { val builder = AlertDialog.Builder(requireContext()).apply { setTitle(R.string.dialog_delete_confirmation_title) setMessage(R.string.dialog_delete_confirmation_message) setNegativeButton(R.string.dialog_delete_confirmation_negative, null) setPositiveButton(R.string.dialog_delete_confirmation_positive) { dialog, _ -> - viewModel.deleteData(adapter.getDeleteList()) + viewModel.deleteData(adapter.getSelectionList()) dialog.dismiss() - onDisableDeleteMode() + onDisableMode(Mode.DELETE) } } builder.show() + } else if (Mode.EXPORT.isActiveIn(viewModel.getCurrentMode())) { + + if (adapter.getSelectionList().size == 1) { + val id = adapter.getSelectionList().first().id + onDisableMode(Mode.EXPORT) + Intent(requireActivity(), DataInspectionActivity::class.java).let { + it.putExtra(DataInspectionActivity.EXTRA_DATA_ID, id) + it.putExtra(DataInspectionActivity.EXTRA_EXPORT_DATA, true) + startActivity(it) + } + } else { + + val view = layoutInflater.inflate(R.layout.dialog_data_export_confirmation_multiple, null) + + val builder = AlertDialog.Builder(requireContext()).apply { + setTitle(R.string.dialog_data_export_start_title) + if (adapter.getSelectionList().any { it.encrypted }) { + setView(view) + view.findViewById(R.id.dialog_data_export_encrypted_information).text = + resources.getQuantityString( + R.plurals.dialog_data_export_multiple_encrypted, + adapter.getSelectionList().count { it.encrypted }, + adapter.getSelectionList().count { it.encrypted }) + view.findViewById(R.id.dialog_data_export_encrypted_warning).text = + resources.getString(R.string.dialog_data_export_multiple_encrypted_warning, getString(R.string.menu_item_inspect)) + } + setMessage( + resources.getQuantityString( + R.plurals.dialog_data_export_start_message, + adapter.getSelectionList().size, + adapter.getSelectionList().size + ) + ) + setNegativeButton(R.string.dialog_data_export_start_cancel, null) + setPositiveButton(R.string.dialog_data_export_start_confirm) { dialog, _ -> + if (adapter.getSelectionList().isEmpty()) { + dialog.dismiss() + return@setPositiveButton + } + + exportEncrypted = false + exportEncrypted = true + + val filename = DataExporter.getMultipleExportFileName() + + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + type = "*/*" + addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_TITLE, filename) + startActivityForResult(Intent.createChooser(this, ""), REQUEST_CODE_CREATE_DOCUMENT) + } + dialog.dismiss() + } + } + builder.show() + } } } @@ -145,7 +224,7 @@ class BackupOverviewFragment : BaseFragment(), highlightSpecificBackup = arguments?.getLong(BACKUP_ID) ?: -1L Log.d(TAG, "predefinedFilter = $predefinedFilter") - if(!predefinedFilter.isNullOrEmpty()) { + if (!predefinedFilter.isNullOrEmpty()) { viewModel.setFilterText(predefinedFilter) } @@ -165,7 +244,7 @@ class BackupOverviewFragment : BaseFragment(), val colorTo = ContextCompat.getColor(requireContext(), mode.color) // enabled search - if(!Mode.SEARCH.isActiveIn(oldMode) && Mode.SEARCH.isActiveIn(mode)) { + if (!Mode.SEARCH.isActiveIn(oldMode) && Mode.SEARCH.isActiveIn(mode)) { val searchRevealOpen = animateSearchToolbar(42, false, true) val colorFade = playColorAnimation(colorFrom, colorTo) { activity?.window?.statusBarColor = it.animatedValue as Int @@ -175,7 +254,7 @@ class BackupOverviewFragment : BaseFragment(), set.start() // disable search - } else if(Mode.SEARCH.isActiveIn(oldMode) && !Mode.SEARCH.isActiveIn(mode)) { + } else if (Mode.SEARCH.isActiveIn(oldMode) && !Mode.SEARCH.isActiveIn(mode)) { val searchToolbarClose = animateSearchToolbar(2, false, false) val colorFade = playColorAnimation(colorFrom, colorTo) { activity?.window?.statusBarColor = it.animatedValue as Int @@ -189,30 +268,105 @@ class BackupOverviewFragment : BaseFragment(), playColorAnimation(colorFrom, colorTo).start() } - if(Mode.SEARCH.isActiveIn(mode)) { + if (Mode.SEARCH.isActiveIn(mode)) { searchIcon?.expandActionView() } else { searchIcon?.collapseActionView() } - if(Mode.DELETE.isActiveIn(mode)) { - onEnableDeleteMode() - } else { - onDisableDeleteMode() + when { + Mode.DELETE.isActiveIn(mode) -> { + binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.red)) + binding.fab.rippleColor = ContextCompat.getColor(requireContext(), R.color.lightred) + binding.fab.setImageResource(R.drawable.ic_delete_24) + onEnableMode(mode) + binding.fab.show() + } + + Mode.EXPORT.isActiveIn(mode) -> { + binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.green)) + binding.fab.rippleColor = ContextCompat.getColor(requireContext(), R.color.lightgreen) + binding.fab.setImageResource(R.drawable.ic_save_alt_24) + onEnableMode(mode) + binding.fab.show() + } + + else -> { + onDisableMode(mode) + binding.fab.hide() + } + } + + if (Mode.DELETE.isActiveIn(mode) or Mode.EXPORT.isActiveIn(mode)) { + toolbarDeleteIcon?.isVisible = false + selectAllIcon?.isVisible = true + } + + if (mode != Mode.NORMAL && mode != Mode.SEARCH) { + toolbar.setNavigationIcon(R.drawable.ic_arrow_back_24) + } + + if (mode != Mode.NORMAL && mode != Mode.SEARCH) { + toolbarDeleteIcon?.isVisible = false + selectAllIcon?.isVisible = true } oldMode = mode } + + viewModel.exportStatus.observe(viewLifecycleOwner, Observer { status -> + when (status.status) { + BackupOverviewViewModel.ExportStatus.Status.UNKNOWN -> {} + BackupOverviewViewModel.ExportStatus.Status.LOADING -> { + if (System.currentTimeMillis() > lastToastTime + 2000) { + Toast.makeText(context, getString(R.string.data_export_toast_loading, status.completed, status.total), Toast.LENGTH_SHORT).show() + lastToastTime = System.currentTimeMillis() + } + } + + BackupOverviewViewModel.ExportStatus.Status.WRITING -> + Toast.makeText(context, getString(R.string.data_export_toast_writing), Toast.LENGTH_SHORT).show() + + BackupOverviewViewModel.ExportStatus.Status.ERROR -> + Toast.makeText(context, getString(R.string.data_export_toast_export_error), Toast.LENGTH_LONG).show() + + BackupOverviewViewModel.ExportStatus.Status.COMPLETE -> + Toast.makeText(context, getString(R.string.data_export_toast_export_successful), Toast.LENGTH_LONG).show() + } + }) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (requestCode == REQUEST_CODE_CREATE_DOCUMENT) { + when (resultCode) { + AppCompatActivity.RESULT_OK -> { + if (data != null && data.data != null) { + try { + viewModel.exportData(data.data, HashSet(adapter.getSelectionList()), exportEncrypted) + onDisableMode(Mode.EXPORT) + } catch (e: FileNotFoundException) { + Log.e(TAG, e.message, e) + } + } + } + + AppCompatActivity.RESULT_CANCELED -> { + /* canceled */ + } + } + } } private fun displayNoElementsImage(show: Boolean) { - binding.backupOverviewNoEntries.visibility = if(show) View.VISIBLE else View.GONE + binding.backupOverviewNoEntries.visibility = if (show) View.VISIBLE else View.GONE } private fun playAnimationIfApplicable(data: List) { - if(data.isNotEmpty() && highlightSpecificBackup != -1L) { + if (data.isNotEmpty() && highlightSpecificBackup != -1L) { for (i in data.indices) { - if(data[i].id == highlightSpecificBackup) { + if (data[i].id == highlightSpecificBackup) { Log.d(TAG, "## found backup in recyclerView at position $i") val highlight = highlightSpecificBackup highlightSpecificBackup = -1L @@ -225,7 +379,7 @@ class BackupOverviewFragment : BaseFragment(), delay(250L) Log.d(TAG, "## finding viewholder for item id $highlight") val vh = binding.fragmentBackupOverviewList.findViewHolderForItemId(highlight) - if(vh != null) { + if (vh != null) { (vh as FilterableBackupAdapter.ViewHolder).apply { ObjectAnimator.ofObject( mCard, @@ -263,14 +417,20 @@ class BackupOverviewFragment : BaseFragment(), val currentMode = viewModel.getCurrentMode() val activity = activity - if(activity != null && activity is DisplayMenuItemActivity) { + if (activity != null && activity is DisplayMenuItemActivity) { when { Mode.SEARCH.isActiveIn(currentMode) -> { activity.pressBack() } + Mode.DELETE.isActiveIn(currentMode) -> { - onDisableDeleteMode() + onDisableMode(Mode.DELETE) + } + + Mode.EXPORT.isActiveIn(currentMode) -> { + onDisableMode(Mode.EXPORT) } + else -> { activity.finish() } @@ -294,13 +454,13 @@ class BackupOverviewFragment : BaseFragment(), selectAllIcon = menu.findItem(R.id.action_select_all) - if(Mode.DELETE.isActiveIn(viewModel.getCurrentMode())) { - onEnableDeleteMode() + if (Mode.DELETE.isActiveIn(viewModel.getCurrentMode())) { + onEnableMode(Mode.DELETE) } else { - onDisableDeleteMode() + onDisableMode(Mode.DELETE) } - if(!predefinedFilter.isNullOrEmpty()) { + if (!predefinedFilter.isNullOrEmpty()) { searchIcon?.isVisible = false return } @@ -323,7 +483,7 @@ class BackupOverviewFragment : BaseFragment(), } }) - if(Mode.SEARCH.isActiveIn(viewModel.getCurrentMode())) { + if (Mode.SEARCH.isActiveIn(viewModel.getCurrentMode())) { searchIcon?.expandActionView() } else { searchIcon?.collapseActionView() @@ -340,23 +500,28 @@ class BackupOverviewFragment : BaseFragment(), super.onDestroy() searchIcon?.collapseActionView() - onDisableDeleteMode() + onDisableMode(Mode.EXPORT) + onDisableMode(Mode.DELETE) } override fun onOptionsItemSelected(item: MenuItem): Boolean { - when(item.itemId) { + when (item.itemId) { android.R.id.home -> { if (Mode.DELETE.isActiveIn(viewModel.getCurrentMode())) { - onDisableDeleteMode() + onDisableMode(Mode.DELETE) + } else if (Mode.EXPORT.isActiveIn(viewModel.getCurrentMode())) { + onDisableMode(Mode.EXPORT) } else { return false } } - R.id.action_delete -> onEnableDeleteMode() + + R.id.action_delete -> onEnableMode(Mode.DELETE) R.id.action_search -> { /* nothing to do here */ //Toast.makeText(requireContext(), "action_search", Toast.LENGTH_SHORT).show() } + R.id.action_select_all -> { if (currentDeleteCount > 0) { adapter.deselectAll() @@ -364,28 +529,31 @@ class BackupOverviewFragment : BaseFragment(), adapter.selectAll() } } + R.id.action_add -> { showImportStartDialog() } + R.id.action_sort -> { // viewModel.insertTestData() // TODO: to implement sorting - just swap the comparator :) } + else -> return false } return true } private fun showImportStartDialog() { - if(PreferenceManager.getDefaultSharedPreferences(requireActivity()).getBoolean(DIALOG_SKIP_IMPORT_START, false)) { + if (PreferenceManager.getDefaultSharedPreferences(requireActivity()).getBoolean(DIALOG_SKIP_IMPORT_START, false)) { startImport() } else { AlertDialog.Builder(requireActivity()).apply { - setTitle(R.string.dialog_data_export_start_title) - setMessage(R.string.dialog_data_export_start_message) + setTitle(R.string.dialog_data_import_start_title) + setMessage(R.string.dialog_data_import_start_message) val view = requireActivity().layoutInflater.inflate(R.layout.dialog_checkbox, null) setView(view) - setPositiveButton(R.string.dialog_data_export_start_confirm) { d, _ -> + setPositiveButton(R.string.dialog_data_import_start_confirm) { d, _ -> val checkBox = view.findViewById(R.id.dialog_checkbox) if (checkBox.isChecked) { @@ -396,7 +564,7 @@ class BackupOverviewFragment : BaseFragment(), startImport() d.dismiss() } - setNegativeButton(R.string.dialog_data_export_start_cancel, null) + setNegativeButton(R.string.dialog_data_import_start_cancel, null) }.create().show() } } @@ -408,26 +576,18 @@ class BackupOverviewFragment : BaseFragment(), } } - override fun onDeleteCountChanged(count: Int) { + override fun onSelectionCountChanged(count: Int) { currentDeleteCount = count - when(count) { + when (count) { 0 -> selectAllIcon?.setIcon(R.drawable.ic_check_box_outline_blank_24) adapter.completeData.size -> selectAllIcon?.setIcon(R.drawable.ic_check_box_24) else -> selectAllIcon?.setIcon(R.drawable.ic_indeterminate_check_box_24) } } - override fun onEnableDeleteMode() { - viewModel.enableMode(Mode.DELETE) - - toolbar.setNavigationIcon(R.drawable.ic_arrow_back_24) - - adapter.enableDeleteMode() - - toolbarDeleteIcon?.isVisible = false - selectAllIcon?.isVisible = true - - binding.fab.show() + override fun onEnableMode(mode: Mode) { + viewModel.enableMode(mode) + adapter.enableMode(mode) } override fun onItemClick( @@ -445,12 +605,27 @@ class BackupOverviewFragment : BaseFragment(), popup.show() } + /* override fun onItemLongClick( + id: Long, + backupData: BackupDataStorageRepository.BackupData, + view: View + ) { + val popup = PopupMenu(requireContext(), view) + popup.menuInflater.inflate(R.menu.menu_popup_backup, popup.menu) + popup.gravity = Gravity.END + popup.setOnMenuItemClickListener { item -> + handlePopupMenuClick(id, backupData, item.itemId) + return@setOnMenuItemClickListener true + } + popup.show() + } */ + private fun handlePopupMenuClick( id: Long, backupData: BackupDataStorageRepository.BackupData, itemId: Int ) { - when(itemId) { + when (itemId) { R.id.menu_restore -> { val builder = AlertDialog.Builder(requireContext()) builder.setPositiveButton(R.string.restore) { _, _ -> @@ -461,35 +636,43 @@ class BackupOverviewFragment : BaseFragment(), builder.setTitle(R.string.dialog_restore_confirmation_title) builder.create().show() } + R.id.menu_inspect -> { Intent(requireActivity(), DataInspectionActivity::class.java).let { it.putExtra(DataInspectionActivity.EXTRA_DATA_ID, id) startActivity(it) } } + R.id.menu_export -> { - Intent(requireActivity(), DataInspectionActivity::class.java).let { + onEnableMode(Mode.EXPORT) + adapter.selectItem(backupData) + /*Intent(requireActivity(), DataInspectionActivity::class.java).let { it.putExtra(DataInspectionActivity.EXTRA_DATA_ID, id) it.putExtra(DataInspectionActivity.EXTRA_EXPORT_DATA, true) startActivity(it) - } + }*/ } + else -> {} } } - fun onDisableDeleteMode() { - viewModel.disableMode(Mode.DELETE) + fun onDisableMode(mode: Mode) { + viewModel.disableMode(mode) - if(!isPortrait() && isXLargeTablet()) { + if (!isPortrait() && isXLargeTablet()) { toolbar.navigationIcon = null } toolbarDeleteIcon?.isVisible = true selectAllIcon?.isVisible = false - binding.fab.hide() - adapter.disableDeleteMode() + adapter.disableMode(mode) + + if (Mode.DELETE.isActiveIn(mode) or Mode.EXPORT.isActiveIn(mode)) { + adapter.deselectAll() + } } @SuppressLint("PrivateResource") @@ -497,7 +680,7 @@ class BackupOverviewFragment : BaseFragment(), numberOfMenuIcon: Int, containsOverflow: Boolean, show: Boolean - ) : Animator { + ): Animator { //appBar.setBackgroundColor(ContextCompat.getColor(requireContext(), viewModel.getCurrentMode().color)) if (show) { @@ -523,7 +706,7 @@ class BackupOverviewFragment : BaseFragment(), createCircularReveal.duration = 250 - if(!show) { + if (!show) { createCircularReveal.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) @@ -553,4 +736,4 @@ class BackupOverviewFragment : BaseFragment(), return true } -} \ No newline at end of file +} diff --git a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/backup/BackupOverviewViewModel.kt b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/backup/BackupOverviewViewModel.kt index 31f50d4..ae007f4 100644 --- a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/backup/BackupOverviewViewModel.kt +++ b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/backup/BackupOverviewViewModel.kt @@ -1,27 +1,45 @@ package org.secuso.privacyfriendlybackup.ui.backup import android.app.Application -import androidx.lifecycle.* +import android.net.Uri +import android.widget.Toast +import androidx.collection.ArraySet +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.async import kotlinx.coroutines.launch import org.secuso.privacyfriendlybackup.data.BackupDataStorageRepository import org.secuso.privacyfriendlybackup.data.BackupDataStorageRepository.BackupData import org.secuso.privacyfriendlybackup.data.BackupJobManager import org.secuso.privacyfriendlybackup.data.apps.PFApplicationHelper -import org.secuso.privacyfriendlybackup.data.cloud.WebserviceProvider +import org.secuso.privacyfriendlybackup.data.exporter.DataExporter import org.secuso.privacyfriendlybackup.data.internal.InternalBackupDataStoreHelper -import org.secuso.privacyfriendlybackup.data.room.BackupDatabase +import org.secuso.privacyfriendlybackup.data.room.model.enums.StorageType import org.secuso.privacyfriendlybackup.ui.common.Mode import java.io.ByteArrayInputStream -import java.util.* -import kotlin.collections.ArrayList +import java.util.Date -class BackupOverviewViewModel(app : Application) : AndroidViewModel(app) { - private val internalBackupLiveData : MediatorLiveData> = MediatorLiveData() +class BackupOverviewViewModel(app: Application) : AndroidViewModel(app) { + private val internalBackupLiveData: MediatorLiveData> = MediatorLiveData() - val backupLiveData : LiveData> = internalBackupLiveData - val filterLiveData : LiveData = MutableLiveData("") - val filteredBackupLiveData : LiveData> = MutableLiveData(emptyList()) - val currentMode : LiveData = MutableLiveData(Mode.NORMAL) + val backupLiveData: LiveData> = internalBackupLiveData + val filterLiveData: LiveData = MutableLiveData("") + val filteredBackupLiveData: LiveData> = MutableLiveData(emptyList()) + val currentMode: LiveData = MutableLiveData(Mode.NORMAL) + private val _exportStatus = MutableLiveData(ExportStatus(ExportStatus.Status.UNKNOWN, 0, 0)) + val exportStatus: LiveData + get() = _exportStatus + + class ExportStatus(val status: ExportStatus.Status, val completed: Int, val total: Int) { + enum class Status { + UNKNOWN, LOADING, WRITING, ERROR, COMPLETE + } + } init { viewModelScope.launch { @@ -36,29 +54,30 @@ class BackupOverviewViewModel(app : Application) : AndroidViewModel(app) { } } - fun getCurrentMode() : Mode { + fun getCurrentMode(): Mode { return currentMode.value!! } fun enableMode(mode: Mode) { - if(!mode.isActiveIn(currentMode.value!!)) { + if (!mode.isActiveIn(currentMode.value!!)) { (currentMode as MutableLiveData).postValue(Mode[getCurrentMode().value or mode.value]) } } + fun disableMode(mode: Mode) { - if(mode.isActiveIn(currentMode.value!!)) { + if (mode.isActiveIn(currentMode.value!!)) { (currentMode as MutableLiveData).postValue(Mode[getCurrentMode().value and mode.value.inv()]) } } - fun setFilterText(filter : String?) { + fun setFilterText(filter: String?) { val result = ArrayList() val internalData = internalBackupLiveData.value ?: emptyList() - for(data in internalData) { + for (data in internalData) { val pfaInfo = PFApplicationHelper.getDataForPackageName(getApplication(), data.packageName) - if(filter.isNullOrEmpty() + if (filter.isNullOrEmpty() || data.packageName.contains(filter, true) || pfaInfo?.label?.contains(filter, true) == true ) { @@ -68,14 +87,14 @@ class BackupOverviewViewModel(app : Application) : AndroidViewModel(app) { (filteredBackupLiveData as MutableLiveData>).postValue(result) - if(filter != filterLiveData.value) { + if (filter != filterLiveData.value) { (filterLiveData as MutableLiveData).postValue(filter) } } fun insertTestData() { viewModelScope.launch { - for(i in 1 .. 10) { + for (i in 1..10) { val dataId = InternalBackupDataStoreHelper.storeData( getApplication(), "org.secuso.example", @@ -101,4 +120,47 @@ class BackupOverviewViewModel(app : Application) : AndroidViewModel(app) { jobManager.createRestoreJobChain(backupData.packageName, backupData.id) } } + + fun exportData(uri: Uri?, selectionList: Set, exportEncrypted: Boolean) { + _exportStatus.postValue(ExportStatus(ExportStatus.Status.UNKNOWN, 0, selectionList.size)) + val write = viewModelScope.async(IO) { + val storageRepository = BackupDataStorageRepository.getInstance(getApplication()) + return@async uri?.let { + //Create BackupData objects to export + val exportBackupData = ArraySet() + _exportStatus.postValue(ExportStatus(ExportStatus.Status.LOADING, 0, selectionList.size)) + for (backupData in selectionList) { + val encrypted = exportEncrypted && backupData.encrypted + + val fullBackupData = storageRepository.getFile(getApplication(), backupData.id) + if (fullBackupData?.data == null) { + return@let false + } + + val exportData = BackupData( + filename = DataExporter.getSingleExportFileName(fullBackupData, exportEncrypted), + encrypted = encrypted, + timestamp = fullBackupData.timestamp, + packageName = fullBackupData.packageName, + available = true, + data = if (encrypted) fullBackupData.data else + String(fullBackupData.data).toByteArray(Charsets.UTF_8),//TODO decrypt data and get decrypted data + storageType = StorageType.EXTERNAL + ) + exportBackupData.add(exportData) + _exportStatus.postValue(ExportStatus(ExportStatus.Status.LOADING, exportBackupData.size, selectionList.size)) + } + _exportStatus.postValue(ExportStatus(ExportStatus.Status.WRITING, 0, selectionList.size)) + return@let DataExporter.exportDataZip(getApplication(), uri, exportBackupData) + } + } + + viewModelScope.launch(Main) { + if (write.await() == true) { + _exportStatus.postValue(ExportStatus(ExportStatus.Status.COMPLETE, selectionList.size, selectionList.size)) + } else { + _exportStatus.postValue(ExportStatus(ExportStatus.Status.ERROR, selectionList.size, selectionList.size)) + } + } + } } diff --git a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/backup/FilterableBackupAdapter.kt b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/backup/FilterableBackupAdapter.kt index cb3c3cb..3defa88 100644 --- a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/backup/FilterableBackupAdapter.kt +++ b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/backup/FilterableBackupAdapter.kt @@ -1,7 +1,6 @@ package org.secuso.privacyfriendlybackup.ui.backup import android.content.Context -import android.text.format.DateFormat import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -17,6 +16,7 @@ import org.secuso.privacyfriendlybackup.R import org.secuso.privacyfriendlybackup.data.BackupDataStorageRepository.BackupData import org.secuso.privacyfriendlybackup.data.apps.PFApplicationHelper import org.secuso.privacyfriendlybackup.data.room.model.enums.StorageType +import org.secuso.privacyfriendlybackup.ui.common.Mode import java.lang.ref.WeakReference import java.text.SimpleDateFormat import java.util.* @@ -24,8 +24,8 @@ import java.util.* class FilterableBackupAdapter(val context : Context, adapterCallback : ManageListAdapterCallback) : RecyclerView.Adapter() { interface ManageListAdapterCallback { - fun onDeleteCountChanged(count: Int) - fun onEnableDeleteMode() + fun onSelectionCountChanged(count: Int) + fun onEnableMode(mode: Mode) fun onItemClick(id : Long, backupData: BackupData, view: View) } @@ -52,9 +52,11 @@ class FilterableBackupAdapter(val context : Context, adapterCallback : ManageLis override fun areItemsTheSame(a: BackupData, b: BackupData): Boolean = a.id == b.id } ) - private val deleteList: MutableSet = HashSet() - private var deleteMode = false + private val selectionList: MutableSet = HashSet() private val callback = WeakReference(adapterCallback) + private var mode = Mode.NORMAL + private val selectionActive : Boolean + get() = Mode.DELETE.isActiveIn(mode) or Mode.EXPORT.isActiveIn(mode) init { setHasStableIds(true) @@ -87,35 +89,35 @@ class FilterableBackupAdapter(val context : Context, adapterCallback : ManageLis val data = this.sortedList[position] val pfaInfo = PFApplicationHelper.getDataForPackageName(context, data.packageName) - holder.mCheckbox.visibility = if (deleteMode) View.VISIBLE else View.INVISIBLE - holder.mCheckbox.isChecked = deleteList.contains(data) + holder.mCheckbox.visibility = if (selectionActive) View.VISIBLE else View.INVISIBLE + holder.mCheckbox.isChecked = selectionList.contains(data) holder.mCard.setOnLongClickListener { - if (deleteMode) { + if (selectionActive) { if (holder.mCheckbox.isChecked) { - deleteList.remove(data) + selectionList.remove(data) holder.mCheckbox.isChecked = false } else { - deleteList.add(data) + selectionList.add(data) holder.mCheckbox.isChecked = true } - notifyDeleteCount() + notifySelectionCount() } else { - callback.get()?.onEnableDeleteMode() + callback.get()?.onEnableMode(Mode.DELETE) } false } holder.mCard.setOnClickListener { - if (deleteMode) { + if (selectionActive) { if (holder.mCheckbox.isChecked) { - deleteList.remove(data) + selectionList.remove(data) holder.mCheckbox.isChecked = false } else { - deleteList.add(data) + selectionList.add(data) holder.mCheckbox.isChecked = true } - notifyDeleteCount() + notifySelectionCount() } else { callback.get()?.onItemClick(data.id, data, it) } @@ -149,8 +151,8 @@ class FilterableBackupAdapter(val context : Context, adapterCallback : ManageLis } } - private fun notifyDeleteCount() { - callback.get()?.onDeleteCountChanged(deleteList.size) + private fun notifySelectionCount() { + callback.get()?.onSelectionCountChanged(selectionList.size) } class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { @@ -162,33 +164,39 @@ class FilterableBackupAdapter(val context : Context, adapterCallback : ManageLis val mStorageImage : ImageView = itemView.findViewById(R.id.storageImage) } - fun enableDeleteMode() { - deleteList.clear() - deleteMode = true - notifyDeleteCount() + fun enableMode(_mode: Mode) { + mode = mode.activate(_mode) + notifySelectionCount() notifyDataSetChanged() } fun selectAll() { for (i in 0 until sortedList.size()) { - deleteList.add(sortedList[i]) + selectionList.add(sortedList[i]) } - notifyDeleteCount() + notifySelectionCount() notifyDataSetChanged() } fun deselectAll() { - deleteList.clear() - notifyDeleteCount() + selectionList.clear() + notifySelectionCount() notifyDataSetChanged() } - fun disableDeleteMode() { - deleteMode = false - deleteList.clear() - notifyDeleteCount() + fun disableMode(_mode: Mode) { + mode = mode.deactivate(_mode) + notifySelectionCount() notifyDataSetChanged() } - fun getDeleteList() : Set = deleteList + fun getSelectionList() : Set = selectionList + + fun selectItem(data: BackupData) { + if(selectionActive) { + selectionList.add(data) + notifySelectionCount() + notifyDataSetChanged() + } + } } \ No newline at end of file diff --git a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/common/Mode.kt b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/common/Mode.kt index f3afd5b..2f79dab 100644 --- a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/common/Mode.kt +++ b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/common/Mode.kt @@ -2,17 +2,31 @@ package org.secuso.privacyfriendlybackup.ui.common import androidx.annotation.ColorRes import org.secuso.privacyfriendlybackup.R +import java.lang.Exception +/** + * Marks which modes are active. Multiple modes can be active at the same time. + */ enum class Mode(var value: Int, @ColorRes var color: Int) { NORMAL(0, R.color.colorPrimary), SEARCH(1, R.color.colorAccent), DELETE(2, R.color.middlegrey), - SEARCH_AND_DELETE(3, R.color.middleblue); + SEARCH_AND_DELETE(3, R.color.middleblue), + EXPORT(4, R.color.green), + SEARCH_AND_EXPORT(5, R.color.lightgreen); fun isActiveIn(currentMode: Mode): Boolean { return currentMode.value and value == value } + fun activate(_mode: Mode): Mode { + return values().find { it.value == (this.value or _mode.value) } ?: this + } + + fun deactivate(_mode: Mode): Mode { + return values().find { it.value == (this.value and _mode.value.inv()) } ?: this + } + companion object { operator fun get(i: Int): Mode { for (mode in Mode.values()) { diff --git a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/encryption/UserInteractionRequiredActivity.kt b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/encryption/UserInteractionRequiredActivity.kt index 4f7ff32..4d5400d 100644 --- a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/encryption/UserInteractionRequiredActivity.kt +++ b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/encryption/UserInteractionRequiredActivity.kt @@ -91,10 +91,6 @@ class UserInteractionRequiredActivity : AppCompatActivity() { // restart the encryption worker (applicationContext as BackupApplication).schedulePeriodicWork() - - // after the answer was received, there is no need to display this translucent activity - finish() - /* var encryptionWorker : OneTimeWorkRequest? @@ -122,6 +118,9 @@ class UserInteractionRequiredActivity : AppCompatActivity() { WorkManager.getInstance(this).beginUniqueWork("$callingPackageName($dataId)", ExistingWorkPolicy.REPLACE, encryptionWorker!!).enqueue() */ } + + // after the answer was received, there is no need to display this translucent activity + finish() } } \ No newline at end of file diff --git a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/importbackup/ImportBackupActivity.kt b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/importbackup/ImportBackupActivity.kt index 97fab80..f9a5a08 100644 --- a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/importbackup/ImportBackupActivity.kt +++ b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/importbackup/ImportBackupActivity.kt @@ -3,6 +3,7 @@ package org.secuso.privacyfriendlybackup.ui.importbackup import android.content.Intent import android.net.Uri import android.os.Bundle +import android.webkit.MimeTypeMap import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat @@ -21,29 +22,31 @@ class ImportBackupActivity : AppCompatActivity() { companion object { const val ACTION_OPEN_FILE = "ImportBackupActivity.ACTION_OPEN_FILE" - const val REQUEST_CODE_OPEN_DOCUMENT : Int = 362 + const val REQUEST_CODE_OPEN_DOCUMENT: Int = 362 } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_import_backup) - when(intent?.action ) { + when (intent?.action) { ACTION_OPEN_FILE -> { sendOpenIntent() } + Intent.ACTION_VIEW -> { intent.data?.also { uri -> showImportConfirmationDialog(uri) } } + else -> { finish() } } } - private fun showImportConfirmationDialog(uri : Uri) { + private fun showImportConfirmationDialog(uri: Uri) { AlertDialog.Builder(this).apply { setTitle(R.string.data_import_confirmation_dialog_title) setIcon(ContextCompat.getDrawable(this@ImportBackupActivity, R.drawable.ic_outline_info_24)?.apply { @@ -83,13 +86,27 @@ class ImportBackupActivity : AppCompatActivity() { fun import(uri: Uri) { GlobalScope.launch(IO) { - val (success, data) = DataImporter.importData(this@ImportBackupActivity, uri) - - withContext(Main) { - if(success) { - showSuccessDialog(data!!) - } else { - showErrorDialog() + if (MimeTypeMap.getSingleton().getExtensionFromMimeType(contentResolver.getType(uri)).equals("zip")) { + val result = DataImporter.importDataZip(this@ImportBackupActivity, uri) + withContext(Main) { + if (result == null) { + showResultDialogZip(0, 0) + } else { + val completed = result.count { + val (success, _) = it + return@count success + } + showResultDialogZip(completed, result.size) + } + } + } else { + val (success, data) = DataImporter.importData(this@ImportBackupActivity, uri) + withContext(Main) { + if (success) { + showSuccessDialog(data!!) + } else { + showErrorDialog() + } } } } @@ -102,7 +119,56 @@ class ImportBackupActivity : AppCompatActivity() { this.setTint(ContextCompat.getColor(this@ImportBackupActivity, R.color.red)) }) setMessage(R.string.data_import_error_dialog_message) - setPositiveButton(R.string.data_import_error_dialog_confirm) { _,_ -> + setPositiveButton(R.string.data_import_error_dialog_confirm) { _, _ -> + Intent(this@ImportBackupActivity, MainActivity::class.java).apply { + //flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra(MainActivity.SELECTED_MENU_ITEM, MainActivity.MenuItem.MENU_MAIN_BACKUP_OVERVIEW.name) + startActivity(this) + } + this@ImportBackupActivity.finish() + } + }.create().show() + } + + private fun showResultDialogZip(completed: Int, total: Int) { + val success = completed == total && total != 0 + val partial = completed != total && total != 0 + AlertDialog.Builder(this).apply { + if (success) { + setTitle(R.string.data_import_success_dialog_title) + setIcon(ContextCompat.getDrawable(this@ImportBackupActivity, R.drawable.ic_check_box_24)?.apply { + this.setTint(ContextCompat.getColor(this@ImportBackupActivity, R.color.green)) + }) + } else { + setTitle(R.string.data_import_error_dialog_title) + setIcon(ContextCompat.getDrawable(this@ImportBackupActivity, R.drawable.ic_baseline_error_outline_24)?.apply { + this.setTint(ContextCompat.getColor(this@ImportBackupActivity, R.color.red)) + }) + } + if (success) { + setMessage( + getString(R.string.data_import_success_message) + + "\n" + + getString( + R.string.data_import_multiple_imported_message, + completed, + total + ) + ) + } else if (partial) { + setMessage( + getString(R.string.data_import_error_dialog_message) + + "\n" + + getString( + R.string.data_import_multiple_imported_message, + completed, + total + ) + ) + } else { + setMessage(getString(R.string.data_import_error_dialog_message)) + } + setPositiveButton(R.string.data_import_error_dialog_confirm) { _, _ -> Intent(this@ImportBackupActivity, MainActivity::class.java).apply { //flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP putExtra(MainActivity.SELECTED_MENU_ITEM, MainActivity.MenuItem.MENU_MAIN_BACKUP_OVERVIEW.name) diff --git a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/inspection/DataInspectionFragment.kt b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/inspection/DataInspectionFragment.kt index 24e5192..3c45eda 100644 --- a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/inspection/DataInspectionFragment.kt +++ b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/inspection/DataInspectionFragment.kt @@ -1,34 +1,31 @@ package org.secuso.privacyfriendlybackup.ui.inspection -import android.content.Context import android.content.Intent -import android.graphics.Color import android.graphics.PorterDuff -import android.graphics.drawable.Drawable import android.os.Bundle import android.text.TextUtils import android.util.Log -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import android.widget.CheckBox import android.widget.TextView import android.widget.Toast -import androidx.activity.result.ActivityResultLauncher import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintSet import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ViewModelProvider import androidx.preference.PreferenceManager import com.bumptech.glide.Glide import org.openintents.openpgp.OpenPgpSignatureResult import org.secuso.privacyfriendlybackup.R import org.secuso.privacyfriendlybackup.databinding.DataInspectionFragmentBinding -import org.secuso.privacyfriendlybackup.databinding.ItemApplicationJobBinding import org.secuso.privacyfriendlybackup.preference.PreferenceKeys -import org.secuso.privacyfriendlybackup.ui.backup.BackupOverviewFragment import java.io.FileNotFoundException @@ -296,7 +293,7 @@ class DataInspectionFragment : Fragment() { var statusText = "" var statusIcon = (ContextCompat.getDrawable(requireActivity(), R.drawable.ic_close_24)) var color = ContextCompat.getColor(requireActivity(), R.color.red) - when(it.signature?.result) { + when(it?.signature?.result) { OpenPgpSignatureResult.RESULT_NO_SIGNATURE -> { // not signed statusText = requireActivity().getString(R.string.signature_result_no_signature) @@ -342,9 +339,9 @@ class DataInspectionFragment : Fragment() { } dataBinding.dataInspectionSignatureStatus.setImageDrawable(statusIcon) dataBinding.dataInspectionSignatureStatusText.text = statusText - dataBinding.dataInspectionSignatureUserId.text = requireActivity().getString(R.string.data_inspection_signature_user_id, it.signature?.primaryUserId) - dataBinding.dataInspectionSignatureKeyId.text = requireActivity().getString(R.string.data_inspection_signature_key_id, it.signature?.keyId.toString()) + dataBinding.dataInspectionSignatureUserId.text = requireActivity().getString(R.string.data_inspection_signature_user_id, it?.signature?.primaryUserId) + dataBinding.dataInspectionSignatureKeyId.text = requireActivity().getString(R.string.data_inspection_signature_key_id, it?.signature?.keyId.toString()) } } -} \ No newline at end of file +} diff --git a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/inspection/DataInspectionViewModel.kt b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/inspection/DataInspectionViewModel.kt index 3bb3d82..b025ba5 100644 --- a/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/inspection/DataInspectionViewModel.kt +++ b/BackupApp/src/main/java/org/secuso/privacyfriendlybackup/ui/inspection/DataInspectionViewModel.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import androidx.preference.PreferenceManager +import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.WorkInfo import androidx.work.WorkManager @@ -36,10 +37,9 @@ class DataInspectionViewModel(app : Application) : AndroidViewModel(app) { private val backupDataLiveData = MutableLiveData() private val loadStatusLiveData = MediatorLiveData().apply { postValue(LoadStatus.UNKNOWN) } - private val decryptionMetaLiveData = MutableLiveData() + private val decryptionMetaLiveData = MutableLiveData() private var dataId = -1L private var localLoadedDataId = -1L - private var fileName : String = "" private var encryptedFileName : String = "" private var backupMetaData : BackupDataStorageRepository.BackupData? = null var isEncrypted : Boolean = false @@ -47,19 +47,11 @@ class DataInspectionViewModel(app : Application) : AndroidViewModel(app) { private var backupData : String = "" - fun getFileName(encrypted: Boolean) : String { - return if(backupMetaData?.encrypted == true && encrypted) { - getEncryptedFilename(fileName) - } else { - getUnencryptedFilename(fileName) - } - } - fun getLoadStatus() : LiveData { return loadStatusLiveData } - fun getDecryptionMetaData() : LiveData = decryptionMetaLiveData + fun getDecryptionMetaData() : LiveData = decryptionMetaLiveData fun loadData(dataId: Long) { this.dataId = dataId @@ -74,13 +66,11 @@ class DataInspectionViewModel(app : Application) : AndroidViewModel(app) { return@launch } - fileName = data.filename - if(data.encrypted) { isEncrypted = true val internalDataId = InternalBackupDataStoreHelper.storeData(getApplication(), data.packageName, data.data.inputStream(), data.timestamp, data.encrypted) encryptedFileName = InternalBackupDataStoreHelper.getInternalDataFileName(getApplication(), internalDataId) - val fileName = getUnencryptedFilename(encryptedFileName) + val fileName = encryptedFileName.replace("_encrypted.backup", ".backup") val job = BackupJob( packageName = data.packageName, @@ -153,19 +143,6 @@ class DataInspectionViewModel(app : Application) : AndroidViewModel(app) { decryptionMetaData?.let { decryptionMetaLiveData.postValue(it) } } - private fun getUnencryptedFilename(filename: String) = - filename.replace("_encrypted.backup", ".backup") - - private fun getEncryptedFilename(filename: String) : String { - return if(filename.contains("_encrypted.backup", true)) { - filename - } else { - filename.replace(".backup", "_encrypted.backup", true) - } - } - - - private fun setBackupData(backupDataString: String) { backupData = backupDataString @@ -205,7 +182,7 @@ class DataInspectionViewModel(app : Application) : AndroidViewModel(app) { val encrypted = exportEncrypted && backupMetaData!!.encrypted val backupData = BackupDataStorageRepository.BackupData( - filename = getFileName(exportEncrypted), + filename = DataExporter.getSingleExportFileName(backupMetaData!!, exportEncrypted), encrypted = encrypted, timestamp = backupMetaData!!.timestamp, packageName = backupMetaData!!.packageName, @@ -226,4 +203,10 @@ class DataInspectionViewModel(app : Application) : AndroidViewModel(app) { } } } + + fun getFileName(exportEncrypted: Boolean): String { + return backupMetaData?.let { + DataExporter.getSingleExportFileName(backupMetaData!!, exportEncrypted) + } ?: "" + } } \ No newline at end of file diff --git a/BackupApp/src/main/res/layout/dialog_data_export_confirmation_multiple.xml b/BackupApp/src/main/res/layout/dialog_data_export_confirmation_multiple.xml new file mode 100644 index 0000000..e0a2c30 --- /dev/null +++ b/BackupApp/src/main/res/layout/dialog_data_export_confirmation_multiple.xml @@ -0,0 +1,30 @@ + + + + + + + + \ No newline at end of file diff --git a/BackupApp/src/main/res/layout/fragment_backup_overview.xml b/BackupApp/src/main/res/layout/fragment_backup_overview.xml index 5386a72..bc053c1 100644 --- a/BackupApp/src/main/res/layout/fragment_backup_overview.xml +++ b/BackupApp/src/main/res/layout/fragment_backup_overview.xml @@ -70,7 +70,6 @@ Exportieren Datenimport abgeschlossen Das Backup wurde erfolgreich importiert.\n App: %s \n Datum: %s + Das Backup wurde erfolgreich importiert. + %d von %d Backups wurden importiert. Okay Okay Datenimport fehlgeschlagen @@ -80,8 +82,24 @@ Dieses Backup scheint beschädigt zu sein. Versuchen Sie, ein neues Backup zu erstellen, oder versuchen Sie, es erneut zu importieren. Ja Nein - Importieren des Backups - Möchten Sie ein Backup importieren? + Backup Export + Ja + Nein + Backup Importieren + Möchten Sie ein Backup importieren? + Lade Backups %d/%d… + Datei wird geschrieben… + Export abgeschlossen + Etwas ist schief gelaufen + + Möchten Sie das ausgewählte Backup exportieren? + Möchten Sie die %1$d ausgewählten Backups exportieren? + + + Ihre Auswahl enthält %d verschlüsseltes Backup. + Ihre Auswahl enthält %d verschlüsselte Backups. + + Wichtig: Verschlüsselte Backups bleiben verschlüsselt. Falls Sie ein Backup unverschlüsselt exportieren möchten, drücken sie auf \"%s\" auf dem Backup und nutzen die dortige Export-Funktion. Nicht mehr anzeigen. Verschlüsselungsinformationen Version: %s diff --git a/BackupApp/src/main/res/values/colors.xml b/BackupApp/src/main/res/values/colors.xml index 9872e68..5623ae5 100644 --- a/BackupApp/src/main/res/values/colors.xml +++ b/BackupApp/src/main/res/values/colors.xml @@ -1,4 +1,5 @@ #3680BB + #6ACD6F \ No newline at end of file diff --git a/BackupApp/src/main/res/values/strings.xml b/BackupApp/src/main/res/values/strings.xml index 984f9eb..6ed951b 100644 --- a/BackupApp/src/main/res/values/strings.xml +++ b/BackupApp/src/main/res/values/strings.xml @@ -66,6 +66,8 @@ Export Data Import Complete The backup was imported successfully.\nApp: %s \nDate: %s + The backup was imported successfully. + %d out of %d backups imported. Okay Okay Data Import Failed @@ -74,6 +76,10 @@ Do you want to import this backup into the list of backups? Backup Import Cancel + Yes + No + Backup Import + Do you want to import a backup file? Cancel Export Backup Export @@ -83,8 +89,20 @@ The backup seems to be corrupted. Try creating a new backup or retry importing the backup. Yes No - Backup Import - Do you want to import a backup file? + Backup Export + + Do you want to export the selected backup? + Do you want to export the %1$d selected backups? + + + Your selection contains %d encrypted backup. + Your selection contains %d encrypted backups. + + Important: Encrypted backups will stay encrypted. If you want to export a backup without encryption use \"%s\" on a backup and use the export function there. + Loading Backups %d/%d… + Writing to file… + Export complete + Something went wrong Do not show this again. Encryption Info There is currently no backup. Either create or import new backups to see them in this list.