Move password export to the IO dispatcher (#918)

* Move password export to the IO dispatcher

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

* Simplify snackbars and disable exit operations during export

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

* Move export password logic to service

Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>

* Reformat

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

* Use explicit null check

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

* Remove unneeded hack

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

* Fixup strings

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

* Don't use coroutines in a service

Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>

* Update notification icon

Signed-off-by: Aditya Wasan <adityawasan55@gmail.com>

* Rollback unwanted formatting

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

Co-authored-by: Aditya Wasan <adityawasan55@gmail.com>
This commit is contained in:
Harsh Shandilya 2020-07-09 14:00:24 +05:30 committed by GitHub
parent 0ead6b2a4d
commit fc00de61dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 185 additions and 77 deletions

View file

@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file.
- Top-level password names had inconsistent top margin making them look askew
- Autofill can now be made more reliable in Chrome by enabling an accessibility service that works around known Chrome limitations
- Password Store no longer ignores the selected OpenKeychain key
- Password export now happens in a separate process, preventing possible freezes
### Added

View file

@ -115,6 +115,9 @@
<service
android:name=".ClipboardService"
android:process=":clipboard_service_process" />
<service
android:name=".PasswordExportService"
android:process=":password_export_service_process" />
<service
android:name=".autofill.oreo.OreoAutofillService"
android:permission="android.permission.BIND_AUTOFILL_SERVICE">

View file

@ -0,0 +1,152 @@
package com.zeapo.pwdstore
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
import androidx.documentfile.provider.DocumentFile
import com.github.ajalt.timberkt.d
import com.zeapo.pwdstore.utils.PasswordRepository
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Calendar
import java.util.TimeZone
class PasswordExportService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null) {
when (intent.action) {
ACTION_EXPORT_PASSWORD -> {
val uri = intent.getParcelableExtra<Uri>("uri")
if (uri != null) {
val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri)
if (targetDirectory != null) {
createNotification()
exportPasswords(targetDirectory)
stopSelf()
return START_NOT_STICKY
}
}
}
}
}
return super.onStartCommand(intent, flags, startId)
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
/**
* 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 = requireNotNull(PasswordRepository.getRepositoryDirectory(applicationContext))
val sourcePassDir = DocumentFile.fromFile(repositoryDirectory)
d { "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()
}
}
}
/**
* Recursively copies a directory to a destination.
*
* @param sourceDirectory directory to copy from.
* @param targetDirectory 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)
}
}
}
private fun createNotification() {
createNotificationChannel()
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.exporting_passwords))
.setSmallIcon(R.drawable.ic_round_import_export)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
startForeground(2, notification)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val serviceChannel = NotificationChannel(
CHANNEL_ID,
getString(R.string.app_name),
NotificationManager.IMPORTANCE_LOW
)
val manager = getSystemService<NotificationManager>()
if (manager != null) {
manager.createNotificationChannel(serviceChannel)
} else {
d { "Failed to create notification channel" }
}
}
}
companion object {
const val ACTION_EXPORT_PASSWORD = "ACTION_EXPORT_PASSWORD"
private const val CHANNEL_ID = "NotificationService"
}
}

View file

@ -59,11 +59,7 @@ import com.zeapo.pwdstore.utils.autofillManager
import com.zeapo.pwdstore.utils.getEncryptedPrefs
import java.io.File
import java.io.IOException
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Calendar
import java.util.HashSet
import java.util.TimeZone
import me.msfjarvis.openpgpktx.util.OpenPgpUtils
typealias ClickListener = Preference.OnPreferenceClickListener
@ -643,6 +639,13 @@ class UserPreference : AppCompatActivity() {
* Exports the passwords
*/
private fun exportPasswords() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
}
registerForActivityResult(StartActivityForResult()) { result ->
if (!validateResult(result)) return@registerForActivityResult
val uri = result.data?.data
@ -651,10 +654,19 @@ class UserPreference : AppCompatActivity() {
val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri)
if (targetDirectory != null) {
exportPasswords(targetDirectory)
val service = Intent(applicationContext, PasswordExportService::class.java).apply {
action = PasswordExportService.ACTION_EXPORT_PASSWORD
putExtra("uri", uri)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(service)
} else {
startService(service)
}
}
}.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE))
}
}.launch(intent)
}
/**
@ -772,77 +784,6 @@ class UserPreference : AppCompatActivity() {
return autofillManager?.hasEnabledAutofillServices() == true
}
/**
* 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 = requireNotNull(PasswordRepository.getRepositoryDirectory(applicationContext))
val sourcePassDir = DocumentFile.fromFile(repositoryDirectory)
tag(TAG).d { "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()
}
}
}
/**
* 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)
}
}
}
companion object {
private const val TAG = "UserPreference"

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M8.65,3.35L5.86,6.14c-0.32,0.31 -0.1,0.85 0.35,0.85H8V13c0,0.55 0.45,1 1,1s1,-0.45 1,-1V6.99h1.79c0.45,0 0.67,-0.54 0.35,-0.85L9.35,3.35c-0.19,-0.19 -0.51,-0.19 -0.7,0zM16,17.01V11c0,-0.55 -0.45,-1 -1,-1s-1,0.45 -1,1v6.01h-1.79c-0.45,0 -0.67,0.54 -0.35,0.85l2.79,2.78c0.2,0.19 0.51,0.19 0.71,0l2.79,-2.78c0.32,-0.31 0.09,-0.85 -0.35,-0.85H16z"/>
</vector>

View file

@ -400,4 +400,5 @@
<string name="otp_import_failure">Failed to import TOTP configuration</string>
<string name="oreo_autofill_chrome_compat_fix_preference_title">Improve reliability in Chrome</string>
<string name="oreo_autofill_chrome_compat_fix_preference_summary">Requires activating an accessibility service and may affect overall Chrome performance</string>
<string name="exporting_passwords">Exporting passwords…</string>
</resources>