Use storage access framework (#469)

* use storage access framework

* UserPreference: Add back warning about using SDCard root directory

* UserPreference: Fix IDE warnings

Signed-off-by: Harsh Shandilya <msfjarvis@gmail.com>
This commit is contained in:
Hussein Al Abry 2019-04-25 04:41:32 +01:00 committed by Harsh Shandilya
parent f272e4dde2
commit e54010906f
6 changed files with 204 additions and 245 deletions

View file

@ -74,7 +74,6 @@ dependencies {
implementation("com.google.android.material:material:1.0.0")
implementation("androidx.annotation:annotation:1.0.2")
implementation("org.sufficientlysecure:openpgp-api:12.0")
implementation("com.nononsenseapps:filepicker:2.4.2")
implementation("org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r") {
exclude(group = "org.apache.httpcomponents", module = "httpclient")
}

View file

@ -68,15 +68,6 @@
<meta-data android:name="android.support.PARENT_ACTIVITY" android:value="com.zeapo.pwdstore.PasswordStore" />
</activity>
<activity
android:name="com.nononsenseapps.filepicker.FilePickerActivity"
android:label="@string/app_name"
android:theme="@style/FilePickerTheme">
<intent-filter>
<action android:name="android.intent.action.GET_CONTENT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity android:name=".crypto.PgpActivity"
android:parentActivityName=".PasswordStore"/>
<activity android:name=".SelectFolderActivity" />

View file

@ -1,46 +1,40 @@
package com.zeapo.pwdstore
import android.Manifest
import android.accessibilityservice.AccessibilityServiceInfo
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.preference.CheckBoxPreference
import android.preference.Preference
import android.preference.PreferenceFragment
import android.preference.PreferenceManager
import android.provider.DocumentsContract
import android.provider.Settings
import android.util.Log
import android.view.MenuItem
import android.view.accessibility.AccessibilityManager
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.google.android.material.snackbar.Snackbar
import com.nononsenseapps.filepicker.FilePickerActivity
import androidx.documentfile.provider.DocumentFile
import com.zeapo.pwdstore.autofill.AutofillPreferenceActivity
import com.zeapo.pwdstore.crypto.PgpActivity
import com.zeapo.pwdstore.git.GitActivity
import com.zeapo.pwdstore.utils.PasswordRepository
import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import org.openintents.openpgp.util.OpenPgpUtils
import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.ArrayList
import java.util.Date
import java.util.Locale
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.*
class UserPreference : AppCompatActivity() {
private lateinit var prefsFragment: PrefsFragment
class PrefsFragment : PreferenceFragment() {
@ -61,7 +55,7 @@ class UserPreference : AppCompatActivity() {
}
findPreference("ssh_key").onPreferenceClickListener = Preference.OnPreferenceClickListener {
callingActivity.getSshKeyWithPermissions(sharedPreferences.getBoolean("use_android_file_picker", false))
callingActivity.getSshKey()
true
}
@ -115,7 +109,7 @@ class UserPreference : AppCompatActivity() {
FileUtils.cleanDirectory(PasswordRepository.getRepositoryDirectory(callingActivity.applicationContext))
PasswordRepository.closeRepository()
} catch (e: Exception) {
//TODO Handle the diffent cases of exceptions
//TODO Handle the different cases of exceptions
}
sharedPreferences.edit().putBoolean("repository_initialized", false).apply()
@ -164,10 +158,13 @@ class UserPreference : AppCompatActivity() {
true
}
findPreference("export_passwords").onPreferenceClickListener = Preference.OnPreferenceClickListener {
callingActivity.exportPasswordsWithPermissions()
findPreference("export_passwords").apply {
isEnabled = sharedPreferences.getBoolean("repository_initialized", false)
onPreferenceClickListener = Preference.OnPreferenceClickListener {
callingActivity.exportPasswords()
true
}
}
findPreference("general_show_time").onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _: Preference?, newValue: Any? ->
try {
@ -217,10 +214,9 @@ class UserPreference : AppCompatActivity() {
}
public override fun onCreate(savedInstanceState: Bundle?) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this.applicationContext)
super.onCreate(savedInstanceState)
when (intent?.getStringExtra("operation")) {
"get_ssh_key" -> getSshKeyWithPermissions(sharedPreferences.getBoolean("use_android_file_picker", false))
"get_ssh_key" -> getSshKey()
"make_ssh_key" -> makeSshKey(false)
"git_external" -> selectExternalGitRepository()
}
@ -232,24 +228,12 @@ class UserPreference : AppCompatActivity() {
}
fun selectExternalGitRepository() {
val activity = this
AlertDialog.Builder(this)
.setTitle(this.resources.getString(R.string.external_repository_dialog_title))
.setMessage(this.resources.getString(R.string.external_repository_dialog_text))
.setPositiveButton(R.string.dialog_ok) { _, _ ->
// This always works
val i = Intent(activity.applicationContext, FilePickerActivity::class.java)
// This works if you defined the intent filter
// Intent i = new Intent(Intent.ACTION_GET_CONTENT);
// Set these depending on your use case. These are the defaults.
i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR)
i.putExtra(FilePickerActivity.EXTRA_START_PATH, Environment.getExternalStorageDirectory().path)
startActivityForResult(i, SELECT_GIT_DIRECTORY)
val i = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
startActivityForResult(Intent.createChooser(i, "Choose Directory"), SELECT_GIT_DIRECTORY)
}.setNegativeButton(R.string.dialog_cancel, null).show()
}
@ -257,103 +241,33 @@ class UserPreference : AppCompatActivity() {
// Handle action bar item clicks here. The action bar will
// automatically handle clicks on the Home/Up button, so long
// as you specify a parent activity in AndroidManifest.xml.
val id = item.itemId
when (id) {
return when (item.itemId) {
android.R.id.home -> {
setResult(Activity.RESULT_OK)
finish()
return true
true
}
else -> super.onOptionsItemSelected(item)
}
return super.onOptionsItemSelected(item)
}
/**
* Opens a file explorer to import the private key
*/
fun getSshKeyWithPermissions(useDefaultPicker: Boolean) = runWithPermissions(
requestedPermission = Manifest.permission.READ_EXTERNAL_STORAGE,
requestCode = REQUEST_EXTERNAL_STORAGE_SSH_KEY,
reason = "We need access to the sd-card to import the ssh-key"
) {
getSshKey(useDefaultPicker)
}
/**
* Opens a file explorer to import the private key
*/
private fun getSshKey(useDefaultPicker: Boolean) {
val intent = if (useDefaultPicker) {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.setType("*/*")
} else {
// This always works
val intent = Intent(applicationContext, FilePickerActivity::class.java)
// Set these depending on your use case. These are the defaults.
intent.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
intent.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, false)
intent.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_FILE)
intent.putExtra(FilePickerActivity.EXTRA_START_PATH, Environment.getExternalStorageDirectory().path)
private fun getSshKey() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}
startActivityForResult(intent, IMPORT_SSH_KEY)
}
/**
* Run a function after checking that the permissions have been requested
*
* @param requestedPermission The permission to request
* @param requestCode The code passed to onRequestPermissionsResult
* @param reason The text to be shown to the user to explain why we're requesting this permission
* @param body The function to run
*/
private fun runWithPermissions(requestedPermission: String, requestCode: Int, reason: String, body: () -> Unit) {
if (ContextCompat.checkSelfPermission(this, requestedPermission) != PackageManager.PERMISSION_GRANTED) {
if (ActivityCompat.shouldShowRequestPermissionRationale(this, requestedPermission)) {
val snack = Snackbar.make(prefsFragment.view, reason, Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.dialog_ok) {
ActivityCompat.requestPermissions(this, arrayOf(requestedPermission), requestCode)
}
snack.show()
val view = snack.view
val tv = view.findViewById<TextView>(com.google.android.material.R.id.snackbar_text)
tv.setTextColor(Color.WHITE)
tv.maxLines = 10
} else {
// No explanation needed, we can request the permission.
ActivityCompat.requestPermissions(this, arrayOf(requestedPermission), requestCode)
}
} else {
body()
}
}
/**
* Exports the passwords after requesting permissions
*/
fun exportPasswordsWithPermissions() = runWithPermissions(
requestedPermission = Manifest.permission.WRITE_EXTERNAL_STORAGE,
requestCode = REQUEST_EXTERNAL_STORAGE_SSH_KEY,
reason = "We need access to the sd-card to export the passwords"
) {
exportPasswords()
}
/**
* Exports the passwords
*/
private fun exportPasswords() {
val i = Intent(applicationContext, FilePickerActivity::class.java)
// Set these depending on your use case. These are the defaults.
i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR)
i.putExtra(FilePickerActivity.EXTRA_START_PATH, Environment.getExternalStorageDirectory().path)
startActivityForResult(i, EXPORT_PASSWORDS)
val i = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
startActivityForResult(Intent.createChooser(i, "Choose Directory"), EXPORT_PASSWORDS)
}
/**
@ -370,11 +284,25 @@ class UserPreference : AppCompatActivity() {
@Throws(IOException::class)
private fun copySshKey(uri: Uri) {
val sshKey = this.contentResolver.openInputStream(uri)
if (sshKey != null) {
val privateKey = IOUtils.toByteArray(sshKey)
FileUtils.writeByteArrayToFile(File(filesDir.toString() + "/.ssh_key"), privateKey)
sshKey.close()
// TODO: Check if valid SSH Key before import
val sshKeyInputStream = contentResolver.openInputStream(uri)
if (sshKeyInputStream != null) {
val internalKeyFile = File("""$filesDir/.ssh_key""")
if (internalKeyFile.exists()) {
internalKeyFile.delete()
internalKeyFile.createNewFile()
}
val sshKeyOutputSteam = internalKeyFile.outputStream()
sshKeyInputStream.copyTo(sshKeyOutputSteam, 1024)
sshKeyInputStream.close()
sshKeyOutputSteam.close()
} else {
Toast.makeText(this, getString(R.string.ssh_key_does_not_exist), Toast.LENGTH_LONG).show()
}
@ -408,6 +336,7 @@ class UserPreference : AppCompatActivity() {
val uri: Uri = data.data ?: throw IOException("Unable to open file")
copySshKey(uri)
Toast.makeText(
this,
this.resources.getString(R.string.ssh_key_success_dialog_title),
@ -417,9 +346,8 @@ class UserPreference : AppCompatActivity() {
prefs.edit().putBoolean("use_generated_key", false).apply()
//delete the public key from generation
val file = File(filesDir.toString() + "/.ssh_key.pub")
file.delete()
// Delete the public key from generation
File("""$filesDir/.ssh_key.pub""").delete()
setResult(Activity.RESULT_OK)
finish()
@ -427,9 +355,8 @@ class UserPreference : AppCompatActivity() {
AlertDialog.Builder(this)
.setTitle(this.resources.getString(R.string.ssh_key_error_dialog_title))
.setMessage(this.resources.getString(R.string.ssh_key_error_dialog_text) + e.message)
.setPositiveButton(this.resources.getString(R.string.dialog_ok)) { _, _ ->
// pass
}.show()
.setPositiveButton(this.resources.getString(R.string.dialog_ok), null)
.show()
}
}
EDIT_GIT_INFO -> {
@ -438,62 +365,114 @@ class UserPreference : AppCompatActivity() {
SELECT_GIT_DIRECTORY -> {
val uri = data.data
if (uri?.path == Environment.getExternalStorageDirectory().path) {
// the user wants to use the root of the sdcard as a store...
Log.d(TAG, "Selected repository URI is $uri")
// TODO: This is fragile. Workaround until PasswordItem is backed by DocumentFile
val docId = DocumentsContract.getTreeDocumentId(uri)
val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
val repoPath = "${Environment.getExternalStorageDirectory()}/${split[1]}"
Log.d(TAG, "Selected repository path is $repoPath")
if (Environment.getExternalStorageDirectory().path == repoPath) {
AlertDialog.Builder(this)
.setTitle("SD-Card root selected")
.setMessage(
"You have selected the root of your sdcard for the store. " +
"This is extremely dangerous and you will lose your data " +
"as its content will, eventually, be deleted"
)
.setTitle(getString(R.string.sdcard_root_warning_title))
.setMessage(getString(R.string.sdcard_root_warning_message))
.setPositiveButton("Remove everything") { _, _ ->
PreferenceManager.getDefaultSharedPreferences(applicationContext)
.edit()
.putString("git_external_repo", uri?.path)
.apply()
}.setNegativeButton(R.string.dialog_cancel, null).show()
} else {
}
PreferenceManager.getDefaultSharedPreferences(applicationContext)
.edit()
.putString("git_external_repo", uri?.path)
.putString("git_external_repo", repoPath)
.apply()
}
}
EXPORT_PASSWORDS -> {
val uri = data.data
val repositoryDirectory = PasswordRepository.getRepositoryDirectory(applicationContext)
val fmtOut = SimpleDateFormat("yyyy_MM_dd_HH_mm_ss", Locale.US)
val date = Date()
val passwordNow = "/password_store_" + fmtOut.format(date)
val targetDirectory = File(uri?.path + passwordNow)
if (repositoryDirectory != null) {
try {
FileUtils.copyDirectory(repositoryDirectory, targetDirectory, true)
} catch (e: IOException) {
Log.d("PWD_EXPORT", "Exception happened : " + e.message)
if (uri != null) {
val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri)
if (targetDirectory != null) {
exportPasswords(targetDirectory)
}
}
}
else -> {
}
}
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this.applicationContext)
when (requestCode) {
REQUEST_EXTERNAL_STORAGE_SSH_KEY -> {
// If request is cancelled, the result arrays are empty.
if (grantResults.isNotEmpty() && PackageManager.PERMISSION_GRANTED in grantResults) {
getSshKey(sharedPreferences.getBoolean("use_android_file_picker", false))
/**
* Exports passwords to the given directory.
*
* Recursively copies the existing password store to an external directory.
*
* @param targetDirectory directory to copy password directory to.
*/
private fun exportPasswords(targetDirectory: DocumentFile) {
val repositoryDirectory = PasswordRepository.getRepositoryDirectory(applicationContext)
val sourcePassDir = DocumentFile.fromFile(repositoryDirectory)
Log.d(TAG, "Copying ${repositoryDirectory.path} to $targetDirectory")
val dateString = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
LocalDateTime
.now()
.format(DateTimeFormatter.ISO_DATE_TIME)
} else {
String.format("%tFT%<tRZ", Calendar.getInstance(TimeZone.getTimeZone("Z")))
}
val passDir = targetDirectory.createDirectory("password_store_$dateString")
if (passDir != null) {
copyDirToDir(sourcePassDir, passDir)
}
}
/**
* Copies a password file to a given directory.
*
* Note: this does not preserve last modified time.
*
* @param passwordFile password file to copy.
* @param targetDirectory target directory to copy password.
*/
private fun copyFileToDir(passwordFile: DocumentFile, targetDirectory: DocumentFile) {
val sourceInputStream = contentResolver.openInputStream(passwordFile.uri)
val name = passwordFile.name
val targetPasswordFile = targetDirectory.createFile("application/octet-stream", name!!)
if (targetPasswordFile?.exists() == true) {
val destOutputStream = contentResolver.openOutputStream(targetPasswordFile.uri)
if (destOutputStream != null && sourceInputStream != null) {
sourceInputStream.copyTo(destOutputStream, 1024)
sourceInputStream.close()
destOutputStream.close()
}
}
REQUEST_EXTERNAL_STORAGE_EXPORT_PWD -> {
if (grantResults.isNotEmpty() && PackageManager.PERMISSION_GRANTED in grantResults) {
exportPasswords()
}
/**
* Recursively copies a directory to a destination.
*
* @param sourceDirectory directory to copy from.
* @param sourceDirectory directory to copy to.
*/
private fun copyDirToDir(sourceDirectory: DocumentFile, targetDirectory: DocumentFile) {
sourceDirectory.listFiles().forEach { file ->
if (file.isDirectory) {
// Create new directory and recurse
val newDir = targetDirectory.createDirectory(file.name!!)
copyDirToDir(file, newDir!!)
} else {
copyFileToDir(file, targetDirectory)
}
}
}
@ -505,7 +484,6 @@ class UserPreference : AppCompatActivity() {
private const val SELECT_GIT_DIRECTORY = 4
private const val EXPORT_PASSWORDS = 5
private const val EDIT_GIT_CONFIG = 6
private const val REQUEST_EXTERNAL_STORAGE_SSH_KEY = 50
private const val REQUEST_EXTERNAL_STORAGE_EXPORT_PWD = 51
private const val TAG = "UserPreference"
}
}

View file

@ -256,4 +256,6 @@
<string name="no_ssh_api_provider">No SSH API provider found. Is OpenKeychain installed?</string>
<string name="ssh_api_pending_intent_failed">SSH API pending intent failed</string>
<string name="ssh_api_unknown_error">Unknown SSH API Error</string>
<string name="sdcard_root_warning_title">SD-Card root selected</string>
<string name="sdcard_root_warning_message">You have selected the root of your sdcard for the store. This is extremely dangerous and you will lose your data as its content will, eventually, be deleted</string>
</resources>

View file

@ -14,17 +14,6 @@
<item name="background">@color/blue_grey_700</item>
</style>
<!-- You can also inherit from NNF_BaseTheme.Light -->
<style name="FilePickerTheme" parent="NNF_BaseTheme">
<!-- Set these to match your theme -->
<item name="colorPrimary">@color/blue_grey_500</item>
<item name="colorPrimaryDark">@color/blue_grey_700</item>
<item name="colorAccent">@color/teal_A700</item>
<!-- Need to set this also to style create folder dialog -->
<item name="alertDialogTheme">@style/FilePickerAlertDialogTheme</item>
</style>
<style name="FilePickerAlertDialogTheme" parent="Theme.AppCompat.Dialog.Alert">
<item name="colorPrimary">@color/blue_grey_500</item>
<item name="colorPrimaryDark">@color/blue_grey_700</item>

View file

@ -28,7 +28,7 @@ tasks.named<DependencyUpdatesTask>("dependencyUpdates") {
resolutionStrategy {
componentSelection {
all {
val blacklistedGroups = listOf("com.nononsenseapps", "commons-io", "org.eclipse.jgit")
val blacklistedGroups = listOf("commons-io", "org.eclipse.jgit")
val rejected = listOf("alpha", "beta", "rc", "cr", "m", "preview")
.map { qualifier -> Regex("(?i).*[.-]$qualifier[.\\d-]*") }
.any { it.matches(candidate.version) && blacklistedGroups.contains(candidate.group) }