X-Git-Url: https://gitweb.stoutner.com/?a=blobdiff_plain;f=app%2Fsrc%2Fmain%2Fjava%2Fcom%2Fstoutner%2Fprivacybrowser%2Factivities%2FImportExportActivity.kt;fp=app%2Fsrc%2Fmain%2Fjava%2Fcom%2Fstoutner%2Fprivacybrowser%2Factivities%2FImportExportActivity.kt;h=2e55dd52cdab7b13e9d39ae0828f24c2cba7b43c;hb=153a29723774971e62fa0102db87393f1afef193;hp=0000000000000000000000000000000000000000;hpb=6f8247bfc1b2c231e99302568a7b962b24174580;p=PrivacyBrowserAndroid.git diff --git a/app/src/main/java/com/stoutner/privacybrowser/activities/ImportExportActivity.kt b/app/src/main/java/com/stoutner/privacybrowser/activities/ImportExportActivity.kt new file mode 100644 index 00000000..2e55dd52 --- /dev/null +++ b/app/src/main/java/com/stoutner/privacybrowser/activities/ImportExportActivity.kt @@ -0,0 +1,900 @@ +/* + * Copyright 2018-2022 Soren Stoutner . + * + * This file is part of Privacy Browser Android . + * + * Privacy Browser Android is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Privacy Browser Android is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Privacy Browser Android. If not, see . + */ + +package com.stoutner.privacybrowser.activities + +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import android.view.WindowManager +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.RadioButton +import android.widget.Spinner +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts + +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.Toolbar +import androidx.cardview.widget.CardView +import androidx.core.content.FileProvider +import androidx.preference.PreferenceManager + +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.textfield.TextInputLayout + +import com.stoutner.privacybrowser.R +import com.stoutner.privacybrowser.BuildConfig +import com.stoutner.privacybrowser.helpers.ImportExportDatabaseHelper + +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.lang.Exception +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Arrays + +import javax.crypto.Cipher +import javax.crypto.CipherInputStream +import javax.crypto.CipherOutputStream +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.system.exitProcess + +// Define the encryption constants. +private const val NO_ENCRYPTION = 0 +private const val PASSWORD_ENCRYPTION = 1 +private const val OPENPGP_ENCRYPTION = 2 + +// Define the saved instance state constants. +private const val ENCRYPTION_PASSWORD_TEXTINPUTLAYOUT_VISIBILITY = "A" +private const val OPEN_KEYCHAIN_REQUIRED_TEXTVIEW_VISIBILITY = "B" +private const val FILE_LOCATION_CARD_VIEW = "C" +private const val FILE_NAME_LINEARLAYOUT_VISIBILITY = "D" +private const val OPEN_KEYCHAIN_IMPORT_INSTRUCTIONS_TEXTVIEW_VISIBILITY = "E" +private const val IMPORT_EXPORT_BUTTON_VISIBILITY = "F" +private const val FILE_NAME_TEXT = "G" +private const val IMPORT_EXPORT_BUTTON_TEXT = "H" + +class ImportExportActivity : AppCompatActivity() { + // Define the class views. + private lateinit var encryptionSpinner: Spinner + private lateinit var encryptionPasswordTextInputLayout: TextInputLayout + private lateinit var encryptionPasswordEditText: EditText + private lateinit var openKeychainRequiredTextView: TextView + private lateinit var fileLocationCardView: CardView + private lateinit var importRadioButton: RadioButton + private lateinit var fileNameLinearLayout: LinearLayout + private lateinit var fileNameEditText: EditText + private lateinit var openKeychainImportInstructionsTextView: TextView + private lateinit var importExportButton: Button + + // Define the class variables. + private lateinit var fileProviderDirectory: File + private var openKeychainInstalled = false + private lateinit var temporaryPgpEncryptedImportFile: File + private lateinit var temporaryPreEncryptedExportFile: File + + // Define the browse for import activity result launcher. It must be defined before `onCreate()` is run or the app will crash. + private val browseForImportActivityResultLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { fileUri: Uri? -> + // Only do something if the user didn't press back from the file picker. + if (fileUri != null) { + // Get the file name string from the URI. + val fileNameString = fileUri.toString() + + // Set the file name name text. + fileNameEditText.setText(fileNameString) + + // Move the cursor to the end of the file name edit text. + fileNameEditText.setSelection(fileNameString.length) + } + } + + private val browseForExportActivityResultLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { fileUri: Uri? -> + // Only do something if the user didn't press back from the file picker. + if (fileUri != null) { + // Get the file name string from the URI. + val fileNameString = fileUri.toString() + + // Set the file name name text. + fileNameEditText.setText(fileNameString) + + // Move the cursor to the end of the file name edit text. + fileNameEditText.setSelection(fileNameString.length) + } + } + + private val openKeychainDecryptActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + // Delete the temporary PGP encrypted import file. + if (temporaryPgpEncryptedImportFile.exists()) + temporaryPgpEncryptedImportFile.delete() + + // Delete the file provider directory if it exists. + if (fileProviderDirectory.exists()) + fileProviderDirectory.delete() + } + + private val openKeychainEncryptActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + // Delete the temporary pre-encrypted export file if it exists. + if (temporaryPreEncryptedExportFile.exists()) + temporaryPreEncryptedExportFile.delete() + + // Delete the file provider directory if it exists. + if (fileProviderDirectory.exists()) + fileProviderDirectory.delete() + } + + public override fun onCreate(savedInstanceState: Bundle?) { + // Get a handle for the shared preferences. + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) + + // Get the preferences. + val allowScreenshots = sharedPreferences.getBoolean(getString(R.string.allow_screenshots_key), false) + val bottomAppBar = sharedPreferences.getBoolean(getString(R.string.bottom_app_bar_key), false) + + // Disable screenshots if not allowed. + if (!allowScreenshots) + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + + // Run the default commands. + super.onCreate(savedInstanceState) + + // Set the content view. + if (bottomAppBar) + setContentView(R.layout.import_export_bottom_appbar) + else + setContentView(R.layout.import_export_top_appbar) + + // Get a handle for the toolbar. + val toolbar = findViewById(R.id.import_export_toolbar) + + // Set the support action bar. + setSupportActionBar(toolbar) + + // Get a handle for the action bar. + val actionBar = supportActionBar!! + + // Display the home arrow on the support action bar. + actionBar.setDisplayHomeAsUpEnabled(true) + + // Find out if OpenKeychain is installed. + openKeychainInstalled = try { + // The newer method can be used once the minimum API >= 33. + @Suppress("DEPRECATION") + packageManager.getPackageInfo("org.sufficientlysecure.keychain", 0).versionName.isNotEmpty() + } catch (exception: PackageManager.NameNotFoundException) { + false + } + + // Get handles for the views. + encryptionSpinner = findViewById(R.id.encryption_spinner) + encryptionPasswordTextInputLayout = findViewById(R.id.encryption_password_textinputlayout) + encryptionPasswordEditText = findViewById(R.id.encryption_password_edittext) + openKeychainRequiredTextView = findViewById(R.id.openkeychain_required_textview) + fileLocationCardView = findViewById(R.id.file_location_cardview) + importRadioButton = findViewById(R.id.import_radiobutton) + val exportRadioButton = findViewById(R.id.export_radiobutton) + fileNameLinearLayout = findViewById(R.id.file_name_linearlayout) + fileNameEditText = findViewById(R.id.file_name_edittext) + openKeychainImportInstructionsTextView = findViewById(R.id.openkeychain_import_instructions_textview) + importExportButton = findViewById(R.id.import_export_button) + + // Create an array adapter for the spinner. + val encryptionArrayAdapter = ArrayAdapter.createFromResource(this, R.array.encryption_type, R.layout.spinner_item) + + // Set the drop down view resource on the spinner. + encryptionArrayAdapter.setDropDownViewResource(R.layout.spinner_dropdown_items) + + // Set the array adapter for the spinner. + encryptionSpinner.adapter = encryptionArrayAdapter + + // Update the UI when the spinner changes. + encryptionSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View, position: Int, id: Long) { + when (position) { + NO_ENCRYPTION -> { + // Hide the unneeded layout items. + encryptionPasswordTextInputLayout.visibility = View.GONE + openKeychainRequiredTextView.visibility = View.GONE + openKeychainImportInstructionsTextView.visibility = View.GONE + + // Show the file location card. + fileLocationCardView.visibility = View.VISIBLE + + // Show the file name linear layout if either import or export is checked. + if (importRadioButton.isChecked || exportRadioButton.isChecked) + fileNameLinearLayout.visibility = View.VISIBLE + + // Reset the text of the import button, which may have been changed to `Decrypt`. + if (importRadioButton.isChecked) + importExportButton.setText(R.string.import_button) + + // Clear the file name edit text. + fileNameEditText.text.clear() + + // Disable the import/export button. + importExportButton.isEnabled = false + } + + PASSWORD_ENCRYPTION -> { + // Hide the OpenPGP layout items. + openKeychainRequiredTextView.visibility = View.GONE + openKeychainImportInstructionsTextView.visibility = View.GONE + + // Show the password encryption layout items. + encryptionPasswordTextInputLayout.visibility = View.VISIBLE + + // Show the file location card. + fileLocationCardView.visibility = View.VISIBLE + + // Show the file name linear layout if either import or export is checked. + if (importRadioButton.isChecked || exportRadioButton.isChecked) + fileNameLinearLayout.visibility = View.VISIBLE + + // Reset the text of the import button, which may have been changed to `Decrypt`. + if (importRadioButton.isChecked) + importExportButton.setText(R.string.import_button) + + // Clear the file name edit text. + fileNameEditText.text.clear() + + // Disable the import/export button. + importExportButton.isEnabled = false + } + + OPENPGP_ENCRYPTION -> { + // Hide the password encryption layout items. + encryptionPasswordTextInputLayout.visibility = View.GONE + + // Updated items based on the installation status of OpenKeychain. + if (openKeychainInstalled) { // OpenKeychain is installed. + // Show the file location card. + fileLocationCardView.visibility = View.VISIBLE + + // Update the layout based on the checked radio button. + if (importRadioButton.isChecked) { + // Show the file name linear layout and the OpenKeychain import instructions. + fileNameLinearLayout.visibility = View.VISIBLE + openKeychainImportInstructionsTextView.visibility = View.VISIBLE + + // Set the text of the import button to be `Decrypt`. + importExportButton.setText(R.string.decrypt) + + // Clear the file name edit text. + fileNameEditText.text.clear() + + // Disable the import/export button. + importExportButton.isEnabled = false + } else if (exportRadioButton.isChecked) { + // Hide the file name linear layout and the OpenKeychain import instructions. + fileNameLinearLayout.visibility = View.GONE + openKeychainImportInstructionsTextView.visibility = View.GONE + + // Enable the export button. + importExportButton.isEnabled = true + } + } else { // OpenKeychain is not installed. + // Show the OpenPGP required layout item. + openKeychainRequiredTextView.visibility = View.VISIBLE + + // Hide the file location card. + fileLocationCardView.visibility = View.GONE + } + } + } + } + + override fun onNothingSelected(parent: AdapterView<*>?) {} + } + + // Update the status of the import/export button when the password changes. + encryptionPasswordEditText.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + // Do nothing. + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + // Do nothing. + } + + override fun afterTextChanged(s: Editable) { + // Enable the import/export button if both the file string and the password are populated. + importExportButton.isEnabled = fileNameEditText.text.toString().isNotEmpty() && encryptionPasswordEditText.text.toString().isNotEmpty() + } + }) + + // Update the UI when the file name edit text changes. + fileNameEditText.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + // Do nothing. + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + // Do nothing. + } + + override fun afterTextChanged(s: Editable) { + // Adjust the UI according to the encryption spinner position. + if (encryptionSpinner.selectedItemPosition == PASSWORD_ENCRYPTION) { + // Enable the import/export button if both the file name and the password are populated. + importExportButton.isEnabled = fileNameEditText.text.toString().isNotEmpty() && encryptionPasswordEditText.text.toString().isNotEmpty() + } else { + // Enable the export button if the file name is populated. + importExportButton.isEnabled = fileNameEditText.text.toString().isNotEmpty() + } + } + }) + + // Check to see if the activity has been restarted. + if (savedInstanceState == null) { // The app has not been restarted. + // Initially hide the unneeded views. + encryptionPasswordTextInputLayout.visibility = View.GONE + openKeychainRequiredTextView.visibility = View.GONE + fileNameLinearLayout.visibility = View.GONE + openKeychainImportInstructionsTextView.visibility = View.GONE + importExportButton.visibility = View.GONE + } else { // The app has been restarted. + // Restore the visibility of the views. + encryptionPasswordTextInputLayout.visibility = savedInstanceState.getInt(ENCRYPTION_PASSWORD_TEXTINPUTLAYOUT_VISIBILITY) + openKeychainRequiredTextView.visibility = savedInstanceState.getInt(OPEN_KEYCHAIN_REQUIRED_TEXTVIEW_VISIBILITY) + fileLocationCardView.visibility = savedInstanceState.getInt(FILE_LOCATION_CARD_VIEW) + fileNameLinearLayout.visibility = savedInstanceState.getInt(FILE_NAME_LINEARLAYOUT_VISIBILITY) + openKeychainImportInstructionsTextView.visibility = savedInstanceState.getInt(OPEN_KEYCHAIN_IMPORT_INSTRUCTIONS_TEXTVIEW_VISIBILITY) + importExportButton.visibility = savedInstanceState.getInt(IMPORT_EXPORT_BUTTON_VISIBILITY) + + // Restore the text. + fileNameEditText.post { fileNameEditText.setText(savedInstanceState.getString(FILE_NAME_TEXT)) } + importExportButton.text = savedInstanceState.getString(IMPORT_EXPORT_BUTTON_TEXT) + } + } + + public override fun onSaveInstanceState(savedInstanceState: Bundle) { + // Run the default commands. + super.onSaveInstanceState(savedInstanceState) + + // Save the visibility of the views. + savedInstanceState.putInt(ENCRYPTION_PASSWORD_TEXTINPUTLAYOUT_VISIBILITY, encryptionPasswordTextInputLayout.visibility) + savedInstanceState.putInt(OPEN_KEYCHAIN_REQUIRED_TEXTVIEW_VISIBILITY, openKeychainRequiredTextView.visibility) + savedInstanceState.putInt(FILE_LOCATION_CARD_VIEW, fileLocationCardView.visibility) + savedInstanceState.putInt(FILE_NAME_LINEARLAYOUT_VISIBILITY, fileNameLinearLayout.visibility) + savedInstanceState.putInt(OPEN_KEYCHAIN_IMPORT_INSTRUCTIONS_TEXTVIEW_VISIBILITY, openKeychainImportInstructionsTextView.visibility) + savedInstanceState.putInt(IMPORT_EXPORT_BUTTON_VISIBILITY, importExportButton.visibility) + + // Save the text. + savedInstanceState.putString(FILE_NAME_TEXT, fileNameEditText.text.toString()) + savedInstanceState.putString(IMPORT_EXPORT_BUTTON_TEXT, importExportButton.text.toString()) + } + + fun onClickRadioButton(@Suppress("UNUSED_PARAMETER") view: View) { + // Check to see if import or export was selected. + if (view.id == R.id.import_radiobutton) { // The import radio button is selected. + // Check to see if OpenPGP encryption is selected. + if (encryptionSpinner.selectedItemPosition == OPENPGP_ENCRYPTION) { // OpenPGP encryption selected. + // Show the OpenKeychain import instructions. + openKeychainImportInstructionsTextView.visibility = View.VISIBLE + + // Set the text on the import/export button to be `Decrypt`. + importExportButton.setText(R.string.decrypt) + } else { // OpenPGP encryption not selected. + // Hide the OpenKeychain import instructions. + openKeychainImportInstructionsTextView.visibility = View.GONE + + // Set the text on the import/export button to be `Import`. + importExportButton.setText(R.string.import_button) + } + + // Display the file name views. + fileNameLinearLayout.visibility = View.VISIBLE + importExportButton.visibility = View.VISIBLE + + // Clear the file name edit text. + fileNameEditText.text.clear() + + // Disable the import/export button. + importExportButton.isEnabled = false + } else { // The export radio button is selected. + // Hide the OpenKeychain import instructions. + openKeychainImportInstructionsTextView.visibility = View.GONE + + // Set the text on the import/export button to be `Export`. + importExportButton.setText(R.string.export) + + // Show the import/export button. + importExportButton.visibility = View.VISIBLE + + // Check to see if OpenPGP encryption is selected. + if (encryptionSpinner.selectedItemPosition == OPENPGP_ENCRYPTION) { // OpenPGP encryption is selected. + // Hide the file name views. + fileNameLinearLayout.visibility = View.GONE + + // Enable the export button. + importExportButton.isEnabled = true + } else { // OpenPGP encryption is not selected. + // Show the file name view. + fileNameLinearLayout.visibility = View.VISIBLE + + // Clear the file name edit text. + fileNameEditText.text.clear() + + // Disable the import/export button. + importExportButton.isEnabled = false + } + } + } + + fun browse(@Suppress("UNUSED_PARAMETER") view: View) { + // Check to see if import or export is selected. + if (importRadioButton.isChecked) { // Import is selected. + // Open the file picker. + browseForImportActivityResultLauncher.launch("*/*") + } else { // Export is selected + // Open the file picker with the export name according to the encryption type. + if (encryptionSpinner.selectedItemPosition == NO_ENCRYPTION) // No encryption is selected. + browseForExportActivityResultLauncher.launch(getString(R.string.privacy_browser_settings_pbs, BuildConfig.VERSION_NAME)) + else // Password encryption is selected. + browseForExportActivityResultLauncher.launch(getString(R.string.privacy_browser_settings_pbs_aes, BuildConfig.VERSION_NAME)) + } + } + + fun importExport(@Suppress("UNUSED_PARAMETER") view: View) { + // Instantiate the import export database helper. + val importExportDatabaseHelper = ImportExportDatabaseHelper() + + // Check to see if import or export is selected. + if (importRadioButton.isChecked) { // Import is selected. + // Initialize the import status string + var importStatus = "" + + // Get the file name string. + val fileNameString = fileNameEditText.text.toString() + + // Import according to the encryption type. + when (encryptionSpinner.selectedItemPosition) { + NO_ENCRYPTION -> { + try { + // Get an input stream for the file name. + // A file may be opened directly once the minimum API >= 29. + val inputStream = contentResolver.openInputStream(Uri.parse(fileNameString))!! + + // Import the unencrypted file. + importStatus = importExportDatabaseHelper.importUnencrypted(inputStream, this) + + // Close the input stream. + inputStream.close() + } catch (exception: FileNotFoundException) { + // Update the import status. + importStatus = exception.toString() + } + + // Restart Privacy Browser if successful. + if (importStatus == ImportExportDatabaseHelper.IMPORT_SUCCESSFUL) + restartPrivacyBrowser() + } + + PASSWORD_ENCRYPTION -> { + try { + // Get the encryption password. + val encryptionPasswordString = encryptionPasswordEditText.text.toString() + + // Get an input stream for the file name. + val inputStream = contentResolver.openInputStream(Uri.parse(fileNameString))!! + + // Initialize a salt byte array. Salt protects against rainbow table attacks. + val saltByteArray = ByteArray(32) + + // Get the salt from the beginning of the import file. + inputStream.read(saltByteArray) + + // Create an initialization vector. + val initializationVector = ByteArray(12) + + // Get the initialization vector from the import file. + inputStream.read(initializationVector) + + // Convert the encryption password to a byte array. + val encryptionPasswordByteArray = encryptionPasswordString.toByteArray(StandardCharsets.UTF_8) + + // Create an encryption password with salt byte array. + val encryptionPasswordWithSaltByteArray = ByteArray(encryptionPasswordByteArray.size + saltByteArray.size) + + // Populate the first part of the encryption password with salt byte array with the encryption password. + System.arraycopy(encryptionPasswordByteArray, 0, encryptionPasswordWithSaltByteArray, 0, encryptionPasswordByteArray.size) + + // Populate the second part of the encryption password with salt byte array with the salt. + System.arraycopy(saltByteArray, 0, encryptionPasswordWithSaltByteArray, encryptionPasswordByteArray.size, saltByteArray.size) + + // Get a SHA-512 message digest. + val messageDigest = MessageDigest.getInstance("SHA-512") + + // Hash the salted encryption password. Otherwise, any characters after the 32nd character in the password are ignored. + val hashedEncryptionPasswordWithSaltByteArray = messageDigest.digest(encryptionPasswordWithSaltByteArray) + + // Truncate the encryption password byte array to 256 bits (32 bytes). + val truncatedHashedEncryptionPasswordWithSaltByteArray = Arrays.copyOf(hashedEncryptionPasswordWithSaltByteArray, 32) + + // Create an AES secret key from the encryption password byte array. + val secretKey = SecretKeySpec(truncatedHashedEncryptionPasswordWithSaltByteArray, "AES") + + // Get a Advanced Encryption Standard, Galois/Counter Mode, No Padding cipher instance. Galois/Counter mode protects against modification of the ciphertext. It doesn't use padding. + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + + // Set the GCM tag length to be 128 bits (the maximum) and apply the initialization vector. + val gcmParameterSpec = GCMParameterSpec(128, initializationVector) + + // Initialize the cipher. + cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmParameterSpec) + + // Create a cipher input stream. + val cipherInputStream = CipherInputStream(inputStream, cipher) + + // Initialize variables to store data as it is moved from the cipher input stream to the unencrypted import file output stream. Move 128 bits (16 bytes) at a time. + var numberOfBytesRead: Int + val decryptedBytes = ByteArray(16) + + // Create a private temporary unencrypted import file. + val temporaryUnencryptedImportFile = File.createTempFile("temporary_unencrypted_import_file", null, applicationContext.cacheDir) + + // Create an temporary unencrypted import file output stream. + val temporaryUnencryptedImportFileOutputStream = FileOutputStream(temporaryUnencryptedImportFile) + + // Read up to 128 bits (16 bytes) of data from the cipher input stream. `-1` will be returned when the end of the file is reached. + while (cipherInputStream.read(decryptedBytes).also { numberOfBytesRead = it } != -1) { + // Write the data to the temporary unencrypted import file output stream. + temporaryUnencryptedImportFileOutputStream.write(decryptedBytes, 0, numberOfBytesRead) + } + + // Flush the temporary unencrypted import file output stream. + temporaryUnencryptedImportFileOutputStream.flush() + + // Close the streams. + temporaryUnencryptedImportFileOutputStream.close() + cipherInputStream.close() + inputStream.close() + + // Wipe the encryption data from memory. + Arrays.fill(saltByteArray, 0.toByte()) + Arrays.fill(initializationVector, 0.toByte()) + Arrays.fill(encryptionPasswordByteArray, 0.toByte()) + Arrays.fill(encryptionPasswordWithSaltByteArray, 0.toByte()) + Arrays.fill(hashedEncryptionPasswordWithSaltByteArray, 0.toByte()) + Arrays.fill(truncatedHashedEncryptionPasswordWithSaltByteArray, 0.toByte()) + Arrays.fill(decryptedBytes, 0.toByte()) + + // Create a temporary unencrypted import file input stream. + val temporaryUnencryptedImportFileInputStream = FileInputStream(temporaryUnencryptedImportFile) + + // Import the temporary unencrypted import file. + importStatus = importExportDatabaseHelper.importUnencrypted(temporaryUnencryptedImportFileInputStream, this) + + // Close the temporary unencrypted import file input stream. + temporaryUnencryptedImportFileInputStream.close() + + // Delete the temporary unencrypted import file. + temporaryUnencryptedImportFile.delete() + + // Restart Privacy Browser if successful. + if (importStatus == ImportExportDatabaseHelper.IMPORT_SUCCESSFUL) + restartPrivacyBrowser() + } catch (exception: Exception) { + // Update the import status. + importStatus = exception.toString() + } + } + + OPENPGP_ENCRYPTION -> { + try { + // Get a handle for the file provider directory. + fileProviderDirectory = File(applicationContext.cacheDir.toString() + "/" + getString(R.string.file_provider_directory)) + + // Create the file provider directory. Any errors will be handled by the catch statement below. + fileProviderDirectory.mkdir() + + // Set the temporary PGP encrypted import file. + temporaryPgpEncryptedImportFile = File.createTempFile("temporary_pgp_encrypted_import_file", null, fileProviderDirectory) + + // Create a temporary PGP encrypted import file output stream. + val temporaryPgpEncryptedImportFileOutputStream = FileOutputStream(temporaryPgpEncryptedImportFile) + + // Get an input stream for the file name. + val inputStream = contentResolver.openInputStream(Uri.parse(fileNameString))!! + + // Create a transfer byte array. + val transferByteArray = ByteArray(1024) + + // Create an integer to track the number of bytes read. + var bytesRead: Int + + // Copy the input stream to the temporary PGP encrypted import file. + while (inputStream.read(transferByteArray).also { bytesRead = it } > 0) + temporaryPgpEncryptedImportFileOutputStream.write(transferByteArray, 0, bytesRead) + + // Flush the temporary PGP encrypted import file output stream. + temporaryPgpEncryptedImportFileOutputStream.flush() + + // Close the streams. + inputStream.close() + temporaryPgpEncryptedImportFileOutputStream.close() + + // Create a decryption intent for OpenKeychain. + val openKeychainDecryptIntent = Intent("org.sufficientlysecure.keychain.action.DECRYPT_DATA") + + // Include the URI to be decrypted. + openKeychainDecryptIntent.data = FileProvider.getUriForFile(this, getString(R.string.file_provider), temporaryPgpEncryptedImportFile) + + // Allow OpenKeychain to read the file URI. + openKeychainDecryptIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + + // Send the intent to the OpenKeychain package. + openKeychainDecryptIntent.setPackage("org.sufficientlysecure.keychain") + + // Make it so. + openKeychainDecryptActivityResultLauncher.launch(openKeychainDecryptIntent) + + // Update the import status. + importStatus = ImportExportDatabaseHelper.IMPORT_SUCCESSFUL + } catch (exception: Exception) { + // Update the import status. + importStatus = exception.toString() + } + } + } + + // Respond to the import status. + if (importStatus != ImportExportDatabaseHelper.IMPORT_SUCCESSFUL) { + // Display a snack bar with the import error. + Snackbar.make(fileNameEditText, getString(R.string.import_failed, importStatus), Snackbar.LENGTH_INDEFINITE).show() + } + } else { // Export is selected. + // Export according to the encryption type. + when (encryptionSpinner.selectedItemPosition) { + NO_ENCRYPTION -> { + // Get the file name string. + val noEncryptionFileNameString = fileNameEditText.text.toString() + + try { + // Get the export file output stream. + // A file may be opened directly once the minimum API >= 29. + val exportFileOutputStream = contentResolver.openOutputStream(Uri.parse(noEncryptionFileNameString))!! + + // Export the unencrypted file. + val noEncryptionExportStatus = importExportDatabaseHelper.exportUnencrypted(exportFileOutputStream, this) + + // Close the output stream. + exportFileOutputStream.close() + + // Display an export disposition snackbar. + if (noEncryptionExportStatus == ImportExportDatabaseHelper.EXPORT_SUCCESSFUL) { + Snackbar.make(fileNameEditText, getString(R.string.export_successful), Snackbar.LENGTH_SHORT).show() + } else { + Snackbar.make(fileNameEditText, getString(R.string.export_failed, noEncryptionExportStatus), Snackbar.LENGTH_INDEFINITE).show() + } + } catch (fileNotFoundException: FileNotFoundException) { + // Display a snackbar with the exception. + Snackbar.make(fileNameEditText, getString(R.string.export_failed, fileNotFoundException), Snackbar.LENGTH_INDEFINITE).show() + } + } + + PASSWORD_ENCRYPTION -> { + try { + // Create a temporary unencrypted export file. + val temporaryUnencryptedExportFile = File.createTempFile("temporary_unencrypted_export_file", null, applicationContext.cacheDir) + + // Create a temporary unencrypted export output stream. + val temporaryUnencryptedExportOutputStream = FileOutputStream(temporaryUnencryptedExportFile) + + // Populate the temporary unencrypted export. + val passwordEncryptionExportStatus = importExportDatabaseHelper.exportUnencrypted(temporaryUnencryptedExportOutputStream, this) + + // Close the temporary unencrypted export output stream. + temporaryUnencryptedExportOutputStream.close() + + // Create an unencrypted export file input stream. + val unencryptedExportFileInputStream = FileInputStream(temporaryUnencryptedExportFile) + + // Get the encryption password. + val encryptionPasswordString = encryptionPasswordEditText.text.toString() + + // Initialize a secure random number generator. + val secureRandom = SecureRandom() + + // Initialize a salt byte array. Salt protects against rainbow table attacks. + val saltByteArray = ByteArray(32) + + // Get a 256 bit (32 byte) random salt. + secureRandom.nextBytes(saltByteArray) + + // Convert the encryption password to a byte array. + val encryptionPasswordByteArray = encryptionPasswordString.toByteArray(StandardCharsets.UTF_8) + + // Create an encryption password with salt byte array. + val encryptionPasswordWithSaltByteArray = ByteArray(encryptionPasswordByteArray.size + saltByteArray.size) + + // Populate the first part of the encryption password with salt byte array with the encryption password. + System.arraycopy(encryptionPasswordByteArray, 0, encryptionPasswordWithSaltByteArray, 0, encryptionPasswordByteArray.size) + + // Populate the second part of the encryption password with salt byte array with the salt. + System.arraycopy(saltByteArray, 0, encryptionPasswordWithSaltByteArray, encryptionPasswordByteArray.size, saltByteArray.size) + + // Get a SHA-512 message digest. + val messageDigest = MessageDigest.getInstance("SHA-512") + + // Hash the salted encryption password. Otherwise, any characters after the 32nd character in the password are ignored. + val hashedEncryptionPasswordWithSaltByteArray = messageDigest.digest(encryptionPasswordWithSaltByteArray) + + // Truncate the encryption password byte array to 256 bits (32 bytes). + val truncatedHashedEncryptionPasswordWithSaltByteArray = Arrays.copyOf(hashedEncryptionPasswordWithSaltByteArray, 32) + + // Create an AES secret key from the encryption password byte array. + val secretKey = SecretKeySpec(truncatedHashedEncryptionPasswordWithSaltByteArray, "AES") + + // Create an initialization vector. According to NIST, a 12 byte initialization vector is more secure than a 16 byte one. + val initializationVector = ByteArray(12) + + // Populate the initialization vector with random data. + secureRandom.nextBytes(initializationVector) + + // Get a Advanced Encryption Standard, Galois/Counter Mode, No Padding cipher instance. Galois/Counter mode protects against modification of the ciphertext. It doesn't use padding. + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + + // Set the GCM tag length to be 128 bits (the maximum) and apply the initialization vector. + val gcmParameterSpec = GCMParameterSpec(128, initializationVector) + + // Initialize the cipher. + cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmParameterSpec) + + // Get the file name string. + val passwordEncryptionFileNameString = fileNameEditText.text.toString() + + // Get the export file output stream. + val exportFileOutputStream = contentResolver.openOutputStream(Uri.parse(passwordEncryptionFileNameString))!! + + // Add the salt and the initialization vector to the export file output stream. + exportFileOutputStream.write(saltByteArray) + exportFileOutputStream.write(initializationVector) + + // Create a cipher output stream. + val cipherOutputStream = CipherOutputStream(exportFileOutputStream, cipher) + + // Initialize variables to store data as it is moved from the unencrypted export file input stream to the cipher output stream. Move 128 bits (16 bytes) at a time. + var numberOfBytesRead: Int + val encryptedBytes = ByteArray(16) + + // Read up to 128 bits (16 bytes) of data from the unencrypted export file stream. `-1` will be returned when the end of the file is reached. + while (unencryptedExportFileInputStream.read(encryptedBytes).also { numberOfBytesRead = it } != -1) + // Write the data to the cipher output stream. + cipherOutputStream.write(encryptedBytes, 0, numberOfBytesRead) + + // Close the streams. + cipherOutputStream.flush() + cipherOutputStream.close() + exportFileOutputStream.close() + unencryptedExportFileInputStream.close() + + // Wipe the encryption data from memory. + Arrays.fill(saltByteArray, 0.toByte()) + Arrays.fill(encryptionPasswordByteArray, 0.toByte()) + Arrays.fill(encryptionPasswordWithSaltByteArray, 0.toByte()) + Arrays.fill(hashedEncryptionPasswordWithSaltByteArray, 0.toByte()) + Arrays.fill(truncatedHashedEncryptionPasswordWithSaltByteArray, 0.toByte()) + Arrays.fill(initializationVector, 0.toByte()) + Arrays.fill(encryptedBytes, 0.toByte()) + + // Delete the temporary unencrypted export file. + temporaryUnencryptedExportFile.delete() + + // Display an export disposition snackbar. + if (passwordEncryptionExportStatus == ImportExportDatabaseHelper.EXPORT_SUCCESSFUL) + Snackbar.make(fileNameEditText, getString(R.string.export_successful), Snackbar.LENGTH_SHORT).show() + else + Snackbar.make(fileNameEditText, getString(R.string.export_failed, passwordEncryptionExportStatus), Snackbar.LENGTH_INDEFINITE).show() + } catch (exception: Exception) { + // Display a snackbar with the exception. + Snackbar.make(fileNameEditText, getString(R.string.export_failed, exception), Snackbar.LENGTH_INDEFINITE).show() + } + } + + OPENPGP_ENCRYPTION -> { + try { + // Get a handle for the file provider directory. + fileProviderDirectory = File(applicationContext.cacheDir.toString() + "/" + getString(R.string.file_provider_directory)) + + // Create the file provider directory. Any errors will be handled by the catch statement below. + fileProviderDirectory.mkdir() + + // Set the temporary pre-encrypted export file. + temporaryPreEncryptedExportFile = File(fileProviderDirectory.toString() + "/" + getString(R.string.privacy_browser_settings_pbs, BuildConfig.VERSION_NAME)) + + // Delete the temporary pre-encrypted export file if it already exists. + if (temporaryPreEncryptedExportFile.exists()) + temporaryPreEncryptedExportFile.delete() + + // Create a temporary pre-encrypted export output stream. + val temporaryPreEncryptedExportOutputStream = FileOutputStream(temporaryPreEncryptedExportFile) + + // Populate the temporary pre-encrypted export file. + val openpgpEncryptionExportStatus = importExportDatabaseHelper.exportUnencrypted(temporaryPreEncryptedExportOutputStream, this) + + // Flush the temporary pre-encryption export output stream. + temporaryPreEncryptedExportOutputStream.flush() + + // Close the temporary pre-encryption export output stream. + temporaryPreEncryptedExportOutputStream.close() + + // Display an export error snackbar if the temporary pre-encrypted export failed. + if (openpgpEncryptionExportStatus != ImportExportDatabaseHelper.EXPORT_SUCCESSFUL) + Snackbar.make(fileNameEditText, getString(R.string.export_failed, openpgpEncryptionExportStatus), Snackbar.LENGTH_INDEFINITE).show() + + // Create an encryption intent for OpenKeychain. + val openKeychainEncryptIntent = Intent("org.sufficientlysecure.keychain.action.ENCRYPT_DATA") + + // Include the temporary unencrypted export file URI. + openKeychainEncryptIntent.data = FileProvider.getUriForFile(this, getString(R.string.file_provider), temporaryPreEncryptedExportFile) + + // Allow OpenKeychain to read the file URI. + openKeychainEncryptIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + + // Send the intent to the OpenKeychain package. + openKeychainEncryptIntent.setPackage("org.sufficientlysecure.keychain") + + // Make it so. + openKeychainEncryptActivityResultLauncher.launch(openKeychainEncryptIntent) + } catch (exception: Exception) { + // Display a snackbar with the exception. + Snackbar.make(fileNameEditText, getString(R.string.export_failed, exception), Snackbar.LENGTH_INDEFINITE).show() + } + } + } + } + } + + private fun restartPrivacyBrowser() { + // Create an intent to restart Privacy Browser. + val restartIntent = parentActivityIntent!! + + // `Intent.FLAG_ACTIVITY_CLEAR_TASK` removes all activities from the stack. It requires `Intent.FLAG_ACTIVITY_NEW_TASK`. + restartIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + + // Create a restart handler. + val restartHandler = Handler(mainLooper) + + // Create a restart runnable. + val restartRunnable = Runnable { + + // Restart Privacy Browser. + startActivity(restartIntent) + + // Kill this instance of Privacy Browser. Otherwise, the app exhibits sporadic behavior after the restart. + exitProcess(0) + } + + // Restart Privacy Browser after 150 milliseconds to allow enough time for the preferences to be saved. + restartHandler.postDelayed(restartRunnable, 150) + } +} \ No newline at end of file