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
|
||||
- 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
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
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 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"
|
||||
|
|
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="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>
|
||||
|
|
Loading…
Reference in a new issue