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.