Resolve various memory leaks (#637)

This migrates the clipboard clear logic into a foreground service that allows us to also provide a notification that runs the clear task immediately on click, rather than wait for the timeout.

Co-authored-by: Aditya Wasan <adityawasan55@gmail.com>
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
Harsh Shandilya 2020-03-05 21:05:50 +05:30 committed by GitHub
parent addefdc9a3
commit 73058d10a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 259 additions and 84 deletions

View file

@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file.
- Swipe on password list to synchronize repository
### Fixed
- Resolve memory leaks on password decryption
- Can't delete folders containing a password
## [1.5.0] - 2020-02-21

View file

@ -80,9 +80,12 @@ dependencies {
implementation deps.androidx.annotation
implementation deps.androidx.appcompat
implementation deps.androidx.biometric
implementation deps.androidx.core_ktx
implementation deps.androidx.constraint_layout
implementation deps.androidx.core_ktx
implementation deps.androidx.documentfile
implementation deps.androidx.lifecycle_runtime_ktx
implementation deps.androidx.local_broadcast_manager
implementation deps.androidx.material
implementation deps.androidx.preference
implementation deps.androidx.swiperefreshlayout
constraints {
@ -90,7 +93,6 @@ dependencies {
because 'versions above 1.0.0 have an accessibility related bug that causes crashes'
}
}
implementation deps.androidx.material
implementation deps.kotlin.coroutines.android
implementation deps.kotlin.coroutines.core

View file

@ -14,6 +14,7 @@
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!--suppress DeprecatedClassUsageInspection -->
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
android:name=".Application"
@ -53,6 +54,9 @@
android:name="android.accessibilityservice"
android:resource="@xml/autofill_config" />
</service>
<service
android:name=".ClipboardService"
android:process=":clipboard_service_process" />
<activity
android:name=".autofill.AutofillActivity"

View file

@ -0,0 +1,156 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.ClipboardManager
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.preference.PreferenceManager
import com.zeapo.pwdstore.utils.ClipboardUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
class ClipboardService : Service() {
private val scope = CoroutineScope(Job() + Dispatchers.Main)
private val settings: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null) {
when (intent.action) {
ACTION_CLEAR -> {
clearClipboard()
stopForeground(true)
stopSelf()
return super.onStartCommand(intent, flags, startId)
}
ACTION_START -> {
val time = try {
Integer.parseInt(settings.getString("general_show_time", "45") as String)
} catch (e: NumberFormatException) {
45
}
if (time == 0) {
stopSelf()
}
createNotification()
scope.launch {
withContext(Dispatchers.IO) {
startTimer(time)
}
withContext(Dispatchers.Main) {
emitBroadcast()
clearClipboard()
stopForeground(true)
stopSelf()
}
}
return START_NOT_STICKY
}
}
}
return super.onStartCommand(intent, flags, startId)
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private fun clearClipboard() {
val deepClear = settings.getBoolean("clear_clipboard_20x", false)
val clipboardManager = getSystemService<ClipboardManager>()
if (clipboardManager is ClipboardManager) {
scope.launch {
ClipboardUtils.clearClipboard(clipboardManager, deepClear)
}
} else {
Timber.tag("ClipboardService").d("Cannot get clipboard manager service")
}
}
private suspend fun startTimer(showTime: Int) {
var current = 0
while (scope.isActive && current < showTime) {
// Block for 1s or until cancel is signalled
current++
delay(1000)
}
}
private fun emitBroadcast() {
val localBroadcastManager = LocalBroadcastManager.getInstance(this)
val clearIntent = Intent(ACTION_CLEAR)
localBroadcastManager.sendBroadcast(clearIntent)
}
private fun createNotification() {
createNotificationChannel()
val clearIntent = Intent(this, ClipboardService::class.java)
clearIntent.action = ACTION_CLEAR
val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
} else {
PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
}
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.tap_clear_clipboard))
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentIntent(pendingIntent)
.setUsesChronometer(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
startForeground(1, 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 {
Timber.tag("ClipboardService").d("Failed to create notification channel")
}
}
}
companion object {
private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD"
private const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER"
private const val CHANNEL_ID = "NotificationService"
}
}

