diff options
| author | Harsh Shandilya <me@msfjarvis.dev> | 2020-12-05 06:07:18 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-12-05 06:07:18 +0530 |
| commit | 5e66d99c852ea67a88b650c03b0e8d55e83eccde (patch) | |
| tree | aee323a08253fe7b863976eff92a1bd412bd1430 /app/src/main/java/dev/msfjarvis/aps/ui | |
| parent | 8eb55f18a11d6b2f155c7c9d48ca833313ee13db (diff) | |
| download | HardenedPass-5e66d99c852ea67a88b650c03b0e8d55e83eccde.tar.gz HardenedPass-5e66d99c852ea67a88b650c03b0e8d55e83eccde.zip | |
Refactor package structure (#1233)
* idea: default test runner to Gradle
* Kick off package structure revamp
* Reparent all classes under dev.msfjarvis.aps
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Diffstat (limited to 'app/src/main/java/dev/msfjarvis/aps/ui')
35 files changed, 5880 insertions, 0 deletions
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt b/app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt new file mode 100644 index 00000000..92b0fe37 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt @@ -0,0 +1,83 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.adapters + +import android.text.SpannableString +import android.text.style.RelativeSizeSpan +import android.view.MotionEvent +import android.view.View +import androidx.appcompat.widget.AppCompatImageView +import androidx.appcompat.widget.AppCompatTextView +import androidx.recyclerview.selection.ItemDetailsLookup +import androidx.recyclerview.selection.Selection +import androidx.recyclerview.widget.RecyclerView +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.data.password.PasswordItem +import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryAdapter +import dev.msfjarvis.aps.util.viewmodel.stableId + +open class PasswordItemRecyclerAdapter : + SearchableRepositoryAdapter<PasswordItemRecyclerAdapter.PasswordItemViewHolder>( + R.layout.password_row_layout, + ::PasswordItemViewHolder, + PasswordItemViewHolder::bind + ) { + + fun makeSelectable(recyclerView: RecyclerView) { + makeSelectable(recyclerView, ::PasswordItemDetailsLookup) + } + + override fun onItemClicked(listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit): PasswordItemRecyclerAdapter { + return super.onItemClicked(listener) as PasswordItemRecyclerAdapter + } + + override fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): PasswordItemRecyclerAdapter { + return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter + } + + class PasswordItemViewHolder(view: View) : RecyclerView.ViewHolder(view) { + + private val name: AppCompatTextView = itemView.findViewById(R.id.label) + private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count) + private val folderIndicator: AppCompatImageView = + itemView.findViewById(R.id.folder_indicator) + lateinit var itemDetails: ItemDetailsLookup.ItemDetails<String> + + fun bind(item: PasswordItem) { + val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "") + val source = if (parentPath.isNotEmpty()) { + "$parentPath\n$item" + } else { + "$item" + } + val spannable = SpannableString(source) + spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0) + name.text = spannable + if (item.type == PasswordItem.TYPE_CATEGORY) { + folderIndicator.visibility = View.VISIBLE + val count = item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size + ?: 0 + childCount.visibility = if (count > 0) View.VISIBLE else View.GONE + childCount.text = "$count" + } else { + childCount.visibility = View.GONE + folderIndicator.visibility = View.GONE + } + itemDetails = object : ItemDetailsLookup.ItemDetails<String>() { + override fun getPosition() = absoluteAdapterPosition + override fun getSelectionKey() = item.stableId + } + } + } + + class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) : + ItemDetailsLookup<String>() { + + override fun getItemDetails(event: MotionEvent): ItemDetails<String>? { + val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null + return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt new file mode 100644 index 00000000..5d6fb886 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt @@ -0,0 +1,245 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.autofill + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.view.autofill.AutofillManager +import android.widget.Toast +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess +import com.github.michaelbull.result.runCatching +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.Credentials +import dev.msfjarvis.aps.util.autofill.AutofillPreferences +import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder +import dev.msfjarvis.aps.util.autofill.DirectoryStructure +import dev.msfjarvis.aps.data.password.PasswordEntry +import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import org.openintents.openpgp.IOpenPgpService2 +import org.openintents.openpgp.OpenPgpError + +@RequiresApi(Build.VERSION_CODES.O) +class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope { + + companion object { + + private const val EXTRA_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH" + private const val EXTRA_SEARCH_ACTION = + "dev.msfjarvis.aps.autofill.oreo.EXTRA_SEARCH_ACTION" + + private var decryptFileRequestCode = 1 + + fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent { + return Intent(context, AutofillDecryptActivity::class.java).apply { + putExtras(forwardedExtras) + putExtra(EXTRA_SEARCH_ACTION, true) + putExtra(EXTRA_FILE_PATH, file.absolutePath) + } + } + + fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender { + val intent = Intent(context, AutofillDecryptActivity::class.java).apply { + putExtra(EXTRA_SEARCH_ACTION, false) + putExtra(EXTRA_FILE_PATH, file.absolutePath) + } + return PendingIntent.getActivity( + context, + decryptFileRequestCode++, + intent, + PendingIntent.FLAG_CANCEL_CURRENT + ).intentSender + } + } + + private val decryptInteractionRequiredAction = registerForActivityResult(StartIntentSenderForResult()) { result -> + if (continueAfterUserInteraction != null) { + val data = result.data + if (result.resultCode == RESULT_OK && data != null) { + continueAfterUserInteraction?.resume(data) + } else { + continueAfterUserInteraction?.resumeWithException(Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction")) + } + continueAfterUserInteraction = null + } + } + + private var continueAfterUserInteraction: Continuation<Intent>? = null + private lateinit var directoryStructure: DirectoryStructure + + override val coroutineContext + get() = Dispatchers.IO + SupervisorJob() + + override fun onStart() { + super.onStart() + val filePath = intent?.getStringExtra(EXTRA_FILE_PATH) ?: run { + e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" } + finish() + return + } + val clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { + e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } + finish() + return + } + val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!! + val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match + directoryStructure = AutofillPreferences.directoryStructure(this) + d { action.toString() } + launch { + val credentials = decryptCredential(File(filePath)) + if (credentials == null) { + setResult(RESULT_CANCELED) + } else { + val fillInDataset = + AutofillResponseBuilder.makeFillInDataset( + this@AutofillDecryptActivity, + credentials, + clientState, + action + ) + withContext(Dispatchers.Main) { + setResult(RESULT_OK, Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) + }) + } + } + withContext(Dispatchers.Main) { + finish() + } + } + } + + override fun onDestroy() { + super.onDestroy() + coroutineContext.cancelChildren() + } + + private suspend fun executeOpenPgpApi( + data: Intent, + input: InputStream, + output: OutputStream + ): Intent? { + var openPgpServiceConnection: OpenPgpServiceConnection? = null + val openPgpService = suspendCoroutine<IOpenPgpService2> { cont -> + openPgpServiceConnection = OpenPgpServiceConnection( + this, + OPENPGP_PROVIDER, + object : OpenPgpServiceConnection.OnBound { + override fun onBound(service: IOpenPgpService2) { + cont.resume(service) + } + + override fun onError(e: Exception) { + cont.resumeWithException(e) + } + }).also { it.bindToService() } + } + return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also { + openPgpServiceConnection?.unbindFromService() + } + } + + private suspend fun decryptCredential( + file: File, + resumeIntent: Intent? = null + ): Credentials? { + val command = resumeIntent ?: Intent().apply { + action = OpenPgpApi.ACTION_DECRYPT_VERIFY + } + runCatching { + file.inputStream() + }.onFailure { e -> + e(e) { "File to decrypt not found" } + return null + }.onSuccess { encryptedInput -> + val decryptedOutput = ByteArrayOutputStream() + runCatching { + executeOpenPgpApi(command, encryptedInput, decryptedOutput) + }.onFailure { e -> + e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed" } + return null + }.onSuccess { result -> + return when (val resultCode = + result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + runCatching { + val entry = withContext(Dispatchers.IO) { + @Suppress("BlockingMethodInNonBlockingContext") + (PasswordEntry(decryptedOutput)) + } + AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure) + }.getOrElse { e -> + e(e) { "Failed to parse password entry" } + return null + } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val pendingIntent: PendingIntent = + result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!! + runCatching { + val intentToResume = withContext(Dispatchers.Main) { + suspendCoroutine<Intent> { cont -> + continueAfterUserInteraction = cont + decryptInteractionRequiredAction.launch(IntentSenderRequest.Builder(pendingIntent.intentSender).build()) + } + } + decryptCredential(file, intentToResume) + }.getOrElse { e -> + e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction" } + return null + } + } + OpenPgpApi.RESULT_CODE_ERROR -> { + val error = result.getParcelableExtra<OpenPgpError>(OpenPgpApi.RESULT_ERROR) + if (error != null) { + withContext(Dispatchers.Main) { + Toast.makeText( + applicationContext, + "Error from OpenKeyChain: ${error.message}", + Toast.LENGTH_LONG + ).show() + } + e { "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" } + } + null + } + else -> { + e { "Unrecognized OpenPgpApi result: $resultCode" } + null + } + } + } + } + return null + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt new file mode 100644 index 00000000..fe19a636 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt @@ -0,0 +1,220 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.autofill + +import android.annotation.TargetApi +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.view.View +import android.view.autofill.AutofillManager +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.text.bold +import androidx.core.text.buildSpannedString +import androidx.core.text.underline +import androidx.core.widget.addTextChangedListener +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.FormOrigin +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.autofill.AutofillMatcher +import dev.msfjarvis.aps.util.autofill.AutofillPreferences +import dev.msfjarvis.aps.util.autofill.DirectoryStructure +import dev.msfjarvis.aps.databinding.ActivityOreoAutofillFilterBinding +import dev.msfjarvis.aps.data.password.PasswordItem +import dev.msfjarvis.aps.util.extensions.viewBinding +import dev.msfjarvis.aps.util.viewmodel.FilterMode +import dev.msfjarvis.aps.util.viewmodel.ListMode +import dev.msfjarvis.aps.util.viewmodel.SearchMode +import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryAdapter +import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel + +@TargetApi(Build.VERSION_CODES.O) +class AutofillFilterView : AppCompatActivity() { + + companion object { + + private const val HEIGHT_PERCENTAGE = 0.9 + private const val WIDTH_PERCENTAGE = 0.75 + + private const val EXTRA_FORM_ORIGIN_WEB = + "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB" + private const val EXTRA_FORM_ORIGIN_APP = + "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP" + private var matchAndDecryptFileRequestCode = 1 + + fun makeMatchAndDecryptFileIntentSender( + context: Context, + formOrigin: FormOrigin + ): IntentSender { + val intent = Intent(context, AutofillFilterView::class.java).apply { + when (formOrigin) { + is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier) + is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier) + } + } + return PendingIntent.getActivity( + context, + matchAndDecryptFileRequestCode++, + intent, + PendingIntent.FLAG_CANCEL_CURRENT + ).intentSender + } + } + + private lateinit var formOrigin: FormOrigin + private lateinit var directoryStructure: DirectoryStructure + private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate) + + private val model: SearchableRepositoryViewModel by viewModels { + ViewModelProvider.AndroidViewModelFactory(application) + } + + private val decryptAction = registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + setResult(RESULT_OK, result.data) + } + finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + setFinishOnTouchOutside(true) + + val params = window.attributes + params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt() + params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt() + window.attributes = params + + if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) { + e { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" } + finish() + return + } + formOrigin = when { + intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> { + FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!) + } + intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> { + FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!) + } + else -> { + e { "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP" } + finish() + return + } + } + directoryStructure = AutofillPreferences.directoryStructure(this) + + supportActionBar?.hide() + bindUI() + updateSearch() + setResult(RESULT_CANCELED) + } + + private fun bindUI() { + with(binding) { + rvPassword.apply { + adapter = SearchableRepositoryAdapter( + R.layout.oreo_autofill_filter_row, + ::PasswordViewHolder + ) { item -> + val file = item.file.relativeTo(item.rootDir) + val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file) + val identifier = directoryStructure.getIdentifierFor(file) + val accountPart = directoryStructure.getAccountPartFor(file) + check(identifier != null || accountPart != null) { "At least one of identifier and accountPart should always be non-null" } + title.text = if (identifier != null) { + buildSpannedString { + if (pathToIdentifier != null) + append("$pathToIdentifier/") + bold { underline { append(identifier) } } + } + } else { + accountPart + } + subtitle.apply { + if (identifier != null && accountPart != null) { + text = accountPart + visibility = View.VISIBLE + } else { + visibility = View.GONE + } + } + }.onItemClicked { _, item -> + decryptAndFill(item) + } + layoutManager = LinearLayoutManager(context) + } + search.apply { + val initialSearch = + formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) + setText(initialSearch, TextView.BufferType.EDITABLE) + addTextChangedListener { updateSearch() } + } + origin.text = buildSpannedString { + append(getString(R.string.oreo_autofill_select_and_fill_into)) + append("\n") + bold { + append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)) + } + } + strictDomainSearch.apply { + visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE + isChecked = formOrigin is FormOrigin.Web + setOnCheckedChangeListener { _, _ -> updateSearch() } + } + shouldMatch.text = getString( + R.string.oreo_autofill_match_with, + formOrigin.getPrettyIdentifier(applicationContext) + ) + model.searchResult.observe(this@AutofillFilterView) { result -> + val list = result.passwordItems + (rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) { + rvPassword.scrollToPosition(0) + } + // Switch RecyclerView out for a "no results" message if the new list is empty and + // the message is not yet shown (and vice versa). + if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) || + (list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id) + ) { + rvPasswordSwitcher.showNext() + } + } + } + } + + private fun updateSearch() { + model.search( + binding.search.text.toString().trim(), + filterMode = if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy, + searchMode = SearchMode.RecursivelyInSubdirectories, + listMode = ListMode.FilesOnly + ) + } + + private fun decryptAndFill(item: PasswordItem) { + if (binding.shouldClear.isChecked) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin) + if (binding.shouldMatch.isChecked) AutofillMatcher.addMatchFor( + applicationContext, + formOrigin, + item.file + ) + // intent?.extras? is checked to be non-null in onCreate + decryptAction.launch(AutofillDecryptActivity.makeDecryptFileIntent( + item.file, + intent!!.extras!!, + this + )) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt new file mode 100644 index 00000000..a8edabbe --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt @@ -0,0 +1,114 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.autofill + +import android.annotation.TargetApi +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.service.autofill.FillResponse +import android.text.format.DateUtils +import android.view.View +import android.view.autofill.AutofillManager +import androidx.appcompat.app.AppCompatActivity +import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.github.androidpasswordstore.autofillparser.computeCertificatesHash +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.autofill.AutofillMatcher +import dev.msfjarvis.aps.util.autofill.AutofillPublisherChangedException +import dev.msfjarvis.aps.databinding.ActivityOreoAutofillPublisherChangedBinding +import dev.msfjarvis.aps.util.extensions.viewBinding + +@TargetApi(Build.VERSION_CODES.O) +class AutofillPublisherChangedActivity : AppCompatActivity() { + + companion object { + + private const val EXTRA_APP_PACKAGE = + "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_APP_PACKAGE" + private const val EXTRA_FILL_RESPONSE_AFTER_RESET = + "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FILL_RESPONSE_AFTER_RESET" + private var publisherChangedRequestCode = 1 + + fun makePublisherChangedIntentSender( + context: Context, + publisherChangedException: AutofillPublisherChangedException, + fillResponseAfterReset: FillResponse?, + ): IntentSender { + val intent = Intent(context, AutofillPublisherChangedActivity::class.java).apply { + putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier) + putExtra(EXTRA_FILL_RESPONSE_AFTER_RESET, fillResponseAfterReset) + } + return PendingIntent.getActivity( + context, publisherChangedRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT + ).intentSender + } + } + + private lateinit var appPackage: String + private val binding by viewBinding(ActivityOreoAutofillPublisherChangedBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + setFinishOnTouchOutside(true) + + appPackage = intent.getStringExtra(EXTRA_APP_PACKAGE) ?: run { + e { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" } + finish() + return + } + supportActionBar?.hide() + showPackageInfo() + with(binding) { + okButton.setOnClickListener { finish() } + advancedButton.setOnClickListener { + advancedButton.visibility = View.GONE + warningAppAdvancedInfo.visibility = View.VISIBLE + resetButton.visibility = View.VISIBLE + } + resetButton.setOnClickListener { + AutofillMatcher.clearMatchesFor(this@AutofillPublisherChangedActivity, FormOrigin.App(appPackage)) + val fillResponse = intent.getParcelableExtra<FillResponse>(EXTRA_FILL_RESPONSE_AFTER_RESET) + setResult(RESULT_OK, Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) + }) + finish() + } + } + } + + private fun showPackageInfo() { + runCatching { + with(binding) { + val packageInfo = + packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA) + val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime) + warningAppInstallDate.text = + getString(R.string.oreo_autofill_warning_publisher_install_time, installTime) + val appInfo = + packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA) + warningAppName.text = "“${packageManager.getApplicationLabel(appInfo)}”" + + val currentHash = computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage) + warningAppAdvancedInfo.text = getString( + R.string.oreo_autofill_warning_publisher_advanced_info_template, + appPackage, + currentHash + ) + } + }.onFailure { e -> + e(e) { "Failed to retrieve package info for $appPackage" } + finish() + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt new file mode 100644 index 00000000..46234c4f --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt @@ -0,0 +1,149 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.autofill + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.view.autofill.AutofillManager +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.core.os.bundleOf +import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.androidpasswordstore.autofillparser.FormOrigin +import dev.msfjarvis.aps.util.autofill.AutofillMatcher +import dev.msfjarvis.aps.util.autofill.AutofillPreferences +import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder +import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity +import dev.msfjarvis.aps.data.repo.PasswordRepository +import java.io.File + +@RequiresApi(Build.VERSION_CODES.O) +class AutofillSaveActivity : AppCompatActivity() { + + companion object { + + private const val EXTRA_FOLDER_NAME = + "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FOLDER_NAME" + private const val EXTRA_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_PASSWORD" + private const val EXTRA_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_NAME" + private const val EXTRA_SHOULD_MATCH_APP = + "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP" + private const val EXTRA_SHOULD_MATCH_WEB = + "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB" + private const val EXTRA_GENERATE_PASSWORD = + "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD" + + private var saveRequestCode = 1 + + fun makeSaveIntentSender( + context: Context, + credentials: Credentials?, + formOrigin: FormOrigin + ): IntentSender { + val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false) + // Prevent directory traversals + val sanitizedIdentifier = identifier.replace('\\', '_') + .replace('/', '_') + .trimStart('.') + .takeUnless { it.isBlank() } ?: formOrigin.identifier + val directoryStructure = AutofillPreferences.directoryStructure(context) + val folderName = directoryStructure.getSaveFolderName( + sanitizedIdentifier = sanitizedIdentifier, + username = credentials?.username + ) + val fileName = directoryStructure.getSaveFileName(username = credentials?.username, identifier = identifier) + val intent = Intent(context, AutofillSaveActivity::class.java).apply { + putExtras( + bundleOf( + EXTRA_FOLDER_NAME to folderName, + EXTRA_NAME to fileName, + EXTRA_PASSWORD to credentials?.password, + EXTRA_SHOULD_MATCH_APP to formOrigin.identifier.takeIf { formOrigin is FormOrigin.App }, + EXTRA_SHOULD_MATCH_WEB to formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web }, + EXTRA_GENERATE_PASSWORD to (credentials == null) + ) + ) + } + return PendingIntent.getActivity( + context, + saveRequestCode++, + intent, + PendingIntent.FLAG_CANCEL_CURRENT + ).intentSender + } + } + + private val formOrigin by lazy(LazyThreadSafetyMode.NONE) { + val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP) + val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB) + if (shouldMatchApp != null && shouldMatchWeb == null) { + FormOrigin.App(shouldMatchApp) + } else if (shouldMatchApp == null && shouldMatchWeb != null) { + FormOrigin.Web(shouldMatchWeb) + } else { + null + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val repo = PasswordRepository.getRepositoryDirectory() + val saveIntent = Intent(this, PasswordCreationActivity::class.java).apply { + putExtras( + bundleOf( + "REPO_PATH" to repo.absolutePath, + "FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath, + PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME), + PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD), + PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) + ) + ) + } + registerForActivityResult(StartActivityForResult()) { result -> + val data = result.data + if (result.resultCode == RESULT_OK && data != null) { + val createdPath = data.getStringExtra("CREATED_FILE")!! + formOrigin?.let { + AutofillMatcher.addMatchFor(this, it, File(createdPath)) + } + val password = data.getStringExtra("PASSWORD") + val resultIntent = if (password != null) { + // Password was generated and should be filled into a form. + val username = data.getStringExtra("USERNAME") + val clientState = + intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { + e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } + finish() + return@registerForActivityResult + } + val credentials = Credentials(username, password, null) + val fillInDataset = AutofillResponseBuilder.makeFillInDataset( + this, + credentials, + clientState, + AutofillAction.Generate + ) + Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) + } + } else { + // Password was extracted from a form, there is nothing to fill. + Intent() + } + setResult(RESULT_OK, resultIntent) + } else { + setResult(RESULT_CANCELED) + } + finish() + }.launch(saveIntent) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt new file mode 100644 index 00000000..e2fbaf76 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt @@ -0,0 +1,16 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.autofill + +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import dev.msfjarvis.aps.R + +class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) { + + val title: TextView = itemView.findViewById(R.id.title) + val subtitle: TextView = itemView.findViewById(R.id.subtitle) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt new file mode 100644 index 00000000..e75d6f0a --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt @@ -0,0 +1,310 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.crypto + +import android.app.PendingIntent +import android.content.ClipData +import android.content.Intent +import android.content.IntentSender +import android.content.SharedPreferences +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.text.format.DateUtils +import android.view.WindowManager +import androidx.annotation.CallSuper +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import com.github.ajalt.timberkt.Timber.tag +import com.github.ajalt.timberkt.e +import com.github.ajalt.timberkt.i +import com.github.michaelbull.result.getOr +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import dev.msfjarvis.aps.util.services.ClipboardService +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.clipboard +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import dev.msfjarvis.aps.util.extensions.snackbar +import java.io.File +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import org.openintents.openpgp.IOpenPgpService2 +import org.openintents.openpgp.OpenPgpError + +@Suppress("Registered") +open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { + + /** + * Full path to the repository + */ + val repoPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("REPO_PATH")!! } + + /** + * Full path to the password file being worked on + */ + val fullPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("FILE_PATH")!! } + + /** + * Name of the password file + * + * Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org + */ + val name: String by lazy(LazyThreadSafetyMode.NONE) { File(fullPath).nameWithoutExtension } + + /** + * Get the timestamp for when this file was last modified. + */ + val lastChangedString: CharSequence by lazy(LazyThreadSafetyMode.NONE) { + getLastChangedString( + intent.getLongExtra( + "LAST_CHANGED_TIMESTAMP", + -1L + ) + ) + } + + /** + * [SharedPreferences] instance used by subclasses to persist settings + */ + val settings: SharedPreferences by lazy(LazyThreadSafetyMode.NONE) { sharedPrefs } + + /** + * Handle to the [OpenPgpApi] instance that is used by subclasses to interface with OpenKeychain. + */ + private var serviceConnection: OpenPgpServiceConnection? = null + var api: OpenPgpApi? = null + + /** + * A [OpenPgpServiceConnection.OnBound] instance for the last listener that we wish to bind with + * in case the previous attempt was cancelled due to missing [OPENPGP_PROVIDER] package. + */ + private var previousListener: OpenPgpServiceConnection.OnBound? = null + + /** + * [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots + * or recent apps screen. + */ + @CallSuper + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + tag(TAG) + } + + /** + * [onDestroy] handles unbinding from the OpenPgp service linked with [serviceConnection]. This + * is annotated with [CallSuper] because it's critical to unbind the service to ensure we're not + * leaking things. + */ + @CallSuper + override fun onDestroy() { + super.onDestroy() + serviceConnection?.unbindFromService() + previousListener = null + } + + /** + * [onResume] controls the flow for resumption of a PGP operation that was previously interrupted + * by the [OPENPGP_PROVIDER] package being missing. + */ + override fun onResume() { + super.onResume() + previousListener?.let { bindToOpenKeychain(it) } + } + + /** + * Sets up [api] once the service is bound. Downstream consumers must call super this to + * initialize [api] + */ + @CallSuper + override fun onBound(service: IOpenPgpService2) { + api = OpenPgpApi(this, service) + } + + /** + * Mandatory error handling from [OpenPgpServiceConnection.OnBound]. All subclasses must handle + * their own errors, and hence this class simply logs and rethrows. Subclasses Must NOT call super. + */ + override fun onError(e: Exception) { + e(e) { "Callers must handle their own exceptions" } + throw e + } + + /** + * Method for subclasses to initiate binding with [OpenPgpServiceConnection]. + */ + fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) { + val installed = runCatching { + packageManager.getPackageInfo(OPENPGP_PROVIDER, 0) + true + }.getOr(false) + if (!installed) { + previousListener = onBoundListener + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.openkeychain_not_installed_title)) + .setMessage(getString(R.string.openkeychain_not_installed_message)) + .setPositiveButton(getString(R.string.openkeychain_not_installed_google_play)) { _, _ -> + runCatching { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(getString(R.string.play_deeplink_template, OPENPGP_PROVIDER)) + setPackage("com.android.vending") + } + startActivity(intent) + } + } + .setNeutralButton(getString(R.string.openkeychain_not_installed_fdroid)) { _, _ -> + runCatching { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(getString(R.string.fdroid_deeplink_template, OPENPGP_PROVIDER)) + } + startActivity(intent) + } + } + .setOnCancelListener { finish() } + .show() + return + } else { + previousListener = null + serviceConnection = OpenPgpServiceConnection(this, OPENPGP_PROVIDER, onBoundListener).also { + it.bindToService() + } + } + } + + /** + * Handle the case where OpenKeychain returns that it needs to interact with the user + * + * @param result The intent returned by OpenKeychain + */ + fun getUserInteractionRequestIntent(result: Intent): IntentSender { + i { "RESULT_CODE_USER_INTERACTION_REQUIRED" } + return result.getParcelableExtra<PendingIntent>(OpenPgpApi.RESULT_INTENT)!!.intentSender + } + + /** + * Gets a relative string describing when this shape was last changed + * (e.g. "one hour ago") + */ + private fun getLastChangedString(timeStamp: Long): CharSequence { + if (timeStamp < 0) { + throw RuntimeException() + } + + return DateUtils.getRelativeTimeSpanString(this, timeStamp, true) + } + + /** + * Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses + * can use this when they want to default to sane error handling. + */ + fun handleError(result: Intent) { + val error: OpenPgpError? = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR) + if (error != null) { + when (error.errorId) { + OpenPgpError.NO_OR_WRONG_PASSPHRASE -> { + snackbar(message = getString(R.string.openpgp_error_wrong_passphrase)) + } + OpenPgpError.NO_USER_IDS -> { + snackbar(message = getString(R.string.openpgp_error_no_user_ids)) + } + else -> { + snackbar(message = getString(R.string.openpgp_error_unknown, error.message)) + e { "onError getErrorId: ${error.errorId}" } + e { "onError getMessage: ${error.message}" } + } + } + } + } + + /** + * Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing + * [showSnackbar] as false. + */ + fun copyTextToClipboard( + text: String?, + showSnackbar: Boolean = true, + @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text + ) { + val clipboard = clipboard ?: return + val clip = ClipData.newPlainText("pgp_handler_result_pm", text) + clipboard.setPrimaryClip(clip) + if (showSnackbar) { + snackbar(message = resources.getString(snackbarTextRes)) + } + } + + /** + * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to + * hide the default [Snackbar] and starts off an instance of [ClipboardService] to provide a + * way of clearing the clipboard. + */ + fun copyPasswordToClipboard(password: String?) { + copyTextToClipboard(password, showSnackbar = false) + + val clearAfter = settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() ?: 45 + + if (clearAfter != 0) { + val service = Intent(this, ClipboardService::class.java).apply { + action = ClipboardService.ACTION_START + putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(service) + } else { + startService(service) + } + snackbar(message = resources.getString(R.string.clipboard_password_toast_text, clearAfter)) + } else { + snackbar(message = resources.getString(R.string.clipboard_password_no_clear_toast_text)) + } + } + + companion object { + + private const val TAG = "APS/BasePgpActivity" + const val KEY_PWGEN_TYPE_CLASSIC = "classic" + const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd" + + /** + * Gets the relative path to the repository + */ + fun getRelativePath(fullPath: String, repositoryPath: String): String = + fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") + + /** + * Gets the Parent path, relative to the repository + */ + fun getParentPath(fullPath: String, repositoryPath: String): String { + val relativePath = getRelativePath(fullPath, repositoryPath) + val index = relativePath.lastIndexOf("/") + return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace("/+".toRegex(), "/") + } + + /** + * /path/to/store/social/facebook.gpg -> social/facebook + */ + @JvmStatic + fun getLongName(fullPath: String, repositoryPath: String, basename: String): String { + var relativePath = getRelativePath(fullPath, repositoryPath) + return if (relativePath.isNotEmpty() && relativePath != "/") { + // remove preceding '/' + relativePath = relativePath.substring(1) + if (relativePath.endsWith('/')) { + relativePath + basename + } else { + "$relativePath/$basename" + } + } else { + basename + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt new file mode 100644 index 00000000..8f36cd25 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt @@ -0,0 +1,260 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.crypto + +import android.content.Intent +import android.graphics.Typeface +import android.os.Bundle +import android.text.method.PasswordTransformationMethod +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.DecryptLayoutBinding +import dev.msfjarvis.aps.data.password.PasswordEntry +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.viewBinding +import java.io.ByteArrayOutputStream +import java.io.File +import kotlin.time.ExperimentalTime +import kotlin.time.seconds +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import org.openintents.openpgp.IOpenPgpService2 + +class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { + + private val binding by viewBinding(DecryptLayoutBinding::inflate) + + private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) } + private var passwordEntry: PasswordEntry? = null + + private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> + if (result.data == null) { + setResult(RESULT_CANCELED, null) + finish() + return@registerForActivityResult + } + + when (result.resultCode) { + RESULT_OK -> decryptAndVerify(result.data) + RESULT_CANCELED -> { + setResult(RESULT_CANCELED, result.data) + finish() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + bindToOpenKeychain(this) + title = name + with(binding) { + setContentView(root) + passwordCategory.text = relativeParentPath + passwordFile.text = name + passwordFile.setOnLongClickListener { + copyTextToClipboard(name) + true + } + passwordLastChanged.run { + runCatching { + text = resources.getString(R.string.last_changed, lastChangedString) + }.onFailure { + visibility = View.GONE + } + } + } + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.pgp_handler, menu) + passwordEntry?.let { entry -> + if (menu != null) { + menu.findItem(R.id.edit_password).isVisible = true + if (entry.password.isNotEmpty()) { + menu.findItem(R.id.share_password_as_plaintext).isVisible = true + menu.findItem(R.id.copy_password).isVisible = true + } + } + } + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> onBackPressed() + R.id.edit_password -> editPassword() + R.id.share_password_as_plaintext -> shareAsPlaintext() + R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password) + } + return super.onOptionsItemSelected(item) + } + + override fun onBound(service: IOpenPgpService2) { + super.onBound(service) + decryptAndVerify() + } + + override fun onError(e: Exception) { + e(e) + } + + /** + * Automatically finishes the activity 60 seconds after decryption succeeded to prevent + * information leaks from stale activities. + */ + @OptIn(ExperimentalTime::class) + private fun startAutoDismissTimer() { + lifecycleScope.launch { + delay(60.seconds) + finish() + } + } + + /** + * Edit the current password and hide all the fields populated by encrypted data so that when + * the result triggers they can be repopulated with new data. + */ + private fun editPassword() { + val intent = Intent(this, PasswordCreationActivity::class.java) + intent.putExtra("FILE_PATH", relativeParentPath) + intent.putExtra("REPO_PATH", repoPath) + intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name) + intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password) + intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContent) + intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true) + startActivity(intent) + finish() + } + + private fun shareAsPlaintext() { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, passwordEntry?.password) + type = "text/plain" + } + // Always show a picker to give the user a chance to cancel + startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to))) + } + + @OptIn(ExperimentalTime::class) + private fun decryptAndVerify(receivedIntent: Intent? = null) { + if (api == null) { + bindToOpenKeychain(this) + return + } + val data = receivedIntent ?: Intent() + data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY + + val inputStream = File(fullPath).inputStream() + val outputStream = ByteArrayOutputStream() + + lifecycleScope.launch(Dispatchers.IO) { + api?.executeApiAsync(data, inputStream, outputStream) { result -> + when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + startAutoDismissTimer() + runCatching { + val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) + val showExtraContent = settings.getBoolean(PreferenceKeys.SHOW_EXTRA_CONTENT, true) + val monoTypeface = Typeface.createFromAsset(assets, "fonts/sourcecodepro.ttf") + val entry = PasswordEntry(outputStream) + + passwordEntry = entry + invalidateOptionsMenu() + + with(binding) { + if (entry.password.isEmpty()) { + passwordTextContainer.visibility = View.GONE + } else { + passwordTextContainer.visibility = View.VISIBLE + passwordText.typeface = monoTypeface + passwordText.setText(entry.password) + if (!showPassword) { + passwordText.transformationMethod = PasswordTransformationMethod.getInstance() + } + passwordTextContainer.setOnClickListener { copyPasswordToClipboard(entry.password) } + passwordText.setOnClickListener { copyPasswordToClipboard(entry.password) } + } + + if (entry.hasExtraContent()) { + if (entry.extraContentWithoutAuthData.isNotEmpty()) { + extraContentContainer.visibility = View.VISIBLE + extraContent.typeface = monoTypeface + extraContent.setText(entry.extraContentWithoutAuthData) + if (!showExtraContent) { + extraContent.transformationMethod = PasswordTransformationMethod.getInstance() + } + extraContentContainer.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) } + extraContent.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) } + } + + if (entry.hasUsername()) { + usernameText.typeface = monoTypeface + usernameText.setText(entry.username) + usernameTextContainer.setEndIconOnClickListener { copyTextToClipboard(entry.username) } + usernameTextContainer.visibility = View.VISIBLE + } else { + usernameTextContainer.visibility = View.GONE + } + + if (entry.hasTotp()) { + otpTextContainer.visibility = View.VISIBLE + otpTextContainer.setEndIconOnClickListener { + copyTextToClipboard( + otpText.text.toString(), + snackbarTextRes = R.string.clipboard_otp_copied_text + ) + } + launch(Dispatchers.IO) { + // Calculate the actual remaining time for the first pass + // then return to the standard 30 second affair. + val remainingTime = entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod) + withContext(Dispatchers.Main) { + otpText.setText(entry.calculateTotpCode() + ?: "Error") + } + delay(remainingTime.seconds) + repeat(Int.MAX_VALUE) { + val code = entry.calculateTotpCode() ?: "Error" + withContext(Dispatchers.Main) { + otpText.setText(code) + } + delay(30.seconds) + } + } + } + } + } + + if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) { + copyPasswordToClipboard(entry.password) + } + }.onFailure { e -> + e(e) + } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val sender = getUserInteractionRequestIntent(result) + userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) + } + OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + } + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt new file mode 100644 index 00000000..f49537aa --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt @@ -0,0 +1,76 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.crypto + +import android.content.Intent +import android.os.Bundle +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpUtils +import org.openintents.openpgp.IOpenPgpService2 + +class GetKeyIdsActivity : BasePgpActivity() { + + private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> + if (result.data == null || result.resultCode == RESULT_CANCELED) { + setResult(RESULT_CANCELED, result.data) + finish() + return@registerForActivityResult + } + getKeyIds(result.data!!) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + bindToOpenKeychain(this) + } + + override fun onBound(service: IOpenPgpService2) { + super.onBound(service) + getKeyIds() + } + + override fun onError(e: Exception) { + e(e) + } + + /** + * Get the Key ids from OpenKeychain + */ + private fun getKeyIds(data: Intent = Intent()) { + data.action = OpenPgpApi.ACTION_GET_KEY_IDS + lifecycleScope.launch(Dispatchers.IO) { + api?.executeApiAsync(data, null, null) { result -> + when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + runCatching { + val ids = result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS)?.map { + OpenPgpUtils.convertKeyIdToHex(it) + } ?: emptyList() + val keyResult = Intent().putExtra(OpenPgpApi.EXTRA_KEY_IDS, ids.toTypedArray()) + setResult(RESULT_OK, keyResult) + finish() + }.onFailure { e -> + e(e) + } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val sender = getUserInteractionRequestIntent(result) + userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) + } + OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + } + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt new file mode 100644 index 00000000..403f6e2a --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt @@ -0,0 +1,513 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.crypto + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.text.InputType +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.core.content.edit +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.zxing.integration.android.IntentIntegrator +import com.google.zxing.integration.android.IntentIntegrator.QR_CODE +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.data.password.PasswordEntry +import dev.msfjarvis.aps.util.autofill.AutofillPreferences +import dev.msfjarvis.aps.util.autofill.DirectoryStructure +import dev.msfjarvis.aps.ui.dialogs.PasswordGeneratorDialogFragment +import dev.msfjarvis.aps.ui.dialogs.XkPasswordGeneratorDialogFragment +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.databinding.PasswordCreationActivityBinding +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.base64 +import dev.msfjarvis.aps.util.extensions.commitChange +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.isInsideRepository +import dev.msfjarvis.aps.util.extensions.snackbar +import dev.msfjarvis.aps.util.extensions.viewBinding +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import me.msfjarvis.openpgpktx.util.OpenPgpUtils + +class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { + + private val binding by viewBinding(PasswordCreationActivityBinding::inflate) + + private val suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) } + private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) } + private val suggestedExtra by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } + private val shouldGeneratePassword by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) } + private val editing by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_EDITING, false) } + private val oldFileName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) } + private var oldCategory: String? = null + private var copy: Boolean = false + private var encryptionIntent: Intent = Intent() + + private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> + if (result.data == null) { + setResult(RESULT_CANCELED, null) + finish() + return@registerForActivityResult + } + + when (result.resultCode) { + RESULT_OK -> encrypt(result.data) + RESULT_CANCELED -> { + setResult(RESULT_CANCELED, result.data) + finish() + } + } + } + + private val otpImportAction = registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + binding.otpImportButton.isVisible = false + val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data) + val contents = "${intentResult.contents}\n" + val currentExtras = binding.extraContent.text.toString() + if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') + binding.extraContent.append("\n$contents") + else + binding.extraContent.append(contents) + snackbar(message = getString(R.string.otp_import_success)) + } else { + snackbar(message = getString(R.string.otp_import_failure)) + } + } + + private val gpgKeySelectAction = registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> + lifecycleScope.launch { + val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id") + withContext(Dispatchers.IO) { + gpgIdentifierFile.writeText(keyIds.joinToString("\n")) + } + commitChange(getString( + R.string.git_commit_gpg_id, + getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name) + )).onSuccess { + encrypt(encryptionIntent) + } + } + } + } + } + + private fun File.findTillRoot(fileName: String, rootPath: File): File? { + val gpgFile = File(this, fileName) + if (gpgFile.exists()) return gpgFile + + if (this.absolutePath == rootPath.absolutePath) { + return null + } + + val parent = parentFile + return if (parent != null && parent.exists()) { + parent.findTillRoot(fileName, rootPath) + } else { + null + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + bindToOpenKeychain(this) + title = if (editing) + getString(R.string.edit_password) + else + getString(R.string.new_password_title) + with(binding) { + setContentView(root) + generatePassword.setOnClickListener { generatePassword() } + otpImportButton.setOnClickListener { + otpImportAction.launch(IntentIntegrator(this@PasswordCreationActivity) + .setOrientationLocked(false) + .setBeepEnabled(false) + .setDesiredBarcodeFormats(QR_CODE) + .createScanIntent() + ) + } + + directoryInputLayout.apply { + if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) { + isEnabled = true + } else { + setBackgroundColor(getColor(android.R.color.transparent)) + } + val path = getRelativePath(fullPath, repoPath) + // Keep empty path field visible if it is editable. + if (path.isEmpty() && !isEnabled) + visibility = View.GONE + else { + directory.setText(path) + oldCategory = path + } + } + if (suggestedName != null) { + filename.setText(suggestedName) + } else { + filename.requestFocus() + } + // Allow the user to quickly switch between storing the username as the filename or + // in the encrypted extras. This only makes sense if the directory structure is + // FileBased. + if (suggestedName == null && + AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == + DirectoryStructure.FileBased + ) { + encryptUsername.apply { + visibility = View.VISIBLE + setOnClickListener { + if (isChecked) { + // User wants to enable username encryption, so we add it to the + // encrypted extras as the first line. + val username = filename.text.toString() + val extras = "username:$username\n${extraContent.text}" + + filename.text?.clear() + extraContent.setText(extras) + } else { + // User wants to disable username encryption, so we extract the + // username from the encrypted extras and use it as the filename. + val entry = PasswordEntry("PASSWORD\n${extraContent.text}") + val username = entry.username + + // username should not be null here by the logic in + // updateViewState, but it could still happen due to + // input lag. + if (username != null) { + filename.setText(username) + extraContent.setText(entry.extraContentWithoutAuthData) + } + } + } + } + listOf(filename, extraContent).forEach { + it.doOnTextChanged { _, _, _, _ -> updateViewState() } + } + } + suggestedPass?.let { + password.setText(it) + password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + suggestedExtra?.let { extraContent.setText(it) } + if (shouldGeneratePassword) { + generatePassword() + password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + } + updateViewState() + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.pgp_handler_new_password, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + setResult(RESULT_CANCELED) + finish() + } + R.id.save_password -> { + copy = false + encrypt() + } + R.id.save_and_copy_password -> { + copy = true + encrypt() + } + else -> return super.onOptionsItemSelected(item) + } + return true + } + + private fun generatePassword() { + when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) { + KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment() + .show(supportFragmentManager, "generator") + KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment() + .show(supportFragmentManager, "xkpwgenerator") + } + } + + private fun updateViewState() = with(binding) { + // Use PasswordEntry to parse extras for username + val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}") + encryptUsername.apply { + if (visibility != View.VISIBLE) + return@apply + val hasUsernameInFileName = filename.text.toString().isNotBlank() + val hasUsernameInExtras = entry.hasUsername() + isEnabled = hasUsernameInFileName xor hasUsernameInExtras + isChecked = hasUsernameInExtras + } + otpImportButton.isVisible = !entry.hasTotp() + } + + private sealed class GpgIdentifier { + data class KeyId(val id: Long) : GpgIdentifier() + data class UserId(val email: String) : GpgIdentifier() + } + + @OptIn(ExperimentalUnsignedTypes::class) + private fun parseGpgIdentifier(identifier: String): GpgIdentifier? { + if (identifier.isEmpty()) return null + // Match long key IDs: + // FF22334455667788 or 0xFF22334455667788 + val maybeLongKeyId = identifier.removePrefix("0x").takeIf { + it.matches("[a-fA-F0-9]{16}".toRegex()) + } + if (maybeLongKeyId != null) { + val keyId = maybeLongKeyId.toULong(16) + return GpgIdentifier.KeyId(keyId.toLong()) + } + + // Match fingerprints: + // FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899 + val maybeFingerprint = identifier.removePrefix("0x").takeIf { + it.matches("[a-fA-F0-9]{40}".toRegex()) + } + if (maybeFingerprint != null) { + // Truncating to the long key ID is not a security issue since OpenKeychain only accepts + // non-ambiguous key IDs. + val keyId = maybeFingerprint.takeLast(16).toULong(16) + return GpgIdentifier.KeyId(keyId.toLong()) + } + + return OpenPgpUtils.splitUserId(identifier).email?.let { GpgIdentifier.UserId(it) } + } + + /** + * Encrypts the password and the extra content + */ + private fun encrypt(receivedIntent: Intent? = null) { + with(binding) { + val editName = filename.text.toString().trim() + val editPass = password.text.toString() + val editExtra = extraContent.text.toString() + + if (editName.isEmpty()) { + snackbar(message = resources.getString(R.string.file_toast_text)) + return@with + } else if (editName.contains('/')) { + snackbar(message = resources.getString(R.string.invalid_filename_text)) + return@with + } + + if (editPass.isEmpty() && editExtra.isEmpty()) { + snackbar(message = resources.getString(R.string.empty_toast_text)) + return@with + } + + if (copy) { + copyPasswordToClipboard(editPass) + } + + encryptionIntent = receivedIntent ?: Intent() + encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT + + // pass enters the key ID into `.gpg-id`. + val repoRoot = PasswordRepository.getRepositoryDirectory() + val gpgIdentifierFile = File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot) + if (gpgIdentifierFile == null) { + snackbar(message = resources.getString(R.string.failed_to_find_key_id)) + return@with + } + val gpgIdentifiers = gpgIdentifierFile.readLines() + .filter { it.isNotBlank() } + .map { line -> + parseGpgIdentifier(line) ?: run { + // The line being empty means this is most likely an empty `.gpg-id` file + // we created. Skip the validation so we can make the user add a real ID. + if (line.isEmpty()) return@run + if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) { + snackbar(message = resources.getString(R.string.short_key_ids_unsupported)) + } else { + snackbar(message = resources.getString(R.string.invalid_gpg_id)) + } + return@with + } + } + if (gpgIdentifiers.isEmpty()) { + gpgKeySelectAction.launch(Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java)) + return@with + } + val keyIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.KeyId>().map { it.id }.toLongArray() + if (keyIds.isNotEmpty()) { + encryptionIntent.putExtra(OpenPgpApi.EXTRA_KEY_IDS, keyIds) + } + val userIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.UserId>().map { it.email }.toTypedArray() + if (userIds.isNotEmpty()) { + encryptionIntent.putExtra(OpenPgpApi.EXTRA_USER_IDS, userIds) + } + + encryptionIntent.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true) + + val content = "$editPass\n$editExtra" + val inputStream = ByteArrayInputStream(content.toByteArray()) + val outputStream = ByteArrayOutputStream() + + val path = when { + // If we allowed the user to edit the relative path, we have to consider it here instead + // of fullPath. + directoryInputLayout.isEnabled -> { + val editRelativePath = directory.text.toString().trim() + if (editRelativePath.isEmpty()) { + snackbar(message = resources.getString(R.string.path_toast_text)) + return + } + val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}") + if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) { + snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}") + return + } + + "${passwordDirectory.path}/$editName.gpg" + } + else -> "$fullPath/$editName.gpg" + } + + lifecycleScope.launch(Dispatchers.IO) { + api?.executeApiAsync(encryptionIntent, inputStream, outputStream) { result -> + when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + runCatching { + val file = File(path) + // If we're not editing, this file should not already exist! + if (!editing && file.exists()) { + snackbar(message = getString(R.string.password_creation_duplicate_error)) + return@executeApiAsync + } + + if (!file.isInsideRepository()) { + snackbar(message = getString(R.string.message_error_destination_outside_repo)) + return@executeApiAsync + } + + file.outputStream().use { + it.write(outputStream.toByteArray()) + } + + //associate the new password name with the last name's timestamp in history + val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64() + val timestamp = preference.getString(oldFilePathHash) + if (timestamp != null) { + preference.edit { + remove(oldFilePathHash) + putString(file.absolutePath.base64(), timestamp) + } + } + + val returnIntent = Intent() + returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path) + returnIntent.putExtra(RETURN_EXTRA_NAME, editName) + returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName)) + + if (shouldGeneratePassword) { + val directoryStructure = + AutofillPreferences.directoryStructure(applicationContext) + val entry = PasswordEntry(content) + returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password) + val username = entry.username + ?: directoryStructure.getUsernameFor(file) + returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) + } + + if (directoryInputLayout.isVisible && directoryInputLayout.isEnabled && oldFileName != null) { + val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg") + if (oldFile.path != file.path && !oldFile.delete()) { + setResult(RESULT_CANCELED) + MaterialAlertDialogBuilder(this@PasswordCreationActivity) + .setTitle(R.string.password_creation_file_fail_title) + .setMessage(getString(R.string.password_creation_file_delete_fail_message, oldFileName)) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> + finish() + } + .show() + return@executeApiAsync + } + } + + val commitMessageRes = if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text + lifecycleScope.launch { + commitChange(resources.getString( + commitMessageRes, + getLongName(fullPath, repoPath, editName) + )).onSuccess { + setResult(RESULT_OK, returnIntent) + finish() + } + } + + }.onFailure { e -> + if (e is IOException) { + e(e) { "Failed to write password file" } + setResult(RESULT_CANCELED) + MaterialAlertDialogBuilder(this@PasswordCreationActivity) + .setTitle(getString(R.string.password_creation_file_fail_title)) + .setMessage(getString(R.string.password_creation_file_write_fail_message)) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> + finish() + } + .show() + } else { + e(e) + } + } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val sender = getUserInteractionRequestIntent(result) + userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) + } + OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + } + } + } + } + } + + companion object { + + private const val KEY_PWGEN_TYPE_CLASSIC = "classic" + private const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd" + const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE" + const val RETURN_EXTRA_NAME = "NAME" + const val RETURN_EXTRA_LONG_NAME = "LONG_NAME" + const val RETURN_EXTRA_USERNAME = "USERNAME" + const val RETURN_EXTRA_PASSWORD = "PASSWORD" + const val EXTRA_FILE_NAME = "FILENAME" + const val EXTRA_PASSWORD = "PASSWORD" + const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT" + const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD" + const val EXTRA_EDITING = "EDITING" + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt new file mode 100644 index 00000000..c2577443 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt @@ -0,0 +1,161 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.dialogs + +import android.content.Context +import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.widget.FrameLayout +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.BasicBottomSheetBinding +import dev.msfjarvis.aps.util.extensions.resolveAttribute +import dev.msfjarvis.aps.util.extensions.viewBinding + +/** + * [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like + * API through [Builder] to create a similar UI, just at the bottom of the screen. + */ +class BasicBottomSheet private constructor( + val title: String?, + val message: String, + val positiveButtonLabel: String?, + val negativeButtonLabel: String?, + val positiveButtonClickListener: View.OnClickListener?, + val negativeButtonClickListener: View.OnClickListener?, +) : BottomSheetDialogFragment() { + + private val binding by viewBinding(BasicBottomSheetBinding::bind) + + private var behavior: BottomSheetBehavior<FrameLayout>? = null + private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() { + override fun onSlide(bottomSheet: View, slideOffset: Float) { + } + + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_COLLAPSED) { + dismiss() + } + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + if (savedInstanceState != null) dismiss() + return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + view.viewTreeObserver.removeOnGlobalLayoutListener(this) + val dialog = dialog as BottomSheetDialog? ?: return + behavior = dialog.behavior + behavior?.apply { + state = BottomSheetBehavior.STATE_EXPANDED + peekHeight = 0 + addBottomSheetCallback(bottomSheetCallback) + } + if (!title.isNullOrEmpty()) { + binding.bottomSheetTitle.isVisible = true + binding.bottomSheetTitle.text = title + } + binding.bottomSheetMessage.text = message + if (positiveButtonClickListener != null) { + positiveButtonLabel?.let { buttonLbl -> + binding.bottomSheetOkButton.text = buttonLbl + } + binding.bottomSheetOkButton.isVisible = true + binding.bottomSheetOkButton.setOnClickListener { + positiveButtonClickListener.onClick(it) + dismiss() + } + } + if (negativeButtonClickListener != null) { + binding.bottomSheetCancelButton.isVisible = true + negativeButtonLabel?.let { buttonLbl -> + binding.bottomSheetCancelButton.text = buttonLbl + } + binding.bottomSheetCancelButton.setOnClickListener { + negativeButtonClickListener.onClick(it) + dismiss() + } + } + } + }) + val gradientDrawable = GradientDrawable().apply { + setColor(requireContext().resolveAttribute(android.R.attr.windowBackground)) + } + view.background = gradientDrawable + } + + override fun dismiss() { + super.dismiss() + behavior?.removeBottomSheetCallback(bottomSheetCallback) + } + + class Builder(val context: Context) { + + private var title: String? = null + private var message: String? = null + private var positiveButtonLabel: String? = null + private var negativeButtonLabel: String? = null + private var positiveButtonClickListener: View.OnClickListener? = null + private var negativeButtonClickListener: View.OnClickListener? = null + + fun setTitleRes(@StringRes titleRes: Int): Builder { + this.title = context.resources.getString(titleRes) + return this + } + + fun setTitle(title: String): Builder { + this.title = title + return this + } + + fun setMessageRes(@StringRes messageRes: Int): Builder { + this.message = context.resources.getString(messageRes) + return this + } + + fun setMessage(message: String): Builder { + this.message = message + return this + } + + fun setPositiveButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder { + this.positiveButtonClickListener = listener + this.positiveButtonLabel = buttonLabel + return this + } + + fun setNegativeButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder { + this.negativeButtonClickListener = listener + this.negativeButtonLabel = buttonLabel + return this + } + + fun build(): BasicBottomSheet { + require(message != null) { "Message needs to be set" } + return BasicBottomSheet( + title, + message!!, + positiveButtonLabel, + negativeButtonLabel, + positiveButtonClickListener, + negativeButtonClickListener + ) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt new file mode 100644 index 00000000..7f50a619 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt @@ -0,0 +1,106 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.dialogs + +import android.app.Dialog +import android.content.Intent +import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.checkbox.MaterialCheckBox +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import dev.msfjarvis.aps.ui.passwords.PasswordStore +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.ui.crypto.BasePgpActivity +import dev.msfjarvis.aps.ui.crypto.GetKeyIdsActivity +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.extensions.commitChange +import dev.msfjarvis.aps.util.extensions.requestInputFocusOnView +import java.io.File +import kotlinx.coroutines.launch +import me.msfjarvis.openpgpktx.util.OpenPgpApi + +class FolderCreationDialogFragment : DialogFragment() { + + private lateinit var newFolder: File + + private val keySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == AppCompatActivity.RESULT_OK) { + result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> + val gpgIdentifierFile = File(newFolder, ".gpg-id") + gpgIdentifierFile.writeText(keyIds.joinToString("\n")) + val repo = PasswordRepository.getRepository(null) + if (repo != null) { + lifecycleScope.launch { + val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath + requireActivity().commitChange( + getString( + R.string.git_commit_gpg_id, + BasePgpActivity.getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name) + ), + ) + dismiss() + } + } + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext()) + alertDialogBuilder.setTitle(R.string.title_create_folder) + alertDialogBuilder.setView(R.layout.folder_dialog_fragment) + alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null) + alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ -> + dismiss() + } + val dialog = alertDialogBuilder.create() + dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text) + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!) + } + } + return dialog + } + + private fun createDirectory(currentDir: String) { + val dialog = requireDialog() + val folderNameView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text) + val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container) + newFolder = File("$currentDir/${folderNameView.text}") + folderNameViewContainer.error = when { + newFolder.isFile -> getString(R.string.folder_creation_err_file_exists) + newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists) + else -> null + } + if (folderNameViewContainer.error != null) return + newFolder.mkdirs() + (requireActivity() as PasswordStore).refreshPasswordList(newFolder) + if (dialog.findViewById<MaterialCheckBox>(R.id.set_gpg_key).isChecked) { + keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java)) + return + } else { + dismiss() + } + } + + companion object { + + private const val CURRENT_DIR_EXTRA = "CURRENT_DIRECTORY" + fun newInstance(startingDirectory: String): FolderCreationDialogFragment { + val extras = bundleOf(CURRENT_DIR_EXTRA to startingDirectory) + val fragment = FolderCreationDialogFragment() + fragment.arguments = extras + return fragment + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt new file mode 100644 index 00000000..4889f1e5 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt @@ -0,0 +1,77 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.dialogs + +import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.widget.FrameLayout +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dev.msfjarvis.aps.ui.passwords.PasswordFragment.Companion.ACTION_FOLDER +import dev.msfjarvis.aps.ui.passwords.PasswordFragment.Companion.ACTION_KEY +import dev.msfjarvis.aps.ui.passwords.PasswordFragment.Companion.ACTION_PASSWORD +import dev.msfjarvis.aps.ui.passwords.PasswordFragment.Companion.ITEM_CREATION_REQUEST_KEY +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.extensions.resolveAttribute + +class ItemCreationBottomSheet : BottomSheetDialogFragment() { + + private var behavior: BottomSheetBehavior<FrameLayout>? = null + private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() { + override fun onSlide(bottomSheet: View, slideOffset: Float) { + } + + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_COLLAPSED) { + dismiss() + } + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + if (savedInstanceState != null) dismiss() + return inflater.inflate(R.layout.item_create_sheet, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + view.viewTreeObserver.removeOnGlobalLayoutListener(this) + val dialog = dialog as BottomSheetDialog? ?: return + behavior = dialog.behavior + behavior?.apply { + state = BottomSheetBehavior.STATE_EXPANDED + peekHeight = 0 + addBottomSheetCallback(bottomSheetCallback) + } + dialog.findViewById<View>(R.id.create_folder)?.setOnClickListener { + setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_FOLDER)) + dismiss() + } + dialog.findViewById<View>(R.id.create_password)?.setOnClickListener { + setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_PASSWORD)) + dismiss() + } + } + }) + val gradientDrawable = GradientDrawable().apply { + setColor(requireContext().resolveAttribute(android.R.attr.windowBackground)) + } + view.background = gradientDrawable + } + + override fun dismiss() { + super.dismiss() + behavior?.removeBottomSheetCallback(bottomSheetCallback) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt new file mode 100644 index 00000000..0676ef1c --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt @@ -0,0 +1,101 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.dialogs + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.app.Dialog +import android.content.Context +import android.graphics.Typeface +import android.os.Bundle +import android.widget.CheckBox +import android.widget.EditText +import android.widget.Toast +import androidx.annotation.IdRes +import androidx.appcompat.widget.AppCompatEditText +import androidx.appcompat.widget.AppCompatTextView +import androidx.fragment.app.DialogFragment +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.pwgen.PasswordGenerator +import dev.msfjarvis.aps.util.pwgen.PasswordGenerator.generate +import dev.msfjarvis.aps.util.pwgen.PasswordGenerator.setPrefs +import dev.msfjarvis.aps.util.pwgen.PasswordOption +import dev.msfjarvis.aps.util.settings.PreferenceKeys + +class PasswordGeneratorDialogFragment : DialogFragment() { + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val callingActivity = requireActivity() + val inflater = callingActivity.layoutInflater + + @SuppressLint("InflateParams") + val view = inflater.inflate(R.layout.fragment_pwgen, null) + val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf") + val prefs = requireActivity().applicationContext + .getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) + + view.findViewById<CheckBox>(R.id.numerals)?.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false) + view.findViewById<CheckBox>(R.id.symbols)?.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false) + view.findViewById<CheckBox>(R.id.uppercase)?.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false) + view.findViewById<CheckBox>(R.id.lowercase)?.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false) + view.findViewById<CheckBox>(R.id.ambiguous)?.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false) + view.findViewById<CheckBox>(R.id.pronounceable)?.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true) + + val textView: AppCompatEditText = view.findViewById(R.id.lengthNumber) + textView.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString()) + val passwordText: AppCompatTextView = view.findViewById(R.id.passwordText) + passwordText.typeface = monoTypeface + return MaterialAlertDialogBuilder(requireContext()).run { + setTitle(R.string.pwgen_title) + setView(view) + setPositiveButton(R.string.dialog_ok) { _, _ -> + val edit = callingActivity.findViewById<EditText>(R.id.password) + edit.setText(passwordText.text) + } + setNeutralButton(R.string.dialog_cancel) { _, _ -> } + setNegativeButton(R.string.pwgen_generate, null) + create() + }.apply { + setOnShowListener { + generate(passwordText) + getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { + generate(passwordText) + } + } + } + } + + private fun generate(passwordField: AppCompatTextView) { + setPreferences() + passwordField.text = runCatching { + generate(requireContext().applicationContext) + }.getOrElse { e -> + Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show() + "" + } + } + + private fun isChecked(@IdRes id: Int): Boolean { + return requireDialog().findViewById<CheckBox>(id).isChecked + } + + private fun setPreferences() { + val preferences = listOfNotNull( + PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) }, + PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) }, + PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) }, + PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) }, + PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) }, + PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) } + ) + val lengthText = requireDialog().findViewById<EditText>(R.id.lengthNumber).text.toString() + val length = lengthText.toIntOrNull()?.takeIf { it >= 0 } + ?: PasswordGenerator.DEFAULT_LENGTH + setPrefs(requireActivity().applicationContext, preferences, length) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/XkPasswordGeneratorDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/XkPasswordGeneratorDialogFragment.kt new file mode 100644 index 00000000..995c7e2f --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/XkPasswordGeneratorDialogFragment.kt @@ -0,0 +1,131 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.dialogs + +import android.app.Dialog +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Typeface +import android.os.Bundle +import android.widget.EditText +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.content.edit +import androidx.fragment.app.DialogFragment +import com.github.ajalt.timberkt.Timber.tag +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.getOr +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.FragmentXkpwgenBinding +import dev.msfjarvis.aps.util.pwgenxkpwd.CapsType +import dev.msfjarvis.aps.util.pwgenxkpwd.PasswordBuilder +import dev.msfjarvis.aps.util.extensions.getString + +/** A placeholder fragment containing a simple view. */ +class XkPasswordGeneratorDialogFragment : DialogFragment() { + + private lateinit var prefs: SharedPreferences + private lateinit var binding: FragmentXkpwgenBinding + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = MaterialAlertDialogBuilder(requireContext()) + val callingActivity = requireActivity() + val inflater = callingActivity.layoutInflater + binding = FragmentXkpwgenBinding.inflate(inflater) + + val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf") + + builder.setView(binding.root) + + prefs = callingActivity.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) + + val previousStoredCapStyle: String = runCatching { + prefs.getString(PREF_KEY_CAPITALS_STYLE)!! + }.getOr(DEFAULT_CAPS_STYLE) + + val lastCapitalsStyleIndex: Int = runCatching { + CapsType.valueOf(previousStoredCapStyle).ordinal + }.getOr(DEFAULT_CAPS_INDEX) + binding.xkCapType.setSelection(lastCapitalsStyleIndex) + binding.xkNumWords.setText(prefs.getString(PREF_KEY_NUM_WORDS, DEFAULT_NUMBER_OF_WORDS)) + + binding.xkSeparator.setText(prefs.getString(PREF_KEY_SEPARATOR, DEFAULT_WORD_SEPARATOR)) + binding.xkNumberSymbolMask.setText(prefs.getString(PREF_KEY_EXTRA_SYMBOLS_MASK, DEFAULT_EXTRA_SYMBOLS_MASK)) + + binding.xkPasswordText.typeface = monoTypeface + + builder.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> + setPreferences() + val edit = callingActivity.findViewById<EditText>(R.id.password) + edit.setText(binding.xkPasswordText.text) + } + + // flip neutral and negative buttons + builder.setNeutralButton(resources.getString(R.string.dialog_cancel)) { _, _ -> } + builder.setNegativeButton(resources.getString(R.string.pwgen_generate), null) + + val dialog = builder.setTitle(this.resources.getString(R.string.xkpwgen_title)).create() + + dialog.setOnShowListener { + setPreferences() + makeAndSetPassword(binding.xkPasswordText) + + dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { + setPreferences() + makeAndSetPassword(binding.xkPasswordText) + } + } + return dialog + } + + private fun makeAndSetPassword(passwordText: AppCompatTextView) { + PasswordBuilder(requireContext()) + .setNumberOfWords(Integer.valueOf(binding.xkNumWords.text.toString())) + .setMinimumWordLength(DEFAULT_MIN_WORD_LENGTH) + .setMaximumWordLength(DEFAULT_MAX_WORD_LENGTH) + .setSeparator(binding.xkSeparator.text.toString()) + .appendNumbers(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_DIGIT }) + .appendSymbols(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_SYMBOL }) + .setCapitalization(CapsType.valueOf(binding.xkCapType.selectedItem.toString())).create() + .fold( + success = { passwordText.text = it }, + failure = { e -> + Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show() + tag("xkpw").e(e, "failure generating xkpasswd") + passwordText.text = FALLBACK_ERROR_PASS + }, + ) + } + + private fun setPreferences() { + prefs.edit { + putString(PREF_KEY_CAPITALS_STYLE, binding.xkCapType.selectedItem.toString()) + putString(PREF_KEY_NUM_WORDS, binding.xkNumWords.text.toString()) + putString(PREF_KEY_SEPARATOR, binding.xkSeparator.text.toString()) + putString(PREF_KEY_EXTRA_SYMBOLS_MASK, binding.xkNumberSymbolMask.text.toString()) + } + } + + companion object { + + const val PREF_KEY_CAPITALS_STYLE = "pref_key_capitals_style" + const val PREF_KEY_NUM_WORDS = "pref_key_num_words" + const val PREF_KEY_SEPARATOR = "pref_key_separator" + const val PREF_KEY_EXTRA_SYMBOLS_MASK = "pref_key_xkpwgen_extra_symbols_mask" + val DEFAULT_CAPS_STYLE = CapsType.Sentence.name + val DEFAULT_CAPS_INDEX = CapsType.Sentence.ordinal + const val DEFAULT_NUMBER_OF_WORDS = "3" + const val DEFAULT_WORD_SEPARATOR = "." + const val DEFAULT_EXTRA_SYMBOLS_MASK = "ds" + const val DEFAULT_MIN_WORD_LENGTH = 3 + const val DEFAULT_MAX_WORD_LENGTH = 9 + const val FALLBACK_ERROR_PASS = "42" + const val EXTRA_CHAR_PLACEHOLDER_DIGIT = 'd' + const val EXTRA_CHAR_PLACEHOLDER_SYMBOL = 's' + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt new file mode 100644 index 00000000..f2d655bb --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt @@ -0,0 +1,63 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.folderselect + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.commit +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.ui.passwords.PASSWORD_FRAGMENT_TAG +import dev.msfjarvis.aps.ui.passwords.PasswordStore + + +class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) { + + private lateinit var passwordList: SelectFolderFragment + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + passwordList = SelectFolderFragment() + val args = Bundle() + args.putString(PasswordStore.REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath) + + passwordList.arguments = args + + supportActionBar?.show() + + supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + + supportFragmentManager.commit { + replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG) + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.pgp_handler_select_folder, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + setResult(RESULT_CANCELED) + finish() + return true + } + R.id.crypto_select -> selectFolder() + } + return super.onOptionsItemSelected(item) + } + + private fun selectFolder() { + intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath) + setResult(RESULT_OK, intent) + finish() + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt new file mode 100644 index 00000000..597b968c --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt @@ -0,0 +1,81 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.folderselect + +import android.content.Context +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.util.viewmodel.ListMode +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel +import dev.msfjarvis.aps.databinding.PasswordRecyclerViewBinding +import dev.msfjarvis.aps.ui.adapters.PasswordItemRecyclerAdapter +import dev.msfjarvis.aps.data.password.PasswordItem +import dev.msfjarvis.aps.ui.passwords.PasswordStore +import dev.msfjarvis.aps.util.extensions.viewBinding +import java.io.File +import me.zhanghai.android.fastscroll.FastScrollerBuilder + +class SelectFolderFragment : Fragment(R.layout.password_recycler_view) { + + private val binding by viewBinding(PasswordRecyclerViewBinding::bind) + private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter + private lateinit var listener: OnFragmentInteractionListener + + private val model: SearchableRepositoryViewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.fab.hide() + recyclerAdapter = PasswordItemRecyclerAdapter() + .onItemClicked { _, item -> + listener.onFragmentInteraction(item) + } + binding.passRecycler.apply { + layoutManager = LinearLayoutManager(requireContext()) + itemAnimator = null + adapter = recyclerAdapter + } + + FastScrollerBuilder(binding.passRecycler).build() + registerForContextMenu(binding.passRecycler) + + val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) + model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false) + model.searchResult.observe(viewLifecycleOwner) { result -> + recyclerAdapter.submitList(result.passwordItems) + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + runCatching { + listener = object : OnFragmentInteractionListener { + override fun onFragmentInteraction(item: PasswordItem) { + if (item.type == PasswordItem.TYPE_CATEGORY) { + model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly) + (requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + } + } + }.onFailure { + throw ClassCastException("$context must implement OnFragmentInteractionListener") + } + } + + val currentDir: File + get() = model.currentDir.value!! + + interface OnFragmentInteractionListener { + + fun onFragmentInteraction(item: PasswordItem) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt new file mode 100644 index 00000000..89455c90 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt @@ -0,0 +1,146 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.git.base + +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import com.github.ajalt.timberkt.d +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.andThen +import com.github.michaelbull.result.mapError +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.settings.GitSettings +import dev.msfjarvis.aps.util.git.operation.BreakOutOfDetached +import dev.msfjarvis.aps.util.git.operation.CloneOperation +import dev.msfjarvis.aps.util.git.operation.PullOperation +import dev.msfjarvis.aps.util.git.operation.PushOperation +import dev.msfjarvis.aps.util.git.operation.ResetToRemoteOperation +import dev.msfjarvis.aps.util.git.operation.SyncOperation +import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import dev.msfjarvis.aps.util.git.ErrorMessages +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.SSHException +import net.schmizz.sshj.userauth.UserAuthException + +/** + * Abstract [AppCompatActivity] that holds some information that is commonly shared across git-related + * tasks and makes sense to be held here. + */ +abstract class BaseGitActivity : ContinuationContainerActivity() { + + /** + * Enum of possible Git operations than can be run through [launchGitOperation]. + */ + enum class GitOp { + + BREAK_OUT_OF_DETACHED, + CLONE, + PULL, + PUSH, + RESET, + SYNC, + } + + /** + * Attempt to launch the requested Git operation. + * @param operation The type of git operation to launch + */ + suspend fun launchGitOperation(operation: GitOp): Result<Unit, Throwable> { + if (GitSettings.url == null) { + return Err(IllegalStateException("Git url is not set!")) + } + if (operation == GitOp.SYNC && !GitSettings.useMultiplexing) { + // If the server does not support multiple SSH channels per connection, we cannot run + // a sync operation without reconnecting and thus break sync into its two parts. + return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) } + } + val op = when (operation) { + GitOp.CLONE -> CloneOperation(this, GitSettings.url!!) + GitOp.PULL -> PullOperation(this) + GitOp.PUSH -> PushOperation(this) + GitOp.SYNC -> SyncOperation(this) + GitOp.BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(this) + GitOp.RESET -> ResetToRemoteOperation(this) + } + return op.executeAfterAuthentication(GitSettings.authMode).mapError { throwable -> + val err = rootCauseException(throwable) + if (err.message?.contains("cannot open additional channels") == true) { + GitSettings.useMultiplexing = false + SSHException(DisconnectReason.TOO_MANY_CONNECTIONS, "The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used.") + } else { + err + } + } + } + + fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) { + finish() + } + + suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) { + val error = rootCauseException(err) + if (!isExplicitlyUserInitiatedError(error)) { + getEncryptedGitPrefs().edit { + remove(PreferenceKeys.HTTPS_PASSWORD) + } + sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } + d(error) + withContext(Dispatchers.Main) { + MaterialAlertDialogBuilder(this@BaseGitActivity).run { + setTitle(resources.getString(R.string.jgit_error_dialog_title)) + setMessage(ErrorMessages[error]) + setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } + setOnDismissListener { + onPromptDone() + } + show() + } + } + } else { + onPromptDone() + } + } + + /** + * Check if a given [Throwable] is the result of an error caused by the user cancelling the + * operation. + */ + private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean { + var cause: Throwable? = throwable + while (cause != null) { + if (cause is SSHException && + cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER) + return true + cause = cause.cause + } + return false + } + + /** + * Get the real root cause of a [Throwable] by traversing until known wrapping exceptions are no + * longer found. + */ + private fun rootCauseException(throwable: Throwable): Throwable { + var rootCause = throwable + // JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ exceptions. + // Also, SSHJ's UserAuthException about exhausting available authentication methods hides + // more useful exceptions. + while ((rootCause is org.eclipse.jgit.errors.TransportException || + rootCause is org.eclipse.jgit.api.errors.TransportException || + rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException || + (rootCause is UserAuthException && + rootCause.message == "Exhausted available authentication methods"))) { + rootCause = rootCause.cause ?: break + } + return rootCause + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt new file mode 100644 index 00000000..4067fda9 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt @@ -0,0 +1,153 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.git.config + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Patterns +import android.view.MenuItem +import androidx.core.os.postDelayed +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.ActivityGitConfigBinding +import dev.msfjarvis.aps.util.settings.GitSettings +import dev.msfjarvis.aps.ui.git.log.GitLogActivity +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.ui.git.base.BaseGitActivity +import dev.msfjarvis.aps.util.extensions.viewBinding +import kotlinx.coroutines.launch +import org.eclipse.jgit.lib.Constants +import org.eclipse.jgit.lib.Repository + +class GitConfigActivity : BaseGitActivity() { + + private val binding by viewBinding(ActivityGitConfigBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + if (GitSettings.authorName.isEmpty()) + binding.gitUserName.requestFocus() + else + binding.gitUserName.setText(GitSettings.authorName) + binding.gitUserEmail.setText(GitSettings.authorEmail) + setupTools() + binding.saveButton.setOnClickListener { + val email = binding.gitUserEmail.text.toString().trim() + val name = binding.gitUserName.text.toString().trim() + if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) { + MaterialAlertDialogBuilder(this) + .setMessage(getString(R.string.invalid_email_dialog_text)) + .setPositiveButton(getString(R.string.dialog_ok), null) + .show() + } else { + GitSettings.authorEmail = email + GitSettings.authorName = name + Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show() + Handler(Looper.getMainLooper()).postDelayed(500) { finish() } + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + finish() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + /** + * Sets up the UI components of the tools section. + */ + private fun setupTools() { + val repo = PasswordRepository.getRepository(null) + if (repo != null) { + binding.gitHeadStatus.text = headStatusMsg(repo) + // enable the abort button only if we're rebasing + val isRebasing = repo.repositoryState.isRebasing + binding.gitAbortRebase.isEnabled = isRebasing + binding.gitAbortRebase.alpha = if (isRebasing) 1.0f else 0.5f + } + binding.gitLog.setOnClickListener { + runCatching { + startActivity(Intent(this, GitLogActivity::class.java)) + }.onFailure { ex -> + e(ex) { "Failed to start GitLogActivity" } + } + } + binding.gitAbortRebase.setOnClickListener { + lifecycleScope.launch { + launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED).fold( + success = { + MaterialAlertDialogBuilder(this@GitConfigActivity).run { + setTitle(resources.getString(R.string.git_abort_and_push_title)) + setMessage(resources.getString( + R.string.git_break_out_of_detached_success, + GitSettings.branch, + "conflicting-${GitSettings.branch}-...", + )) + setOnDismissListener { finish() } + setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } + show() + } + }, + failure = { err -> + promptOnErrorHandler(err) { + finish() + } + }, + ) + } + } + binding.gitResetToRemote.setOnClickListener { + lifecycleScope.launch { + launchGitOperation(GitOp.RESET).fold( + success = ::finishOnSuccessHandler, + failure = { err -> + promptOnErrorHandler(err) { + finish() + } + }, + ) + } + } + } + + /** + * Returns a user-friendly message about the current state of HEAD. + * + * The state is recognized to be either pointing to a branch or detached. + */ + private fun headStatusMsg(repo: Repository): String { + return runCatching { + val headRef = repo.getRef(Constants.HEAD) + if (headRef.isSymbolic) { + val branchName = headRef.target.name + val shortBranchName = Repository.shortenRefName(branchName) + getString(R.string.git_head_on_branch, shortBranchName) + } else { + val commitHash = headRef.objectId.abbreviate(8).name() + getString(R.string.git_head_detached, commitHash) + } + }.getOrElse { ex -> + e(ex) { "Error getting HEAD reference" } + getString(R.string.git_head_missing) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt new file mode 100644 index 00000000..a0750eeb --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt @@ -0,0 +1,267 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.git.config + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.MenuItem +import android.view.View +import androidx.core.os.postDelayed +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.ActivityGitCloneBinding +import dev.msfjarvis.aps.util.settings.AuthMode +import dev.msfjarvis.aps.util.settings.GitSettings +import dev.msfjarvis.aps.util.settings.Protocol +import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.ui.git.base.BaseGitActivity +import dev.msfjarvis.aps.util.extensions.snackbar +import dev.msfjarvis.aps.util.extensions.viewBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Activity that encompasses both the initial clone as well as editing the server config for future + * changes. + */ +class GitServerConfigActivity : BaseGitActivity() { + + private val binding by viewBinding(ActivityGitCloneBinding::inflate) + + private lateinit var newAuthMode: AuthMode + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val isClone = intent?.extras?.getBoolean("cloning") ?: false + if (isClone) { + binding.saveButton.text = getString(R.string.clone_button) + } + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + newAuthMode = GitSettings.authMode + + binding.authModeGroup.apply { + when (newAuthMode) { + AuthMode.SshKey -> check(binding.authModeSshKey.id) + AuthMode.Password -> check(binding.authModePassword.id) + AuthMode.OpenKeychain -> check(binding.authModeOpenKeychain.id) + AuthMode.None -> check(View.NO_ID) + } + addOnButtonCheckedListener { _, _, _ -> + when (checkedButtonId) { + binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey + binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain + binding.authModePassword.id -> newAuthMode = AuthMode.Password + View.NO_ID -> newAuthMode = AuthMode.None + } + } + } + + binding.serverUrl.setText(GitSettings.url.also { + if (it.isNullOrEmpty()) return@also + setAuthModes(it.startsWith("http://") || it.startsWith("https://")) + }) + binding.serverBranch.setText(GitSettings.branch) + + binding.serverUrl.doOnTextChanged { text, _, _, _ -> + if (text.isNullOrEmpty()) return@doOnTextChanged + setAuthModes(text.startsWith("http://") || text.startsWith("https://")) + } + + binding.saveButton.setOnClickListener { + val newUrl = binding.serverUrl.text.toString().trim() + // If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://` + // in the beginning will cause the port to be seen as part of the path. Let users know + // about it and offer a quickfix. + if (newUrl.contains(PORT_REGEX)) { + if (newUrl.startsWith("https://")) { + BasicBottomSheet.Builder(this) + .setTitleRes(R.string.https_scheme_with_port_title) + .setMessageRes(R.string.https_scheme_with_port_message) + .setPositiveButtonClickListener { + binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/")) + } + .build() + .show(supportFragmentManager, "SSH_SCHEME_WARNING") + return@setOnClickListener + } else if (!newUrl.startsWith("ssh://")) { + BasicBottomSheet.Builder(this) + .setTitleRes(R.string.ssh_scheme_needed_title) + .setMessageRes(R.string.ssh_scheme_needed_message) + .setPositiveButtonClickListener { + @Suppress("SetTextI18n") + binding.serverUrl.setText("ssh://$newUrl") + } + .build() + .show(supportFragmentManager, "SSH_SCHEME_WARNING") + return@setOnClickListener + } + } + when (val updateResult = GitSettings.updateConnectionSettingsIfValid( + newAuthMode = newAuthMode, + newUrl = binding.serverUrl.text.toString().trim(), + newBranch = binding.serverBranch.text.toString().trim())) { + GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> { + Snackbar.make(binding.root, getString(R.string.git_server_config_save_error), Snackbar.LENGTH_LONG).show() + } + + is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> { + when (updateResult.newProtocol) { + Protocol.Https -> + BasicBottomSheet.Builder(this) + .setTitleRes(R.string.ssh_scheme_needed_title) + .setMessageRes(R.string.git_server_config_save_missing_username_https) + .setPositiveButtonClickListener { + } + .build() + .show(supportFragmentManager, "HTTPS_MISSING_USERNAME") + Protocol.Ssh -> + BasicBottomSheet.Builder(this) + .setTitleRes(R.string.ssh_scheme_needed_title) + .setMessageRes(R.string.git_server_config_save_missing_username_ssh) + .setPositiveButtonClickListener { + } + .build() + .show(supportFragmentManager, "SSH_MISSING_USERNAME") + } + } + GitSettings.UpdateConnectionSettingsResult.Valid -> { + if (isClone && PasswordRepository.getRepository(null) == null) + PasswordRepository.initialize() + if (!isClone) { + Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show() + Handler(Looper.getMainLooper()).postDelayed(500) { finish() } + } else { + cloneRepository() + } + } + is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> { + val message = getString( + R.string.git_server_config_save_auth_mode_mismatch, + updateResult.newProtocol, + updateResult.validModes.joinToString(", "), + ) + Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() + } + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + finish() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun setAuthModes(isHttps: Boolean) = with(binding) { + if (isHttps) { + authModeSshKey.isVisible = false + authModeOpenKeychain.isVisible = false + authModePassword.isVisible = true + if (authModeGroup.checkedButtonId != authModePassword.id) + authModeGroup.check(View.NO_ID) + } else { + authModeSshKey.isVisible = true + authModeOpenKeychain.isVisible = true + authModePassword.isVisible = true + if (authModeGroup.checkedButtonId == View.NO_ID) + authModeGroup.check(authModeSshKey.id) + } + } + + /** + * Clones the repository, the directory exists, deletes it + */ + private fun cloneRepository() { + val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory()) + val localDirFiles = localDir.listFiles() ?: emptyArray() + // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder + if (localDir.exists() && localDirFiles.isNotEmpty() && + !(localDirFiles.size == 1 && localDirFiles[0].name == ".git")) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.dialog_delete_title) + .setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString())) + .setCancelable(false) + .setPositiveButton(R.string.dialog_delete) { dialog, _ -> + runCatching { + lifecycleScope.launch { + val snackbar = snackbar(message = getString(R.string.delete_directory_progress_text), length = Snackbar.LENGTH_INDEFINITE) + withContext(Dispatchers.IO) { + localDir.deleteRecursively() + } + snackbar.dismiss() + launchGitOperation(GitOp.CLONE).fold( + success = { + setResult(RESULT_OK) + finish() + }, + failure = { err -> + promptOnErrorHandler(err) { + finish() + } + } + ) + } + }.onFailure { e -> + e.printStackTrace() + MaterialAlertDialogBuilder(this).setMessage(e.message).show() + } + dialog.cancel() + } + .setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> + dialog.cancel() + } + .show() + } else { + runCatching { + // Silently delete & replace the lone .git folder if it exists + if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") { + localDir.deleteRecursively() + } + }.onFailure { e -> + e(e) + MaterialAlertDialogBuilder(this).setMessage(e.message).show() + } + lifecycleScope.launch { + launchGitOperation(GitOp.CLONE).fold( + success = { + setResult(RESULT_OK) + finish() + }, + failure = { promptOnErrorHandler(it) }, + ) + } + } + } + + companion object { + + private val PORT_REGEX = ":[0-9]{1,5}/".toRegex() + + fun createCloneIntent(context: Context): Intent { + return Intent(context, GitServerConfigActivity::class.java).apply { + putExtra("cloning", true) + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt new file mode 100644 index 00000000..b02733fd --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt @@ -0,0 +1,49 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.git.log + +import android.os.Bundle +import android.view.MenuItem +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import dev.msfjarvis.aps.databinding.ActivityGitLogBinding +import dev.msfjarvis.aps.ui.git.base.BaseGitActivity +import dev.msfjarvis.aps.util.extensions.viewBinding + +/** + * Displays the repository's git commits in git-log fashion. + * + * It provides basic information about each commit by way of a non-interactive RecyclerView. + */ +class GitLogActivity : BaseGitActivity() { + + private val binding by viewBinding(ActivityGitLogBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + createRecyclerView() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + finish() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun createRecyclerView() { + binding.gitLogRecyclerView.apply { + setHasFixedSize(true) + addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL)) + adapter = GitLogAdapter() + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt new file mode 100644 index 00000000..bccbe3b4 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt @@ -0,0 +1,58 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.git.log + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.github.ajalt.timberkt.e +import dev.msfjarvis.aps.databinding.GitLogRowLayoutBinding +import dev.msfjarvis.aps.util.git.GitCommit +import dev.msfjarvis.aps.util.git.GitLogModel +import java.text.DateFormat +import java.util.Date + +private fun shortHash(hash: String): String { + return hash.substring(0 until 8) +} + +private fun stringFrom(date: Date): String { + return DateFormat.getDateTimeInstance().format(date) +} + +/** + * @see GitLogActivity + */ +class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() { + + private val model = GitLogModel() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + val commit = model.get(position) + if (commit == null) { + e { "There is no git commit for view holder at position $position." } + return + } + viewHolder.bind(commit) + } + + override fun getItemCount() = model.size + + class ViewHolder(private val binding: GitLogRowLayoutBinding) : RecyclerView.ViewHolder(binding.root) { + + fun bind(commit: GitCommit) = with(binding) { + gitLogRowMessage.text = commit.shortMessage + gitLogRowHash.text = shortHash(commit.hash) + gitLogRowTime.text = stringFrom(commit.time) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt new file mode 100644 index 00000000..a7eee919 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt @@ -0,0 +1,63 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.main + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import dev.msfjarvis.aps.ui.crypto.DecryptActivity +import dev.msfjarvis.aps.ui.passwords.PasswordStore +import dev.msfjarvis.aps.util.auth.BiometricAuthenticator +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.sharedPrefs + +class LaunchActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val prefs = sharedPrefs + if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH, false)) { + BiometricAuthenticator.authenticate(this) { + when (it) { + is BiometricAuthenticator.Result.Success -> { + startTargetActivity(false) + } + is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> { + prefs.edit { remove(PreferenceKeys.BIOMETRIC_AUTH) } + startTargetActivity(false) + } + is BiometricAuthenticator.Result.Failure, BiometricAuthenticator.Result.Cancelled -> { + finish() + } + } + } + } else { + startTargetActivity(true) + } + } + + private fun startTargetActivity(noAuth: Boolean) { + val intentToStart = if (intent.action == ACTION_DECRYPT_PASS) + Intent(this, DecryptActivity::class.java).apply { + putExtra("NAME", intent.getStringExtra("NAME")) + putExtra("FILE_PATH", intent.getStringExtra("FILE_PATH")) + putExtra("REPO_PATH", intent.getStringExtra("REPO_PATH")) + putExtra("LAST_CHANGED_TIMESTAMP", intent.getLongExtra("LAST_CHANGED_TIMESTAMP", 0L)) + } + else + Intent(this, PasswordStore::class.java) + startActivity(intentToStart) + + Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L) + } + + companion object { + + const val ACTION_DECRYPT_PASS = "DECRYPT_PASS" + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt new file mode 100644 index 00000000..b0443447 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt @@ -0,0 +1,26 @@ +/* + * Copyright © 2019-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.onboarding.activity + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import dev.msfjarvis.aps.R + +class OnboardingActivity : AppCompatActivity(R.layout.activity_onboarding) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.hide() + } + + override fun onBackPressed() { + if (supportFragmentManager.backStackEntryCount == 0) { + finishAffinity() + } else { + super.onBackPressed() + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt new file mode 100644 index 00000000..aed0b7a3 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt @@ -0,0 +1,59 @@ +/* + * Copyright © 2019-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + * + */ + +package dev.msfjarvis.aps.ui.onboarding.fragments + +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.fragment.app.Fragment +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.FragmentCloneBinding +import dev.msfjarvis.aps.ui.git.config.GitServerConfigActivity +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.finish +import dev.msfjarvis.aps.util.extensions.performTransactionWithBackStack +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import dev.msfjarvis.aps.util.extensions.viewBinding + +class CloneFragment : Fragment(R.layout.fragment_clone) { + + private val binding by viewBinding(FragmentCloneBinding::bind) + + private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs } + + private val cloneAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == AppCompatActivity.RESULT_OK) { + settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } + finish() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.cloneRemote.setOnClickListener { + cloneToHiddenDir() + } + binding.createLocal.setOnClickListener { + parentFragmentManager.performTransactionWithBackStack(RepoLocationFragment.newInstance()) + } + } + + /** + * Clones a remote Git repository to the app's private directory + */ + private fun cloneToHiddenDir() { + settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, false) } + cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext())) + } + + companion object { + + fun newInstance(): CloneFragment = CloneFragment() + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt new file mode 100644 index 00000000..5df830b1 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt @@ -0,0 +1,66 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.onboarding.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.ui.crypto.GetKeyIdsActivity +import dev.msfjarvis.aps.databinding.FragmentKeySelectionBinding +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.commitChange +import dev.msfjarvis.aps.util.extensions.finish +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import dev.msfjarvis.aps.util.extensions.viewBinding +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.msfjarvis.openpgpktx.util.OpenPgpApi + +class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) { + + private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs } + private val binding by viewBinding(FragmentKeySelectionBinding::bind) + + private val gpgKeySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == AppCompatActivity.RESULT_OK) { + result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> + lifecycleScope.launch { + withContext(Dispatchers.IO) { + val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id") + gpgIdentifierFile.writeText(keyIds.joinToString("\n")) + } + settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } + requireActivity().commitChange(getString( + R.string.git_commit_gpg_id, + getString(R.string.app_name) + )) + } + } + } else { + throw IllegalStateException("Failed to initialize repository state.") + } + finish() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.selectKey.setOnClickListener { gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java)) } + } + + companion object { + + fun newInstance() = KeySelectionFragment() + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/RepoLocationFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/RepoLocationFragment.kt new file mode 100644 index 00000000..08090d1b --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/RepoLocationFragment.kt @@ -0,0 +1,192 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + * + */ + +package dev.msfjarvis.aps.ui.onboarding.fragments + +import android.Manifest +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.fragment.app.Fragment +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.ui.settings.UserPreference +import dev.msfjarvis.aps.databinding.FragmentRepoLocationBinding +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.settings.PasswordSortOrder +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.finish +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.isPermissionGranted +import dev.msfjarvis.aps.util.extensions.listFilesRecursively +import dev.msfjarvis.aps.util.extensions.performTransactionWithBackStack +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import dev.msfjarvis.aps.util.extensions.viewBinding +import java.io.File + +class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) { + + private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs } + private val binding by viewBinding(FragmentRepoLocationBinding::bind) + private val sortOrder: PasswordSortOrder + get() = PasswordSortOrder.getSortOrder(settings) + + private val repositoryInitAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == AppCompatActivity.RESULT_OK) { + initializeRepositoryInfo() + } + } + + private val externalDirectorySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == AppCompatActivity.RESULT_OK) { + if (checkExternalDirectory()) { + finish() + } else { + createRepository() + } + } + } + + private val externalDirPermGrantedAction = createPermGrantedAction { + externalDirectorySelectAction.launch(UserPreference.createDirectorySelectionIntent(requireContext())) + } + + private val repositoryUsePermGrantedAction = createPermGrantedAction { + initializeRepositoryInfo() + } + + private val repositoryChangePermGrantedAction = createPermGrantedAction { + repositoryInitAction.launch(UserPreference.createDirectorySelectionIntent(requireContext())) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.hidden.setOnClickListener { + createRepoInHiddenDir() + } + + binding.sdcard.setOnClickListener { + createRepoFromExternalDir() + } + } + + /** + * Initializes an empty repository in the app's private directory + */ + private fun createRepoInHiddenDir() { + settings.edit { + putBoolean(PreferenceKeys.GIT_EXTERNAL, false) + remove(PreferenceKeys.GIT_EXTERNAL_REPO) + } + initializeRepositoryInfo() + } + + /** + * Initializes an empty repository in a selected directory if one does not already exist + */ + private fun createRepoFromExternalDir() { + settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, true) } + val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) + if (externalRepo == null) { + if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + externalDirPermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } else { + // Unlikely we have storage permissions without user ever selecting a directory, + // but let's not assume. + externalDirectorySelectAction.launch(UserPreference.createDirectorySelectionIntent(requireContext())) + } + } else { + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(resources.getString(R.string.directory_selected_title)) + .setMessage(resources.getString(R.string.directory_selected_message, externalRepo)) + .setPositiveButton(resources.getString(R.string.use)) { _, _ -> + if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + repositoryUsePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } else { + initializeRepositoryInfo() + } + } + .setNegativeButton(resources.getString(R.string.change)) { _, _ -> + if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + repositoryChangePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } else { + repositoryInitAction.launch(UserPreference.createDirectorySelectionIntent(requireContext())) + } + } + .show() + } + } + + private fun checkExternalDirectory(): Boolean { + if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) && + settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) != null) { + val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) + val dir = externalRepoPath?.let { File(it) } + if (dir != null && // The directory could be opened + dir.exists() && // The directory exists + dir.isDirectory && // The directory, is really a directory + dir.listFilesRecursively().isNotEmpty() && // The directory contains files + // The directory contains a non-zero number of password files + PasswordRepository.getPasswords(dir, PasswordRepository.getRepositoryDirectory(), sortOrder).isNotEmpty() + ) { + PasswordRepository.closeRepository() + return true + } + } + return false + } + + private fun createRepository() { + val localDir = PasswordRepository.getRepositoryDirectory() + runCatching { + check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" } + PasswordRepository.createRepository(localDir) + if (!PasswordRepository.isInitialized) { + PasswordRepository.initialize() + } + parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance()) + }.onFailure { e -> + e(e) + if (!localDir.delete()) { + d { "Failed to delete local repository: $localDir" } + } + finish() + } + } + + private fun initializeRepositoryInfo() { + val externalRepo = settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) + val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) + if (externalRepo && !isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + return + } + if (externalRepo && externalRepoPath != null) { + if (checkExternalDirectory()) { + finish() + return + } + } + createRepository() + } + + private fun createPermGrantedAction(block: () -> Unit) = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + block.invoke() + } + } + + companion object { + + fun newInstance(): RepoLocationFragment = RepoLocationFragment() + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt new file mode 100644 index 00000000..696aba17 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt @@ -0,0 +1,30 @@ +/* + * Copyright © 2019-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.onboarding.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.annotation.Keep +import androidx.fragment.app.Fragment +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.ui.settings.UserPreference +import dev.msfjarvis.aps.databinding.FragmentWelcomeBinding +import dev.msfjarvis.aps.util.extensions.performTransactionWithBackStack +import dev.msfjarvis.aps.util.extensions.viewBinding + +@Keep +@Suppress("unused") +class WelcomeFragment : Fragment(R.layout.fragment_welcome) { + + private val binding by viewBinding(FragmentWelcomeBinding::bind) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.letsGo.setOnClickListener { parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance()) } + binding.settingsButton.setOnClickListener { startActivity(Intent(requireContext(), UserPreference::class.java)) } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt new file mode 100644 index 00000000..9d880de8 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt @@ -0,0 +1,344 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.passwords + +import android.content.Context +import android.content.SharedPreferences +import android.os.Bundle +import android.os.Parcelable +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.appcompat.view.ActionMode +import androidx.core.content.edit +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.runCatching +import com.github.michaelbull.result.onFailure +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel +import dev.msfjarvis.aps.databinding.PasswordRecyclerViewBinding +import dev.msfjarvis.aps.ui.git.base.BaseGitActivity +import dev.msfjarvis.aps.ui.git.config.GitServerConfigActivity +import dev.msfjarvis.aps.util.settings.AuthMode +import dev.msfjarvis.aps.util.settings.GitSettings +import dev.msfjarvis.aps.ui.util.OnOffItemAnimator +import dev.msfjarvis.aps.ui.adapters.PasswordItemRecyclerAdapter +import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet +import dev.msfjarvis.aps.ui.dialogs.ItemCreationBottomSheet +import dev.msfjarvis.aps.data.password.PasswordItem +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.settings.PasswordSortOrder +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.base64 +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import dev.msfjarvis.aps.util.extensions.viewBinding +import java.io.File +import kotlinx.coroutines.launch +import me.zhanghai.android.fastscroll.FastScrollerBuilder + +class PasswordFragment : Fragment(R.layout.password_recycler_view) { + + private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter + private lateinit var listener: OnFragmentInteractionListener + private lateinit var settings: SharedPreferences + + private var recyclerViewStateToRestore: Parcelable? = null + private var actionMode: ActionMode? = null + private var scrollTarget: File? = null + + private val model: SearchableRepositoryViewModel by activityViewModels() + private val binding by viewBinding(PasswordRecyclerViewBinding::bind) + private val swipeResult = registerForActivityResult(StartActivityForResult()) { + binding.swipeRefresher.isRefreshing = false + requireStore().refreshPasswordList() + } + + val currentDir: File + get() = model.currentDir.value!! + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + settings = requireContext().sharedPrefs + initializePasswordList() + binding.fab.setOnClickListener { + ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET") + } + childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { _, bundle -> + when (bundle.getString(ACTION_KEY)) { + ACTION_FOLDER -> requireStore().createFolder() + ACTION_PASSWORD -> requireStore().createPassword() + } + } + } + + private fun initializePasswordList() { + val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git") + val hasGitDir = gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true) + binding.swipeRefresher.setOnRefreshListener { + if (!hasGitDir) { + requireStore().refreshPasswordList() + binding.swipeRefresher.isRefreshing = false + } else if (!PasswordRepository.isGitRepo()) { + BasicBottomSheet.Builder(requireContext()) + .setMessageRes(R.string.clone_git_repo) + .setPositiveButtonClickListener(getString(R.string.clone_button)) { + swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext())) + } + .build() + .show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO") + binding.swipeRefresher.isRefreshing = false + } else { + // When authentication is set to AuthMode.None then the only git operation we can + // run is a pull, so automatically fallback to that. + val operationId = when (GitSettings.authMode) { + AuthMode.None -> BaseGitActivity.GitOp.PULL + else -> BaseGitActivity.GitOp.SYNC + } + requireStore().apply { + lifecycleScope.launch { + launchGitOperation(operationId).fold( + success = { + binding.swipeRefresher.isRefreshing = false + refreshPasswordList() + }, + failure = { err -> + promptOnErrorHandler(err) { + binding.swipeRefresher.isRefreshing = false + } + }, + ) + } + } + } + } + + recyclerAdapter = PasswordItemRecyclerAdapter() + .onItemClicked { _, item -> + listener.onFragmentInteraction(item) + } + .onSelectionChanged { selection -> + // In order to not interfere with drag selection, we disable the SwipeRefreshLayout + // once an item is selected. + binding.swipeRefresher.isEnabled = selection.isEmpty + + if (actionMode == null) + actionMode = requireStore().startSupportActionMode(actionModeCallback) + ?: return@onSelectionChanged + + if (!selection.isEmpty) { + actionMode!!.title = resources.getQuantityString(R.plurals.delete_title, selection.size(), selection.size()) + actionMode!!.invalidate() + } else { + actionMode!!.finish() + } + } + val recyclerView = binding.passRecycler + recyclerView.apply { + addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)) + layoutManager = LinearLayoutManager(requireContext()) + itemAnimator = OnOffItemAnimator() + adapter = recyclerAdapter + } + + FastScrollerBuilder(recyclerView).build() + recyclerAdapter.makeSelectable(recyclerView) + registerForContextMenu(recyclerView) + + val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) + model.navigateTo(File(path), pushPreviousLocation = false) + model.searchResult.observe(viewLifecycleOwner) { result -> + // Only run animations when the new list is filtered, i.e., the user submitted a search, + // and not on folder navigations since the latter leads to too many removal animations. + (recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered + recyclerAdapter.submitList(result.passwordItems) { + when { + result.isFiltered -> { + // When the result is filtered, we always scroll to the top since that is where + // the best fuzzy match appears. + recyclerView.scrollToPosition(0) + } + scrollTarget != null -> { + scrollTarget?.let { + recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it)) + } + scrollTarget = null + } + else -> { + // When the result is not filtered and there is a saved scroll position for it, + // we try to restore it. + recyclerViewStateToRestore?.let { + recyclerView.layoutManager!!.onRestoreInstanceState(it) + } + recyclerViewStateToRestore = null + } + } + } + } + } + + private val actionModeCallback = object : ActionMode.Callback { + // Called when the action mode is created; startActionMode() was called + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + // Inflate a menu resource providing context menu items + mode.menuInflater.inflate(R.menu.context_pass, menu) + // hide the fab + animateFab(false) + return true + } + + // Called each time the action mode is shown. Always called after onCreateActionMode, but + // may be called multiple times if the mode is invalidated. + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + menu.findItem(R.id.menu_edit_password).isVisible = + recyclerAdapter.getSelectedItems() + .all { it.type == PasswordItem.TYPE_CATEGORY } + return true + } + + // Called when the user selects a contextual menu item + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.menu_delete_password -> { + requireStore().deletePasswords(recyclerAdapter.getSelectedItems()) + // Action picked, so close the CAB + mode.finish() + true + } + R.id.menu_move_password -> { + requireStore().movePasswords(recyclerAdapter.getSelectedItems()) + false + } + R.id.menu_edit_password -> { + requireStore().renameCategory(recyclerAdapter.getSelectedItems()) + mode.finish() + false + } + else -> false + } + } + + // Called when the user exits the action mode + override fun onDestroyActionMode(mode: ActionMode) { + recyclerAdapter.requireSelectionTracker().clearSelection() + actionMode = null + // show the fab + animateFab(true) + } + + private fun animateFab(show: Boolean) = with(binding.fab) { + val animation = AnimationUtils.loadAnimation( + context, if (show) R.anim.scale_up else R.anim.scale_down + ) + animation.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationRepeat(animation: Animation?) { + } + + override fun onAnimationEnd(animation: Animation?) { + if (!show) visibility = View.GONE + } + + override fun onAnimationStart(animation: Animation?) { + if (show) visibility = View.VISIBLE + } + }) + animate().rotationBy(if (show) -90f else 90f) + .setStartDelay(if (show) 100 else 0) + .setDuration(100) + .start() + startAnimation(animation) + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + runCatching { + listener = object : OnFragmentInteractionListener { + override fun onFragmentInteraction(item: PasswordItem) { + if (settings.getString(PreferenceKeys.SORT_ORDER) == PasswordSortOrder.RECENTLY_USED.name) { + //save the time when password was used + val preferences = context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + preferences.edit { + putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString()) + } + } + + if (item.type == PasswordItem.TYPE_CATEGORY) { + navigateTo(item.file) + } else { + if (requireArguments().getBoolean("matchWith", false)) { + requireStore().matchPasswordWithApp(item) + } else { + requireStore().decryptPassword(item) + } + } + } + } + }.onFailure { + throw ClassCastException("$context must implement OnFragmentInteractionListener") + } + } + + private fun requireStore() = requireActivity() as PasswordStore + + /** + * Returns true if the back press was handled by the [Fragment]. + */ + fun onBackPressedInActivity(): Boolean { + if (!model.canNavigateBack) + return false + // The RecyclerView state is restored when the asynchronous update operation on the + // adapter is completed. + recyclerViewStateToRestore = model.navigateBack() + if (!model.canNavigateBack) + requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false) + return true + } + + fun dismissActionMode() { + actionMode?.finish() + } + + companion object { + + const val ITEM_CREATION_REQUEST_KEY = "creation_key" + const val ACTION_KEY = "action" + const val ACTION_FOLDER = "folder" + const val ACTION_PASSWORD = "password" + + fun newInstance(args: Bundle): PasswordFragment { + val fragment = PasswordFragment() + fragment.arguments = args + return fragment + } + } + + + fun navigateTo(file: File) { + requireStore().clearSearch() + model.navigateTo( + file, + recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState() + ) + requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + fun scrollToOnNextRefresh(file: File) { + scrollTarget = file + } + + interface OnFragmentInteractionListener { + + fun onFragmentInteraction(item: PasswordItem) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt new file mode 100644 index 00000000..c7ed5ee9 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt @@ -0,0 +1,697 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.passwords + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutInfo.Builder +import android.content.pm.ShortcutManager +import android.graphics.drawable.Icon +import android.os.Build +import android.os.Bundle +import android.view.KeyEvent +import android.view.Menu +import android.view.MenuItem +import android.view.MenuItem.OnActionExpandListener +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.activity.viewModels +import androidx.appcompat.widget.SearchView +import androidx.appcompat.widget.SearchView.OnQueryTextListener +import androidx.core.content.edit +import androidx.core.content.getSystemService +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.commit +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.e +import com.github.ajalt.timberkt.i +import com.github.ajalt.timberkt.w +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.getOr +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import dev.msfjarvis.aps.ui.main.LaunchActivity +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel +import dev.msfjarvis.aps.ui.folderselect.SelectFolderActivity +import dev.msfjarvis.aps.ui.settings.UserPreference +import dev.msfjarvis.aps.util.autofill.AutofillMatcher +import dev.msfjarvis.aps.ui.crypto.BasePgpActivity.Companion.getLongName +import dev.msfjarvis.aps.ui.crypto.DecryptActivity +import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity +import dev.msfjarvis.aps.ui.git.base.BaseGitActivity +import dev.msfjarvis.aps.util.settings.AuthMode +import dev.msfjarvis.aps.util.settings.GitSettings +import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet +import dev.msfjarvis.aps.ui.dialogs.FolderCreationDialogFragment +import dev.msfjarvis.aps.ui.onboarding.activity.OnboardingActivity +import dev.msfjarvis.aps.data.password.PasswordItem +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.base64 +import dev.msfjarvis.aps.util.extensions.commitChange +import dev.msfjarvis.aps.util.extensions.contains +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.isInsideRepository +import dev.msfjarvis.aps.util.extensions.isPermissionGranted +import dev.msfjarvis.aps.util.extensions.listFilesRecursively +import dev.msfjarvis.aps.util.extensions.requestInputFocusOnView +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.File +import java.lang.Character.UnicodeBlock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.eclipse.jgit.api.Git + +const val PASSWORD_FRAGMENT_TAG = "PasswordsList" + +class PasswordStore : BaseGitActivity() { + + private lateinit var searchItem: MenuItem + private val settings by lazy { sharedPrefs } + + private val model: SearchableRepositoryViewModel by viewModels { + ViewModelProvider.AndroidViewModelFactory(application) + } + + private val storagePermissionRequest = registerForActivityResult(RequestPermission()) { granted -> + if (granted) checkLocalRepository() + } + + private val directorySelectAction = registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + checkLocalRepository() + } + } + + private val listRefreshAction = registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + refreshPasswordList() + } + } + + private val passwordMoveAction = registerForActivityResult(StartActivityForResult()) { result -> + val intentData = result.data ?: return@registerForActivityResult + val filesToMove = requireNotNull(intentData.getStringArrayExtra("Files")) + val target = File(requireNotNull(intentData.getStringExtra("SELECTED_FOLDER_PATH"))) + val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath + if (!target.isDirectory) { + e { "Tried moving passwords to a non-existing folder." } + return@registerForActivityResult + } + + d { "Moving passwords to ${intentData.getStringExtra("SELECTED_FOLDER_PATH")}" } + d { filesToMove.joinToString(", ") } + + lifecycleScope.launch(Dispatchers.IO) { + for (file in filesToMove) { + val source = File(file) + if (!source.exists()) { + e { "Tried moving something that appears non-existent." } + continue + } + val destinationFile = File(target.absolutePath + "/" + source.name) + val basename = source.nameWithoutExtension + val sourceLongName = getLongName(requireNotNull(source.parent), repositoryPath, basename) + val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename) + if (destinationFile.exists()) { + e { "Trying to move a file that already exists." } + withContext(Dispatchers.Main) { + MaterialAlertDialogBuilder(this@PasswordStore) + .setTitle(resources.getString(R.string.password_exists_title)) + .setMessage(resources.getString( + R.string.password_exists_message, + destinationLongName, + sourceLongName) + ) + .setPositiveButton(R.string.dialog_ok) { _, _ -> + launch(Dispatchers.IO) { + moveFile(source, destinationFile) + } + } + .setNegativeButton(R.string.dialog_cancel, null) + .show() + } + } else { + launch(Dispatchers.IO) { + moveFile(source, destinationFile) + } + } + } + when (filesToMove.size) { + 1 -> { + val source = File(filesToMove[0]) + val basename = source.nameWithoutExtension + val sourceLongName = getLongName(requireNotNull(source.parent), repositoryPath, basename) + val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename) + withContext(Dispatchers.Main) { + commitChange( + resources.getString(R.string.git_commit_move_text, sourceLongName, destinationLongName), + ) + } + } + else -> { + val repoDir = PasswordRepository.getRepositoryDirectory().absolutePath + val relativePath = getRelativePath("${target.absolutePath}/", repoDir) + withContext(Dispatchers.Main) { + commitChange( + resources.getString(R.string.git_commit_move_multiple_text, relativePath), + ) + } + } + } + } + refreshPasswordList() + getPasswordFragment()?.dismissActionMode() + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + // open search view on search key, or Ctr+F + if ((keyCode == KeyEvent.KEYCODE_SEARCH || keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) && + !searchItem.isActionViewExpanded) { + searchItem.expandActionView() + return true + } + + // open search view on any printable character and query for it + val c = event.unicodeChar.toChar() + val printable = isPrintable(c) + if (printable && !searchItem.isActionViewExpanded) { + searchItem.expandActionView() + (searchItem.actionView as SearchView).setQuery(c.toString(), true) + return true + } + return super.onKeyDown(keyCode, event) + } + + @SuppressLint("NewApi") + override fun onCreate(savedInstanceState: Bundle?) { + // If user opens app with permission granted then revokes and returns, + // prevent attempt to create password list fragment + var savedInstance = savedInstanceState + if (savedInstanceState != null && (!settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) || + !isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE))) { + savedInstance = null + } + super.onCreate(savedInstance) + setContentView(R.layout.activity_pwdstore) + + model.currentDir.observe(this) { dir -> + val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile + supportActionBar!!.apply { + if (dir != basePath) + title = dir.name + else + setTitle(R.string.app_name) + } + } + } + + override fun onStart() { + super.onStart() + refreshPasswordList() + } + + override fun onResume() { + super.onResume() + if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) { + hasRequiredStoragePermissions() + } else { + checkLocalRepository() + } + if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false) && ::searchItem.isInitialized) { + if (!searchItem.isActionViewExpanded) { + searchItem.expandActionView() + } + } + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + val menuRes = when { + GitSettings.authMode == AuthMode.None -> R.menu.main_menu_no_auth + PasswordRepository.isGitRepo() -> R.menu.main_menu_git + else -> R.menu.main_menu_non_git + } + menuInflater.inflate(menuRes, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + // Invalidation forces onCreateOptionsMenu to be called again. This is cheap and quick so + // we can get by without any noticeable difference in performance. + invalidateOptionsMenu() + searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + searchView.setOnQueryTextListener( + object : OnQueryTextListener { + override fun onQueryTextSubmit(s: String): Boolean { + searchView.clearFocus() + return true + } + + override fun onQueryTextChange(s: String): Boolean { + val filter = s.trim() + // List the contents of the current directory if the user enters a blank + // search term. + if (filter.isEmpty()) + model.navigateTo( + newDirectory = model.currentDir.value!!, + pushPreviousLocation = false + ) + else + model.search(filter) + return true + } + }) + + // When using the support library, the setOnActionExpandListener() method is + // static and accepts the MenuItem object as an argument + searchItem.setOnActionExpandListener( + object : OnActionExpandListener { + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + refreshPasswordList() + return true + } + + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + return true + } + }) + if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) { + searchItem.expandActionView() + } + return super.onPrepareOptionsMenu(menu) + } + + // 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. + override fun onOptionsItemSelected(item: MenuItem): Boolean { + val id = item.itemId + val initBefore = MaterialAlertDialogBuilder(this) + .setMessage(resources.getString(R.string.creation_dialog_text)) + .setPositiveButton(resources.getString(R.string.dialog_ok), null) + when (id) { + R.id.user_pref -> { + runCatching { + startActivity(Intent(this, UserPreference::class.java)) + }.onFailure { e -> + e.printStackTrace() + } + return true + } + R.id.git_push -> { + if (!PasswordRepository.isInitialized) { + initBefore.show() + return false + } + runGitOperation(GitOp.PUSH) + return true + } + R.id.git_pull -> { + if (!PasswordRepository.isInitialized) { + initBefore.show() + return false + } + runGitOperation(GitOp.PULL) + return true + } + R.id.git_sync -> { + if (!PasswordRepository.isInitialized) { + initBefore.show() + return false + } + runGitOperation(GitOp.SYNC) + return true + } + R.id.refresh -> { + refreshPasswordList() + return true + } + android.R.id.home -> onBackPressed() + else -> { + } + } + return super.onOptionsItemSelected(item) + } + + override fun onBackPressed() { + if (getPasswordFragment()?.onBackPressedInActivity() != true) + super.onBackPressed() + } + + private fun getPasswordFragment(): PasswordFragment? { + return supportFragmentManager.findFragmentByTag(PASSWORD_FRAGMENT_TAG) as? PasswordFragment + } + + fun clearSearch() { + if (searchItem.isActionViewExpanded) + searchItem.collapseActionView() + } + + private fun runGitOperation(operation: GitOp) = lifecycleScope.launch { + launchGitOperation(operation).fold( + success = { refreshPasswordList() }, + failure = { promptOnErrorHandler(it) }, + ) + } + + /** + * Validates if storage permission is granted, and requests for it if not. The return value + * is true if the permission has been granted. + */ + private fun hasRequiredStoragePermissions(): Boolean { + return if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + BasicBottomSheet.Builder(this) + .setMessageRes(R.string.access_sdcard_text) + .setPositiveButtonClickListener(getString(R.string.snackbar_action_grant)) { + storagePermissionRequest.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + .build() + .show(supportFragmentManager, "STORAGE_PERMISSION_MISSING") + false + } else { + checkLocalRepository() + true + } + } + + private fun checkLocalRepository() { + val repo = PasswordRepository.initialize() + if (repo == null) { + directorySelectAction.launch(UserPreference.createDirectorySelectionIntent(this)) + } else { + checkLocalRepository(PasswordRepository.getRepositoryDirectory()) + } + } + + private fun checkLocalRepository(localDir: File?) { + if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) { + d { "Check, dir: ${localDir.absolutePath}" } + // do not push the fragment if we already have it + if (getPasswordFragment() == null || + settings.getBoolean(PreferenceKeys.REPO_CHANGED, false)) { + settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) } + val args = Bundle() + args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath) + + // if the activity was started from the autofill settings, the + // intent is to match a clicked pwd with app. pass this to fragment + if (intent.getBooleanExtra("matchWith", false)) { + args.putBoolean("matchWith", true) + } + supportActionBar?.apply { + show() + setDisplayHomeAsUpEnabled(false) + } + supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + supportFragmentManager.commit { + replace(R.id.main_layout, PasswordFragment.newInstance(args), PASSWORD_FRAGMENT_TAG) + } + } + } else { + startActivity(Intent(this, OnboardingActivity::class.java)) + } + } + + private fun getRelativePath(fullPath: String, repositoryPath: String): String { + return fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") + } + + private fun getLastChangedTimestamp(fullPath: String): Long { + val repoPath = PasswordRepository.getRepositoryDirectory() + val repository = PasswordRepository.getRepository(repoPath) + if (repository == null) { + d { "getLastChangedTimestamp: No git repository" } + return File(fullPath).lastModified() + } + val git = Git(repository) + val relativePath = getRelativePath(fullPath, repoPath.absolutePath).substring(1) // Removes leading '/' + return runCatching { + val iterator = git.log().addPath(relativePath).call().iterator() + if (!iterator.hasNext()) { + w { "getLastChangedTimestamp: No commits for file: $relativePath" } + return -1 + } + iterator.next().commitTime.toLong() * 1000 + }.getOr(-1) + } + + fun decryptPassword(item: PasswordItem) { + val decryptIntent = Intent(this, DecryptActivity::class.java) + val authDecryptIntent = Intent(this, LaunchActivity::class.java) + for (intent in arrayOf(decryptIntent, authDecryptIntent)) { + intent.putExtra("NAME", item.toString()) + intent.putExtra("FILE_PATH", item.file.absolutePath) + intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath) + intent.putExtra("LAST_CHANGED_TIMESTAMP", getLastChangedTimestamp(item.file.absolutePath)) + } + // Needs an action to be a shortcut intent + authDecryptIntent.action = LaunchActivity.ACTION_DECRYPT_PASS + + startActivity(decryptIntent) + + // Adds shortcut + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + val shortcutManager: ShortcutManager = getSystemService() ?: return + val shortcut = Builder(this, item.fullPathToParent) + .setShortLabel(item.toString()) + .setLongLabel(item.fullPathToParent + item.toString()) + .setIcon(Icon.createWithResource(this, R.drawable.ic_lock_open_24px)) + .setIntent(authDecryptIntent) + .build() + val shortcuts = shortcutManager.dynamicShortcuts + if (shortcuts.size >= shortcutManager.maxShortcutCountPerActivity && shortcuts.size > 0) { + shortcuts.removeAt(shortcuts.size - 1) + shortcuts.add(0, shortcut) + shortcutManager.dynamicShortcuts = shortcuts + } else { + shortcutManager.addDynamicShortcuts(listOf(shortcut)) + } + } + } + + private fun validateState(): Boolean { + if (!PasswordRepository.isInitialized) { + MaterialAlertDialogBuilder(this) + .setMessage(resources.getString(R.string.creation_dialog_text)) + .setPositiveButton(resources.getString(R.string.dialog_ok), null) + .show() + return false + } + return true + } + + fun createPassword() { + if (!validateState()) return + val currentDir = currentDir + i { "Adding file to : ${currentDir.absolutePath}" } + val intent = Intent(this, PasswordCreationActivity::class.java) + intent.putExtra("FILE_PATH", currentDir.absolutePath) + intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath) + listRefreshAction.launch(intent) + } + + fun createFolder() { + if (!validateState()) return + FolderCreationDialogFragment.newInstance(currentDir.path).show(supportFragmentManager, null) + } + + fun deletePasswords(selectedItems: List<PasswordItem>) { + var size = 0 + selectedItems.forEach { + if (it.file.isFile) + size++ + else + size += it.file.listFilesRecursively().size + } + if (size == 0) { + selectedItems.map { item -> item.file.deleteRecursively() } + refreshPasswordList() + return + } + MaterialAlertDialogBuilder(this) + .setMessage(resources.getQuantityString(R.plurals.delete_dialog_text, size, size)) + .setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ -> + val filesToDelete = arrayListOf<File>() + selectedItems.forEach { item -> + if (item.file.isDirectory) + filesToDelete.addAll(item.file.listFilesRecursively()) + else + filesToDelete.add(item.file) + } + selectedItems.map { item -> item.file.deleteRecursively() } + refreshPasswordList() + AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete) + val fmt = selectedItems.joinToString(separator = ", ") { item -> + item.file.toRelativeString(PasswordRepository.getRepositoryDirectory()) + } + lifecycleScope.launch { + commitChange( + resources.getString(R.string.git_commit_remove_text, fmt), + ) + } + } + .setNegativeButton(resources.getString(R.string.dialog_no), null) + .show() + } + + fun movePasswords(values: List<PasswordItem>) { + val intent = Intent(this, SelectFolderActivity::class.java) + val fileLocations = values.map { it.file.absolutePath }.toTypedArray() + intent.putExtra("Files", fileLocations) + passwordMoveAction.launch(intent) + } + + enum class CategoryRenameError(val resource: Int) { + None(0), + EmptyField(R.string.message_category_error_empty_field), + CategoryExists(R.string.message_category_error_category_exists), + DestinationOutsideRepo(R.string.message_error_destination_outside_repo), + } + + /** + * Prompt the user with a new category name to assign, + * if the new category forms/leads a path (i.e. contains "/"), intermediate directories will be created + * and new category will be placed inside. + * + * @param oldCategory The category to change its name + * @param error Determines whether to show an error to the user in the alert dialog, + * this error may be due to the new category the user entered already exists or the field was empty or the + * destination path is outside the repository + * + * @see [CategoryRenameError] + * @see [isInsideRepository] + */ + private fun renameCategory(oldCategory: PasswordItem, error: CategoryRenameError = CategoryRenameError.None) { + val view = layoutInflater.inflate(R.layout.folder_dialog_fragment, null) + val newCategoryEditText = view.findViewById<TextInputEditText>(R.id.folder_name_text) + + if (error != CategoryRenameError.None) { + newCategoryEditText.error = getString(error.resource) + } + + val dialog = MaterialAlertDialogBuilder(this) + .setTitle(R.string.title_rename_folder) + .setView(view) + .setMessage(getString(R.string.message_rename_folder, oldCategory.name)) + .setPositiveButton(R.string.dialog_ok) { _, _ -> + val newCategory = File("${oldCategory.file.parent}/${newCategoryEditText.text}") + when { + newCategoryEditText.text.isNullOrBlank() -> renameCategory(oldCategory, CategoryRenameError.EmptyField) + newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists) + !newCategory.isInsideRepository() -> renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo) + else -> lifecycleScope.launch(Dispatchers.IO) { + moveFile(oldCategory.file, newCategory) + + //associate the new category with the last category's timestamp in history + val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val timestamp = preference.getString(oldCategory.file.absolutePath.base64()) + if (timestamp != null) { + preference.edit { + remove(oldCategory.file.absolutePath.base64()) + putString(newCategory.absolutePath.base64(), timestamp) + } + } + + withContext(Dispatchers.Main) { + commitChange( + resources.getString(R.string.git_commit_move_text, oldCategory.name, newCategory.name), + ) + } + } + } + } + .setNegativeButton(R.string.dialog_skip, null) + .create() + + dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text) + dialog.show() + } + + fun renameCategory(categories: List<PasswordItem>) { + for (oldCategory in categories) { + renameCategory(oldCategory) + } + } + + /** + * Refreshes the password list by re-executing the last navigation or search action, preserving + * the navigation stack and scroll position. If the current directory no longer exists, + * navigation is reset to the repository root. If the optional [target] argument is provided, + * it will be entered if it is a directory or scrolled into view if it is a file (both inside + * the current directory). + */ + fun refreshPasswordList(target: File? = null) { + val plist = getPasswordFragment() + if (target?.isDirectory == true && model.currentDir.value?.contains(target) == true) { + plist?.navigateTo(target) + } else if (target?.isFile == true && model.currentDir.value?.contains(target) == true) { + // Creating new passwords is handled by an activity, so we will refresh in onStart. + plist?.scrollToOnNextRefresh(target) + } else if (model.currentDir.value?.isDirectory == true) { + model.forceRefresh() + } else { + model.reset() + supportActionBar!!.setDisplayHomeAsUpEnabled(false) + } + } + + private val currentDir: File + get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory() + + private suspend fun moveFile(source: File, destinationFile: File) { + val sourceDestinationMap = if (source.isDirectory) { + destinationFile.mkdirs() + // Recursively list all files (not directories) below `source`, then + // obtain the corresponding target file by resolving the relative path + // starting at the destination folder. + source.listFilesRecursively().associateWith { destinationFile.resolve(it.relativeTo(source)) } + } else { + mapOf(source to destinationFile) + } + if (!source.renameTo(destinationFile)) { + e { "Something went wrong while moving $source to $destinationFile." } + withContext(Dispatchers.Main) { + MaterialAlertDialogBuilder(this@PasswordStore) + .setTitle(R.string.password_move_error_title) + .setMessage(getString(R.string.password_move_error_message, source, destinationFile)) + .setCancelable(true) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } else { + AutofillMatcher.updateMatches(this, sourceDestinationMap) + } + } + + fun matchPasswordWithApp(item: PasswordItem) { + val path = item.file + .absolutePath + .replace(PasswordRepository.getRepositoryDirectory().toString() + "/", "") + .replace(".gpg", "") + val data = Intent() + data.putExtra("path", path) + setResult(RESULT_OK, data) + finish() + } + + companion object { + + const val REQUEST_ARG_PATH = "PATH" + private fun isPrintable(c: Char): Boolean { + val block = UnicodeBlock.of(c) + return (!Character.isISOControl(c) && + block != null && block !== UnicodeBlock.SPECIALS) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt new file mode 100644 index 00000000..7154f217 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt @@ -0,0 +1,75 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.proxy + +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Patterns +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.core.os.postDelayed +import androidx.core.widget.doOnTextChanged +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.ActivityProxySelectorBinding +import dev.msfjarvis.aps.util.settings.GitSettings +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.proxy.ProxyUtils +import dev.msfjarvis.aps.util.extensions.getEncryptedProxyPrefs +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.viewBinding + +private val IP_ADDRESS_REGEX = Patterns.IP_ADDRESS.toRegex() +private val WEB_ADDRESS_REGEX = Patterns.WEB_URL.toRegex() + +class ProxySelectorActivity : AppCompatActivity() { + + private val binding by viewBinding(ActivityProxySelectorBinding::inflate) + private val proxyPrefs by lazy(LazyThreadSafetyMode.NONE) { applicationContext.getEncryptedProxyPrefs() } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + with(binding) { + proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST)) + proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME)) + proxyPrefs.getInt(PreferenceKeys.PROXY_PORT, -1).takeIf { it != -1 }?.let { + proxyPort.setText("$it") + } + proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD)) + save.setOnClickListener { saveSettings() } + proxyHost.doOnTextChanged { text, _, _, _ -> + if (text != null) { + proxyHost.error = if (text.matches(IP_ADDRESS_REGEX) || text.matches(WEB_ADDRESS_REGEX)) { + null + } else { + getString(R.string.invalid_proxy_url) + } + } + } + } + + } + + private fun saveSettings() { + proxyPrefs.edit { + binding.proxyHost.text?.toString()?.takeIf { it.isNotEmpty() }.let { + GitSettings.proxyHost = it + } + binding.proxyUser.text?.toString()?.takeIf { it.isNotEmpty() }.let { + GitSettings.proxyUsername = it + } + binding.proxyPort.text?.toString()?.takeIf { it.isNotEmpty() }?.let { + GitSettings.proxyPort = it.toInt() + } + binding.proxyPassword.text?.toString()?.takeIf { it.isNotEmpty() }.let { + GitSettings.proxyPassword = it + } + } + ProxyUtils.setDefaultProxy() + Handler(Looper.getMainLooper()).postDelayed(500) { finish() } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/UserPreference.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/UserPreference.kt new file mode 100644 index 00000000..df51562f --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/settings/UserPreference.kt @@ -0,0 +1,676 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.settings + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.ShortcutManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.provider.DocumentsContract +import android.provider.Settings +import android.text.TextUtils +import android.view.MenuItem +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts.OpenDocument +import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.content.edit +import androidx.core.content.getSystemService +import androidx.documentfile.provider.DocumentFile +import androidx.preference.CheckBoxPreference +import androidx.preference.EditTextPreference +import androidx.preference.ListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreferenceCompat +import com.github.ajalt.timberkt.Timber.tag +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.w +import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel +import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel +import com.github.michaelbull.result.getOr +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.BuildConfig +import dev.msfjarvis.aps.util.services.PasswordExportService +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.ui.crypto.BasePgpActivity +import dev.msfjarvis.aps.ui.git.config.GitConfigActivity +import dev.msfjarvis.aps.ui.git.config.GitServerConfigActivity +import dev.msfjarvis.aps.util.git.sshj.SshKey +import dev.msfjarvis.aps.util.pwgenxkpwd.XkpwdDictionary +import dev.msfjarvis.aps.ui.sshkeygen.ShowSshKeyFragment +import dev.msfjarvis.aps.ui.sshkeygen.SshKeyGenActivity +import dev.msfjarvis.aps.ui.proxy.ProxySelectorActivity +import dev.msfjarvis.aps.util.auth.BiometricAuthenticator +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.autofillManager +import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.File + +typealias ClickListener = Preference.OnPreferenceClickListener +typealias ChangeListener = Preference.OnPreferenceChangeListener + +class UserPreference : AppCompatActivity() { + + private lateinit var prefsFragment: PrefsFragment + private var fromIntent = false + + @Suppress("DEPRECATION") + private val directorySelectAction = registerForActivityResult(OpenDocumentTree()) { uri: Uri? -> + if (uri == null) return@registerForActivityResult + + tag(TAG).d { "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 path = if (split.size > 1) split[1] else split[0] + val repoPath = "${Environment.getExternalStorageDirectory()}/$path" + val prefs = sharedPrefs + + tag(TAG).d { "Selected repository path is $repoPath" } + + if (Environment.getExternalStorageDirectory().path == repoPath) { + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.sdcard_root_warning_title)) + .setMessage(getString(R.string.sdcard_root_warning_message)) + .setPositiveButton("Remove everything") { _, _ -> + prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, uri.path) } + } + .setNegativeButton(R.string.dialog_cancel, null) + .show() + } + prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, repoPath) } + if (fromIntent) { + setResult(RESULT_OK) + finish() + } + + } + + private val sshKeyImportAction = registerForActivityResult(OpenDocument()) { uri: Uri? -> + if (uri == null) return@registerForActivityResult + runCatching { + SshKey.import(uri) + + Toast.makeText(this, resources.getString(R.string.ssh_key_success_dialog_title), Toast.LENGTH_LONG).show() + setResult(RESULT_OK) + finish() + }.onFailure { e -> + MaterialAlertDialogBuilder(this) + .setTitle(resources.getString(R.string.ssh_key_error_dialog_title)) + .setMessage(e.message) + .setPositiveButton(resources.getString(R.string.dialog_ok), null) + .show() + } + } + + private val storeExportAction = registerForActivityResult(object : OpenDocumentTree() { + override fun createIntent(context: Context, input: Uri?): Intent { + return super.createIntent(context, input).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 + } + } + }) { uri: Uri? -> + if (uri == null) return@registerForActivityResult + val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri) + + if (targetDirectory != null) { + 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) + } + } + } + + private val storeCustomXkpwdDictionaryAction = registerForActivityResult(OpenDocument()) { uri -> + if (uri == null) return@registerForActivityResult + + Toast.makeText( + this, + this.resources.getString(R.string.xkpwgen_custom_dict_imported, uri.path), + Toast.LENGTH_SHORT + ).show() + + sharedPrefs.edit { putString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, uri.toString()) } + + val customDictPref = prefsFragment.findPreference<Preference>(PreferenceKeys.PREF_KEY_CUSTOM_DICT) + setCustomDictSummary(customDictPref, uri) + // copy user selected file to internal storage + val inputStream = contentResolver.openInputStream(uri) + val customDictFile = File(filesDir.toString(), XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE).outputStream() + inputStream?.copyTo(customDictFile, 1024) + inputStream?.close() + customDictFile.close() + + setResult(RESULT_OK) + } + + class PrefsFragment : PreferenceFragmentCompat() { + + private var autoFillEnablePreference: SwitchPreferenceCompat? = null + private var clearSavedPassPreference: Preference? = null + private var viewSshKeyPreference: Preference? = null + private lateinit var oreoAutofillDependencies: List<Preference> + private lateinit var prefsActivity: UserPreference + private lateinit var sharedPreferences: SharedPreferences + private lateinit var encryptedPreferences: SharedPreferences + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + prefsActivity = requireActivity() as UserPreference + val context = requireContext() + sharedPreferences = preferenceManager.sharedPreferences + encryptedPreferences = requireActivity().getEncryptedGitPrefs() + + addPreferencesFromResource(R.xml.preference) + + // Git preferences + val gitServerPreference = findPreference<Preference>(PreferenceKeys.GIT_SERVER_INFO) + val openkeystoreIdPreference = findPreference<Preference>(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) + val gitConfigPreference = findPreference<Preference>(PreferenceKeys.GIT_CONFIG) + val sshKeyPreference = findPreference<Preference>(PreferenceKeys.SSH_KEY) + val sshKeygenPreference = findPreference<Preference>(PreferenceKeys.SSH_KEYGEN) + viewSshKeyPreference = findPreference(PreferenceKeys.SSH_SEE_KEY) + clearSavedPassPreference = findPreference(PreferenceKeys.CLEAR_SAVED_PASS) + val deleteRepoPreference = findPreference<Preference>(PreferenceKeys.GIT_DELETE_REPO) + val externalGitRepositoryPreference = findPreference<Preference>(PreferenceKeys.GIT_EXTERNAL) + val selectExternalGitRepositoryPreference = findPreference<Preference>(PreferenceKeys.PREF_SELECT_EXTERNAL) + + if (!PasswordRepository.isGitRepo()) { + listOfNotNull( + gitServerPreference, + gitConfigPreference, + sshKeyPreference, + viewSshKeyPreference, + clearSavedPassPreference, + ).forEach { + it.parent?.removePreference(it) + } + } + + // General preferences + val showTimePreference = findPreference<Preference>(PreferenceKeys.GENERAL_SHOW_TIME) + val clearClipboard20xPreference = findPreference<CheckBoxPreference>(PreferenceKeys.CLEAR_CLIPBOARD_20X) + + // Autofill preferences + autoFillEnablePreference = findPreference(PreferenceKeys.AUTOFILL_ENABLE) + val oreoAutofillDirectoryStructurePreference = findPreference<ListPreference>(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE) + val oreoAutofillDefaultUsername = findPreference<EditTextPreference>(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) + val oreoAutofillCustomPublixSuffixes = findPreference<EditTextPreference>(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) + oreoAutofillDependencies = listOfNotNull( + oreoAutofillDirectoryStructurePreference, + oreoAutofillDefaultUsername, + oreoAutofillCustomPublixSuffixes, + ) + oreoAutofillCustomPublixSuffixes?.apply { + setOnBindEditTextListener { + it.isSingleLine = false + it.setHint(R.string.preference_custom_public_suffixes_hint) + } + } + + // Misc preferences + val appVersionPreference = findPreference<Preference>(PreferenceKeys.APP_VERSION) + + selectExternalGitRepositoryPreference?.summary = sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO) + ?: getString(R.string.no_repo_selected) + deleteRepoPreference?.isVisible = !sharedPreferences.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) + clearClipboard20xPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toInt() != 0 + openkeystoreIdPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty() + ?: false + + updateAutofillSettings() + updateClearSavedPassphrasePrefs() + + appVersionPreference?.summary = "Version: ${BuildConfig.VERSION_NAME}" + + sshKeyPreference?.onPreferenceClickListener = ClickListener { + prefsActivity.getSshKey() + true + } + + sshKeygenPreference?.onPreferenceClickListener = ClickListener { + prefsActivity.makeSshKey(true) + true + } + + viewSshKeyPreference?.onPreferenceClickListener = ClickListener { + val df = ShowSshKeyFragment() + df.show(parentFragmentManager, "public_key") + true + } + + clearSavedPassPreference?.onPreferenceClickListener = ClickListener { + encryptedPreferences.edit { + if (encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD) != null) + remove(PreferenceKeys.HTTPS_PASSWORD) + else if (encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) != null) + remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) + } + updateClearSavedPassphrasePrefs() + true + } + + openkeystoreIdPreference?.onPreferenceClickListener = ClickListener { + sharedPreferences.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) } + it.isVisible = false + true + } + + gitServerPreference?.onPreferenceClickListener = ClickListener { + startActivity(Intent(prefsActivity, GitServerConfigActivity::class.java)) + true + } + + gitConfigPreference?.onPreferenceClickListener = ClickListener { + startActivity(Intent(prefsActivity, GitConfigActivity::class.java)) + true + } + + deleteRepoPreference?.onPreferenceClickListener = ClickListener { + val repoDir = PasswordRepository.getRepositoryDirectory() + MaterialAlertDialogBuilder(prefsActivity) + .setTitle(R.string.pref_dialog_delete_title) + .setMessage(resources.getString(R.string.dialog_delete_msg, repoDir)) + .setCancelable(false) + .setPositiveButton(R.string.dialog_delete) { dialogInterface, _ -> + runCatching { + PasswordRepository.getRepositoryDirectory().deleteRecursively() + PasswordRepository.closeRepository() + }.onFailure { + // TODO Handle the different cases of exceptions + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + requireContext().getSystemService<ShortcutManager>()?.apply { + removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList()) + } + } + sharedPreferences.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) } + dialogInterface.cancel() + prefsActivity.finish() + } + .setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ -> run { dialogInterface.cancel() } } + .show() + + true + } + + selectExternalGitRepositoryPreference?.summary = + sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO) + ?: context.getString(R.string.no_repo_selected) + selectExternalGitRepositoryPreference?.onPreferenceClickListener = ClickListener { + prefsActivity.selectExternalGitRepository() + true + } + + val resetRepo = Preference.OnPreferenceChangeListener { _, o -> + deleteRepoPreference?.isVisible = !(o as Boolean) + PasswordRepository.closeRepository() + sharedPreferences.edit { putBoolean(PreferenceKeys.REPO_CHANGED, true) } + true + } + + selectExternalGitRepositoryPreference?.onPreferenceChangeListener = resetRepo + externalGitRepositoryPreference?.onPreferenceChangeListener = resetRepo + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + autoFillEnablePreference?.onPreferenceClickListener = ClickListener { + onEnableAutofillClick() + true + } + } + + findPreference<Preference>(PreferenceKeys.EXPORT_PASSWORDS)?.apply { + isVisible = sharedPreferences.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) + onPreferenceClickListener = Preference.OnPreferenceClickListener { + prefsActivity.exportPasswords() + true + } + } + + showTimePreference?.onPreferenceChangeListener = ChangeListener { _, newValue: Any? -> + runCatching { + val isEnabled = newValue.toString().toInt() != 0 + clearClipboard20xPreference?.isVisible = isEnabled + true + }.getOr(false) + } + + showTimePreference?.summaryProvider = Preference.SummaryProvider<Preference> { + getString(R.string.pref_clipboard_timeout_summary, sharedPreferences.getString + (PreferenceKeys.GENERAL_SHOW_TIME, "45")) + } + + findPreference<CheckBoxPreference>(PreferenceKeys.ENABLE_DEBUG_LOGGING)?.isVisible = !BuildConfig.ENABLE_DEBUG_FEATURES + + findPreference<CheckBoxPreference>(PreferenceKeys.BIOMETRIC_AUTH)?.apply { + val canAuthenticate = BiometricAuthenticator.canAuthenticate(prefsActivity) + + if (!canAuthenticate) { + isEnabled = false + isChecked = false + summary = getString(R.string.biometric_auth_summary_error) + } else { + setOnPreferenceClickListener { + isEnabled = false + sharedPreferences.edit { + val checked = isChecked + BiometricAuthenticator.authenticate(requireActivity()) { result -> + when (result) { + is BiometricAuthenticator.Result.Success -> { + // Apply the changes + putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked) + isEnabled = true + } + else -> { + // If any error occurs, revert back to the previous state. This + // catch-all clause includes the cancellation case. + putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked) + isChecked = !checked + isEnabled = true + } + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + requireContext().getSystemService<ShortcutManager>()?.apply { + removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList()) + } + } + } + true + } + } + } + + findPreference<Preference>(PreferenceKeys.PROXY_SETTINGS)?.onPreferenceClickListener = ClickListener { + startActivity(Intent(requireContext(), ProxySelectorActivity::class.java)) + true + } + + val prefCustomXkpwdDictionary = findPreference<Preference>(PreferenceKeys.PREF_KEY_CUSTOM_DICT) + prefCustomXkpwdDictionary?.onPreferenceClickListener = ClickListener { + prefsActivity.storeCustomDictionaryPath() + true + } + val dictUri = sharedPreferences.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: "" + + if (!TextUtils.isEmpty(dictUri)) { + setCustomDictSummary(prefCustomXkpwdDictionary, Uri.parse(dictUri)) + } + + val prefIsCustomDict = findPreference<CheckBoxPreference>(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT) + val prefCustomDictPicker = findPreference<Preference>(PreferenceKeys.PREF_KEY_CUSTOM_DICT) + val prefPwgenType = findPreference<ListPreference>(PreferenceKeys.PREF_KEY_PWGEN_TYPE) + updateXkPasswdPrefsVisibility(prefPwgenType?.value, prefIsCustomDict, prefCustomDictPicker) + + prefPwgenType?.onPreferenceChangeListener = ChangeListener { _, newValue -> + updateXkPasswdPrefsVisibility(newValue, prefIsCustomDict, prefCustomDictPicker) + true + } + + prefIsCustomDict?.onPreferenceChangeListener = ChangeListener { _, newValue -> + if (!(newValue as Boolean)) { + val customDictFile = File(context.filesDir, XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE) + if (customDictFile.exists() && !customDictFile.delete()) { + w { "Failed to delete custom XkPassword dictionary: $customDictFile" } + } + prefCustomDictPicker?.setSummary(R.string.xkpwgen_pref_custom_dict_picker_summary) + } + true + } + } + + private fun updateXkPasswdPrefsVisibility(newValue: Any?, prefIsCustomDict: CheckBoxPreference?, prefCustomDictPicker: Preference?) { + when (newValue as String) { + BasePgpActivity.KEY_PWGEN_TYPE_CLASSIC -> { + prefIsCustomDict?.isVisible = false + prefCustomDictPicker?.isVisible = false + } + BasePgpActivity.KEY_PWGEN_TYPE_XKPASSWD -> { + prefIsCustomDict?.isVisible = true + prefCustomDictPicker?.isVisible = true + } + } + } + + private fun updateAutofillSettings() { + val isAutofillServiceEnabled = prefsActivity.isAutofillServiceEnabled + val isAutofillSupported = prefsActivity.isAutofillServiceSupported + if (!isAutofillSupported) { + autoFillEnablePreference?.isVisible = false + } else { + autoFillEnablePreference?.isChecked = isAutofillServiceEnabled + } + oreoAutofillDependencies.forEach { + it.isVisible = isAutofillServiceEnabled + } + } + + private fun updateClearSavedPassphrasePrefs() { + clearSavedPassPreference?.apply { + val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) + val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD) + if (sshPass == null && httpsPass == null) { + isVisible = false + return@apply + } + title = when { + httpsPass != null -> getString(R.string.clear_saved_passphrase_https) + sshPass != null -> getString(R.string.clear_saved_passphrase_ssh) + else -> null + } + isVisible = true + } + } + + private fun updateViewSshPubkeyPref() { + viewSshKeyPreference?.isVisible = SshKey.canShowSshPublicKey + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun onEnableAutofillClick() { + if (prefsActivity.isAutofillServiceEnabled) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + prefsActivity.autofillManager!!.disableAutofillServices() + else + throw IllegalStateException("isAutofillServiceEnabled == true, but Build.VERSION.SDK_INT < Build.VERSION_CODES.O") + } else { + MaterialAlertDialogBuilder(prefsActivity).run { + setTitle(R.string.pref_autofill_enable_title) + @SuppressLint("InflateParams") + val layout = + layoutInflater.inflate(R.layout.oreo_autofill_instructions, null) + val supportedBrowsersTextView = + layout.findViewById<AppCompatTextView>(R.id.supportedBrowsers) + supportedBrowsersTextView.text = + getInstalledBrowsersWithAutofillSupportLevel(context).joinToString( + separator = "\n" + ) { + val appLabel = it.first + val supportDescription = when (it.second) { + BrowserAutofillSupportLevel.None -> getString(R.string.oreo_autofill_no_support) + BrowserAutofillSupportLevel.FlakyFill -> getString(R.string.oreo_autofill_flaky_fill_support) + BrowserAutofillSupportLevel.PasswordFill -> getString(R.string.oreo_autofill_password_fill_support) + BrowserAutofillSupportLevel.GeneralFill -> getString(R.string.oreo_autofill_general_fill_support) + BrowserAutofillSupportLevel.GeneralFillAndSave -> getString(R.string.oreo_autofill_general_fill_and_save_support) + } + "$appLabel: $supportDescription" + } + setView(layout) + setPositiveButton(R.string.dialog_ok) { _, _ -> + val intent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { + data = Uri.parse("package:${BuildConfig.APPLICATION_ID}") + } + startActivity(intent) + } + setNegativeButton(R.string.dialog_cancel, null) + setOnDismissListener { updateAutofillSettings() } + show() + } + } + } + + override fun onResume() { + super.onResume() + updateAutofillSettings() + updateClearSavedPassphrasePrefs() + updateViewSshPubkeyPref() + } + } + + override fun onBackPressed() { + super.onBackPressed() + setResult(RESULT_OK) + finish() + } + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + when (intent?.getStringExtra("operation")) { + "get_ssh_key" -> getSshKey() + "make_ssh_key" -> makeSshKey(false) + "git_external" -> { + fromIntent = true + selectExternalGitRepository() + } + } + prefsFragment = PrefsFragment() + + supportFragmentManager + .beginTransaction() + .replace(android.R.id.content, prefsFragment) + .commit() + + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + @Suppress("Deprecation") // for Environment.getExternalStorageDirectory() + fun selectExternalGitRepository() { + MaterialAlertDialogBuilder(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) { _, _ -> + directorySelectAction.launch(null) + } + .setNegativeButton(R.string.dialog_cancel, null) + .show() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + // 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. + return when (item.itemId) { + android.R.id.home -> { + setResult(RESULT_OK) + finish() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun importSshKey() { + sshKeyImportAction.launch(arrayOf("*/*")) + } + + /** + * Opens a file explorer to import the private key + */ + private fun getSshKey() { + if (SshKey.exists) { + MaterialAlertDialogBuilder(this).run { + setTitle(R.string.ssh_keygen_existing_title) + setMessage(R.string.ssh_keygen_existing_message) + setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> + importSshKey() + } + setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> } + show() + } + } else { + importSshKey() + } + } + + /** + * Exports the passwords + */ + private fun exportPasswords() { + storeExportAction.launch(null) + } + + /** + * Opens a key generator to generate a public/private key pair + */ + fun makeSshKey(fromPreferences: Boolean) { + val intent = Intent(applicationContext, SshKeyGenActivity::class.java) + startActivity(intent) + if (!fromPreferences) { + setResult(RESULT_OK) + finish() + } + } + + /** + * Pick custom xkpwd dictionary from sdcard + */ + private fun storeCustomDictionaryPath() { + storeCustomXkpwdDictionaryAction.launch(arrayOf("*/*")) + } + + private val isAutofillServiceSupported: Boolean + get() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + return autofillManager?.isAutofillSupported != null + } + + private val isAutofillServiceEnabled: Boolean + get() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + return autofillManager?.hasEnabledAutofillServices() == true + } + + companion object { + + private const val TAG = "UserPreference" + + fun createDirectorySelectionIntent(context: Context): Intent { + return Intent(context, UserPreference::class.java).run { + putExtra("operation", "git_external") + } + } + + /** + * Set custom dictionary summary + */ + @JvmStatic + private fun setCustomDictSummary(customDictPref: Preference?, uri: Uri) { + val fileName = uri.path?.substring(uri.path?.lastIndexOf(":")!! + 1) + customDictPref?.summary = "Selected dictionary: $fileName" + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt new file mode 100644 index 00000000..0486b452 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt @@ -0,0 +1,38 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.sshkeygen + +import android.app.Dialog +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.git.sshj.SshKey + +class ShowSshKeyFragment : DialogFragment() { + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val activity = requireActivity() + val publicKey = SshKey.sshPublicKey + return MaterialAlertDialogBuilder(requireActivity()).run { + setMessage(getString(R.string.ssh_keygen_message, publicKey)) + setTitle(R.string.your_public_key) + setNegativeButton(R.string.ssh_keygen_later) { _, _ -> + (activity as? SshKeyGenActivity)?.finish() + } + setPositiveButton(R.string.ssh_keygen_share) { _, _ -> + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, publicKey) + } + startActivity(Intent.createChooser(sendIntent, null)) + (activity as? SshKeyGenActivity)?.finish() + } + create() + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt new file mode 100644 index 00000000..39819988 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt @@ -0,0 +1,164 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.sshkeygen + +import android.os.Bundle +import android.security.keystore.UserNotAuthenticatedException +import android.view.MenuItem +import android.view.View +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.core.content.getSystemService +import androidx.lifecycle.lifecycleScope +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.ActivitySshKeygenBinding +import dev.msfjarvis.aps.util.git.sshj.SshKey +import dev.msfjarvis.aps.util.auth.BiometricAuthenticator +import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs +import dev.msfjarvis.aps.util.extensions.keyguardManager +import dev.msfjarvis.aps.util.extensions.viewBinding +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) { + Rsa({ requireAuthentication -> + SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) + }), + Ecdsa({ requireAuthentication -> + SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) + }), + Ed25519({ requireAuthentication -> + SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication) + }), +} + +class SshKeyGenActivity : AppCompatActivity() { + + private var keyGenType = KeyGenType.Ecdsa + private val binding by viewBinding(ActivitySshKeygenBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + with(binding) { + generate.setOnClickListener { + if (SshKey.exists) { + MaterialAlertDialogBuilder(this@SshKeyGenActivity).run { + setTitle(R.string.ssh_keygen_existing_title) + setMessage(R.string.ssh_keygen_existing_message) + setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> + lifecycleScope.launch { + generate() + } + } + setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> + finish() + } + show() + } + } else { + lifecycleScope.launch { + generate() + } + } + } + keyTypeGroup.check(R.id.key_type_ecdsa) + keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa) + keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (isChecked) { + keyGenType = when (checkedId) { + R.id.key_type_ed25519 -> KeyGenType.Ed25519 + R.id.key_type_ecdsa -> KeyGenType.Ecdsa + R.id.key_type_rsa -> KeyGenType.Rsa + else -> throw IllegalStateException("Impossible key type selection") + } + keyTypeExplanation.setText(when (keyGenType) { + KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519 + KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa + KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa + }) + } + } + keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure + keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + // The back arrow in the action bar should act the same as the back button. + return when (item.itemId) { + android.R.id.home -> { + onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private suspend fun generate() { + binding.generate.apply { + text = getString(R.string.ssh_key_gen_generating_progress) + isEnabled = false + } + binding.generate.text = getString(R.string.ssh_key_gen_generating_progress) + val result = runCatching { + withContext(Dispatchers.IO) { + val requireAuthentication = binding.keyRequireAuthentication.isChecked + if (requireAuthentication) { + val result = withContext(Dispatchers.Main) { + suspendCoroutine<BiometricAuthenticator.Result> { cont -> + BiometricAuthenticator.authenticate(this@SshKeyGenActivity, R.string.biometric_prompt_title_ssh_keygen) { + cont.resume(it) + } + } + } + if (result !is BiometricAuthenticator.Result.Success) + throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure)) + } + keyGenType.generateKey(requireAuthentication) + } + } + getEncryptedGitPrefs().edit { + remove("ssh_key_local_passphrase") + } + binding.generate.apply { + text = getString(R.string.ssh_keygen_generate) + isEnabled = true + } + result.fold( + success = { + ShowSshKeyFragment().show(supportFragmentManager, "public_key") + }, + failure = { e -> + e.printStackTrace() + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.error_generate_ssh_key)) + .setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message) + .setPositiveButton(getString(R.string.dialog_ok)) { _, _ -> + finish() + } + .show() + }, + ) + hideKeyboard() + } + + private fun hideKeyboard() { + val imm = getSystemService<InputMethodManager>() ?: return + var view = currentFocus + if (view == null) { + view = View(this) + } + imm.hideSoftInputFromWindow(view.windowToken, 0) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt b/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt new file mode 100644 index 00000000..356a914c --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt @@ -0,0 +1,71 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.util + +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView + +class OnOffItemAnimator : DefaultItemAnimator() { + + var isEnabled: Boolean = true + set(value) { + // Defer update until no animation is running anymore. + isRunning { field = value } + } + + private fun dontAnimate(viewHolder: RecyclerView.ViewHolder): Boolean { + dispatchAnimationFinished(viewHolder) + return false + } + + override fun animateAppearance( + viewHolder: RecyclerView.ViewHolder, + preLayoutInfo: ItemHolderInfo?, + postLayoutInfo: ItemHolderInfo + ): Boolean { + return if (isEnabled) { + super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo) + } else { + dontAnimate(viewHolder) + } + } + + override fun animateChange( + oldHolder: RecyclerView.ViewHolder, + newHolder: RecyclerView.ViewHolder, + preInfo: ItemHolderInfo, + postInfo: ItemHolderInfo + ): Boolean { + return if (isEnabled) { + super.animateChange(oldHolder, newHolder, preInfo, postInfo) + } else { + dontAnimate(oldHolder) + } + } + + override fun animateDisappearance( + viewHolder: RecyclerView.ViewHolder, + preLayoutInfo: ItemHolderInfo, + postLayoutInfo: ItemHolderInfo? + ): Boolean { + return if (isEnabled) { + super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo) + } else { + dontAnimate(viewHolder) + } + } + + override fun animatePersistence( + viewHolder: RecyclerView.ViewHolder, + preInfo: ItemHolderInfo, + postInfo: ItemHolderInfo + ): Boolean { + return if (isEnabled) { + super.animatePersistence(viewHolder, preInfo, postInfo) + } else { + dontAnimate(viewHolder) + } + } +} |
