aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main/java/dev/msfjarvis/aps/util
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/dev/msfjarvis/aps/util')
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt131
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt236
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt202
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt148
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt233
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt120
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt42
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt133
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt98
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt41
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt70
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/features/Feature.kt23
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/features/Features.kt21
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt66
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt133
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt23
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt60
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt66
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt24
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt116
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/GcOperation.kt22
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt245
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt35
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt17
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt27
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt27
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt34
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt225
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt103
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt372
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt289
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt218
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt71
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt206
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt172
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt159
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt186
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt154
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt53
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt88
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/shortcuts/ShortcutHandler.kt106
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt53
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt473
43 files changed, 0 insertions, 5321 deletions
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt b/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt
deleted file mode 100644
index 38ff812a..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt
+++ /dev/null
@@ -1,131 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.auth
-
-import android.app.KeyguardManager
-import androidx.annotation.StringRes
-import androidx.biometric.BiometricManager
-import androidx.biometric.BiometricManager.Authenticators
-import androidx.biometric.BiometricPrompt
-import androidx.core.content.ContextCompat
-import androidx.core.content.getSystemService
-import androidx.fragment.app.FragmentActivity
-import dev.msfjarvis.aps.R
-import logcat.logcat
-
-object BiometricAuthenticator {
-
- private const val TAG = "BiometricAuthenticator"
- private const val validAuthenticators =
- Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK
-
- /**
- * Sealed class to wrap [BiometricPrompt]'s [Int]-based return codes into more easily-interpreted
- * types.
- */
- sealed class Result {
-
- /** Biometric authentication was a success. */
- data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result()
-
- /** Biometric authentication has irreversibly failed. */
- data class Failure(val code: Int?, val message: CharSequence) : Result()
-
- /**
- * An incorrect biometric was entered, but the prompt UI is offering the option to retry the
- * operation.
- */
- object Retry : Result()
-
- /** The biometric hardware is unavailable or disabled on a software or hardware level. */
- object HardwareUnavailableOrDisabled : Result()
-
- /** The prompt was dismissed. */
- object Cancelled : Result()
- }
-
- fun canAuthenticate(activity: FragmentActivity): Boolean {
- return BiometricManager.from(activity).canAuthenticate(validAuthenticators) ==
- BiometricManager.BIOMETRIC_SUCCESS
- }
-
- fun authenticate(
- activity: FragmentActivity,
- @StringRes dialogTitleRes: Int = R.string.biometric_prompt_title,
- callback: (Result) -> Unit
- ) {
- val authCallback =
- object : BiometricPrompt.AuthenticationCallback() {
- override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
- super.onAuthenticationError(errorCode, errString)
- logcat(TAG) { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" }
- callback(
- when (errorCode) {
- BiometricPrompt.ERROR_CANCELED,
- BiometricPrompt.ERROR_USER_CANCELED,
- BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
- Result.Cancelled
- }
- BiometricPrompt.ERROR_HW_NOT_PRESENT,
- BiometricPrompt.ERROR_HW_UNAVAILABLE,
- BiometricPrompt.ERROR_NO_BIOMETRICS,
- BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> {
- Result.HardwareUnavailableOrDisabled
- }
- BiometricPrompt.ERROR_LOCKOUT,
- BiometricPrompt.ERROR_LOCKOUT_PERMANENT,
- BiometricPrompt.ERROR_NO_SPACE,
- BiometricPrompt.ERROR_TIMEOUT,
- BiometricPrompt.ERROR_VENDOR -> {
- Result.Failure(
- errorCode,
- activity.getString(R.string.biometric_auth_error_reason, errString)
- )
- }
- BiometricPrompt.ERROR_UNABLE_TO_PROCESS -> {
- Result.Retry
- }
- // We cover all guaranteed values above, but [errorCode] is still an Int
- // at the end of
- // the day so a
- // catch-all else will always be required.
- else -> {
- Result.Failure(
- errorCode,
- activity.getString(R.string.biometric_auth_error_reason, errString)
- )
- }
- }
- )
- }
-
- override fun onAuthenticationFailed() {
- super.onAuthenticationFailed()
- callback(Result.Retry)
- }
-
- override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
- super.onAuthenticationSucceeded(result)
- callback(Result.Success(result.cryptoObject))
- }
- }
- val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true
- if (canAuthenticate(activity) || deviceHasKeyguard) {
- val promptInfo =
- BiometricPrompt.PromptInfo.Builder()
- .setTitle(activity.getString(dialogTitleRes))
- .setAllowedAuthenticators(validAuthenticators)
- .build()
- BiometricPrompt(
- activity,
- ContextCompat.getMainExecutor(activity.applicationContext),
- authCallback
- )
- .authenticate(promptInfo)
- } else {
- callback(Result.HardwareUnavailableOrDisabled)
- }
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt
deleted file mode 100644
index 745bad73..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt
+++ /dev/null
@@ -1,236 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.autofill
-
-import android.content.Context
-import android.content.IntentSender
-import android.service.autofill.Dataset
-import android.service.autofill.FillCallback
-import android.service.autofill.FillResponse
-import android.service.autofill.SaveInfo
-import android.view.inputmethod.InlineSuggestionsRequest
-import android.widget.inline.InlinePresentationSpec
-import androidx.annotation.RequiresApi
-import com.github.androidpasswordstore.autofillparser.AutofillAction
-import com.github.androidpasswordstore.autofillparser.FillableForm
-import com.github.androidpasswordstore.autofillparser.fillWith
-import com.github.michaelbull.result.fold
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
-import dev.msfjarvis.aps.autofill.oreo.ui.AutofillSmsActivity
-import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity
-import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivityV2
-import dev.msfjarvis.aps.ui.autofill.AutofillFilterView
-import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
-import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
-import dev.msfjarvis.aps.util.features.Feature
-import dev.msfjarvis.aps.util.features.Features
-import java.io.File
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-
-/** Implements [AutofillResponseBuilder]'s methods for API 30 and above */
-@RequiresApi(30)
-class Api30AutofillResponseBuilder
-@AssistedInject
-constructor(
- @Assisted form: FillableForm,
- private val features: Features,
-) {
-
- @AssistedFactory
- interface Factory {
- fun create(form: FillableForm): Api30AutofillResponseBuilder
- }
-
- private val formOrigin = form.formOrigin
- private val scenario = form.scenario
- private val ignoredIds = form.ignoredIds
- private val saveFlags = form.saveFlags
- private val clientState = form.toClientState()
-
- // We do not offer save when the only relevant field is a username field or there is no field.
- private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
- private val canBeSaved = saveFlags != null && scenarioSupportsSave
-
- private fun makeIntentDataset(
- context: Context,
- action: AutofillAction,
- intentSender: IntentSender,
- metadata: DatasetMetadata,
- imeSpec: InlinePresentationSpec?,
- ): Dataset {
- return Dataset.Builder(makeRemoteView(context, metadata)).run {
- fillWith(scenario, action, credentials = null)
- setAuthentication(intentSender)
- if (imeSpec != null) {
- val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata)
- if (inlinePresentation != null) {
- setInlinePresentation(inlinePresentation)
- }
- }
- build()
- }
- }
-
- private fun makeMatchDataset(
- context: Context,
- file: File,
- imeSpec: InlinePresentationSpec?
- ): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
- val metadata = makeFillMatchMetadata(context, file)
- val intentSender =
- if (features.isEnabled(Feature.EnablePGPainlessBackend)) {
- AutofillDecryptActivityV2.makeDecryptFileIntentSender(file, context)
- } else {
- AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
- }
- return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
- }
-
- private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
- val metadata = makeSearchAndFillMetadata(context)
- val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
- return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec)
- }
-
- private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
- val metadata = makeGenerateAndFillMetadata(context)
- val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
- return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec)
- }
-
- private fun makeFillOtpFromSmsDataset(
- context: Context,
- imeSpec: InlinePresentationSpec?
- ): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
- if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
- val metadata = makeFillOtpFromSmsMetadata(context)
- val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
- return makeIntentDataset(
- context,
- AutofillAction.FillOtpFromSms,
- intentSender,
- metadata,
- imeSpec
- )
- }
-
- private fun makePublisherChangedDataset(
- context: Context,
- publisherChangedException: AutofillPublisherChangedException,
- imeSpec: InlinePresentationSpec?
- ): Dataset {
- val metadata = makeWarningMetadata(context)
- // If the user decides to trust the new publisher, they can choose reset the list of
- // matches. In this case we need to immediately show a new `FillResponse` as if the app were
- // autofilled for the first time. This `FillResponse` needs to be returned as a result from
- // `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
- val fillResponseAfterReset = makeFillResponse(context, null, emptyList())
- val intentSender =
- AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
- context,
- publisherChangedException,
- fillResponseAfterReset
- )
- return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
- }
-
- private fun makePublisherChangedResponse(
- context: Context,
- inlineSuggestionsRequest: InlineSuggestionsRequest?,
- publisherChangedException: AutofillPublisherChangedException
- ): FillResponse {
- val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull()
- return FillResponse.Builder().run {
- addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec))
- setIgnoredIds(*ignoredIds.toTypedArray())
- build()
- }
- }
-
- private fun makeFillResponse(
- context: Context,
- inlineSuggestionsRequest: InlineSuggestionsRequest?,
- matchedFiles: List<File>
- ): FillResponse? {
- var datasetCount = 0
- val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()
- return FillResponse.Builder().run {
- for (file in matchedFiles) {
- makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let {
- datasetCount++
- addDataset(it)
- }
- }
- makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
- datasetCount++
- addDataset(it)
- }
- makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
- datasetCount++
- addDataset(it)
- }
- makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
- datasetCount++
- addDataset(it)
- }
- if (datasetCount == 0) return null
- setHeader(
- makeRemoteView(
- context,
- makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))
- )
- )
- makeSaveInfo()?.let { setSaveInfo(it) }
- setClientState(clientState)
- setIgnoredIds(*ignoredIds.toTypedArray())
- build()
- }
- }
-
- // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
- // See:
- // https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
- private fun makeSaveInfo(): SaveInfo? {
- if (!canBeSaved) return null
- check(saveFlags != null)
- val idsToSave = scenario.fieldsToSave.toTypedArray()
- if (idsToSave.isEmpty()) return null
- var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
- if (scenario.hasUsername) {
- saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
- }
- return SaveInfo.Builder(saveDataTypes, idsToSave).run {
- setFlags(saveFlags)
- build()
- }
- }
-
- /** Creates and returns a suitable [FillResponse] to the Autofill framework. */
- fun fillCredentials(
- context: Context,
- inlineSuggestionsRequest: InlineSuggestionsRequest?,
- callback: FillCallback
- ) {
- AutofillMatcher.getMatchesFor(context, formOrigin)
- .fold(
- success = { matchedFiles ->
- callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles))
- },
- failure = { e ->
- logcat(ERROR) { e.asLog() }
- callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e))
- }
- )
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt
deleted file mode 100644
index 42c1d693..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt
+++ /dev/null
@@ -1,202 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.autofill
-
-import android.content.Context
-import android.content.SharedPreferences
-import android.widget.Toast
-import androidx.core.content.edit
-import com.github.androidpasswordstore.autofillparser.FormOrigin
-import com.github.androidpasswordstore.autofillparser.computeCertificatesHash
-import com.github.michaelbull.result.Err
-import com.github.michaelbull.result.Ok
-import com.github.michaelbull.result.Result
-import dev.msfjarvis.aps.R
-import java.io.File
-import logcat.LogPriority.ERROR
-import logcat.LogPriority.WARN
-import logcat.logcat
-
-private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches"
-private val Context.autofillAppMatches
- get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE)
-
-private const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches"
-private val Context.autofillWebMatches
- get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE)
-
-private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences {
- return when (formOrigin) {
- is FormOrigin.App -> autofillAppMatches
- is FormOrigin.Web -> autofillWebMatches
- }
-}
-
-class AutofillPublisherChangedException(val formOrigin: FormOrigin) :
- Exception(
- "The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app"
- ) {
-
- init {
- require(formOrigin is FormOrigin.App)
- }
-}
-
-/** Manages "matches", i.e., associations between apps or websites and Password Store entries. */
-class AutofillMatcher {
-
- companion object {
-
- private const val MAX_NUM_MATCHES = 10
-
- private const val PREFERENCE_PREFIX_TOKEN = "token;"
- private fun tokenKey(formOrigin: FormOrigin.App) =
- "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
-
- private const val PREFERENCE_PREFIX_MATCHES = "matches;"
- private fun matchesKey(formOrigin: FormOrigin) =
- "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
-
- private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean {
- return when (formOrigin) {
- is FormOrigin.Web -> false
- is FormOrigin.App -> {
- val packageName = formOrigin.identifier
- val certificatesHash = computeCertificatesHash(context, packageName)
- val storedCertificatesHash =
- context.autofillAppMatches.getString(tokenKey(formOrigin), null) ?: return false
- val hashHasChanged = certificatesHash != storedCertificatesHash
- if (hashHasChanged) {
- logcat(ERROR) { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" }
- true
- } else {
- false
- }
- }
- }
- }
-
- private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) {
- if (formOrigin is FormOrigin.App) {
- val packageName = formOrigin.identifier
- val certificatesHash = computeCertificatesHash(context, packageName)
- context.autofillAppMatches.edit { putString(tokenKey(formOrigin), certificatesHash) }
- }
- // We don't need to store a hash for FormOrigin.Web since it can only originate from
- // browsers we trust to verify the origin.
- }
-
- /**
- * Get all Password Store entries that have already been associated with [formOrigin] by the
- * user.
- *
- * If [formOrigin] represents an app and that app's certificates have changed since the first
- * time the user associated an entry with it, an [AutofillPublisherChangedException] will be
- * thrown.
- */
- fun getMatchesFor(
- context: Context,
- formOrigin: FormOrigin
- ): Result<List<File>, AutofillPublisherChangedException> {
- if (hasFormOriginHashChanged(context, formOrigin)) {
- return Err(AutofillPublisherChangedException(formOrigin))
- }
- val matchPreferences = context.matchPreferences(formOrigin)
- val matchedFiles =
- matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
- return Ok(
- matchedFiles
- .filter { it.exists() }
- .also { validFiles ->
- matchPreferences.edit {
- putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet())
- }
- }
- )
- }
-
- fun clearMatchesFor(context: Context, formOrigin: FormOrigin) {
- context.matchPreferences(formOrigin).edit {
- remove(matchesKey(formOrigin))
- if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin))
- }
- }
-
- /**
- * Associates the store entry [file] with [formOrigin], such that future Autofill responses to
- * requests from this app or website offer this entry as a dataset.
- *
- * The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of Android
- * may crash when too many datasets are offered.
- */
- fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) {
- if (!file.exists()) return
- if (hasFormOriginHashChanged(context, formOrigin)) {
- // This should never happen since we already verified the publisher in
- // getMatchesFor.
- logcat(ERROR) { "App publisher changed between getMatchesFor and addMatchFor" }
- throw AutofillPublisherChangedException(formOrigin)
- }
- val matchPreferences = context.matchPreferences(formOrigin)
- val matchedFiles =
- matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
- val newFiles = setOf(file.absoluteFile).union(matchedFiles)
- if (newFiles.size > MAX_NUM_MATCHES) {
- Toast.makeText(
- context,
- context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES),
- Toast.LENGTH_LONG
- )
- .show()
- return
- }
- matchPreferences.edit {
- putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet())
- }
- storeFormOriginHash(context, formOrigin)
- logcat { "Stored match for $formOrigin" }
- }
-
- /**
- * Goes through all existing matches and updates their associated entries by using [moveFromTo]
- * as a lookup table and deleting the matches for files in [delete].
- */
- fun updateMatches(
- context: Context,
- moveFromTo: Map<File, File> = emptyMap(),
- delete: Collection<File> = emptyList()
- ) {
- val deletePathList = delete.map { it.absolutePath }
- val oldNewPathMap =
- moveFromTo.mapValues { it.value.absolutePath }.mapKeys { it.key.absolutePath }
- for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) {
- for ((key, value) in prefs.all) {
- if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue
- // We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were
- // created with `putStringSet`.
- @Suppress("UNCHECKED_CAST") val oldMatches = value as? Set<String>
- if (oldMatches == null) {
- logcat(WARN) { "Failed to read matches for $key" }
- continue
- }
- // Delete all matches for file locations that are going to be overwritten, then
- // transfer matches over to the files at their new locations.
- val newMatches =
- oldMatches
- .asSequence()
- .minus(deletePathList)
- .minus(oldNewPathMap.values)
- .map { match ->
- val newPath = oldNewPathMap[match] ?: return@map match
- logcat { "Updating match for $key: $match --> $newPath" }
- newPath
- }
- .toSet()
- if (newMatches != oldMatches) prefs.edit { putStringSet(key, newMatches) }
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt
deleted file mode 100644
index 6803da47..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt
+++ /dev/null
@@ -1,148 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.autofill
-
-import android.content.Context
-import androidx.annotation.RequiresApi
-import com.github.androidpasswordstore.autofillparser.Credentials
-import dev.msfjarvis.aps.data.passfile.PasswordEntry
-import dev.msfjarvis.aps.util.extensions.getString
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.services.getDefaultUsername
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import java.io.File
-import java.nio.file.Paths
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.runBlocking
-
-enum class DirectoryStructure(val value: String) {
- EncryptedUsername("encrypted_username"),
- FileBased("file"),
- DirectoryBased("directory");
-
- /**
- * Returns the username associated to [file], following the convention of the current
- * [DirectoryStructure].
- *
- * Examples:
- * - * --> null (EncryptedUsername)
- * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
- * - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased)
- * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
- */
- fun getUsernameFor(file: File): String? =
- when (this) {
- EncryptedUsername -> null
- FileBased -> file.nameWithoutExtension
- DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension
- }
-
- /**
- * Returns the origin identifier associated to [file], following the convention of the current
- * [DirectoryStructure].
- *
- * At least one of [DirectoryStructure.getIdentifierFor] and
- * [DirectoryStructure.getAccountPartFor] will always return a non-null result.
- *
- * Examples:
- * - work/example.org.gpg --> example.org (EncryptedUsername)
- * - work/example.org/john@doe.org.gpg --> example.org (FileBased)
- * - example.org.gpg --> example.org (FileBased, fallback)
- * - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased)
- * - Temporary PIN.gpg --> null (DirectoryBased)
- */
- fun getIdentifierFor(file: File): String? =
- when (this) {
- EncryptedUsername -> file.nameWithoutExtension
- FileBased -> file.parentFile?.name ?: file.nameWithoutExtension
- DirectoryBased -> file.parentFile?.parent
- }
-
- /**
- * Returns the path components of [file] until right before the component that contains the origin
- * identifier according to the current [DirectoryStructure].
- *
- * Examples:
- * - work/example.org.gpg --> work (EncryptedUsername)
- * - work/example.org/john@doe.org.gpg --> work (FileBased)
- * - example.org/john@doe.org.gpg --> null (FileBased)
- * - john@doe.org.gpg --> null (FileBased)
- * - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased)
- * - example.org/john@doe.org/password.gpg --> null (DirectoryBased)
- */
- fun getPathToIdentifierFor(file: File): String? =
- when (this) {
- EncryptedUsername -> file.parent
- FileBased -> file.parentFile?.parent
- DirectoryBased -> file.parentFile?.parentFile?.parent
- }
-
- /**
- * Returns the path component of [file] following the origin identifier according to the current
- * [DirectoryStructure](without file extension).
- *
- * At least one of [DirectoryStructure.getIdentifierFor] and
- * [DirectoryStructure.getAccountPartFor] will always return a non-null result.
- *
- * Examples:
- * - * --> null (EncryptedUsername)
- * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
- * - example.org.gpg --> null (FileBased, fallback)
- * - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased)
- * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
- */
- fun getAccountPartFor(file: File): String? =
- when (this) {
- EncryptedUsername -> null
- FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null }
- DirectoryBased ->
- file.parentFile?.let { parentFile -> "${parentFile.name}/${file.nameWithoutExtension}" }
- ?: file.nameWithoutExtension
- }
-
- @RequiresApi(26)
- fun getSaveFolderName(sanitizedIdentifier: String, username: String?) =
- when (this) {
- EncryptedUsername -> "/"
- FileBased -> sanitizedIdentifier
- DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString()
- }
-
- fun getSaveFileName(username: String?, identifier: String) =
- when (this) {
- EncryptedUsername -> identifier
- FileBased -> username
- DirectoryBased -> "password"
- }
-
- companion object {
-
- val DEFAULT = FileBased
-
- private val reverseMap = values().associateBy { it.value }
- fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT
- }
-}
-
-object AutofillPreferences {
-
- fun directoryStructure(context: Context): DirectoryStructure {
- val value = context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE)
- return DirectoryStructure.fromValue(value)
- }
-
- fun credentialsFromStoreEntry(
- context: Context,
- file: File,
- entry: PasswordEntry,
- directoryStructure: DirectoryStructure
- ): Credentials {
- // Always give priority to a username stored in the encrypted extras
- val username =
- entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername()
- val totp = if (entry.hasTotp()) runBlocking { entry.totp.first().value } else null
- return Credentials(username, entry.password, totp)
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt
deleted file mode 100644
index a091e206..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt
+++ /dev/null
@@ -1,233 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.autofill
-
-import android.content.Context
-import android.content.IntentSender
-import android.os.Build
-import android.os.Bundle
-import android.service.autofill.Dataset
-import android.service.autofill.FillCallback
-import android.service.autofill.FillResponse
-import android.service.autofill.SaveInfo
-import androidx.annotation.RequiresApi
-import com.github.androidpasswordstore.autofillparser.AutofillAction
-import com.github.androidpasswordstore.autofillparser.AutofillScenario
-import com.github.androidpasswordstore.autofillparser.Credentials
-import com.github.androidpasswordstore.autofillparser.FillableForm
-import com.github.androidpasswordstore.autofillparser.fillWith
-import com.github.michaelbull.result.fold
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
-import dev.msfjarvis.aps.autofill.oreo.ui.AutofillSmsActivity
-import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity
-import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivityV2
-import dev.msfjarvis.aps.ui.autofill.AutofillFilterView
-import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
-import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
-import dev.msfjarvis.aps.util.features.Feature
-import dev.msfjarvis.aps.util.features.Features
-import java.io.File
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-
-@RequiresApi(26)
-class AutofillResponseBuilder
-@AssistedInject
-constructor(
- @Assisted form: FillableForm,
- private val features: Features,
-) {
-
- @AssistedFactory
- interface Factory {
- fun create(form: FillableForm): AutofillResponseBuilder
- }
-
- private val formOrigin = form.formOrigin
- private val scenario = form.scenario
- private val ignoredIds = form.ignoredIds
- private val saveFlags = form.saveFlags
- private val clientState = form.toClientState()
-
- // We do not offer save when the only relevant field is a username field or there is no field.
- private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
- private val canBeSaved = saveFlags != null && scenarioSupportsSave
-
- private fun makeIntentDataset(
- context: Context,
- action: AutofillAction,
- intentSender: IntentSender,
- metadata: DatasetMetadata,
- ): Dataset {
- return Dataset.Builder(makeRemoteView(context, metadata)).run {
- fillWith(scenario, action, credentials = null)
- setAuthentication(intentSender)
- build()
- }
- }
-
- private fun makeMatchDataset(context: Context, file: File): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
- val metadata = makeFillMatchMetadata(context, file)
- val intentSender =
- if (features.isEnabled(Feature.EnablePGPainlessBackend)) {
- AutofillDecryptActivityV2.makeDecryptFileIntentSender(file, context)
- } else {
- AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
- }
- return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
- }
-
- private fun makeSearchDataset(context: Context): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
- val metadata = makeSearchAndFillMetadata(context)
- val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
- return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata)
- }
-
- private fun makeGenerateDataset(context: Context): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
- val metadata = makeGenerateAndFillMetadata(context)
- val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
- return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata)
- }
-
- private fun makeFillOtpFromSmsDataset(context: Context): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
- if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
- val metadata = makeFillOtpFromSmsMetadata(context)
- val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
- return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata)
- }
-
- private fun makePublisherChangedDataset(
- context: Context,
- publisherChangedException: AutofillPublisherChangedException,
- ): Dataset {
- val metadata = makeWarningMetadata(context)
- // If the user decides to trust the new publisher, they can choose reset the list of
- // matches. In this case we need to immediately show a new `FillResponse` as if the app were
- // autofilled for the first time. This `FillResponse` needs to be returned as a result from
- // `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
- val fillResponseAfterReset = makeFillResponse(context, emptyList())
- val intentSender =
- AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
- context,
- publisherChangedException,
- fillResponseAfterReset
- )
- return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
- }
-
- private fun makePublisherChangedResponse(
- context: Context,
- publisherChangedException: AutofillPublisherChangedException
- ): FillResponse {
- return FillResponse.Builder().run {
- addDataset(makePublisherChangedDataset(context, publisherChangedException))
- setIgnoredIds(*ignoredIds.toTypedArray())
- build()
- }
- }
-
- // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
- // See:
- // https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
- private fun makeSaveInfo(): SaveInfo? {
- if (!canBeSaved) return null
- check(saveFlags != null)
- val idsToSave = scenario.fieldsToSave.toTypedArray()
- if (idsToSave.isEmpty()) return null
- var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
- if (scenario.hasUsername) {
- saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
- }
- return SaveInfo.Builder(saveDataTypes, idsToSave).run {
- setFlags(saveFlags)
- build()
- }
- }
-
- private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
- var datasetCount = 0
- return FillResponse.Builder().run {
- for (file in matchedFiles) {
- makeMatchDataset(context, file)?.let {
- datasetCount++
- addDataset(it)
- }
- }
- makeGenerateDataset(context)?.let {
- datasetCount++
- addDataset(it)
- }
- makeFillOtpFromSmsDataset(context)?.let {
- datasetCount++
- addDataset(it)
- }
- makeSearchDataset(context)?.let {
- datasetCount++
- addDataset(it)
- }
- if (datasetCount == 0) return null
- if (Build.VERSION.SDK_INT >= 28) {
- setHeader(
- makeRemoteView(
- context,
- makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))
- )
- )
- }
- makeSaveInfo()?.let { setSaveInfo(it) }
- setClientState(clientState)
- setIgnoredIds(*ignoredIds.toTypedArray())
- build()
- }
- }
-
- /** Creates and returns a suitable [FillResponse] to the Autofill framework. */
- fun fillCredentials(context: Context, callback: FillCallback) {
- AutofillMatcher.getMatchesFor(context, formOrigin)
- .fold(
- success = { matchedFiles -> callback.onSuccess(makeFillResponse(context, matchedFiles)) },
- failure = { e ->
- logcat(ERROR) { e.asLog() }
- callback.onSuccess(makePublisherChangedResponse(context, e))
- }
- )
- }
-
- companion object {
-
- fun makeFillInDataset(
- context: Context,
- credentials: Credentials,
- clientState: Bundle,
- action: AutofillAction
- ): Dataset {
- val scenario = AutofillScenario.fromClientState(clientState)
- // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even
- // though they are rarely shown.
- // FIXME: We should clone the original dataset here and add the credentials to be filled
- // in. Otherwise, the entry in the cached list of datasets will be overwritten by the
- // fill-in dataset without any visual representation. This causes it to be missing from
- // the Autofill suggestions shown after the user clears the filled out form fields.
- val builder =
- if (Build.VERSION.SDK_INT >= 28) {
- Dataset.Builder()
- } else {
- Dataset.Builder(makeRemoteView(context, makeEmptyMetadata()))
- }
- return builder.run {
- if (scenario != null) fillWith(scenario, action, credentials)
- else logcat(ERROR) { "Failed to recover scenario from client state" }
- build()
- }
- }
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt
deleted file mode 100644
index 20414e6f..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt
+++ /dev/null
@@ -1,120 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.autofill
-
-import android.annotation.SuppressLint
-import android.app.PendingIntent
-import android.content.Context
-import android.content.Intent
-import android.content.res.Resources
-import android.graphics.drawable.Icon
-import android.os.Build
-import android.service.autofill.InlinePresentation
-import android.view.View
-import android.widget.RemoteViews
-import android.widget.inline.InlinePresentationSpec
-import androidx.annotation.DrawableRes
-import androidx.autofill.inline.UiVersions
-import androidx.autofill.inline.v1.InlineSuggestionUi
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.ui.passwords.PasswordStore
-import java.io.File
-
-data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int)
-
-fun makeRemoteView(context: Context, metadata: DatasetMetadata): RemoteViews {
- return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply {
- setTextViewText(R.id.title, metadata.title)
- if (metadata.subtitle != null) {
- setTextViewText(R.id.summary, metadata.subtitle)
- } else {
- setViewVisibility(R.id.summary, View.GONE)
- }
- if (metadata.iconRes != Resources.ID_NULL) {
- setImageViewResource(R.id.icon, metadata.iconRes)
- } else {
- setViewVisibility(R.id.icon, View.GONE)
- }
- }
-}
-
-@SuppressLint("RestrictedApi")
-fun makeInlinePresentation(
- context: Context,
- imeSpec: InlinePresentationSpec,
- metadata: DatasetMetadata
-): InlinePresentation? {
- if (Build.VERSION.SDK_INT < 30) return null
-
- if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) return null
-
- val launchIntent =
- PendingIntent.getActivity(
- context,
- 0,
- Intent(context, PasswordStore::class.java),
- if (Build.VERSION.SDK_INT >= 31) {
- PendingIntent.FLAG_MUTABLE
- } else {
- 0
- },
- )
- val slice =
- InlineSuggestionUi.newContentBuilder(launchIntent).run {
- setTitle(metadata.title)
- if (metadata.subtitle != null) setSubtitle(metadata.subtitle)
- setContentDescription(
- if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}"
- else metadata.title
- )
- setStartIcon(Icon.createWithResource(context, metadata.iconRes))
- build().slice
- }
-
- return InlinePresentation(slice, imeSpec, false)
-}
-
-fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata {
- val directoryStructure = AutofillPreferences.directoryStructure(context)
- val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory())
- val title =
- directoryStructure.getIdentifierFor(relativeFile)
- ?: directoryStructure.getAccountPartFor(relativeFile)!!
- val subtitle = directoryStructure.getAccountPartFor(relativeFile)
- return DatasetMetadata(title, subtitle, R.drawable.ic_person_black_24dp)
-}
-
-fun makeSearchAndFillMetadata(context: Context) =
- DatasetMetadata(
- context.getString(R.string.oreo_autofill_search_in_store),
- null,
- R.drawable.ic_search_black_24dp
- )
-
-fun makeGenerateAndFillMetadata(context: Context) =
- DatasetMetadata(
- context.getString(R.string.oreo_autofill_generate_password),
- null,
- R.drawable.ic_autofill_new_password
- )
-
-fun makeFillOtpFromSmsMetadata(context: Context) =
- DatasetMetadata(
- context.getString(R.string.oreo_autofill_fill_otp_from_sms),
- null,
- R.drawable.ic_autofill_sms
- )
-
-fun makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher)
-
-fun makeWarningMetadata(context: Context) =
- DatasetMetadata(
- context.getString(R.string.oreo_autofill_warning_publisher_dataset_title),
- context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary),
- R.drawable.ic_warning_red_24dp
- )
-
-fun makeHeaderMetadata(title: String) = DatasetMetadata(title, null, 0)
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt b/app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt
deleted file mode 100644
index f99b623c..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.crypto
-
-import me.msfjarvis.openpgpktx.util.OpenPgpUtils
-
-sealed class GpgIdentifier {
- data class KeyId(val id: Long) : GpgIdentifier()
- data class UserId(val email: String) : GpgIdentifier()
-
- companion object {
- @OptIn(ExperimentalUnsignedTypes::class)
- fun fromString(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 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 KeyId(keyId.toLong())
- }
-
- return OpenPgpUtils.splitUserId(identifier).email?.let { UserId(it) }
- }
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt
deleted file mode 100644
index cc243a09..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt
+++ /dev/null
@@ -1,133 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.extensions
-
-import android.app.KeyguardManager
-import android.content.ClipboardManager
-import android.content.Context
-import android.content.Intent
-import android.content.SharedPreferences
-import android.content.pm.PackageManager
-import android.util.Base64
-import android.util.TypedValue
-import android.view.View
-import android.view.autofill.AutofillManager
-import androidx.annotation.RequiresApi
-import androidx.core.content.ContextCompat
-import androidx.core.content.getSystemService
-import androidx.fragment.app.FragmentActivity
-import androidx.security.crypto.EncryptedSharedPreferences
-import androidx.security.crypto.MasterKey
-import com.github.michaelbull.result.Ok
-import com.github.michaelbull.result.Result
-import com.google.android.material.snackbar.Snackbar
-import dev.msfjarvis.aps.BuildConfig
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.util.git.operation.GitOperation
-import logcat.logcat
-
-/** Get an instance of [AutofillManager]. Only available on Android Oreo and above */
-val Context.autofillManager: AutofillManager?
- @RequiresApi(26) get() = getSystemService()
-
-/** Get an instance of [ClipboardManager] */
-val Context.clipboard
- get() = getSystemService<ClipboardManager>()
-
-/** Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at each call site */
-fun Context.getEncryptedGitPrefs() = getEncryptedPrefs("git_operation")
-
-/** Get an instance of [EncryptedSharedPreferences] with the given [fileName] */
-private fun Context.getEncryptedPrefs(fileName: String): SharedPreferences {
- val masterKeyAlias =
- MasterKey.Builder(applicationContext).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
- return EncryptedSharedPreferences.create(
- applicationContext,
- fileName,
- masterKeyAlias,
- EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
- EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
- )
-}
-
-/** Get an instance of [KeyguardManager] */
-val Context.keyguardManager: KeyguardManager
- get() = getSystemService()!!
-
-/** Get the default [SharedPreferences] instance */
-val Context.sharedPrefs: SharedPreferences
- get() = getSharedPreferences("${BuildConfig.APPLICATION_ID}_preferences", 0)
-
-/** Resolve [attr] from the [Context]'s theme */
-fun Context.resolveAttribute(attr: Int): Int {
- val typedValue = TypedValue()
- this.theme.resolveAttribute(attr, typedValue, true)
- return typedValue.data
-}
-
-/**
- * Commit changes to the store from a [FragmentActivity] using a custom implementation of
- * [GitOperation]
- */
-suspend fun FragmentActivity.commitChange(
- message: String,
-): Result<Unit, Throwable> {
- if (!PasswordRepository.isGitRepo()) {
- return Ok(Unit)
- }
- return object : GitOperation(this@commitChange) {
- override val commands =
- arrayOf(
- // Stage all files
- git.add().addFilepattern("."),
- // Populate the changed files count
- git.status(),
- // Commit everything! If anything changed, that is.
- git.commit().setAll(true).setMessage(message),
- )
-
- override fun preExecute(): Boolean {
- logcat { "Committing with message: '$message'" }
- return true
- }
- }
- .execute()
-}
-
-/** Check if [permission] has been granted to the app. */
-fun FragmentActivity.isPermissionGranted(permission: String): Boolean {
- return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
-}
-
-/**
- * Show a [Snackbar] in a [FragmentActivity] and correctly anchor it to a
- * [com.google.android.material.floatingactionbutton.FloatingActionButton] if one exists in the
- * [view]
- */
-fun FragmentActivity.snackbar(
- view: View = findViewById(android.R.id.content),
- message: String,
- length: Int = Snackbar.LENGTH_SHORT,
-): Snackbar {
- val snackbar = Snackbar.make(view, message, length)
- snackbar.anchorView = findViewById(R.id.fab)
- snackbar.show()
- return snackbar
-}
-
-/** Launch an activity denoted by [clazz]. */
-fun <T : FragmentActivity> FragmentActivity.launchActivity(clazz: Class<T>) {
- startActivity(Intent(this, clazz))
-}
-
-/** Simplifies the common `getString(key, null) ?: defaultValue` case slightly */
-fun SharedPreferences.getString(key: String): String? = getString(key, null)
-
-/** Convert this [String] to its [Base64] representation */
-fun String.base64(): String {
- return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP)
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt
deleted file mode 100644
index 8530a216..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.extensions
-
-import com.github.michaelbull.result.Err
-import com.github.michaelbull.result.Ok
-import com.github.michaelbull.result.Result
-import com.github.michaelbull.result.getOrElse
-import com.github.michaelbull.result.runCatching
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import java.io.File
-import java.util.Date
-import logcat.asLog
-import org.eclipse.jgit.lib.ObjectId
-import org.eclipse.jgit.revwalk.RevCommit
-
-/** The default OpenPGP provider for the app */
-const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain"
-
-/** Clears the given [flag] from the value of this [Int] */
-fun Int.clearFlag(flag: Int): Int {
- return this and flag.inv()
-}
-
-/** Checks if this [Int] contains the given [flag] */
-infix fun Int.hasFlag(flag: Int): Boolean {
- return this and flag == flag
-}
-
-/** Checks whether this [File] is a directory that contains [other]. */
-fun File.contains(other: File): Boolean {
- if (!isDirectory) return false
- if (!other.exists()) return false
- val relativePath =
- runCatching { other.relativeTo(this) }
- .getOrElse {
- return false
- }
- // Direct containment is equivalent to the relative path being equal to the filename.
- return relativePath.path == other.name
-}
-
-/**
- * Checks if this [File] is in the password repository directory as given by
- * [PasswordRepository.getRepositoryDirectory]
- */
-fun File.isInsideRepository(): Boolean {
- return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath)
-}
-
-/** Recursively lists the files in this [File], skipping any directories it encounters. */
-fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList()
-
-/**
- * Unique SHA-1 hash of this commit as hexadecimal string.
- *
- * @see RevCommit.getId
- */
-val RevCommit.hash: String
- get() = ObjectId.toString(id)
-
-/**
- * Time this commit was made with second precision.
- *
- * @see RevCommit.commitTime
- */
-val RevCommit.time: Date
- get() {
- val epochSeconds = commitTime.toLong()
- val epochMilliseconds = epochSeconds * 1000
- return Date(epochMilliseconds)
- }
-
-/**
- * Splits this [String] into an [Array] of [String] s, split on the UNIX LF line ending and stripped
- * of any empty lines.
- */
-fun String.splitLines(): Array<String> {
- return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
-}
-
-/** Alias to [lazy] with thread safety mode always set to [LazyThreadSafetyMode.NONE]. */
-fun <T> unsafeLazy(initializer: () -> T) = lazy(LazyThreadSafetyMode.NONE) { initializer.invoke() }
-
-/** A convenience extension to turn a [Throwable] with a message into a loggable string. */
-fun Throwable.asLog(message: String): String = "$message\n${asLog()}"
-
-/** Extension on [Result] that returns if the type is [Ok] */
-fun <V, E> Result<V, E>.isOk(): Boolean {
- return this is Ok<V>
-}
-
-/** Extension on [Result] that returns if the type is [Err] */
-fun <V, E> Result<V, E>.isErr(): Boolean {
- return this is Err<E>
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt
deleted file mode 100644
index 3a256ee7..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.extensions
-
-import androidx.annotation.IdRes
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.FragmentManager
-import androidx.fragment.app.commit
-import dev.msfjarvis.aps.R
-
-/** Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally. */
-fun Fragment.isPermissionGranted(permission: String): Boolean {
- return requireActivity().isPermissionGranted(permission)
-}
-
-/** Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity] */
-fun Fragment.finish() = requireActivity().finish()
-
-/**
- * Perform a [commit] on this [FragmentManager] with custom animations and adding the
- * [destinationFragment] to the fragment backstack
- */
-fun FragmentManager.performTransactionWithBackStack(
- destinationFragment: Fragment,
- @IdRes containerViewId: Int = android.R.id.content
-) {
- commit {
- beginTransaction()
- addToBackStack(destinationFragment.tag)
- setCustomAnimations(
- R.animator.slide_in_left,
- R.animator.slide_out_left,
- R.animator.slide_in_right,
- R.animator.slide_out_right
- )
- replace(containerViewId, destinationFragment)
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt
deleted file mode 100644
index fe885c86..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.extensions
-
-import android.view.LayoutInflater
-import android.view.View
-import androidx.appcompat.app.AppCompatActivity
-import androidx.fragment.app.Fragment
-import androidx.lifecycle.DefaultLifecycleObserver
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.LifecycleOwner
-import androidx.viewbinding.ViewBinding
-import kotlin.properties.ReadOnlyProperty
-import kotlin.reflect.KProperty
-
-/**
- * Imported from
- * https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c
- */
-class FragmentViewBindingDelegate<T : ViewBinding>(
- val fragment: Fragment,
- val viewBindingFactory: (View) -> T
-) : ReadOnlyProperty<Fragment, T> {
-
- private var binding: T? = null
-
- init {
- fragment.lifecycle.addObserver(
- object : DefaultLifecycleObserver {
- override fun onCreate(owner: LifecycleOwner) {
- fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner ->
- viewLifecycleOwner.lifecycle.addObserver(
- object : DefaultLifecycleObserver {
- override fun onDestroy(owner: LifecycleOwner) {
- binding = null
- }
- }
- )
- }
- }
- }
- )
- }
-
- override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
- val binding = binding
- if (binding != null) {
- return binding
- }
-
- val lifecycle = fragment.viewLifecycleOwner.lifecycle
- if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
- throw IllegalStateException(
- "Should not attempt to get bindings when Fragment views are destroyed."
- )
- }
-
- return viewBindingFactory(thisRef.requireView()).also { this.binding = it }
- }
-}
-
-fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) =
- FragmentViewBindingDelegate(this, viewBindingFactory)
-
-inline fun <T : ViewBinding> AppCompatActivity.viewBinding(
- crossinline bindingInflater: (LayoutInflater) -> T
-) = unsafeLazy { bindingInflater.invoke(layoutInflater) }
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/features/Feature.kt b/app/src/main/java/dev/msfjarvis/aps/util/features/Feature.kt
deleted file mode 100644
index 4eef003d..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/features/Feature.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.features
-
-/** List of all feature flags for the app. */
-enum class Feature(
- /** Default value for the flag. */
- val defaultValue: Boolean,
- /** Key to retrieve the current value for the flag. */
- val configKey: String,
-) {
-
- /** Opt into the new PGP backend powered by the PGPainless library. */
- EnablePGPainlessBackend(false, "enable_pgp_v2_backend"),
- ;
-
- companion object {
- @JvmField val VALUES = values()
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/features/Features.kt b/app/src/main/java/dev/msfjarvis/aps/util/features/Features.kt
deleted file mode 100644
index 39b2c3a8..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/features/Features.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.features
-
-import android.content.SharedPreferences
-import dev.msfjarvis.aps.injection.prefs.SettingsPreferences
-import javax.inject.Inject
-
-class Features
-@Inject
-constructor(
- @SettingsPreferences private val preferences: SharedPreferences,
-) {
-
- fun isEnabled(feature: Feature): Boolean {
- return preferences.getBoolean(feature.configKey, feature.defaultValue)
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt
deleted file mode 100644
index 36dc445f..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.git
-
-import android.os.RemoteException
-import androidx.annotation.StringRes
-import dev.msfjarvis.aps.Application
-import dev.msfjarvis.aps.R
-import java.net.UnknownHostException
-
-/**
- * Supertype for all Git-related [Exception] s that can be thrown by [GitCommandExecutor.execute].
- */
-sealed class GitException(@StringRes res: Int, vararg fmt: String) :
- Exception(buildMessage(res, *fmt)) {
-
- override val message = super.message!!
-
- companion object {
-
- private fun buildMessage(@StringRes res: Int, vararg fmt: String) =
- Application.instance.resources.getString(res, *fmt)
- }
-
- /** Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand]. */
- sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
-
- object PullRebaseFailed : PullException(R.string.git_pull_rebase_fail_error)
- object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error)
- }
-
- /** Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand]. */
- sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
-
- object NonFastForward : PushException(R.string.git_push_nff_error)
- object RemoteRejected : PushException(R.string.git_push_other_error)
- class Generic(message: String) : PushException(R.string.git_push_generic_error, message)
- }
-}
-
-object ErrorMessages {
-
- operator fun get(throwable: Throwable?): String {
- val resources = Application.instance.resources
- if (throwable == null) return resources.getString(R.string.git_unknown_error)
- return when (val rootCause = rootCause(throwable)) {
- is GitException -> rootCause.message
- is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message)
- else -> throwable.message ?: resources.getString(R.string.git_unknown_error)
- }
- }
-
- private fun rootCause(throwable: Throwable): Throwable {
- var cause = throwable
- while (cause.cause != null) {
- if (cause is GitException) break
- val nextCause = cause.cause!!
- if (nextCause is RemoteException) break
- cause = nextCause
- }
- return cause
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt
deleted file mode 100644
index e6eb77f5..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt
+++ /dev/null
@@ -1,133 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.git
-
-import android.widget.Toast
-import androidx.fragment.app.FragmentActivity
-import com.github.michaelbull.result.Result
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.snackbar.Snackbar
-import dagger.hilt.EntryPoint
-import dagger.hilt.InstallIn
-import dagger.hilt.android.EntryPointAccessors
-import dagger.hilt.components.SingletonComponent
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.util.extensions.snackbar
-import dev.msfjarvis.aps.util.extensions.unsafeLazy
-import dev.msfjarvis.aps.util.git.GitException.PullException
-import dev.msfjarvis.aps.util.git.GitException.PushException
-import dev.msfjarvis.aps.util.git.operation.GitOperation
-import dev.msfjarvis.aps.util.settings.GitSettings
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import org.eclipse.jgit.api.CommitCommand
-import org.eclipse.jgit.api.PullCommand
-import org.eclipse.jgit.api.PushCommand
-import org.eclipse.jgit.api.StatusCommand
-import org.eclipse.jgit.lib.PersonIdent
-import org.eclipse.jgit.transport.RemoteRefUpdate
-
-class GitCommandExecutor(
- private val activity: FragmentActivity,
- private val operation: GitOperation,
-) {
-
- private val hiltEntryPoint by unsafeLazy {
- EntryPointAccessors.fromApplication(
- activity.applicationContext,
- GitCommandExecutorEntryPoint::class.java
- )
- }
- suspend fun execute(): Result<Unit, Throwable> {
- val gitSettings = hiltEntryPoint.gitSettings()
- val snackbar =
- activity.snackbar(
- message = activity.resources.getString(R.string.git_operation_running),
- length = Snackbar.LENGTH_INDEFINITE,
- )
- // Count the number of uncommitted files
- var nbChanges = 0
- return runCatching {
- for (command in operation.commands) {
- when (command) {
- is StatusCommand -> {
- val res = withContext(Dispatchers.IO) { command.call() }
- nbChanges = res.uncommittedChanges.size
- }
- is CommitCommand -> {
- // the previous status will eventually be used to avoid a commit
- if (nbChanges > 0) {
- withContext(Dispatchers.IO) {
- val name = gitSettings.authorName.ifEmpty { "root" }
- val email = gitSettings.authorEmail.ifEmpty { "localhost" }
- val identity = PersonIdent(name, email)
- command.setAuthor(identity).setCommitter(identity).call()
- }
- }
- }
- is PullCommand -> {
- val result = withContext(Dispatchers.IO) { command.call() }
- if (result.rebaseResult != null) {
- if (!result.rebaseResult.status.isSuccessful) {
- throw PullException.PullRebaseFailed
- }
- } else if (result.mergeResult != null) {
- if (!result.mergeResult.mergeStatus.isSuccessful) {
- throw PullException.PullMergeFailed
- }
- }
- }
- is PushCommand -> {
- val results = withContext(Dispatchers.IO) { command.call() }
- for (result in results) {
- // Code imported (modified) from Gerrit PushOp, license Apache v2
- for (rru in result.remoteUpdates) {
- when (rru.status) {
- RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD ->
- throw PushException.NonFastForward
- RemoteRefUpdate.Status.REJECTED_NODELETE,
- RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED,
- RemoteRefUpdate.Status.NON_EXISTING,
- RemoteRefUpdate.Status.NOT_ATTEMPTED, ->
- throw PushException.Generic(rru.status.name)
- RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> {
- throw if ("non-fast-forward" == rru.message) {
- PushException.RemoteRejected
- } else {
- PushException.Generic(rru.message)
- }
- }
- RemoteRefUpdate.Status.UP_TO_DATE -> {
- withContext(Dispatchers.Main) {
- Toast.makeText(
- activity.applicationContext,
- activity.applicationContext.getString(R.string.git_push_up_to_date),
- Toast.LENGTH_SHORT
- )
- .show()
- }
- }
- else -> {}
- }
- }
- }
- }
- else -> {
- withContext(Dispatchers.IO) { command.call() }
- }
- }
- }
- }
- .also { snackbar.dismiss() }
- }
-
- @EntryPoint
- @InstallIn(SingletonComponent::class)
- interface GitCommandExecutorEntryPoint {
-
- fun gitSettings(): GitSettings
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt
deleted file mode 100644
index 490f5e2c..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.git
-
-import java.util.Date
-
-/**
- * Basic information about a git commit.
- *
- * @property hash full-length hash of the commit object.
- * @property shortMessage the commit's short message (i.e. title line).
- * @property authorName name of the commit's author without email address.
- * @property time time when the commit was created.
- */
-data class GitCommit(
- val hash: String,
- val shortMessage: String,
- val authorName: String,
- val time: Date
-)
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt
deleted file mode 100644
index ed210a65..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.git
-
-import com.github.michaelbull.result.getOrElse
-import com.github.michaelbull.result.runCatching
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.util.extensions.asLog
-import dev.msfjarvis.aps.util.extensions.hash
-import dev.msfjarvis.aps.util.extensions.time
-import dev.msfjarvis.aps.util.extensions.unsafeLazy
-import logcat.LogPriority.ERROR
-import logcat.logcat
-import org.eclipse.jgit.api.Git
-import org.eclipse.jgit.revwalk.RevCommit
-
-private val TAG = GitLogModel::class.java.simpleName
-
-private fun commits(): Iterable<RevCommit> {
- val repo = PasswordRepository.repository
- if (repo == null) {
- logcat(TAG, ERROR) { "Could not access git repository" }
- return listOf()
- }
- return runCatching { Git(repo).log().call() }
- .getOrElse { e ->
- logcat(TAG, ERROR) { e.asLog("Failed to obtain git commits") }
- listOf()
- }
-}
-
-/**
- * Provides [GitCommit] s from a git-log of the password git repository.
- *
- * All commits are acquired on the first request to this object.
- */
-class GitLogModel {
-
- // All commits are acquired here at once. Acquiring the commits in batches would not have been
- // entirely sensible because the amount of computation required to obtain commit number n from
- // the log includes the amount of computation required to obtain commit number n-1 from the log.
- // This is because the commit graph is walked from HEAD to the last commit to obtain.
- // Additionally, tests with 1000 commits in the log have not produced a significant delay in the
- // user experience.
- private val cache: MutableList<GitCommit> by unsafeLazy {
- commits()
- .map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) }
- .toMutableList()
- }
- val size = cache.size
-
- fun get(index: Int): GitCommit? {
- if (index >= size)
- logcat(ERROR) { "Cannot get git commit with index $index. There are only $size." }
- return cache.getOrNull(index)
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt
deleted file mode 100644
index 6ea9b8bb..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.git.operation
-
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.util.extensions.unsafeLazy
-import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
-import org.eclipse.jgit.api.RebaseCommand
-import org.eclipse.jgit.api.ResetCommand
-import org.eclipse.jgit.lib.RepositoryState
-
-class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) :
- GitOperation(callingActivity) {
-
- private val merging = repository.repositoryState == RepositoryState.MERGING
- private val resetCommands =
- arrayOf(
- // git checkout -b conflict-branch
- git
- .checkout()
- .setCreateBranch(true)
- .setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"),
- // push the changes
- git.push().setRemote("origin"),
- // switch back to ${gitBranch}
- git.checkout().setName(remoteBranch),
- )
-
- override val commands by unsafeLazy {
- if (merging) {
- // We need to run some non-command operations first
- repository.writeMergeCommitMsg(null)
- repository.writeMergeHeads(null)
- arrayOf(
- // reset hard back to our local HEAD
- git.reset().setMode(ResetCommand.ResetType.HARD),
- *resetCommands,
- )
- } else {
- arrayOf(
- // abort the rebase
- git.rebase().setOperation(RebaseCommand.Operation.ABORT),
- *resetCommands,
- )
- }
- }
-
- override fun preExecute() =
- if (!git.repository.repositoryState.isRebasing && !merging) {
- MaterialAlertDialogBuilder(callingActivity)
- .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title))
- .setMessage(
- callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded)
- )
- .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ ->
- callingActivity.finish()
- }
- .show()
- false
- } else {
- true
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt
deleted file mode 100644
index 75db2ea5..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.git.operation
-
-import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
-import org.eclipse.jgit.api.Git
-import org.eclipse.jgit.api.GitCommand
-
-/**
- * Creates a new clone operation
- *
- * @param uri URL to clone the repository from
- * @param callingActivity the calling activity
- */
-class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) :
- GitOperation(callingActivity) {
-
- override val commands: Array<GitCommand<out Any>> =
- arrayOf(
- Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri),
- )
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt
deleted file mode 100644
index 623d69ea..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt
+++ /dev/null
@@ -1,116 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.git.operation
-
-import android.annotation.SuppressLint
-import android.content.SharedPreferences
-import android.view.LayoutInflater
-import android.view.WindowManager
-import androidx.annotation.StringRes
-import androidx.core.content.edit
-import androidx.core.widget.doOnTextChanged
-import androidx.fragment.app.FragmentActivity
-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 dagger.hilt.EntryPoint
-import dagger.hilt.InstallIn
-import dagger.hilt.android.EntryPointAccessors
-import dagger.hilt.components.SingletonComponent
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.injection.prefs.GitPreferences
-import dev.msfjarvis.aps.util.git.sshj.InteractivePasswordFinder
-import dev.msfjarvis.aps.util.settings.AuthMode
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import kotlin.coroutines.Continuation
-import kotlin.coroutines.resume
-
-class CredentialFinder(val callingActivity: FragmentActivity, val authMode: AuthMode) :
- InteractivePasswordFinder() {
-
- private val hiltEntryPoint =
- EntryPointAccessors.fromApplication(
- callingActivity.applicationContext,
- CredentialFinderEntryPoint::class.java,
- )
-
- override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) {
- val gitOperationPrefs = hiltEntryPoint.gitPrefs()
- val credentialPref: String
- @StringRes val messageRes: Int
- @StringRes val hintRes: Int
- @StringRes val rememberRes: Int
- @StringRes val errorRes: Int
- when (authMode) {
- AuthMode.SshKey -> {
- credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE
- messageRes = R.string.passphrase_dialog_text
- hintRes = R.string.ssh_keygen_passphrase
- rememberRes = R.string.git_operation_remember_passphrase
- errorRes = R.string.git_operation_wrong_passphrase
- }
- AuthMode.Password -> {
- // Could be either an SSH or an HTTPS password
- credentialPref = PreferenceKeys.HTTPS_PASSWORD
- messageRes = R.string.password_dialog_text
- hintRes = R.string.git_operation_hint_password
- rememberRes = R.string.git_operation_remember_password
- errorRes = R.string.git_operation_wrong_password
- }
- else ->
- throw IllegalStateException("Only SshKey and Password connection mode ask for passwords")
- }
- if (isRetry) gitOperationPrefs.edit { remove(credentialPref) }
- val storedCredential = gitOperationPrefs.getString(credentialPref, null)
- if (storedCredential == null) {
- val layoutInflater = LayoutInflater.from(callingActivity)
-
- @SuppressLint("InflateParams")
- val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null)
- val credentialLayout =
- dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout)
- val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential)
- editCredential.setHint(hintRes)
- val rememberCredential =
- dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential)
- rememberCredential.setText(rememberRes)
- if (isRetry) {
- credentialLayout.error = callingActivity.resources.getString(errorRes)
- // Reset error when user starts entering a password
- editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null }
- }
- MaterialAlertDialogBuilder(callingActivity)
- .run {
- setTitle(R.string.passphrase_dialog_title)
- setMessage(messageRes)
- setView(dialogView)
- setPositiveButton(R.string.dialog_ok) { _, _ ->
- val credential = editCredential.text.toString()
- if (rememberCredential.isChecked) {
- gitOperationPrefs.edit { putString(credentialPref, credential) }
- }
- cont.resume(credential)
- }
- setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(null) }
- setOnCancelListener { cont.resume(null) }
- create()
- }
- .run {
- window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
- show()
- }
- } else {
- cont.resume(storedCredential)
- }
- }
-
- @EntryPoint
- @InstallIn(SingletonComponent::class)
- interface CredentialFinderEntryPoint {
- @GitPreferences fun gitPrefs(): SharedPreferences
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GcOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GcOperation.kt
deleted file mode 100644
index c070027e..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GcOperation.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.git.operation
-
-import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
-import org.eclipse.jgit.api.GitCommand
-
-/**
- * Run an aggressive garbage collection job on the repository, expiring every loose object to
- * achieve the best compression.
- */
-class GcOperation(
- callingActivity: ContinuationContainerActivity,
-) : GitOperation(callingActivity) {
-
- override val requiresAuth: Boolean = false
- override val commands: Array<GitCommand<out Any>> =
- arrayOf(git.gc().setAggressive(true).setExpire(null))
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt
deleted file mode 100644
index 576a802d..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt
+++ /dev/null
@@ -1,245 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.git.operation
-
-import android.content.Intent
-import android.widget.Toast
-import androidx.fragment.app.FragmentActivity
-import com.github.michaelbull.result.Err
-import com.github.michaelbull.result.Ok
-import com.github.michaelbull.result.Result
-import com.github.michaelbull.result.onFailure
-import com.github.michaelbull.result.runCatching
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dagger.hilt.EntryPoint
-import dagger.hilt.InstallIn
-import dagger.hilt.android.EntryPointAccessors
-import dagger.hilt.components.SingletonComponent
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.ui.sshkeygen.SshKeyGenActivity
-import dev.msfjarvis.aps.ui.sshkeygen.SshKeyImportActivity
-import dev.msfjarvis.aps.util.auth.BiometricAuthenticator
-import dev.msfjarvis.aps.util.auth.BiometricAuthenticator.Result.*
-import dev.msfjarvis.aps.util.git.GitCommandExecutor
-import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
-import dev.msfjarvis.aps.util.git.sshj.SshAuthMethod
-import dev.msfjarvis.aps.util.git.sshj.SshKey
-import dev.msfjarvis.aps.util.git.sshj.SshjSessionFactory
-import dev.msfjarvis.aps.util.settings.AuthMode
-import dev.msfjarvis.aps.util.settings.GitSettings
-import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import logcat.LogPriority.ERROR
-import logcat.asLog
-import logcat.logcat
-import net.schmizz.sshj.common.DisconnectReason
-import net.schmizz.sshj.common.SSHException
-import net.schmizz.sshj.userauth.password.PasswordFinder
-import org.eclipse.jgit.api.Git
-import org.eclipse.jgit.api.GitCommand
-import org.eclipse.jgit.api.TransportCommand
-import org.eclipse.jgit.errors.UnsupportedCredentialItem
-import org.eclipse.jgit.transport.CredentialItem
-import org.eclipse.jgit.transport.CredentialsProvider
-import org.eclipse.jgit.transport.SshTransport
-import org.eclipse.jgit.transport.Transport
-import org.eclipse.jgit.transport.URIish
-
-/**
- * Creates a new git operation
- *
- * @param callingActivity the calling activity
- */
-abstract class GitOperation(protected val callingActivity: FragmentActivity) {
-
- /** List of [GitCommand]s that are executed by an operation. */
- abstract val commands: Array<GitCommand<out Any>>
-
- /** Whether the operation requires authentication or not. */
- open val requiresAuth: Boolean = true
- private val hostKeyFile = callingActivity.filesDir.resolve(".host_key")
- private var sshSessionFactory: SshjSessionFactory? = null
- private val hiltEntryPoint =
- EntryPointAccessors.fromApplication(
- callingActivity.applicationContext,
- GitOperationEntryPoint::class.java
- )
-
- protected val repository = PasswordRepository.repository!!
- protected val git = Git(repository)
- protected val remoteBranch = hiltEntryPoint.gitSettings().branch
- private val authActivity
- get() = callingActivity as ContinuationContainerActivity
-
- private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) :
- CredentialsProvider() {
-
- private var cachedPassword: CharArray? = null
-
- override fun isInteractive() = true
-
- override fun get(uri: URIish?, vararg items: CredentialItem): Boolean {
- for (item in items) {
- when (item) {
- is CredentialItem.Username -> item.value = uri?.user
- is CredentialItem.Password -> {
- item.value =
- cachedPassword?.clone()
- ?: passwordFinder.reqPassword(null).also { cachedPassword = it.clone() }
- }
- else -> UnsupportedCredentialItem(uri, item.javaClass.name)
- }
- }
- return true
- }
-
- override fun supports(vararg items: CredentialItem) =
- items.all { it is CredentialItem.Username || it is CredentialItem.Password }
-
- override fun reset(uri: URIish?) {
- cachedPassword?.fill(0.toChar())
- cachedPassword = null
- }
- }
-
- private fun getSshKey(make: Boolean) {
- runCatching {
- val intent =
- if (make) {
- Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java)
- } else {
- Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java)
- }
- callingActivity.startActivity(intent)
- }
- .onFailure { e -> logcat(ERROR) { e.asLog() } }
- }
-
- private fun registerAuthProviders(
- authMethod: SshAuthMethod,
- credentialsProvider: CredentialsProvider? = null
- ) {
- sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile)
- commands.filterIsInstance<TransportCommand<*, *>>().forEach { command ->
- command.setTransportConfigCallback { transport: Transport ->
- (transport as? SshTransport)?.sshSessionFactory = sshSessionFactory
- credentialsProvider?.let { transport.credentialsProvider = it }
- }
- command.setTimeout(CONNECT_TIMEOUT)
- }
- }
-
- /** Executes the GitCommand in an async task. */
- suspend fun execute(): Result<Unit, Throwable> {
- if (!preExecute()) {
- return Ok(Unit)
- }
- val operationResult =
- GitCommandExecutor(
- callingActivity,
- this,
- )
- .execute()
- postExecute()
- return operationResult
- }
-
- private fun onMissingSshKeyFile() {
- MaterialAlertDialogBuilder(callingActivity)
- .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text))
- .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title))
- .setPositiveButton(
- callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)
- ) { _, _ -> getSshKey(false) }
- .setNegativeButton(
- callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)
- ) { _, _ -> getSshKey(true) }
- .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ ->
- // Finish the blank GitActivity so user doesn't have to press back
- callingActivity.finish()
- }
- .show()
- }
-
- suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> {
- when (authMode) {
- AuthMode.SshKey ->
- if (SshKey.exists) {
- if (SshKey.mustAuthenticate) {
- val result =
- withContext(Dispatchers.Main) {
- suspendCoroutine<BiometricAuthenticator.Result> { cont ->
- BiometricAuthenticator.authenticate(
- callingActivity,
- R.string.biometric_prompt_title_ssh_auth
- ) { result -> if (result !is Failure) cont.resume(result) }
- }
- }
- when (result) {
- is Success -> {
- registerAuthProviders(SshAuthMethod.SshKey(authActivity))
- }
- is Cancelled -> {
- return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
- }
- is Failure -> {
- throw IllegalStateException("Biometric authentication failures should be ignored")
- }
- else -> {
- // There is a chance we succeed if the user recently confirmed
- // their screen lock. Doing so would have a potential to confuse
- // users though, who might deduce that the screen lock
- // protection is not effective. Hence, we fail with an error.
- Toast.makeText(
- callingActivity.applicationContext,
- R.string.biometric_auth_generic_failure,
- Toast.LENGTH_LONG
- )
- .show()
- callingActivity.finish()
- }
- }
- } else {
- registerAuthProviders(SshAuthMethod.SshKey(authActivity))
- }
- } else {
- onMissingSshKeyFile()
- // This would correctly cancel the operation but won't surface a user-visible
- // error, allowing users to make the SSH key selection.
- return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
- }
- AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity))
- AuthMode.Password -> {
- val httpsCredentialProvider =
- HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password))
- registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider)
- }
- AuthMode.None -> {}
- }
- return execute()
- }
-
- /** Called before execution of the Git operation. Return false to cancel. */
- open fun preExecute() = true
-
- private suspend fun postExecute() {
- withContext(Dispatchers.IO) { sshSessionFactory?.close() }
- }
-
- companion object {
-
- /** Timeout in seconds before [TransportCommand] will abort a stalled IO operation. */
- private const val CONNECT_TIMEOUT = 10
- }
-
- @EntryPoint
- @InstallIn(SingletonComponent::class)
- interface GitOperationEntryPoint {
- fun gitSettings(): GitSettings
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt
deleted file mode 100644
index 394b7cb4..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.git.operation
-
-import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
-import org.eclipse.jgit.api.GitCommand
-
-class PullOperation(
- callingActivity: ContinuationContainerActivity,
- rebase: Boolean,
-) : GitOperation(callingActivity) {
-
- /**
- * The story of why the pull operation is committing files goes like this: Once upon a time when
- * the world was burning and Blade Runner 2049 was real life (in the worst way), we were made
- * aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing.
- * So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation]
- * and then a [PushOperation]. To make the behavior identical despite this suboptimal situation,
- * we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly
- * replicating [SyncOperation] but leaving the pushing part to [PushOperation].
- */
- override val commands: Array<GitCommand<out Any>> =
- arrayOf(
- // Stage all files
- git.add().addFilepattern("."),
- // Populate the changed files count
- git.status(),
- // Commit everything! If needed, obviously.
- git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
- // Pull and rebase on top of the remote branch
- git.pull().setRebase(rebase).setRemote("origin"),
- )
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt
deleted file mode 100644
index 14f16164..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.git.operation
-
-import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
-import org.eclipse.jgit.api.GitCommand
-
-class PushOperation(callingActivity: ContinuationContainerActivity) :
- GitOperation(callingActivity) {
-
- override val commands: Array<GitCommand<out Any>> =
- arrayOf(
- git.push().setPushAll().setRemote("origin"),
- )
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt
deleted file mode 100644
index 16114f65..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.git.operation
-
-import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
-import org.eclipse.jgit.api.ResetCommand
-
-class ResetToRemoteOperation(callingActivity: ContinuationContainerActivity) :
- GitOperation(callingActivity) {
-
- override val commands =
- arrayOf(
- // Stage all files
- git.add().addFilepattern("."),
- // Fetch everything from the origin remote
- git.fetch().setRemote("origin"),
- // Do a hard reset to the remote branch. Equivalent to git reset --hard
- // origin/$remoteBranch
- git.reset().setRef("origin/$remoteBranch").setMode(ResetCommand.ResetType.HARD),
- // Force-create $remoteBranch if it doesn't exist. This covers the case where you
- // switched
- // branches from 'master' to anything else.
- git.branchCreate().setName(remoteBranch).setForce(true),
- )
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt
deleted file mode 100644
index 589c6305..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.git.operation
-
-import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
-
-class SyncOperation(
- callingActivity: ContinuationContainerActivity,
- rebase: Boolean,
-) : GitOperation(callingActivity) {
-
- override val commands =
- arrayOf(
- // Stage all files
- git.add().addFilepattern("."),
- // Populate the changed files count
- git.status(),
- // Commit everything! If needed, obviously.
- git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
- // Pull and rebase on top of the remote branch
- git.pull().setRebase(rebase).setRemote("origin"),
- // Push it all back
- git.push().setPushAll().setRemote("origin"),
- )
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt
deleted file mode 100644
index 523ff5b6..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.git.sshj
-
-import android.content.Intent
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.annotation.LayoutRes
-import androidx.appcompat.app.AppCompatActivity
-import kotlin.coroutines.Continuation
-import kotlin.coroutines.resume
-import kotlin.coroutines.resumeWithException
-import net.schmizz.sshj.common.DisconnectReason
-import net.schmizz.sshj.userauth.UserAuthException
-
-/** Workaround for https://msfjarvis.dev/aps/issue/1164 */
-open class ContinuationContainerActivity : AppCompatActivity {
-
- constructor() : super()
- constructor(@LayoutRes layoutRes: Int) : super(layoutRes)
-
- var stashedCont: Continuation<Intent>? = null
-
- val continueAfterUserInteraction =
- registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
- stashedCont?.let { cont ->
- stashedCont = null
- val data = result.data
- if (data != null) cont.resume(data)
- else cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER))
- }
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt
deleted file mode 100644
index 8436d1ce..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt
+++ /dev/null
@@ -1,225 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.git.sshj
-
-import android.app.PendingIntent
-import android.content.Intent
-import androidx.activity.result.IntentSenderRequest
-import androidx.core.content.edit
-import androidx.lifecycle.lifecycleScope
-import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import java.io.Closeable
-import java.security.PublicKey
-import java.security.interfaces.ECKey
-import kotlin.coroutines.resume
-import kotlin.coroutines.suspendCoroutine
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.logcat
-import net.schmizz.sshj.common.DisconnectReason
-import net.schmizz.sshj.common.KeyType
-import net.schmizz.sshj.userauth.UserAuthException
-import net.schmizz.sshj.userauth.keyprovider.KeyProvider
-import org.openintents.ssh.authentication.ISshAuthenticationService
-import org.openintents.ssh.authentication.SshAuthenticationApi
-import org.openintents.ssh.authentication.SshAuthenticationApiError
-import org.openintents.ssh.authentication.SshAuthenticationConnection
-import org.openintents.ssh.authentication.request.KeySelectionRequest
-import org.openintents.ssh.authentication.request.Request
-import org.openintents.ssh.authentication.request.SigningRequest
-import org.openintents.ssh.authentication.request.SshPublicKeyRequest
-import org.openintents.ssh.authentication.response.KeySelectionResponse
-import org.openintents.ssh.authentication.response.Response
-import org.openintents.ssh.authentication.response.SigningResponse
-import org.openintents.ssh.authentication.response.SshPublicKeyResponse
-
-class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) :
- KeyProvider, Closeable {
-
- companion object {
-
- suspend fun prepareAndUse(
- activity: ContinuationContainerActivity,
- block: (provider: OpenKeychainKeyProvider) -> Unit
- ) {
- withContext(Dispatchers.Main) { OpenKeychainKeyProvider(activity) }.prepareAndUse(block)
- }
- }
-
- private sealed class ApiResponse {
- data class Success(val response: Response) : ApiResponse()
- data class GeneralError(val exception: Exception) : ApiResponse()
- data class NoSuchKey(val exception: Exception) : ApiResponse()
- }
-
- private val context = activity.applicationContext
- private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER)
- private val preferences = context.sharedPrefs
- private lateinit var sshServiceApi: SshAuthenticationApi
-
- private var keyId
- get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null)
- set(value) {
- preferences.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value) }
- }
- private var publicKey: PublicKey? = null
- private var privateKey: OpenKeychainPrivateKey? = null
-
- private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) {
- prepare()
- use(block)
- }
-
- private suspend fun prepare() {
- sshServiceApi = suspendCoroutine { cont ->
- sshServiceConnection.connect(
- object : SshAuthenticationConnection.OnBound {
- override fun onBound(sshAgent: ISshAuthenticationService) {
- logcat { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" }
- cont.resume(SshAuthenticationApi(context, sshAgent))
- }
-
- override fun onError() {
- throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable")
- }
- }
- )
- }
-
- if (keyId == null) {
- selectKey()
- }
- check(keyId != null)
- fetchPublicKey()
- makePrivateKey()
- }
-
- private suspend fun fetchPublicKey(isRetry: Boolean = false) {
- when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) {
- is ApiResponse.Success -> {
- val response = sshPublicKeyResponse.response as SshPublicKeyResponse
- val sshPublicKey = response.sshPublicKey!!
- publicKey =
- parseSshPublicKey(sshPublicKey)
- ?: throw IllegalStateException("OpenKeychain API returned invalid SSH key")
- }
- is ApiResponse.NoSuchKey ->
- if (isRetry) {
- throw sshPublicKeyResponse.exception
- } else {
- // Allow the user to reselect an authentication key and retry
- selectKey()
- fetchPublicKey(true)
- }
- is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception
- }
- }
-
- private suspend fun selectKey() {
- when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) {
- is ApiResponse.Success ->
- keyId = (keySelectionResponse.response as KeySelectionResponse).keyId
- is ApiResponse.GeneralError -> throw keySelectionResponse.exception
- is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception
- }
- }
-
- private suspend fun executeApiRequest(
- request: Request,
- resultOfUserInteraction: Intent? = null
- ): ApiResponse {
- logcat { "executeRequest($request) called" }
- val result =
- withContext(Dispatchers.Main) {
- // If the request required user interaction, the data returned from the
- // PendingIntent
- // is used as the real request.
- sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!!
- }
- return parseResult(request, result).also { logcat { "executeRequest($request): $it" } }
- }
-
- private suspend fun parseResult(request: Request, result: Intent): ApiResponse {
- return when (
- result.getIntExtra(
- SshAuthenticationApi.EXTRA_RESULT_CODE,
- SshAuthenticationApi.RESULT_CODE_ERROR
- )
- ) {
- SshAuthenticationApi.RESULT_CODE_SUCCESS -> {
- ApiResponse.Success(
- when (request) {
- is KeySelectionRequest -> KeySelectionResponse(result)
- is SshPublicKeyRequest -> SshPublicKeyResponse(result)
- is SigningRequest -> SigningResponse(result)
- else -> throw IllegalArgumentException("Unsupported OpenKeychain request type")
- }
- )
- }
- SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
- val pendingIntent: PendingIntent =
- result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!!
- val resultOfUserInteraction: Intent =
- withContext(Dispatchers.Main) {
- suspendCoroutine { cont ->
- activity.stashedCont = cont
- activity.continueAfterUserInteraction.launch(
- IntentSenderRequest.Builder(pendingIntent).build()
- )
- }
- }
- executeApiRequest(request, resultOfUserInteraction)
- }
- else -> {
- val error =
- result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR)
- val exception =
- UserAuthException(
- DisconnectReason.UNKNOWN,
- "Request ${request::class.simpleName} failed: ${error?.message}"
- )
- when (error?.error) {
- SshAuthenticationApiError.NO_AUTH_KEY,
- SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception)
- else -> ApiResponse.GeneralError(exception)
- }
- }
- }
- }
-
- private fun makePrivateKey() {
- check(keyId != null && publicKey != null)
- privateKey =
- object : OpenKeychainPrivateKey {
- override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) =
- when (
- val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))
- ) {
- is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature
- is ApiResponse.GeneralError -> throw signingResponse.exception
- is ApiResponse.NoSuchKey -> throw signingResponse.exception
- }
-
- override fun getAlgorithm() = publicKey!!.algorithm
- override fun getParams() = (publicKey as? ECKey)?.params
- }
- }
-
- override fun close() {
- activity.lifecycleScope.launch {
- withContext(Dispatchers.Main) { activity.continueAfterUserInteraction.unregister() }
- }
- sshServiceConnection.disconnect()
- }
-
- override fun getPrivate() = privateKey
-
- override fun getPublic() = publicKey
-
- override fun getType(): KeyType = KeyType.fromKey(publicKey)
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt
deleted file mode 100644
index c5cb6eaa..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt
+++ /dev/null
@@ -1,103 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.git.sshj
-
-import com.hierynomus.sshj.key.KeyAlgorithm
-import java.io.ByteArrayOutputStream
-import java.security.PrivateKey
-import java.security.interfaces.ECKey
-import kotlinx.coroutines.runBlocking
-import net.schmizz.sshj.common.Buffer
-import net.schmizz.sshj.common.Factory
-import net.schmizz.sshj.signature.Signature
-import org.openintents.ssh.authentication.SshAuthenticationApi
-
-interface OpenKeychainPrivateKey : PrivateKey, ECKey {
-
- suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray
-
- override fun getFormat() = null
- override fun getEncoded() = null
-}
-
-class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) :
- Factory.Named<KeyAlgorithm> by factory {
-
- override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create())
-}
-
-class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) :
- KeyAlgorithm by keyAlgorithm {
-
- private val hashAlgorithm =
- when (keyAlgorithm.keyAlgorithm) {
- "rsa-sha2-512" -> SshAuthenticationApi.SHA512
- "rsa-sha2-256" -> SshAuthenticationApi.SHA256
- "ssh-rsa",
- "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1
- // Other algorithms don't use this value, but it has to be valid.
- else -> SshAuthenticationApi.SHA512
- }
-
- override fun newSignature() =
- OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm)
-}
-
-class OpenKeychainWrappedSignature(
- private val wrappedSignature: Signature,
- private val hashAlgorithm: Int
-) : Signature by wrappedSignature {
-
- private val data = ByteArrayOutputStream()
-
- private var bridgedPrivateKey: OpenKeychainPrivateKey? = null
-
- override fun initSign(prvkey: PrivateKey?) {
- if (prvkey is OpenKeychainPrivateKey) {
- bridgedPrivateKey = prvkey
- } else {
- wrappedSignature.initSign(prvkey)
- }
- }
-
- override fun update(H: ByteArray?) {
- if (bridgedPrivateKey != null) {
- data.write(H!!)
- } else {
- wrappedSignature.update(H)
- }
- }
-
- override fun update(H: ByteArray?, off: Int, len: Int) {
- if (bridgedPrivateKey != null) {
- data.write(H!!, off, len)
- } else {
- wrappedSignature.update(H, off, len)
- }
- }
-
- override fun sign(): ByteArray? =
- if (bridgedPrivateKey != null) {
- runBlocking { bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm) }
- } else {
- wrappedSignature.sign()
- }
-
- override fun encode(signature: ByteArray?): ByteArray? =
- if (bridgedPrivateKey != null) {
- require(signature != null) { "OpenKeychain signature must not be null" }
- val encodedSignature = Buffer.PlainBuffer(signature)
- // We need to drop the algorithm name and extract the raw signature since SSHJ adds the
- // name
- // later.
- encodedSignature.readString()
- encodedSignature.readBytes().also {
- bridgedPrivateKey = null
- data.reset()
- }
- } else {
- wrappedSignature.encode(signature)
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt
deleted file mode 100644
index ea96af53..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt
+++ /dev/null
@@ -1,372 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.git.sshj
-
-import android.content.Context
-import android.content.pm.PackageManager
-import android.net.Uri
-import android.os.Build
-import android.provider.OpenableColumns
-import android.security.keystore.KeyGenParameterSpec
-import android.security.keystore.KeyInfo
-import android.security.keystore.KeyProperties
-import android.util.Base64
-import androidx.core.content.edit
-import androidx.security.crypto.EncryptedFile
-import androidx.security.crypto.MasterKey
-import com.github.michaelbull.result.getOrElse
-import com.github.michaelbull.result.runCatching
-import dev.msfjarvis.aps.Application
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs
-import dev.msfjarvis.aps.util.extensions.getString
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.extensions.unsafeLazy
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import java.io.File
-import java.io.IOException
-import java.security.KeyFactory
-import java.security.KeyPairGenerator
-import java.security.KeyStore
-import java.security.PrivateKey
-import java.security.PublicKey
-import javax.crypto.SecretKey
-import javax.crypto.SecretKeyFactory
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.withContext
-import logcat.asLog
-import logcat.logcat
-import net.i2p.crypto.eddsa.EdDSAPrivateKey
-import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
-import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec
-import net.schmizz.sshj.SSHClient
-import net.schmizz.sshj.common.Buffer
-import net.schmizz.sshj.common.KeyType
-import net.schmizz.sshj.userauth.keyprovider.KeyProvider
-
-private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore"
-private const val KEYSTORE_ALIAS = "sshkey"
-private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs"
-
-private val androidKeystore: KeyStore by unsafeLazy {
- KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) }
-}
-
-private val KeyStore.sshPrivateKey
- get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey
-
-private val KeyStore.sshPublicKey
- get() = getCertificate(KEYSTORE_ALIAS)?.publicKey
-
-fun parseSshPublicKey(sshPublicKey: String): PublicKey? {
- val sshKeyParts = sshPublicKey.split("""\s+""".toRegex())
- if (sshKeyParts.size < 2) return null
- return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey()
-}
-
-fun toSshPublicKey(publicKey: PublicKey): String {
- val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData
- val keyType = KeyType.fromKey(publicKey)
- return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}"
-}
-
-object SshKey {
-
- val sshPublicKey
- get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null
- val canShowSshPublicKey
- get() = type in listOf(Type.LegacyGenerated, Type.KeystoreNative, Type.KeystoreWrappedEd25519)
- val exists
- get() = type != null
- val mustAuthenticate: Boolean
- get() {
- return runCatching {
- if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) return false
- when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) {
- is PrivateKey -> {
- val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
- return factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired
- }
- is SecretKey -> {
- val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
- (factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired
- }
- else -> throw IllegalStateException("SSH key does not exist in Keystore")
- }
- }
- .getOrElse { error ->
- // It is fine to swallow the exception here since it will reappear when the key
- // is
- // used for SSH authentication and can then be shown in the UI.
- logcat { error.asLog() }
- false
- }
- }
-
- private val context: Context
- get() = Application.instance.applicationContext
-
- private val privateKeyFile
- get() = File(context.filesDir, ".ssh_key")
- private val publicKeyFile
- get() = File(context.filesDir, ".ssh_key.pub")
-
- private var type: Type?
- get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE))
- set(value) =
- context.sharedPrefs.edit { putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) }
-
- private val isStrongBoxSupported by unsafeLazy {
- if (Build.VERSION.SDK_INT >= 28)
- context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
- else false
- }
-
- private enum class Type(val value: String) {
- Imported("imported"),
- KeystoreNative("keystore_native"),
- KeystoreWrappedEd25519("keystore_wrapped_ed25519"),
-
- // Behaves like `Imported`, but allows to view the public key.
- LegacyGenerated("legacy_generated"),
- ;
-
- companion object {
-
- fun fromValue(value: String?): Type? = values().associateBy { it.value }[value]
- }
- }
-
- enum class Algorithm(
- val algorithm: String,
- val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit
- ) {
- Rsa(
- KeyProperties.KEY_ALGORITHM_RSA,
- {
- setKeySize(3072)
- setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
- setDigests(
- KeyProperties.DIGEST_SHA1,
- KeyProperties.DIGEST_SHA256,
- KeyProperties.DIGEST_SHA512
- )
- }
- ),
- Ecdsa(
- KeyProperties.KEY_ALGORITHM_EC,
- {
- setKeySize(256)
- setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
- setDigests(KeyProperties.DIGEST_SHA256)
- if (Build.VERSION.SDK_INT >= 28) {
- setIsStrongBoxBacked(isStrongBoxSupported)
- }
- }
- ),
- }
-
- private fun delete() {
- androidKeystore.deleteEntry(KEYSTORE_ALIAS)
- // Remove Tink key set used by AndroidX's EncryptedFile.
- context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit {
- clear()
- }
- if (privateKeyFile.isFile) {
- privateKeyFile.delete()
- }
- if (publicKeyFile.isFile) {
- publicKeyFile.delete()
- }
- context.getEncryptedGitPrefs().edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) }
- type = null
- }
-
- fun import(uri: Uri) {
- // First check whether the content at uri is likely an SSH private key.
- val fileSize =
- context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use {
- cursor ->
- // Cursor returns only a single row.
- cursor.moveToFirst()
- cursor.getInt(0)
- }
- ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
-
- // We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes.
- if (fileSize > 100_000 || fileSize == 0)
- throw IllegalArgumentException(
- context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)
- )
-
- val sshKeyInputStream =
- context.contentResolver.openInputStream(uri)
- ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
- val lines = sshKeyInputStream.bufferedReader().readLines()
-
- // The file must have more than 2 lines, and the first and last line must have private key
- // markers.
- if (
- lines.size < 2 ||
- !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
- !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
- )
- throw IllegalArgumentException(
- context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)
- )
-
- // At this point, we are reasonably confident that we have actually been provided a private
- // key and delete the old key.
- delete()
- // Canonicalize line endings to '\n'.
- privateKeyFile.writeText(lines.joinToString("\n"))
-
- type = Type.Imported
- }
-
- @Deprecated("To be used only in Migrations.kt")
- fun useLegacyKey(isGenerated: Boolean) {
- type = if (isGenerated) Type.LegacyGenerated else Type.Imported
- }
-
- @Suppress("BlockingMethodInNonBlockingContext")
- private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) =
- withContext(Dispatchers.IO) {
- MasterKey.Builder(context, KEYSTORE_ALIAS).run {
- setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
- setRequestStrongBoxBacked(true)
- setUserAuthenticationRequired(requireAuthentication, 15)
- build()
- }
- }
-
- @Suppress("BlockingMethodInNonBlockingContext")
- private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) =
- withContext(Dispatchers.IO) {
- EncryptedFile.Builder(
- context,
- privateKeyFile,
- getOrCreateWrappingMasterKey(requireAuthentication),
- EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
- )
- .run {
- setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME)
- build()
- }
- }
-
- @Suppress("BlockingMethodInNonBlockingContext")
- suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) =
- withContext(Dispatchers.IO) {
- delete()
-
- val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication)
- // Generate the ed25519 key pair and encrypt the private key.
- val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair()
- encryptedPrivateKeyFile.openFileOutput().use { os ->
- os.write((keyPair.private as EdDSAPrivateKey).seed)
- }
-
- // Write public key in SSH format to .ssh_key.pub.
- publicKeyFile.writeText(toSshPublicKey(keyPair.public))
-
- type = Type.KeystoreWrappedEd25519
- }
-
- fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) {
- delete()
-
- // Generate Keystore-backed private key.
- val parameterSpec =
- KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run {
- apply(algorithm.applyToSpec)
- if (requireAuthentication) {
- setUserAuthenticationRequired(true)
- if (Build.VERSION.SDK_INT >= 30) {
- setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL)
- } else {
- @Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds(30)
- }
- }
- build()
- }
- val keyPair =
- KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run {
- initialize(parameterSpec)
- generateKeyPair()
- }
-
- // Write public key in SSH format to .ssh_key.pub.
- publicKeyFile.writeText(toSshPublicKey(keyPair.public))
-
- type = Type.KeystoreNative
- }
-
- fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? =
- when (type) {
- Type.LegacyGenerated,
- Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder)
- Type.KeystoreNative -> KeystoreNativeKeyProvider
- Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider
- null -> null
- }
-
- private object KeystoreNativeKeyProvider : KeyProvider {
-
- override fun getPublic(): PublicKey =
- runCatching { androidKeystore.sshPublicKey!! }
- .getOrElse { error ->
- logcat { error.asLog() }
- throw IOException(
- "Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore",
- error
- )
- }
-
- override fun getPrivate(): PrivateKey =
- runCatching { androidKeystore.sshPrivateKey!! }
- .getOrElse { error ->
- logcat { error.asLog() }
- throw IOException(
- "Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore",
- error
- )
- }
-
- override fun getType(): KeyType = KeyType.fromKey(public)
- }
-
- private object KeystoreWrappedEd25519KeyProvider : KeyProvider {
-
- override fun getPublic(): PublicKey =
- runCatching { parseSshPublicKey(sshPublicKey!!)!! }
- .getOrElse { error ->
- logcat { error.asLog() }
- throw IOException("Failed to get the public key for wrapped ed25519 key", error)
- }
-
- override fun getPrivate(): PrivateKey =
- runCatching {
- // The current MasterKey API does not allow getting a reference to an existing
- // one
- // without specifying the KeySpec for a new one. However, the value for passed
- // here
- // for `requireAuthentication` is not used as the key already exists at this
- // point.
- val encryptedPrivateKeyFile = runBlocking { getOrCreateWrappedPrivateKeyFile(false) }
- val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() }
- EdDSAPrivateKey(
- EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC)
- )
- }
- .getOrElse { error ->
- logcat { error.asLog() }
- throw IOException("Failed to unwrap wrapped ed25519 key", error)
- }
-
- override fun getType(): KeyType = KeyType.fromKey(public)
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt
deleted file mode 100644
index 948fbd35..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt
+++ /dev/null
@@ -1,289 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.git.sshj
-
-import com.github.michaelbull.result.runCatching
-import com.hierynomus.sshj.key.KeyAlgorithms
-import com.hierynomus.sshj.transport.cipher.BlockCiphers
-import com.hierynomus.sshj.transport.cipher.GcmCiphers
-import com.hierynomus.sshj.transport.kex.ExtInfoClientFactory
-import com.hierynomus.sshj.transport.mac.Macs
-import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile
-import java.security.Security
-import logcat.LogPriority.ERROR
-import logcat.LogPriority.INFO
-import logcat.LogPriority.VERBOSE
-import logcat.LogPriority.WARN
-import logcat.asLog
-import logcat.logcat
-import net.schmizz.keepalive.KeepAliveProvider
-import net.schmizz.sshj.ConfigImpl
-import net.schmizz.sshj.common.LoggerFactory
-import net.schmizz.sshj.common.SecurityUtils
-import net.schmizz.sshj.transport.compression.NoneCompression
-import net.schmizz.sshj.transport.kex.Curve25519SHA256
-import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh
-import net.schmizz.sshj.transport.kex.DHGexSHA256
-import net.schmizz.sshj.transport.kex.ECDHNistP
-import net.schmizz.sshj.transport.random.JCERandom
-import net.schmizz.sshj.transport.random.SingletonRandomFactory
-import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile
-import net.schmizz.sshj.userauth.keyprovider.PKCS5KeyFile
-import net.schmizz.sshj.userauth.keyprovider.PKCS8KeyFile
-import net.schmizz.sshj.userauth.keyprovider.PuTTYKeyFile
-import org.bouncycastle.jce.provider.BouncyCastleProvider
-import org.slf4j.Logger
-import org.slf4j.Marker
-
-fun setUpBouncyCastleForSshj() {
- // Replace the Android BC provider with the Java BouncyCastle provider since the former does
- // not include all the required algorithms.
- // Note: This may affect crypto operations in other parts of the application.
- val bcIndex =
- Security.getProviders().indexOfFirst { it.name == BouncyCastleProvider.PROVIDER_NAME }
- if (bcIndex == -1) {
- // No Android BC found, install Java BC at lowest priority.
- Security.addProvider(BouncyCastleProvider())
- } else {
- // Replace Android BC with Java BC, inserted at the same position.
- Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
- // May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261
- runCatching { Class.forName("sun.security.jca.Providers") }
- Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1)
- }
- logcat("setUpBouncyCastleForSshj") {
- "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}"
- }
- // Prevent sshj from forwarding all cryptographic operations to BC.
- SecurityUtils.setRegisterBouncyCastle(false)
- SecurityUtils.setSecurityProvider(null)
-}
-
-private abstract class AbstractLogger(private val name: String) : Logger {
-
- abstract fun t(message: String, t: Throwable? = null, vararg args: Any?)
- abstract fun d(message: String, t: Throwable? = null, vararg args: Any?)
- abstract fun i(message: String, t: Throwable? = null, vararg args: Any?)
- abstract fun w(message: String, t: Throwable? = null, vararg args: Any?)
- abstract fun e(message: String, t: Throwable? = null, vararg args: Any?)
-
- override fun getName() = name
-
- override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled
- override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled
- override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled
- override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled
- override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled
-
- override fun trace(msg: String) = t(msg)
- override fun trace(format: String, arg: Any?) = t(format, null, arg)
- override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2)
- override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments)
- override fun trace(msg: String, t: Throwable?) = t(msg, t)
- override fun trace(marker: Marker, msg: String) = trace(msg)
- override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg)
- override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
- trace(format, arg1, arg2)
-
- override fun trace(marker: Marker?, format: String, vararg arguments: Any?) =
- trace(format, *arguments)
-
- override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t)
-
- override fun debug(msg: String) = d(msg)
- override fun debug(format: String, arg: Any?) = d(format, null, arg)
- override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2)
- override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments)
- override fun debug(msg: String, t: Throwable?) = d(msg, t)
- override fun debug(marker: Marker, msg: String) = debug(msg)
- override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg)
- override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
- debug(format, arg1, arg2)
-
- override fun debug(marker: Marker?, format: String, vararg arguments: Any?) =
- debug(format, *arguments)
-
- override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t)
-
- override fun info(msg: String) = i(msg)
- override fun info(format: String, arg: Any?) = i(format, null, arg)
- override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2)
- override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments)
- override fun info(msg: String, t: Throwable?) = i(msg, t)
- override fun info(marker: Marker, msg: String) = info(msg)
- override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg)
- override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
- info(format, arg1, arg2)
-
- override fun info(marker: Marker?, format: String, vararg arguments: Any?) =
- info(format, *arguments)
-
- override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t)
-
- override fun warn(msg: String) = w(msg)
- override fun warn(format: String, arg: Any?) = w(format, null, arg)
- override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2)
- override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments)
- override fun warn(msg: String, t: Throwable?) = w(msg, t)
- override fun warn(marker: Marker, msg: String) = warn(msg)
- override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg)
- override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
- warn(format, arg1, arg2)
-
- override fun warn(marker: Marker?, format: String, vararg arguments: Any?) =
- warn(format, *arguments)
-
- override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t)
-
- override fun error(msg: String) = e(msg)
- override fun error(format: String, arg: Any?) = e(format, null, arg)
- override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2)
- override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments)
- override fun error(msg: String, t: Throwable?) = e(msg, t)
- override fun error(marker: Marker, msg: String) = error(msg)
- override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg)
- override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
- error(format, arg1, arg2)
-
- override fun error(marker: Marker?, format: String, vararg arguments: Any?) =
- error(format, *arguments)
-
- override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t)
-}
-
-object LogcatLoggerFactory : LoggerFactory {
- private class LogcatLogger(name: String) : AbstractLogger(name) {
-
- override fun isTraceEnabled() = true
- override fun isDebugEnabled() = true
- override fun isInfoEnabled() = true
- override fun isWarnEnabled() = true
- override fun isErrorEnabled() = true
-
- // Replace slf4j's "{}" format string style with standard Java's "%s".
- // The supposedly redundant escape on the } is not redundant.
- @Suppress("RegExpRedundantEscape")
- private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s")
-
- override fun t(message: String, t: Throwable?, vararg args: Any?) {
- logcat(name, VERBOSE) { message.fix().format(*args) + (t?.asLog() ?: "") }
- }
-
- override fun d(message: String, t: Throwable?, vararg args: Any?) {
- logcat(name) { message.fix().format(*args) + (t?.asLog() ?: "") }
- }
-
- override fun i(message: String, t: Throwable?, vararg args: Any?) {
- logcat(name, INFO) { message.fix().format(*args) + (t?.asLog() ?: "") }
- }
-
- override fun w(message: String, t: Throwable?, vararg args: Any?) {
- logcat(name, WARN) { message.fix().format(*args) + (t?.asLog() ?: "") }
- }
-
- override fun e(message: String, t: Throwable?, vararg args: Any?) {
- logcat(name, ERROR) { message.fix().format(*args) + (t?.asLog() ?: "") }
- }
- }
-
- override fun getLogger(name: String): Logger {
- return LogcatLogger(name)
- }
-
- override fun getLogger(clazz: Class<*>): Logger {
- return LogcatLogger(clazz.name)
- }
-}
-
-class SshjConfig : ConfigImpl() {
-
- init {
- loggerFactory = LogcatLoggerFactory
- keepAliveProvider = KeepAliveProvider.HEARTBEAT
- version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1"
-
- initKeyExchangeFactories()
- initKeyAlgorithms()
- initRandomFactory()
- initFileKeyProviderFactories()
- initCipherFactories()
- initCompressionFactories()
- initMACFactories()
- }
-
- private fun initKeyExchangeFactories() {
- keyExchangeFactories =
- listOf(
- Curve25519SHA256.Factory(),
- FactoryLibSsh(),
- ECDHNistP.Factory521(),
- ECDHNistP.Factory384(),
- ECDHNistP.Factory256(),
- DHGexSHA256.Factory(),
- // Sends "ext-info-c" with the list of key exchange algorithms. This is needed to
- // get
- // rsa-sha2-* key types to work with some servers (e.g. GitHub).
- ExtInfoClientFactory(),
- )
- }
-
- private fun initKeyAlgorithms() {
- keyAlgorithms =
- listOf(
- KeyAlgorithms.SSHRSACertV01(),
- KeyAlgorithms.EdDSA25519(),
- KeyAlgorithms.ECDSASHANistp521(),
- KeyAlgorithms.ECDSASHANistp384(),
- KeyAlgorithms.ECDSASHANistp256(),
- KeyAlgorithms.RSASHA512(),
- KeyAlgorithms.RSASHA256(),
- KeyAlgorithms.SSHRSA(),
- )
- .map { OpenKeychainWrappedKeyAlgorithmFactory(it) }
- }
-
- private fun initRandomFactory() {
- randomFactory = SingletonRandomFactory(JCERandom.Factory())
- }
-
- private fun initFileKeyProviderFactories() {
- fileKeyProviderFactories =
- listOf(
- OpenSSHKeyV1KeyFile.Factory(),
- PKCS8KeyFile.Factory(),
- PKCS5KeyFile.Factory(),
- OpenSSHKeyFile.Factory(),
- PuTTYKeyFile.Factory(),
- )
- }
-
- private fun initCipherFactories() {
- cipherFactories =
- listOf(
- GcmCiphers.AES128GCM(),
- GcmCiphers.AES256GCM(),
- BlockCiphers.AES256CTR(),
- BlockCiphers.AES192CTR(),
- BlockCiphers.AES128CTR(),
- )
- }
-
- private fun initMACFactories() {
- macFactories =
- listOf(
- Macs.HMACSHA2512Etm(),
- Macs.HMACSHA2256Etm(),
- Macs.HMACSHA2512(),
- Macs.HMACSHA2256(),
- )
- }
-
- private fun initCompressionFactories() {
- compressionFactories =
- listOf(
- NoneCompression.Factory(),
- )
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt
deleted file mode 100644
index a9f84fa9..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt
+++ /dev/null
@@ -1,218 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.git.sshj
-
-import android.util.Base64
-import com.github.michaelbull.result.getOrElse
-import com.github.michaelbull.result.runCatching
-import dev.msfjarvis.aps.util.git.operation.CredentialFinder
-import dev.msfjarvis.aps.util.settings.AuthMode
-import java.io.File
-import java.io.IOException
-import java.io.InputStream
-import java.io.OutputStream
-import java.security.PublicKey
-import java.util.Collections
-import java.util.concurrent.TimeUnit
-import kotlin.coroutines.Continuation
-import kotlin.coroutines.suspendCoroutine
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runBlocking
-import logcat.LogPriority.WARN
-import logcat.logcat
-import net.schmizz.sshj.SSHClient
-import net.schmizz.sshj.common.Buffer.PlainBuffer
-import net.schmizz.sshj.common.DisconnectReason
-import net.schmizz.sshj.common.SSHException
-import net.schmizz.sshj.common.SSHRuntimeException
-import net.schmizz.sshj.common.SecurityUtils
-import net.schmizz.sshj.connection.channel.direct.Session
-import net.schmizz.sshj.transport.verification.FingerprintVerifier
-import net.schmizz.sshj.transport.verification.HostKeyVerifier
-import net.schmizz.sshj.userauth.method.AuthPassword
-import net.schmizz.sshj.userauth.method.AuthPublickey
-import net.schmizz.sshj.userauth.password.PasswordFinder
-import net.schmizz.sshj.userauth.password.Resource
-import org.eclipse.jgit.transport.CredentialsProvider
-import org.eclipse.jgit.transport.RemoteSession
-import org.eclipse.jgit.transport.SshSessionFactory
-import org.eclipse.jgit.transport.URIish
-import org.eclipse.jgit.util.FS
-
-sealed class SshAuthMethod(val activity: ContinuationContainerActivity) {
- class Password(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
- class SshKey(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
- class OpenKeychain(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
-}
-
-abstract class InteractivePasswordFinder : PasswordFinder {
-
- private var isRetry = false
-
- abstract fun askForPassword(cont: Continuation<String?>, isRetry: Boolean)
-
- final override fun reqPassword(resource: Resource<*>?): CharArray {
- val password =
- runBlocking(Dispatchers.Main) {
- suspendCoroutine<String?> { cont -> askForPassword(cont, isRetry) }
- }
- isRetry = true
- return password?.toCharArray() ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)
- }
-
- final override fun shouldRetry(resource: Resource<*>?) = true
-}
-
-class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) :
- SshSessionFactory() {
-
- private var currentSession: SshjSession? = null
-
- override fun getSession(
- uri: URIish,
- credentialsProvider: CredentialsProvider?,
- fs: FS?,
- tms: Int
- ): RemoteSession {
- return currentSession
- ?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also {
- logcat { "New SSH connection created" }
- currentSession = it
- }
- }
-
- fun close() {
- currentSession?.close()
- }
-}
-
-private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier {
- if (!hostKeyFile.exists()) {
- return object : HostKeyVerifier {
- override fun verify(hostname: String?, port: Int, key: PublicKey?): Boolean {
- val digest =
- runCatching { SecurityUtils.getMessageDigest("SHA-256") }
- .getOrElse { e -> throw SSHRuntimeException(e) }
- digest.update(PlainBuffer().putPublicKey(key).compactData)
- val digestData = digest.digest()
- val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}"
- logcat(SshjSessionFactory::class.java.simpleName) {
- "Trusting host key on first use: $hostKeyEntry"
- }
- hostKeyFile.writeText(hostKeyEntry)
- return true
- }
-
- override fun findExistingAlgorithms(hostname: String?, port: Int): MutableList<String> {
- return Collections.emptyList()
- }
- }
- } else {
- val hostKeyEntry = hostKeyFile.readText()
- logcat(SshjSessionFactory::class.java.simpleName) { "Pinned host key: $hostKeyEntry" }
- return FingerprintVerifier.getInstance(hostKeyEntry)
- }
-}
-
-private class SshjSession(
- uri: URIish,
- private val username: String,
- private val authMethod: SshAuthMethod,
- private val hostKeyFile: File
-) : RemoteSession {
-
- private lateinit var ssh: SSHClient
- private var currentCommand: Session? = null
-
- private val uri =
- if (uri.host.contains('@')) {
- // URIish's String constructor cannot handle '@' in the user part of the URI and the URL
- // constructor can't be used since Java's URL does not recognize the ssh scheme. We thus
- // need to patch everything up ourselves.
- logcat { "Before fixup: user=${uri.user}, host=${uri.host}" }
- val userPlusHost = "${uri.user}@${uri.host}"
- val realUser = userPlusHost.substringBeforeLast('@')
- val realHost = userPlusHost.substringAfterLast('@')
- uri.setUser(realUser).setHost(realHost).also {
- logcat { "After fixup: user=${it.user}, host=${it.host}" }
- }
- } else {
- uri
- }
-
- fun connect(): SshjSession {
- ssh = SSHClient(SshjConfig())
- ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile))
- ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22)
- if (!ssh.isConnected) throw IOException()
- val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password))
- when (authMethod) {
- is SshAuthMethod.Password -> {
- ssh.auth(username, passwordAuth)
- }
- is SshAuthMethod.SshKey -> {
- val pubkeyAuth =
- AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey)))
- ssh.auth(username, pubkeyAuth, passwordAuth)
- }
- is SshAuthMethod.OpenKeychain -> {
- runBlocking {
- OpenKeychainKeyProvider.prepareAndUse(authMethod.activity) { provider ->
- val openKeychainAuth = AuthPublickey(provider)
- ssh.auth(username, openKeychainAuth, passwordAuth)
- }
- }
- }
- }
- return this
- }
-
- override fun exec(commandName: String?, timeout: Int): Process {
- if (currentCommand != null) {
- logcat(WARN) { "Killing old command" }
- disconnect()
- }
- val session = ssh.startSession()
- currentCommand = session
- return SshjProcess(session.exec(commandName), timeout.toLong())
- }
-
- /**
- * Kills the current command if one is running and returns the session into a state where `exec`
- * can be called.
- *
- * Note that this does *not* disconnect the session. Unfortunately, the function has to be called
- * `disconnect` to override the corresponding abstract function in `RemoteSession`.
- */
- override fun disconnect() {
- currentCommand?.close()
- currentCommand = null
- }
-
- fun close() {
- disconnect()
- ssh.close()
- }
-}
-
-private class SshjProcess(private val command: Session.Command, private val timeout: Long) :
- Process() {
-
- override fun waitFor(): Int {
- command.join(timeout, TimeUnit.SECONDS)
- command.close()
- return exitValue()
- }
-
- override fun destroy() = command.close()
-
- override fun getOutputStream(): OutputStream = command.outputStream
-
- override fun getErrorStream(): InputStream = command.errorStream
-
- override fun exitValue(): Int = command.exitStatus
-
- override fun getInputStream(): InputStream = command.inputStream
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt b/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt
deleted file mode 100644
index d21ee24d..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.proxy
-
-import dev.msfjarvis.aps.util.settings.GitSettings
-import java.io.IOException
-import java.net.Authenticator
-import java.net.InetSocketAddress
-import java.net.PasswordAuthentication
-import java.net.Proxy
-import java.net.ProxySelector
-import java.net.SocketAddress
-import java.net.URI
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/** Utility class for [Proxy] handling. */
-@Singleton
-class ProxyUtils @Inject constructor(private val gitSettings: GitSettings) {
-
- /** Set the default [Proxy] and [Authenticator] for the app based on user provided settings. */
- fun setDefaultProxy() {
- ProxySelector.setDefault(
- object : ProxySelector() {
- override fun select(uri: URI?): MutableList<Proxy> {
- val host = gitSettings.proxyHost
- val port = gitSettings.proxyPort
- return if (host == null || port == -1) {
- mutableListOf(Proxy.NO_PROXY)
- } else {
- mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port)))
- }
- }
-
- override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
- if (uri == null || sa == null || ioe == null) {
- throw IllegalArgumentException("Arguments can't be null.")
- }
- }
- }
- )
- val user = gitSettings.proxyUsername ?: ""
- val password = gitSettings.proxyPassword ?: ""
- if (user.isEmpty() || password.isEmpty()) {
- System.clearProperty(HTTP_PROXY_USER_PROPERTY)
- System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY)
- } else {
- System.setProperty(HTTP_PROXY_USER_PROPERTY, user)
- System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password)
- }
- Authenticator.setDefault(
- object : Authenticator() {
- override fun getPasswordAuthentication(): PasswordAuthentication? {
- return if (requestorType == RequestorType.PROXY) {
- PasswordAuthentication(user, password.toCharArray())
- } else {
- null
- }
- }
- }
- )
- }
-
- companion object {
- private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser"
- private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword"
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt b/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt
deleted file mode 100644
index 34c3a989..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt
+++ /dev/null
@@ -1,206 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.services
-
-import android.app.Notification
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.PendingIntent
-import android.app.Service
-import android.content.ClipData
-import android.content.Intent
-import android.os.Build
-import android.os.IBinder
-import androidx.annotation.RequiresApi
-import androidx.core.app.NotificationCompat
-import androidx.core.content.getSystemService
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.util.extensions.clipboard
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import logcat.logcat
-
-class ClipboardService : Service() {
-
- private val scope = CoroutineScope(Job() + Dispatchers.Main)
-
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- if (intent != null) {
- when (intent.action) {
- ACTION_CLEAR -> {
- clearClipboard()
- stopForeground(true)
- stopSelf()
- return super.onStartCommand(intent, flags, startId)
- }
- ACTION_START -> {
- val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45)
-
- if (time == 0) {
- stopSelf()
- }
-
- createNotification(time)
- scope.launch {
- withContext(Dispatchers.IO) { startTimer(time) }
- withContext(Dispatchers.Main) {
- clearClipboard()
- stopForeground(true)
- stopSelf()
- }
- }
- return START_NOT_STICKY
- }
- }
- }
-
- return super.onStartCommand(intent, flags, startId)
- }
-
- override fun onBind(intent: Intent?): IBinder? {
- return null
- }
-
- override fun onDestroy() {
- scope.cancel()
- super.onDestroy()
- }
-
- private fun clearClipboard() {
- val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false)
- val clipboard = clipboard
-
- if (clipboard != null) {
- scope.launch {
- logcat { "Clearing the clipboard" }
- val clip = ClipData.newPlainText("pgp_handler_result_pm", "")
- clipboard.setPrimaryClip(clip)
- if (deepClear) {
- withContext(Dispatchers.IO) {
- repeat(CLIPBOARD_CLEAR_COUNT) {
- val count = (it * 500).toString()
- clipboard.setPrimaryClip(ClipData.newPlainText(count, count))
- }
- }
- }
- }
- } else {
- logcat { "Cannot get clipboard manager service" }
- }
- }
-
- private suspend fun startTimer(showTime: Int) {
- var current = 0
- while (scope.isActive && current < showTime) {
- // Block for 1s or until cancel is signalled
- current++
- delay(1000)
- }
- }
-
- private fun createNotification(clearTime: Int) {
- val clearTimeMs = clearTime * 1000L
- val clearIntent = Intent(this, ClipboardService::class.java).apply { action = ACTION_CLEAR }
- val pendingIntent =
- if (Build.VERSION.SDK_INT >= 26) {
- PendingIntent.getForegroundService(
- this,
- 0,
- clearIntent,
- if (Build.VERSION.SDK_INT >= 31) {
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
- } else {
- PendingIntent.FLAG_UPDATE_CURRENT
- }
- )
- } else {
- PendingIntent.getService(
- this,
- 0,
- clearIntent,
- if (Build.VERSION.SDK_INT >= 31) {
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
- } else {
- PendingIntent.FLAG_UPDATE_CURRENT
- },
- )
- }
- val notification =
- if (Build.VERSION.SDK_INT <= 23) {
- createNotificationApi23(pendingIntent)
- } else {
- createNotificationApi24(pendingIntent, clearTimeMs)
- }
-
- createNotificationChannel()
- startForeground(1, notification)
- }
-
- private fun createNotificationApi23(pendingIntent: PendingIntent): Notification {
- return NotificationCompat.Builder(this, CHANNEL_ID)
- .setContentTitle(getString(R.string.app_name))
- .setContentText(getString(R.string.tap_clear_clipboard))
- .setSmallIcon(R.drawable.ic_action_secure_24dp)
- .setContentIntent(pendingIntent)
- .setUsesChronometer(true)
- .setPriority(NotificationCompat.PRIORITY_LOW)
- .build()
- }
-
- @RequiresApi(24)
- private fun createNotificationApi24(
- pendingIntent: PendingIntent,
- clearTimeMs: Long
- ): Notification {
- return NotificationCompat.Builder(this, CHANNEL_ID)
- .setContentTitle(getString(R.string.app_name))
- .setContentText(getString(R.string.tap_clear_clipboard))
- .setSmallIcon(R.drawable.ic_action_secure_24dp)
- .setContentIntent(pendingIntent)
- .setUsesChronometer(true)
- .setChronometerCountDown(true)
- .setShowWhen(true)
- .setWhen(System.currentTimeMillis() + clearTimeMs)
- .setPriority(NotificationCompat.PRIORITY_LOW)
- .build()
- }
-
- private fun createNotificationChannel() {
- if (Build.VERSION.SDK_INT >= 26) {
- val serviceChannel =
- NotificationChannel(
- CHANNEL_ID,
- getString(R.string.app_name),
- NotificationManager.IMPORTANCE_LOW
- )
- val manager = getSystemService<NotificationManager>()
- if (manager != null) {
- manager.createNotificationChannel(serviceChannel)
- } else {
- logcat { "Failed to create notification channel" }
- }
- }
- }
-
- companion object {
-
- const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER"
- const val EXTRA_NOTIFICATION_TIME = "EXTRA_NOTIFICATION_TIME"
- private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD"
- private const val CHANNEL_ID = "NotificationService"
- // Newest Samsung phones now feature a history of up to 30 items. To err on the side of
- // caution,
- // push 35 fake ones.
- private const val CLIPBOARD_CLEAR_COUNT = 35
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt b/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt
deleted file mode 100644
index 3a1c69d3..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt
+++ /dev/null
@@ -1,172 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.services
-
-import android.content.Context
-import android.os.Build
-import android.os.CancellationSignal
-import android.service.autofill.AutofillService
-import android.service.autofill.FillCallback
-import android.service.autofill.FillRequest
-import android.service.autofill.FillResponse
-import android.service.autofill.SaveCallback
-import android.service.autofill.SaveRequest
-import androidx.annotation.RequiresApi
-import com.github.androidpasswordstore.autofillparser.AutofillScenario
-import com.github.androidpasswordstore.autofillparser.Credentials
-import com.github.androidpasswordstore.autofillparser.FillableForm
-import com.github.androidpasswordstore.autofillparser.FixedSaveCallback
-import com.github.androidpasswordstore.autofillparser.FormOrigin
-import com.github.androidpasswordstore.autofillparser.cachePublicSuffixList
-import com.github.androidpasswordstore.autofillparser.passwordValue
-import com.github.androidpasswordstore.autofillparser.recoverNodes
-import com.github.androidpasswordstore.autofillparser.usernameValue
-import dagger.hilt.android.AndroidEntryPoint
-import dev.msfjarvis.aps.BuildConfig
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
-import dev.msfjarvis.aps.util.autofill.Api30AutofillResponseBuilder
-import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder
-import dev.msfjarvis.aps.util.extensions.getString
-import dev.msfjarvis.aps.util.extensions.hasFlag
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import javax.inject.Inject
-import logcat.LogPriority.ERROR
-import logcat.logcat
-
-@RequiresApi(26)
-@AndroidEntryPoint
-class OreoAutofillService : AutofillService() {
-
- companion object {
-
- // TODO: Provide a user-configurable denylist
- private val DENYLISTED_PACKAGES =
- listOf(
- BuildConfig.APPLICATION_ID,
- "android",
- "com.android.settings",
- "com.android.settings.intelligence",
- "com.android.systemui",
- "com.oneplus.applocker",
- "org.sufficientlysecure.keychain",
- )
-
- private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L
- }
-
- @Inject lateinit var api30ResponseBuilderFactory: Api30AutofillResponseBuilder.Factory
- @Inject lateinit var responseBuilderFactory: AutofillResponseBuilder.Factory
-
- override fun onCreate() {
- super.onCreate()
- cachePublicSuffixList(applicationContext)
- }
-
- override fun onFillRequest(
- request: FillRequest,
- cancellationSignal: CancellationSignal,
- callback: FillCallback
- ) {
- val structure =
- request.fillContexts.lastOrNull()?.structure
- ?: run {
- callback.onSuccess(null)
- return
- }
- if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) {
- if (Build.VERSION.SDK_INT >= 28) {
- callback.onSuccess(
- FillResponse.Builder().run {
- disableAutofill(DISABLE_AUTOFILL_DURATION_MS)
- build()
- }
- )
- } else {
- callback.onSuccess(null)
- }
- return
- }
- val formToFill =
- FillableForm.parseAssistStructure(
- this,
- structure,
- isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST,
- getCustomSuffixes(),
- )
- ?: run {
- logcat { "Form cannot be filled" }
- callback.onSuccess(null)
- return
- }
- if (Build.VERSION.SDK_INT >= 30) {
- api30ResponseBuilderFactory
- .create(formToFill)
- .fillCredentials(this, request.inlineSuggestionsRequest, callback)
- } else {
- responseBuilderFactory.create(formToFill).fillCredentials(this, callback)
- }
- }
-
- override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
- // SaveCallback's behavior and feature set differs based on both target and device SDK, so
- // we replace it with a wrapper that works the same in all situations.
- @Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback)
- val structure =
- request.fillContexts.lastOrNull()?.structure
- ?: run {
- callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported))
- return
- }
- val clientState =
- request.clientState
- ?: run {
- logcat(ERROR) { "Received save request without client state" }
- callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
- return
- }
- val scenario =
- AutofillScenario.fromClientState(clientState)?.recoverNodes(structure)
- ?: run {
- logcat(ERROR) { "Failed to recover client state or nodes from client state" }
- callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
- return
- }
- val formOrigin =
- FormOrigin.fromBundle(clientState)
- ?: run {
- logcat(ERROR) { "Failed to recover form origin from client state" }
- callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
- return
- }
-
- val username = scenario.usernameValue
- val password =
- scenario.passwordValue
- ?: run {
- callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match))
- return
- }
- callback.onSuccess(
- AutofillSaveActivity.makeSaveIntentSender(
- this,
- credentials = Credentials(username, password, null),
- formOrigin = formOrigin
- )
- )
- }
-}
-
-fun Context.getDefaultUsername() =
- sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME)
-
-fun Context.getCustomSuffixes(): Sequence<String> {
- return sharedPrefs
- .getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)
- ?.splitToSequence('\n')
- ?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' }
- ?: emptySequence()
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt b/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt
deleted file mode 100644
index 91a7ac5f..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt
+++ /dev/null
@@ -1,159 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.services
-
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.Service
-import android.content.Intent
-import android.net.Uri
-import android.os.Build
-import android.os.IBinder
-import androidx.core.app.NotificationCompat
-import androidx.core.content.getSystemService
-import androidx.documentfile.provider.DocumentFile
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import java.time.LocalDateTime
-import java.time.format.DateTimeFormatter
-import java.util.Calendar
-import java.util.TimeZone
-import logcat.logcat
-
-class PasswordExportService : Service() {
-
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- if (intent != null) {
- when (intent.action) {
- ACTION_EXPORT_PASSWORD -> {
- val uri = intent.getParcelableExtra<Uri>("uri")
- if (uri != null) {
- val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri)
-
- if (targetDirectory != null) {
- createNotification()
- exportPasswords(targetDirectory)
- stopSelf()
- return START_NOT_STICKY
- }
- }
- }
- }
- }
- return super.onStartCommand(intent, flags, startId)
- }
-
- override fun onBind(intent: Intent?): IBinder? {
- return null
- }
-
- /**
- * Exports passwords to the given directory.
- *
- * Recursively copies the existing password store to an external directory.
- *
- * @param targetDirectory directory to copy password directory to.
- */
- private fun exportPasswords(targetDirectory: DocumentFile) {
-
- val repositoryDirectory = requireNotNull(PasswordRepository.getRepositoryDirectory())
- val sourcePassDir = DocumentFile.fromFile(repositoryDirectory)
-
- logcat { "Copying ${repositoryDirectory.path} to $targetDirectory" }
-
- val dateString =
- if (Build.VERSION.SDK_INT >= 26) {
- LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
- } else {
- String.format("%tFT%<tRZ", Calendar.getInstance(TimeZone.getTimeZone("Z")))
- }
-
- val passDir = targetDirectory.createDirectory("password_store_$dateString")
-
- if (passDir != null) {
- copyDirToDir(sourcePassDir, passDir)
- }
- }
-
- /**
- * Copies a password file to a given directory.
- *
- * Note: this does not preserve last modified time.
- *
- * @param passwordFile password file to copy.
- * @param targetDirectory target directory to copy password.
- */
- private fun copyFileToDir(passwordFile: DocumentFile, targetDirectory: DocumentFile) {
- val sourceInputStream = contentResolver.openInputStream(passwordFile.uri)
- val name = passwordFile.name
- val targetPasswordFile = targetDirectory.createFile("application/octet-stream", name!!)
- if (targetPasswordFile?.exists() == true) {
- val destOutputStream = contentResolver.openOutputStream(targetPasswordFile.uri)
-
- if (destOutputStream != null && sourceInputStream != null) {
- sourceInputStream.copyTo(destOutputStream, 1024)
-
- sourceInputStream.close()
- destOutputStream.close()
- }
- }
- }
-
- /**
- * Recursively copies a directory to a destination.
- *
- * @param sourceDirectory directory to copy from.
- * @param targetDirectory directory to copy to.
- */
- private fun copyDirToDir(sourceDirectory: DocumentFile, targetDirectory: DocumentFile) {
- sourceDirectory.listFiles().forEach { file ->
- if (file.isDirectory) {
- // Create new directory and recurse
- val newDir = targetDirectory.createDirectory(file.name!!)
- copyDirToDir(file, newDir!!)
- } else {
- copyFileToDir(file, targetDirectory)
- }
- }
- }
-
- private fun createNotification() {
- createNotificationChannel()
-
- val notification =
- NotificationCompat.Builder(this, CHANNEL_ID)
- .setContentTitle(getString(R.string.app_name))
- .setContentText(getString(R.string.exporting_passwords))
- .setSmallIcon(R.drawable.ic_round_import_export)
- .setPriority(NotificationCompat.PRIORITY_LOW)
- .build()
-
- startForeground(2, notification)
- }
-
- private fun createNotificationChannel() {
- if (Build.VERSION.SDK_INT >= 26) {
- val serviceChannel =
- NotificationChannel(
- CHANNEL_ID,
- getString(R.string.app_name),
- NotificationManager.IMPORTANCE_LOW
- )
- val manager = getSystemService<NotificationManager>()
- if (manager != null) {
- manager.createNotificationChannel(serviceChannel)
- } else {
- logcat { "Failed to create notification channel" }
- }
- }
- }
-
- companion object {
-
- const val ACTION_EXPORT_PASSWORD = "ACTION_EXPORT_PASSWORD"
- private const val CHANNEL_ID = "NotificationService"
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt
deleted file mode 100644
index 4f5187c4..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt
+++ /dev/null
@@ -1,186 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.settings
-
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import com.github.michaelbull.result.getOrElse
-import com.github.michaelbull.result.runCatching
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.injection.context.FilesDirPath
-import dev.msfjarvis.aps.injection.prefs.GitPreferences
-import dev.msfjarvis.aps.injection.prefs.ProxyPreferences
-import dev.msfjarvis.aps.injection.prefs.SettingsPreferences
-import dev.msfjarvis.aps.util.extensions.getString
-import java.io.File
-import javax.inject.Inject
-import javax.inject.Singleton
-import org.eclipse.jgit.transport.URIish
-
-enum class Protocol(val pref: String) {
- Ssh("ssh://"),
- Https("https://"),
- ;
-
- companion object {
-
- private val map = values().associateBy(Protocol::pref)
- fun fromString(type: String?): Protocol {
- return map[type ?: return Ssh]
- ?: throw IllegalArgumentException("$type is not a valid Protocol")
- }
- }
-}
-
-enum class AuthMode(val pref: String) {
- SshKey("ssh-key"),
- Password("username/password"),
- OpenKeychain("OpenKeychain"),
- None("None"),
- ;
-
- companion object {
-
- private val map = values().associateBy(AuthMode::pref)
- fun fromString(type: String?): AuthMode {
- return map[type ?: return SshKey]
- ?: throw IllegalArgumentException("$type is not a valid AuthMode")
- }
- }
-}
-
-@Singleton
-class GitSettings
-@Inject
-constructor(
- @SettingsPreferences private val settings: SharedPreferences,
- @GitPreferences private val encryptedSettings: SharedPreferences,
- @ProxyPreferences private val proxySettings: SharedPreferences,
- @FilesDirPath private val filesDirPath: String,
-) {
-
- private val hostKeyPath = "$filesDirPath/.host_key"
- var authMode
- get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH))
- private set(value) {
- settings.edit { putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref) }
- }
- var url
- get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL)
- private set(value) {
- require(value != null)
- if (value == url) return
- settings.edit { putString(PreferenceKeys.GIT_REMOTE_URL, value) }
- if (PasswordRepository.isInitialized) PasswordRepository.addRemote("origin", value, true)
- // When the server changes, remote password, multiplexing support and host key file
- // should be deleted/reset.
- useMultiplexing = true
- encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
- clearSavedHostKey()
- }
- var authorName
- get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: ""
- set(value) {
- settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value) }
- }
- var authorEmail
- get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: ""
- set(value) {
- settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value) }
- }
- var branch
- get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH
- private set(value) {
- settings.edit { putString(PreferenceKeys.GIT_BRANCH_NAME, value) }
- }
- var useMultiplexing
- get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true)
- set(value) {
- settings.edit { putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value) }
- }
- var proxyHost
- get() = proxySettings.getString(PreferenceKeys.PROXY_HOST)
- set(value) {
- proxySettings.edit { putString(PreferenceKeys.PROXY_HOST, value) }
- }
- var proxyPort
- get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1)
- set(value) {
- proxySettings.edit { putInt(PreferenceKeys.PROXY_PORT, value) }
- }
- var proxyUsername
- get() = settings.getString(PreferenceKeys.PROXY_USERNAME)
- set(value) {
- proxySettings.edit { putString(PreferenceKeys.PROXY_USERNAME, value) }
- }
- var proxyPassword
- get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD)
- set(value) {
- proxySettings.edit { putString(PreferenceKeys.PROXY_PASSWORD, value) }
- }
- var rebaseOnPull
- get() = settings.getBoolean(PreferenceKeys.REBASE_ON_PULL, true)
- set(value) {
- settings.edit { putBoolean(PreferenceKeys.REBASE_ON_PULL, value) }
- }
-
- sealed class UpdateConnectionSettingsResult {
- class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult()
- class AuthModeMismatch(val newProtocol: Protocol, val validModes: List<AuthMode>) :
- UpdateConnectionSettingsResult()
- object Valid : UpdateConnectionSettingsResult()
- object FailedToParseUrl : UpdateConnectionSettingsResult()
- }
-
- fun updateConnectionSettingsIfValid(
- newAuthMode: AuthMode,
- newUrl: String,
- newBranch: String
- ): UpdateConnectionSettingsResult {
- val parsedUrl =
- runCatching { URIish(newUrl) }
- .getOrElse {
- return UpdateConnectionSettingsResult.FailedToParseUrl
- }
- val newProtocol =
- when (parsedUrl.scheme) {
- in listOf("http", "https") -> Protocol.Https
- in listOf("ssh", null) -> Protocol.Ssh
- else -> return UpdateConnectionSettingsResult.FailedToParseUrl
- }
- if (
- (newAuthMode != AuthMode.None && newProtocol != Protocol.Https) &&
- parsedUrl.user.isNullOrBlank()
- )
- return UpdateConnectionSettingsResult.MissingUsername(newProtocol)
- val validHttpsAuth = listOf(AuthMode.None, AuthMode.Password)
- val validSshAuth = listOf(AuthMode.OpenKeychain, AuthMode.Password, AuthMode.SshKey)
- when {
- newProtocol == Protocol.Https && newAuthMode !in validHttpsAuth -> {
- return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validHttpsAuth)
- }
- newProtocol == Protocol.Ssh && newAuthMode !in validSshAuth -> {
- return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validSshAuth)
- }
- }
-
- url = newUrl
- authMode = newAuthMode
- branch = newBranch
- return UpdateConnectionSettingsResult.Valid
- }
-
- /** Deletes a previously saved SSH host key */
- fun clearSavedHostKey() {
- File(hostKeyPath).delete()
- }
-
- /** Returns true if a host key was previously saved */
- fun hasSavedHostKey(): Boolean = File(hostKeyPath).exists()
-
- companion object {
- private const val DEFAULT_BRANCH = "master"
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt
deleted file mode 100644
index 7e91ab41..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-@file:Suppress("DEPRECATION")
-
-package dev.msfjarvis.aps.util.settings
-
-import android.content.SharedPreferences
-import androidx.core.content.edit
-import com.github.michaelbull.result.get
-import com.github.michaelbull.result.runCatching
-import dev.msfjarvis.aps.util.extensions.getString
-import dev.msfjarvis.aps.util.git.sshj.SshKey
-import java.io.File
-import java.net.URI
-import logcat.LogPriority.ERROR
-import logcat.LogPriority.INFO
-import logcat.logcat
-
-private const val TAG = "Migrations"
-
-fun runMigrations(filesDirPath: String, sharedPrefs: SharedPreferences, gitSettings: GitSettings) {
- migrateToGitUrlBasedConfig(sharedPrefs, gitSettings)
- migrateToHideAll(sharedPrefs)
- migrateToSshKey(filesDirPath, sharedPrefs)
- migrateToClipboardHistory(sharedPrefs)
- migrateToDiceware(sharedPrefs)
- removeExternalStorageProperties(sharedPrefs)
-}
-
-private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences, gitSettings: GitSettings) {
- val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER) ?: return
- logcat(TAG, INFO) { "Migrating to URL-based Git config" }
- val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: ""
- val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: ""
- val serverPath = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_LOCATION) ?: ""
- val protocol = Protocol.fromString(sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
-
- // Whether we need the leading ssh:// depends on the use of a custom port.
- val hostnamePart = serverHostname.removePrefix("ssh://")
- val url =
- when (protocol) {
- Protocol.Ssh -> {
- val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@"
- val portPart = if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort"
- if (portPart.isEmpty()) {
- "$userPart$hostnamePart:$serverPath"
- } else {
- // Only absolute paths are supported with custom ports.
- if (!serverPath.startsWith('/')) {
- null
- } else {
- // We have to specify the ssh scheme as this is the only way to pass a custom
- // port.
- "ssh://$userPart$hostnamePart$portPart$serverPath"
- }
- }
- }
- Protocol.Https -> {
- val portPart = if (serverPort == "443" || serverPort.isEmpty()) "" else ":$serverPort"
- val pathPart = serverPath.trimStart('/', ':')
- val urlWithFreeEntryScheme = "$hostnamePart$portPart/$pathPart"
- val url =
- when {
- urlWithFreeEntryScheme.startsWith("https://") -> urlWithFreeEntryScheme
- urlWithFreeEntryScheme.startsWith("http://") ->
- urlWithFreeEntryScheme.replaceFirst("http", "https")
- else -> "https://$urlWithFreeEntryScheme"
- }
- runCatching { if (URI(url).rawAuthority != null) url else null }.get()
- }
- }
-
- sharedPrefs.edit {
- remove(PreferenceKeys.GIT_REMOTE_LOCATION)
- remove(PreferenceKeys.GIT_REMOTE_PORT)
- remove(PreferenceKeys.GIT_REMOTE_SERVER)
- remove(PreferenceKeys.GIT_REMOTE_USERNAME)
- remove(PreferenceKeys.GIT_REMOTE_PROTOCOL)
- }
- if (
- url == null ||
- gitSettings.updateConnectionSettingsIfValid(
- newAuthMode = gitSettings.authMode,
- newUrl = url,
- newBranch = gitSettings.branch
- ) != GitSettings.UpdateConnectionSettingsResult.Valid
- ) {
- logcat(TAG, ERROR) { "Failed to migrate to URL-based Git config, generated URL is invalid" }
- }
-}
-
-private fun migrateToHideAll(sharedPrefs: SharedPreferences) {
- sharedPrefs.all[PreferenceKeys.SHOW_HIDDEN_FOLDERS] ?: return
- val isHidden = sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false)
- sharedPrefs.edit {
- remove(PreferenceKeys.SHOW_HIDDEN_FOLDERS)
- putBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, isHidden)
- }
-}
-
-private fun migrateToSshKey(filesDirPath: String, sharedPrefs: SharedPreferences) {
- val privateKeyFile = File(filesDirPath, ".ssh_key")
- if (
- sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) &&
- !SshKey.exists &&
- privateKeyFile.exists()
- ) {
- // Currently uses a private key imported or generated with an old version of Password Store.
- // Generated keys come with a public key which the user should still be able to view after
- // the migration (not possible for regular imported keys), hence the special case.
- val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false)
- SshKey.useLegacyKey(isGeneratedKey)
- sharedPrefs.edit { remove(PreferenceKeys.USE_GENERATED_KEY) }
- }
-}
-
-private fun migrateToClipboardHistory(sharedPrefs: SharedPreferences) {
- if (sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X)) {
- sharedPrefs.edit {
- putBoolean(
- PreferenceKeys.CLEAR_CLIPBOARD_HISTORY,
- sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false)
- )
- remove(PreferenceKeys.CLEAR_CLIPBOARD_20X)
- }
- }
-}
-
-private fun migrateToDiceware(sharedPrefs: SharedPreferences) {
- if (sharedPrefs.contains(PreferenceKeys.PREF_KEY_PWGEN_TYPE)) {
- sharedPrefs.edit {
- if (sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd") {
- putString(PreferenceKeys.PREF_KEY_PWGEN_TYPE, "diceware")
- }
- }
- }
-}
-
-private fun removeExternalStorageProperties(prefs: SharedPreferences) {
- logcat(TAG, INFO) { "Removing preferences related to external storage" }
- prefs.edit {
- if (prefs.contains(PreferenceKeys.GIT_EXTERNAL)) {
- if (prefs.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) {
- putBoolean(PreferenceKeys.GIT_EXTERNAL_MIGRATED, true)
- }
- remove(PreferenceKeys.GIT_EXTERNAL)
- }
- if (prefs.contains(PreferenceKeys.GIT_EXTERNAL_REPO)) {
- remove(PreferenceKeys.GIT_EXTERNAL_REPO)
- }
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt
deleted file mode 100644
index 6b48b6a9..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.settings
-
-import android.content.Context
-import android.content.SharedPreferences
-import dev.msfjarvis.aps.Application
-import dev.msfjarvis.aps.data.password.PasswordItem
-import dev.msfjarvis.aps.util.extensions.base64
-import dev.msfjarvis.aps.util.extensions.getString
-
-enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) {
- FOLDER_FIRST(
- Comparator { p1: PasswordItem, p2: PasswordItem ->
- (p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true)
- }
- ),
- INDEPENDENT(
- Comparator { p1: PasswordItem, p2: PasswordItem ->
- p1.name.compareTo(p2.name, ignoreCase = true)
- }
- ),
- RECENTLY_USED(
- Comparator { p1: PasswordItem, p2: PasswordItem ->
- val recentHistory =
- Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
- val timeP1 = recentHistory.getString(p1.file.absolutePath.base64())
- val timeP2 = recentHistory.getString(p2.file.absolutePath.base64())
- when {
- timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1)
- timeP1 != null && timeP2 == null -> return@Comparator -1
- timeP1 == null && timeP2 != null -> return@Comparator 1
- else -> p1.name.compareTo(p2.name, ignoreCase = true)
- }
- }
- ),
- FILE_FIRST(
- Comparator { p1: PasswordItem, p2: PasswordItem ->
- (p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true)
- }
- );
-
- companion object {
-
- @JvmStatic
- fun getSortOrder(settings: SharedPreferences): PasswordSortOrder {
- return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name)
- }
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt
deleted file mode 100644
index 7f4e0daf..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt
+++ /dev/null
@@ -1,88 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.settings
-
-object PreferenceKeys {
-
- const val APP_THEME = "app_theme"
- const val AUTOFILL_ENABLE = "autofill_enable"
- const val BIOMETRIC_AUTH = "biometric_auth"
- @Deprecated(
- message = "Use CLEAR_CLIPBOARD_HISTORY instead",
- replaceWith = ReplaceWith("PreferenceKeys.CLEAR_CLIPBOARD_HISTORY"),
- )
- const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x"
- const val CLEAR_CLIPBOARD_HISTORY = "clear_clipboard_history"
- const val CLEAR_SAVED_PASS = "clear_saved_pass"
- const val COPY_ON_DECRYPT = "copy_on_decrypt"
- const val ENABLE_DEBUG_LOGGING = "enable_debug_logging"
- const val EXPORT_PASSWORDS = "export_passwords"
- const val FILTER_RECURSIVELY = "filter_recursively"
- const val GENERAL_SHOW_TIME = "general_show_time"
- const val GIT_CONFIG = "git_config"
- const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email"
- const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name"
- @Deprecated(message = "We're removing support for external storage")
- const val GIT_EXTERNAL = "git_external"
- @Deprecated(message = "We're removing support for external storage")
- const val GIT_EXTERNAL_REPO = "git_external_repo"
- const val GIT_EXTERNAL_MIGRATED = "git_external_migrated"
- const val GIT_REMOTE_AUTH = "git_remote_auth"
- const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type"
-
- @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_LOCATION = "git_remote_location"
- const val GIT_REMOTE_USE_MULTIPLEXING = "git_remote_use_multiplexing"
-
- @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_PORT = "git_remote_port"
-
- @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_PROTOCOL = "git_remote_protocol"
- const val GIT_DELETE_REPO = "git_delete_repo"
-
- @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_SERVER = "git_remote_server"
- const val GIT_REMOTE_URL = "git_remote_url"
-
- @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_USERNAME = "git_remote_username"
- const val GIT_SERVER_INFO = "git_server_info"
- const val GIT_BRANCH_NAME = "git_branch"
- const val HTTPS_PASSWORD = "https_password"
- const val LENGTH = "length"
- const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes"
- const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username"
- const val OREO_AUTOFILL_DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure"
- const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type"
- const val REPOSITORY_INITIALIZED = "repository_initialized"
- const val REPO_CHANGED = "repo_changed"
- const val SEARCH_ON_START = "search_on_start"
-
- @Deprecated(
- message = "Use SHOW_HIDDEN_CONTENTS instead",
- replaceWith = ReplaceWith("PreferenceKeys.SHOW_HIDDEN_CONTENTS")
- )
- const val SHOW_HIDDEN_FOLDERS = "show_hidden_folders"
- const val SHOW_HIDDEN_CONTENTS = "show_hidden_contents"
- const val SORT_ORDER = "sort_order"
- const val SHOW_PASSWORD = "show_password"
- const val SSH_KEY = "ssh_key"
- const val SSH_KEYGEN = "ssh_keygen"
- const val SSH_KEY_LOCAL_PASSPHRASE = "ssh_key_local_passphrase"
- const val SSH_OPENKEYSTORE_CLEAR_KEY_ID = "ssh_openkeystore_clear_keyid"
- const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid"
- const val SSH_SEE_KEY = "ssh_see_key"
-
- @Deprecated("To be used only in Migrations.kt") const val USE_GENERATED_KEY = "use_generated_key"
-
- const val PROXY_SETTINGS = "proxy_settings"
- const val PROXY_HOST = "proxy_host"
- const val PROXY_PORT = "proxy_port"
- const val PROXY_USERNAME = "proxy_username"
- const val PROXY_PASSWORD = "proxy_password"
-
- const val REBASE_ON_PULL = "rebase_on_pull"
-
- const val DICEWARE_SEPARATOR = "diceware_separator"
- const val DICEWARE_LENGTH = "diceware_length"
- const val DISABLE_SYNC_ACTION = "disable_sync_action"
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/shortcuts/ShortcutHandler.kt b/app/src/main/java/dev/msfjarvis/aps/util/shortcuts/ShortcutHandler.kt
deleted file mode 100644
index 2ca881ed..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/shortcuts/ShortcutHandler.kt
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.shortcuts
-
-import android.content.Context
-import android.content.Intent
-import android.content.pm.ShortcutInfo
-import android.content.pm.ShortcutManager
-import android.graphics.drawable.Icon
-import android.os.Build
-import androidx.annotation.RequiresApi
-import androidx.core.content.getSystemService
-import dagger.Reusable
-import dagger.hilt.android.qualifiers.ApplicationContext
-import dev.msfjarvis.aps.R
-import dev.msfjarvis.aps.data.password.PasswordItem
-import javax.inject.Inject
-import logcat.logcat
-
-@Reusable
-class ShortcutHandler
-@Inject
-constructor(
- @ApplicationContext val context: Context,
-) {
-
- private companion object {
-
- // The max shortcut count from the system is set to 15 for some godforsaken reason, which
- // makes zero sense and is why our update logic just never worked. Capping it at 4 which is
- // what most launchers seem to have agreed upon is the only reasonable solution.
- private const val MAX_SHORTCUT_COUNT = 4
- }
-
- /**
- * Creates a
- * [dynamic shortcut](https://developer.android.com/guide/topics/ui/shortcuts/creating-shortcuts#dynamic)
- * that shows up with the app icon on long press. The list of items is capped to
- * [MAX_SHORTCUT_COUNT] and older items are removed by a simple LRU sweep.
- */
- fun addDynamicShortcut(item: PasswordItem, intent: Intent) {
- if (Build.VERSION.SDK_INT < 25) return
- val shortcutManager: ShortcutManager = context.getSystemService() ?: return
- val shortcut = buildShortcut(item, intent)
- val shortcuts = shortcutManager.dynamicShortcuts
- // If we're above or equal to the maximum shortcuts allowed, drop the last item.
- if (shortcuts.size >= MAX_SHORTCUT_COUNT) {
- shortcuts.removeLast()
- }
- // Reverse the list so we can append our new shortcut at the 'end'.
- shortcuts.reverse()
- shortcuts.add(shortcut)
- // Reverse it again, so the previous items are now in the correct order and our new item
- // is at the front like it's supposed to.
- shortcuts.reverse()
- // Write back the new shortcuts.
- shortcutManager.dynamicShortcuts = shortcuts.map(::rebuildShortcut)
- }
-
- /**
- * Creates a
- * [pinned shortcut](https://developer.android.com/guide/topics/ui/shortcuts/creating-shortcuts#pinned)
- * which presents a UI to users, allowing manual placement on the launcher screen. This method is
- * a no-op if the user's default launcher does not support pinned shortcuts.
- */
- fun addPinnedShortcut(item: PasswordItem, intent: Intent) {
- if (Build.VERSION.SDK_INT < 26) return
- val shortcutManager: ShortcutManager = context.getSystemService() ?: return
- if (!shortcutManager.isRequestPinShortcutSupported) {
- logcat { "addPinnedShortcut: pin shortcuts unsupported" }
- return
- }
- val shortcut = buildShortcut(item, intent)
- shortcutManager.requestPinShortcut(shortcut, null)
- }
-
- /** Creates a [ShortcutInfo] from [item] and assigns [intent] to it. */
- @RequiresApi(25)
- private fun buildShortcut(item: PasswordItem, intent: Intent): ShortcutInfo {
- return ShortcutInfo.Builder(context, item.fullPathToParent)
- .setShortLabel(item.toString())
- .setLongLabel(item.fullPathToParent + item.toString())
- .setIcon(Icon.createWithResource(context, R.drawable.ic_lock_open_24px))
- .setIntent(intent)
- .build()
- }
-
- /**
- * Takes an existing [ShortcutInfo] and builds a fresh instance of [ShortcutInfo] with the same
- * data, which ensures that the get/set dance in [addDynamicShortcut] does not cause invalidation
- * of icon assets, resulting in invisible icons in all but the newest launcher shortcut.
- */
- @RequiresApi(25)
- private fun rebuildShortcut(shortcut: ShortcutInfo): ShortcutInfo {
- // Non-null assertions are fine since we know these values aren't null.
- return ShortcutInfo.Builder(context, shortcut.id)
- .setShortLabel(shortcut.shortLabel!!)
- .setLongLabel(shortcut.longLabel!!)
- .setIcon(Icon.createWithResource(context, R.drawable.ic_lock_open_24px))
- .setIntent(shortcut.intent!!)
- .build()
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt
deleted file mode 100644
index 9c4dc4f4..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package dev.msfjarvis.aps.util.totp
-
-import android.net.Uri
-import javax.inject.Inject
-
-/** [Uri] backed TOTP URL parser. */
-class UriTotpFinder @Inject constructor() : TotpFinder {
-
- override fun findSecret(content: String): String? {
- content.split("\n".toRegex()).forEach { line ->
- if (line.startsWith(TotpFinder.TOTP_FIELDS[0])) {
- return Uri.parse(line).getQueryParameter("secret")
- }
- if (line.startsWith(TotpFinder.TOTP_FIELDS[1], ignoreCase = true)) {
- return line.split(": *".toRegex(), 2).toTypedArray()[1]
- }
- }
- return null
- }
-
- override fun findDigits(content: String): String {
- return getQueryParameter(content, "digits") ?: "6"
- }
-
- override fun findPeriod(content: String): Long {
- return getQueryParameter(content, "period")?.toLongOrNull() ?: 30
- }
-
- override fun findAlgorithm(content: String): String {
- return getQueryParameter(content, "algorithm") ?: "sha1"
- }
-
- override fun findIssuer(content: String): String? {
- return getQueryParameter(content, "issuer") ?: Uri.parse(content).authority
- }
-
- private fun getQueryParameter(content: String, parameterName: String): String? {
- content.split("\n".toRegex()).forEach { line ->
- val uri = Uri.parse(line)
- if (
- line.startsWith(TotpFinder.TOTP_FIELDS[0]) && uri.getQueryParameter(parameterName) != null
- ) {
- return uri.getQueryParameter(parameterName)
- }
- }
- return null
- }
-}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt
deleted file mode 100644
index 5f3d04e3..00000000
--- a/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt
+++ /dev/null
@@ -1,473 +0,0 @@
-/*
- * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved.
- * SPDX-License-Identifier: GPL-3.0-only
- */
-package dev.msfjarvis.aps.util.viewmodel
-
-import android.app.Application
-import android.os.Parcelable
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.annotation.VisibleForTesting
-import androidx.lifecycle.AndroidViewModel
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.asFlow
-import androidx.lifecycle.asLiveData
-import androidx.recyclerview.selection.ItemDetailsLookup
-import androidx.recyclerview.selection.ItemKeyProvider
-import androidx.recyclerview.selection.Selection
-import androidx.recyclerview.selection.SelectionPredicates
-import androidx.recyclerview.selection.SelectionTracker
-import androidx.recyclerview.selection.StorageStrategy
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.ListAdapter
-import androidx.recyclerview.widget.RecyclerView
-import com.github.androidpasswordstore.sublimefuzzy.Fuzzy
-import dev.msfjarvis.aps.data.password.PasswordItem
-import dev.msfjarvis.aps.data.repo.PasswordRepository
-import dev.msfjarvis.aps.util.autofill.AutofillPreferences
-import dev.msfjarvis.aps.util.autofill.DirectoryStructure
-import dev.msfjarvis.aps.util.extensions.sharedPrefs
-import dev.msfjarvis.aps.util.extensions.unsafeLazy
-import dev.msfjarvis.aps.util.settings.PasswordSortOrder
-import dev.msfjarvis.aps.util.settings.PreferenceKeys
-import java.io.File
-import java.text.Collator
-import java.util.Locale
-import java.util.Stack
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.asFlow
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.drop
-import kotlinx.coroutines.flow.emptyFlow
-import kotlinx.coroutines.flow.filter
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.mapLatest
-import kotlinx.coroutines.flow.toList
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.yield
-import me.zhanghai.android.fastscroll.PopupTextProvider
-
-private fun File.toPasswordItem() =
- if (isFile) PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory())
- else PasswordItem.newCategory(name, this, PasswordRepository.getRepositoryDirectory())
-
-private fun PasswordItem.fuzzyMatch(filter: String): Int {
- val (_, score) = Fuzzy.fuzzyMatch(filter, longName)
- return score
-}
-
-private val CaseInsensitiveComparator = Collator.getInstance().apply { strength = Collator.PRIMARY }
-
-private fun PasswordItem.Companion.makeComparator(
- typeSortOrder: PasswordSortOrder,
- directoryStructure: DirectoryStructure
-): Comparator<PasswordItem> {
- return when (typeSortOrder) {
- PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type }
- // In order to let INDEPENDENT not distinguish between items based on their type, we
- // simply
- // declare them all equal at this stage.
- PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 }
- PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type }
- PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator
- }
- .then(
- compareBy(nullsLast(CaseInsensitiveComparator)) {
- directoryStructure.getIdentifierFor(it.file)
- }
- )
- .then(
- compareBy(nullsLast(CaseInsensitiveComparator)) { directoryStructure.getUsernameFor(it.file) }
- )
-}
-
-val PasswordItem.stableId: String
- get() = file.absolutePath
-
-enum class FilterMode {
- NoFilter,
- StrictDomain,
- Fuzzy
-}
-
-enum class SearchMode {
- RecursivelyInSubdirectories,
- InCurrentDirectoryOnly
-}
-
-enum class ListMode {
- FilesOnly,
- DirectoriesOnly,
- AllEntries
-}
-
-@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
-class SearchableRepositoryViewModel(application: Application) : AndroidViewModel(application) {
-
- private var _updateCounter = 0
- private val updateCounter: Int
- get() = _updateCounter
-
- private fun forceUpdateOnNextSearchAction() {
- _updateCounter++
- }
-
- private val root
- get() = PasswordRepository.getRepositoryDirectory()
- private val settings by unsafeLazy { application.sharedPrefs }
- private val showHiddenContents
- get() = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)
- private val defaultSearchMode
- get() =
- if (settings.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true)) {
- SearchMode.RecursivelyInSubdirectories
- } else {
- SearchMode.InCurrentDirectoryOnly
- }
-
- private val typeSortOrder
- get() = PasswordSortOrder.getSortOrder(settings)
- private val directoryStructure
- get() = AutofillPreferences.directoryStructure(getApplication())
- private val itemComparator
- get() = PasswordItem.makeComparator(typeSortOrder, directoryStructure)
-
- private data class SearchAction(
- val baseDirectory: File,
- val filter: String,
- val filterMode: FilterMode,
- val searchMode: SearchMode,
- val listMode: ListMode,
- // This counter can be increased to force a reexecution of the search action even if all
- // other arguments are left unchanged.
- val updateCounter: Int
- )
-
- private fun makeSearchAction(
- baseDirectory: File,
- filter: String,
- filterMode: FilterMode,
- searchMode: SearchMode,
- listMode: ListMode
- ): SearchAction {
- return SearchAction(
- baseDirectory = baseDirectory,
- filter = filter,
- filterMode = filterMode,
- searchMode = searchMode,
- listMode = listMode,
- updateCounter = updateCounter
- )
- }
-
- private fun updateSearchAction(action: SearchAction) = action.copy(updateCounter = updateCounter)
-
- private val searchAction =
- MutableLiveData(
- makeSearchAction(
- baseDirectory = root,
- filter = "",
- filterMode = FilterMode.NoFilter,
- searchMode = SearchMode.InCurrentDirectoryOnly,
- listMode = ListMode.AllEntries
- )
- )
- private val searchActionFlow = searchAction.asFlow().distinctUntilChanged()
-
- data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean)
-
- val searchResult =
- searchActionFlow
- .mapLatest { searchAction ->
- val listResultFlow =
- when (searchAction.searchMode) {
- SearchMode.RecursivelyInSubdirectories ->
- listFilesRecursively(searchAction.baseDirectory)
- SearchMode.InCurrentDirectoryOnly -> listFiles(searchAction.baseDirectory)
- }
- val prefilteredResultFlow =
- when (searchAction.listMode) {
- ListMode.FilesOnly -> listResultFlow.filter { it.isFile }
- ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory }
- ListMode.AllEntries -> listResultFlow
- }
- val passwordList =
- when (if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode) {
- FilterMode.NoFilter -> {
- prefilteredResultFlow.map { it.toPasswordItem() }.toList().sortedWith(itemComparator)
- }
- FilterMode.StrictDomain -> {
- check(searchAction.listMode == ListMode.FilesOnly) {
- "Searches with StrictDomain search mode can only list files"
- }
- val regex = generateStrictDomainRegex(searchAction.filter)
- if (regex != null) {
- prefilteredResultFlow
- .filter { absoluteFile ->
- regex.containsMatchIn(absoluteFile.relativeTo(root).path)
- }
- .map { it.toPasswordItem() }
- .toList()
- .sortedWith(itemComparator)
- } else {
- emptyList()
- }
- }
- FilterMode.Fuzzy -> {
- prefilteredResultFlow
- .map {
- val item = it.toPasswordItem()
- Pair(item.fuzzyMatch(searchAction.filter), item)
- }
- .filter { it.first > 0 }
- .toList()
- .sortedWith(
- compareByDescending<Pair<Int, PasswordItem>> { it.first }
- .thenBy(itemComparator) { it.second }
- )
- .map { it.second }
- }
- }
- SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter)
- }
- .asLiveData(Dispatchers.IO)
-
- private fun shouldTake(file: File) =
- with(file) {
- if (showHiddenContents) {
- return !file.name.startsWith(".git")
- }
- if (isDirectory) {
- !isHidden
- } else {
- !isHidden && file.extension == "gpg"
- }
- }
-
- private fun listFiles(dir: File): Flow<File> {
- return dir.listFiles(::shouldTake)?.asFlow() ?: emptyFlow()
- }
-
- private fun listFilesRecursively(dir: File): Flow<File> {
- return dir
- // Take top directory even if it is hidden.
- .walkTopDown()
- .onEnter { file -> file == dir || shouldTake(file) }
- .asFlow()
- // Skip the root directory
- .drop(1)
- .map {
- yield()
- it
- }
- .filter(::shouldTake)
- }
-
- private val _currentDir = MutableLiveData(root)
- val currentDir = _currentDir as LiveData<File>
-
- data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?)
-
- private val navigationStack = Stack<NavigationStackEntry>()
-
- fun navigateTo(
- newDirectory: File = root,
- listMode: ListMode = ListMode.AllEntries,
- recyclerViewState: Parcelable? = null,
- pushPreviousLocation: Boolean = true
- ) {
- if (!newDirectory.exists()) return
- require(newDirectory.isDirectory) { "Can only navigate to a directory" }
- if (pushPreviousLocation) {
- navigationStack.push(NavigationStackEntry(_currentDir.value!!, recyclerViewState))
- }
- searchAction.postValue(
- makeSearchAction(
- filter = "",
- baseDirectory = newDirectory,
- filterMode = FilterMode.NoFilter,
- searchMode = SearchMode.InCurrentDirectoryOnly,
- listMode = listMode
- )
- )
- _currentDir.postValue(newDirectory)
- }
-
- val canNavigateBack
- get() = navigationStack.isNotEmpty()
-
- /**
- * Navigate back to the last location on the [navigationStack] and restore a cached scroll
- * position if possible.
- *
- * Returns the old RecyclerView's LinearLayoutManager state as a [Parcelable] if it was cached.
- */
- fun navigateBack(): Parcelable? {
- if (!canNavigateBack) return null
- val (oldDir, oldRecyclerViewState) = navigationStack.pop()
- navigateTo(oldDir, pushPreviousLocation = false)
- return oldRecyclerViewState
- }
-
- fun reset() {
- navigationStack.clear()
- forceUpdateOnNextSearchAction()
- navigateTo(pushPreviousLocation = false)
- }
-
- fun search(
- filter: String,
- baseDirectory: File? = null,
- filterMode: FilterMode = FilterMode.Fuzzy,
- searchMode: SearchMode? = null,
- listMode: ListMode = ListMode.AllEntries
- ) {
- require(baseDirectory?.isDirectory != false) { "Can only search in a directory" }
- searchAction.postValue(
- makeSearchAction(
- filter = filter,
- baseDirectory = baseDirectory ?: _currentDir.value!!,
- filterMode = filterMode,
- searchMode = searchMode ?: defaultSearchMode,
- listMode = listMode
- )
- )
- }
-
- fun forceRefresh() {
- forceUpdateOnNextSearchAction()
- searchAction.postValue(updateSearchAction(searchAction.value!!))
- }
-
- companion object {
-
- @VisibleForTesting
- fun generateStrictDomainRegex(domain: String): Regex? {
- // Valid domains do not contain path separators.
- if (domain.contains('/')) return null
- // Matches the start of a path component, which is either the start of the
- // string or a path separator.
- val prefix = """(?:^|/)"""
- val escapedFilter = Regex.escape(domain.replace("/", ""))
- // Matches either the filter literally or a strict subdomain of the filter term.
- // We allow a lot of freedom in what a subdomain is, as long as it is not an
- // email address.
- val subdomain = """(?:(?:[^/@]+\.)?$escapedFilter)"""
- // Matches the end of a path component, which is either the literal ".gpg" or a
- // path separator.
- val suffix = """(?:\.gpg|/)"""
- // Match any relative path with a component that is a subdomain of the filter.
- return Regex(prefix + subdomain + suffix)
- }
- }
-}
-
-private object PasswordItemDiffCallback : DiffUtil.ItemCallback<PasswordItem>() {
-
- override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) =
- oldItem.file.absolutePath == newItem.file.absolutePath
-
- override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem
-}
-
-open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>(
- private val layoutRes: Int,
- private val viewHolderCreator: (view: View) -> T,
- private val coroutineScope: CoroutineScope,
- private val viewHolderBinder: suspend T.(item: PasswordItem) -> Unit,
-) : ListAdapter<PasswordItem, T>(PasswordItemDiffCallback), PopupTextProvider {
-
- fun <T : ItemDetailsLookup<String>> makeSelectable(
- recyclerView: RecyclerView,
- itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T
- ) {
- selectionTracker =
- SelectionTracker.Builder(
- "SearchableRepositoryAdapter",
- recyclerView,
- itemKeyProvider,
- itemDetailsLookupCreator(recyclerView),
- StorageStrategy.createStringStorage()
- )
- .withSelectionPredicate(SelectionPredicates.createSelectAnything())
- .build()
- .apply {
- addObserver(
- object : SelectionTracker.SelectionObserver<String>() {
- override fun onSelectionChanged() {
- this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke(
- requireSelectionTracker().selection
- )
- }
- }
- )
- }
- }
-
- private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null
- open fun onItemClicked(
- listener: (holder: T, item: PasswordItem) -> Unit
- ): SearchableRepositoryAdapter<T> {
- check(onItemClickedListener == null) {
- "Only a single listener can be registered for onItemClicked"
- }
- onItemClickedListener = listener
- return this
- }
-
- private var onSelectionChangedListener: ((selection: Selection<String>) -> Unit)? = null
- open fun onSelectionChanged(
- listener: (selection: Selection<String>) -> Unit
- ): SearchableRepositoryAdapter<T> {
- check(onSelectionChangedListener == null) {
- "Only a single listener can be registered for onSelectionChanged"
- }
- onSelectionChangedListener = listener
- return this
- }
-
- private val itemKeyProvider =
- object : ItemKeyProvider<String>(SCOPE_MAPPED) {
- override fun getKey(position: Int) = getItem(position).stableId
-
- override fun getPosition(key: String) =
- (0 until itemCount).firstOrNull { getItem(it).stableId == key } ?: RecyclerView.NO_POSITION
- }
-
- private var selectionTracker: SelectionTracker<String>? = null
- fun requireSelectionTracker() = selectionTracker!!
-
- private val selectedFiles
- get() = requireSelectionTracker().selection.map { File(it) }
-
- fun getSelectedItems() = selectedFiles.map { it.toPasswordItem() }
-
- fun getPositionForFile(file: File) = itemKeyProvider.getPosition(file.absolutePath)
-
- final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T {
- val view = LayoutInflater.from(parent.context).inflate(layoutRes, parent, false)
- return viewHolderCreator(view)
- }
-
- final override fun onBindViewHolder(holder: T, position: Int) {
- val item = getItem(position)
- holder.apply {
- coroutineScope.launch(Dispatchers.Main.immediate) { viewHolderBinder.invoke(holder, item) }
- selectionTracker?.let { itemView.isSelected = it.isSelected(item.stableId) }
- itemView.setOnClickListener {
- // Do not emit custom click events while the user is selecting items.
- if (selectionTracker?.hasSelection() != true) onItemClickedListener?.invoke(holder, item)
- }
- }
- }
- final override fun getPopupText(position: Int): String {
- return getItem(position).name[0].toString().uppercase(Locale.getDefault())
- }
-}