View file

@ -254,6 +254,11 @@ class PasswordStore : AppCompatActivity() {
return super.onOptionsItemSelected(item)
}
override fun onDestroy() {
plist = null
super.onDestroy()
}
fun openSettings(view: View?) {
val intent: Intent
try {

View file

@ -518,7 +518,7 @@ class AutofillService : AccessibilityService(), CoroutineScope by CoroutineScope
if (entry?.hasUsername() == true) {
lastPassword = entry
val ttl = Integer.parseInt(settings!!.getString("general_show_time", "45")!!)
Toast.makeText(applicationContext, getString(R.string.autofill_toast_username, ttl), Toast.LENGTH_LONG).show()
withContext(Dispatchers.Main) { Toast.makeText(applicationContext, getString(R.string.autofill_toast_username, ttl), Toast.LENGTH_LONG).show() }
lastPasswordMaxDate = System.currentTimeMillis() + ttl * 1000L
}
} catch (e: UnsupportedEncodingException) {
@ -537,7 +537,7 @@ class AutofillService : AccessibilityService(), CoroutineScope by CoroutineScope
OpenPgpApi.RESULT_CODE_ERROR -> {
val error = result.getParcelableExtra<OpenPgpError>(OpenPgpApi.RESULT_ERROR)
if (error != null) {
Toast.makeText(applicationContext, "Error from OpenKeyChain : ${error.message}", Toast.LENGTH_LONG).show()
withContext(Dispatchers.Main) { Toast.makeText(applicationContext, "Error from OpenKeyChain : ${error.message}", Toast.LENGTH_LONG).show() }
Timber.tag(Constants.TAG).e("onError getErrorId: ${error.errorId}")
Timber.tag(Constants.TAG).e("onError getMessage: ${error.message}")
}

View file

@ -5,17 +5,17 @@
package com.zeapo.pwdstore.crypto
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.IntentSender
import android.content.SharedPreferences
import android.graphics.Typeface
import android.os.AsyncTask
import android.os.Build
import android.os.Bundle
import android.os.ConditionVariable
import android.os.Handler
import android.text.TextUtils
import android.text.format.DateUtils
import android.text.method.PasswordTransformationMethod
@ -27,13 +27,15 @@ import android.view.View
import android.view.WindowManager
import android.widget.Button
import android.widget.CheckBox
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.zeapo.pwdstore.ClipboardService
import com.zeapo.pwdstore.PasswordEntry
import com.zeapo.pwdstore.R
import com.zeapo.pwdstore.UserPreference
@ -52,7 +54,6 @@ import kotlinx.android.synthetic.main.encrypt_layout.crypto_password_edit
import kotlinx.android.synthetic.main.encrypt_layout.crypto_password_file_edit
import kotlinx.android.synthetic.main.encrypt_layout.generate_password
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import me.msfjarvis.openpgpktx.util.OpenPgpApi
import me.msfjarvis.openpgpktx.util.OpenPgpApi.Companion.ACTION_DECRYPT_VERIFY
@ -96,10 +97,15 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
private val relativeParentPath: String by lazy { getParentPath(fullPath, repoPath) }
val settings: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
private val keyIDs: MutableSet<String> by lazy {
settings.getStringSet("openpgp_key_ids_set", mutableSetOf()) ?: emptySet()
}
private val keyIDs get() = _keyIDs
private var _keyIDs = emptySet<String>()
private var mServiceConnection: OpenPgpServiceConnection? = null
private var delayTask: DelayShow? = null
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
delayTask?.doOnPostExecute()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -107,6 +113,7 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
Timber.tag(TAG)
// some persistence
_keyIDs = settings.getStringSet("openpgp_key_ids_set", null) ?: emptySet()
val providerPackageName = settings.getString("openpgp_provider_list", "")
if (TextUtils.isEmpty(providerPackageName)) {
@ -127,7 +134,7 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
crypto_password_file.text = name
crypto_password_file.setOnLongClickListener {
val clip = ClipData.newPlainText("pgp_handler_result_pm", name)
clipboard.setPrimaryClip(clip)
clipboard.primaryClip = clip
showSnackbar(this.resources.getString(R.string.clipboard_username_toast_text))
true
}
@ -157,6 +164,16 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
}
}
override fun onResume() {
super.onResume()
LocalBroadcastManager.getInstance(this).registerReceiver(receiver, IntentFilter(ACTION_CLEAR))
}
override fun onStop() {
LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver)
super.onStop()
}
override fun onDestroy() {
checkAndIncrementHotp()
super.onDestroy()
@ -258,7 +275,7 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
val iStream = FileUtils.openInputStream(File(fullPath))
val oStream = ByteArrayOutputStream()
GlobalScope.launch(IO) {
lifecycleScope.launch(IO) {
api?.executeApiAsync(data, iStream, oStream, object : OpenPgpApi.IOpenPgpCallback {
override fun onReturn(result: Intent?) {
when (result?.getIntExtra(RESULT_CODE, RESULT_CODE_ERROR)) {
@ -470,7 +487,7 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
val path = if (intent.getBooleanExtra("fromDecrypt", false)) fullPath else "$fullPath/$editName.gpg"
GlobalScope.launch(IO) {
lifecycleScope.launch(IO) {
api?.executeApiAsync(data, iStream, oStream, object : OpenPgpApi.IOpenPgpCallback {
override fun onReturn(result: Intent?) {
when (result?.getIntExtra(RESULT_CODE, RESULT_CODE_ERROR)) {
@ -582,7 +599,7 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
private fun getKeyIds(receivedIntent: Intent? = null) {
val data = receivedIntent ?: Intent()
data.action = OpenPgpApi.ACTION_GET_KEY_IDS
GlobalScope.launch(IO) {
lifecycleScope.launch(IO) {
api?.executeApiAsync(data, null, null, object : OpenPgpApi.IOpenPgpCallback {
override fun onReturn(result: Intent?) {
when (result?.getIntExtra(RESULT_CODE, RESULT_CODE_ERROR)) {
@ -689,7 +706,7 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
}
val clip = ClipData.newPlainText("pgp_handler_result_pm", pass)
clipboard.setPrimaryClip(clip)
clipboard.primaryClip = clip
var clearAfter = 45
try {
@ -708,13 +725,13 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
private fun copyUsernameToClipBoard(username: String) {
val clip = ClipData.newPlainText("pgp_handler_result_pm", username)
clipboard.setPrimaryClip(clip)
clipboard.primaryClip = clip
showSnackbar(resources.getString(R.string.clipboard_username_toast_text))
}
private fun copyOtpToClipBoard(code: String) {
val clip = ClipData.newPlainText("pgp_handler_result_pm", code)
clipboard.setPrimaryClip(clip)
clipboard.primaryClip = clip
showSnackbar(resources.getString(R.string.clipboard_otp_toast_text))
}
@ -741,8 +758,8 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
delayTask?.cancelAndSignal(true)
// launch a new one
delayTask = DelayShow(this)
delayTask?.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR)
delayTask = DelayShow()
delayTask?.execute()
}
/**
@ -758,11 +775,10 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
}
@Suppress("StaticFieldLeak")
inner class DelayShow(val activity: PgpActivity) : AsyncTask<Void, Int, Boolean>() {
private val pb: ProgressBar? by lazy { pbLoading }
private var skip = false
private var cancelNotify = ConditionVariable()
inner class DelayShow {
private var skip = false
private var service: Intent? = null
private var showTime: Int = 0
// Custom cancellation that can be triggered from another thread.
@ -772,14 +788,25 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
// is true, the cancelled task won't clear the clipboard.
fun cancelAndSignal(skipClearing: Boolean) {
skip = skipClearing
cancelNotify.open()
if (service != null) {
stopService(service)
service = null
}
}
val settings: SharedPreferences by lazy {
PreferenceManager.getDefaultSharedPreferences(activity)
fun execute() {
service = Intent(this@PgpActivity, ClipboardService::class.java).also {
it.action = ACTION_START
}
doOnPreExecute()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(service)
} else {
startService(service)
}
}
override fun onPreExecute() {
private fun doOnPreExecute() {
showTime = try {
Integer.parseInt(settings.getString("general_show_time", "45") as String)
} catch (e: NumberFormatException) {
@ -793,49 +820,12 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
if (extraText?.text?.isNotEmpty() == true)
findViewById<View>(R.id.crypto_extra_show_layout)?.visibility = View.VISIBLE
if (showTime == 0) {
// treat 0 as forever, and the user must exit and/or clear clipboard on their own
cancel(true)
} else {
this.pb?.max = showTime
}
}
override fun doInBackground(vararg params: Void): Boolean? {
var current = 0
while (current < showTime) {
// Block for 1s or until cancel is signalled
if (cancelNotify.block(1000)) {
return true
}
current++
publishProgress(current)
}
return true
}
override fun onPostExecute(b: Boolean?) {
fun doOnPostExecute() {
if (skip) return
checkAndIncrementHotp()
// No need to validate clear_after_copy. It was validated in copyPasswordToClipBoard()
Timber.d("Clearing the clipboard")
val clip = ClipData.newPlainText("pgp_handler_result_pm", "")
clipboard.setPrimaryClip(clip)
if (settings.getBoolean("clear_clipboard_20x", false)) {
val handler = Handler()
for (i in 0..19) {
val count = i.toString()
handler.postDelayed(
{ clipboard.setPrimaryClip(ClipData.newPlainText(count, count)) },
(i * 500).toLong()
)
}
}
if (crypto_password_show != null) {
// clear password; if decrypt changed to encrypt layout via edit button, no need
if (passwordEntry?.hotpIsIncremented() == false) {
@ -849,10 +839,6 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
finish()
}
}
override fun onProgressUpdate(vararg values: Int?) {
this.pb?.progress = values[0] ?: 0
}
}
companion object {
@ -860,13 +846,14 @@ class PgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
const val REQUEST_DECRYPT = 202
const val REQUEST_KEY_ID = 203
private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD"
private const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER"
const val TAG = "PgpActivity"
const val KEY_PWGEN_TYPE_CLASSIC = "classic"
const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
private var delayTask: DelayShow? = null
/**
* Gets the relative path to the repository
*/

View file

@ -0,0 +1,28 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.utils
import android.content.ClipData
import android.content.ClipboardManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
object ClipboardUtils {
suspend fun clearClipboard(clipboard: ClipboardManager, deepClear: Boolean = false) {
Timber.d("Clearing the clipboard")
val clip = ClipData.newPlainText("pgp_handler_result_pm", "")
clipboard.primaryClip = clip
if (deepClear) {
withContext(Dispatchers.IO) {
repeat(20) {
val count = (it * 500).toString()
clipboard.primaryClip = ClipData.newPlainText(count, count)
}
}
}
}
}

View file

@ -94,16 +94,6 @@
app:layout_constraintBaseline_toBaselineOf="@id/crypto_password_show_label"
android:typeface="monospace" />
<ProgressBar
android:id="@+id/pbLoading"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="8dp"
android:layout_marginBottom="8dp"
android:visibility="invisible"
android:layout_marginTop="8dp"
app:layout_constraintTop_toBottomOf="@id/crypto_password_show_label"/>
<com.google.android.material.button.MaterialButton
style="@style/Widget.MaterialComponents.Button"
android:id="@+id/crypto_password_toggle_show"

View file

@ -305,5 +305,5 @@
<string name="pref_search_from_root">Always search from root</string>
<string name="pref_search_from_root_hint">Search from root of store regardless of currently open directory</string>
<string name="password_generator_category_title">Password Generator</string>
<string name="tap_clear_clipboard">Tap here to clear clipboard</string>
</resources>

View file

@ -34,6 +34,8 @@ ext.deps = [
constraint_layout: 'androidx.constraintlayout:constraintlayout:2.0.0-beta4',
core_ktx: 'androidx.core:core-ktx:1.3.0-alpha01',
documentfile: 'androidx.documentfile:documentfile:1.0.1',
lifecycle_runtime_ktx: 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0-alpha01',
local_broadcast_manager: 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0',
material: 'com.google.android.material:material:1.2.0-alpha05',
preference: 'androidx.preference:preference:1.1.0',
recycler_view: 'androidx.recyclerview:recyclerview:1.0.0',