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:
parent
0ead6b2a4d
commit
fc00de61dc
6 changed files with 185 additions and 77 deletions
|
@ -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
|
- 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
|
- 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 Store no longer ignores the selected OpenKeychain key
|
||||||
|
- Password export now happens in a separate process, preventing possible freezes
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
|
|
@ -115,6 +115,9 @@
|
||||||
<service
|
<service
|
||||||
android:name=".ClipboardService"
|
android:name=".ClipboardService"
|
||||||
android:process=":clipboard_service_process" />
|
android:process=":clipboard_service_process" />
|
||||||
|
<service
|
||||||
|
android:name=".PasswordExportService"
|
||||||
|
android:process=":password_export_service_process" />
|
||||||
<service
|
<service
|
||||||
android:name=".autofill.oreo.OreoAutofillService"
|
android:name=".autofill.oreo.OreoAutofillService"
|
||||||
android:permission="android.permission.BIND_AUTOFILL_SERVICE">
|
android:permission="android.permission.BIND_AUTOFILL_SERVICE">
|
||||||
|
|
152
app/src/main/java/com/zeapo/pwdstore/PasswordExportService.kt
Normal file
152
app/src/main/java/com/zeapo/pwdstore/PasswordExportService.kt
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -59,11 +59,7 @@ import com.zeapo.pwdstore.utils.autofillManager
|
||||||
import com.zeapo.pwdstore.utils.getEncryptedPrefs
|
import com.zeapo.pwdstore.utils.getEncryptedPrefs
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.util.Calendar
|
|
||||||
import java.util.HashSet
|
import java.util.HashSet
|
||||||
import java.util.TimeZone
|
|
||||||
import me.msfjarvis.openpgpktx.util.OpenPgpUtils
|
import me.msfjarvis.openpgpktx.util.OpenPgpUtils
|
||||||
|
|
||||||
typealias ClickListener = Preference.OnPreferenceClickListener
|
typealias ClickListener = Preference.OnPreferenceClickListener
|
||||||
|
@ -643,6 +639,13 @@ class UserPreference : AppCompatActivity() {
|
||||||
* Exports the passwords
|
* Exports the passwords
|
||||||
*/
|
*/
|
||||||
private fun exportPasswords() {
|
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 ->
|
registerForActivityResult(StartActivityForResult()) { result ->
|
||||||
if (!validateResult(result)) return@registerForActivityResult
|
if (!validateResult(result)) return@registerForActivityResult
|
||||||
val uri = result.data?.data
|
val uri = result.data?.data
|
||||||
|
@ -651,10 +654,19 @@ class UserPreference : AppCompatActivity() {
|
||||||
val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri)
|
val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri)
|
||||||
|
|
||||||
if (targetDirectory != null) {
|
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
|
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 {
|
companion object {
|
||||||
|
|
||||||
private const val TAG = "UserPreference"
|
private const val TAG = "UserPreference"
|
||||||
|
|
10
app/src/main/res/drawable/ic_round_import_export.xml
Normal file
10
app/src/main/res/drawable/ic_round_import_export.xml
Normal 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>
|
|
@ -400,4 +400,5 @@
|
||||||
<string name="otp_import_failure">Failed to import TOTP configuration</string>
|
<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_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="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>
|
</resources>
|
||||||
|
|
Loading…
Reference in a new issue