From 549ee790d3e52bc62565ddf92e6a556e98b5195e Mon Sep 17 00:00:00 2001 From: Harsh Shandilya Date: Fri, 15 Jul 2022 00:53:48 +0530 Subject: all: re-do package structure yet again --- .../aps/util/auth/BiometricAuthenticator.kt | 131 ------ .../util/autofill/Api30AutofillResponseBuilder.kt | 236 ---------- .../msfjarvis/aps/util/autofill/AutofillMatcher.kt | 202 --------- .../aps/util/autofill/AutofillPreferences.kt | 148 ------- .../aps/util/autofill/AutofillResponseBuilder.kt | 233 ---------- .../aps/util/autofill/AutofillViewUtils.kt | 120 ------ .../dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt | 42 -- .../aps/util/extensions/AndroidExtensions.kt | 133 ------ .../msfjarvis/aps/util/extensions/Extensions.kt | 98 ----- .../aps/util/extensions/FragmentExtensions.kt | 41 -- .../util/extensions/FragmentViewBindingDelegate.kt | 70 --- .../dev/msfjarvis/aps/util/features/Feature.kt | 23 - .../dev/msfjarvis/aps/util/features/Features.kt | 21 - .../dev/msfjarvis/aps/util/git/ErrorMessages.kt | 66 --- .../msfjarvis/aps/util/git/GitCommandExecutor.kt | 133 ------ .../java/dev/msfjarvis/aps/util/git/GitCommit.kt | 23 - .../java/dev/msfjarvis/aps/util/git/GitLogModel.kt | 60 --- .../aps/util/git/operation/BreakOutOfDetached.kt | 66 --- .../aps/util/git/operation/CloneOperation.kt | 24 -- .../aps/util/git/operation/CredentialFinder.kt | 116 ----- .../aps/util/git/operation/GcOperation.kt | 22 - .../aps/util/git/operation/GitOperation.kt | 245 ----------- .../aps/util/git/operation/PullOperation.kt | 35 -- .../aps/util/git/operation/PushOperation.kt | 17 - .../util/git/operation/ResetToRemoteOperation.kt | 27 -- .../aps/util/git/operation/SyncOperation.kt | 27 -- .../util/git/sshj/ContinuationContainerActivity.kt | 34 -- .../aps/util/git/sshj/OpenKeychainKeyProvider.kt | 225 ---------- .../sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt | 103 ----- .../java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt | 372 ---------------- .../dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt | 289 ------------- .../aps/util/git/sshj/SshjSessionFactory.kt | 218 ---------- .../dev/msfjarvis/aps/util/proxy/ProxyUtils.kt | 71 ---- .../aps/util/services/ClipboardService.kt | 206 --------- .../aps/util/services/OreoAutofillService.kt | 172 -------- .../aps/util/services/PasswordExportService.kt | 159 ------- .../dev/msfjarvis/aps/util/settings/GitSettings.kt | 186 -------- .../dev/msfjarvis/aps/util/settings/Migrations.kt | 154 ------- .../aps/util/settings/PasswordSortOrder.kt | 53 --- .../msfjarvis/aps/util/settings/PreferenceKeys.kt | 88 ---- .../aps/util/shortcuts/ShortcutHandler.kt | 106 ----- .../dev/msfjarvis/aps/util/totp/UriTotpFinder.kt | 53 --- .../viewmodel/SearchableRepositoryViewModel.kt | 473 --------------------- 43 files changed, 5321 deletions(-) delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/features/Feature.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/features/Features.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/GcOperation.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/shortcuts/ShortcutHandler.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt delete mode 100644 app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt (limited to 'app/src/main/java/dev/msfjarvis/aps/util') 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()?.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 - ): 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, 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 = emptyMap(), - delete: Collection = 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 - 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): 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() - -/** 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 { - 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 FragmentActivity.launchActivity(clazz: Class) { - 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 { - return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() -} - -/** Alias to [lazy] with thread safety mode always set to [LazyThreadSafetyMode.NONE]. */ -fun 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 Result.isOk(): Boolean { - return this is Ok -} - -/** Extension on [Result] that returns if the type is [Err] */ -fun Result.isErr(): Boolean { - return this is Err -} 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( - val fragment: Fragment, - val viewBindingFactory: (View) -> T -) : ReadOnlyProperty { - - 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 Fragment.viewBinding(viewBindingFactory: (View) -> T) = - FragmentViewBindingDelegate(this, viewBindingFactory) - -inline fun 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 { - 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 { - 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 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> = - 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, 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(R.id.git_auth_passphrase_layout) - val editCredential = dialogView.findViewById(R.id.git_auth_credential) - editCredential.setHint(hintRes) - val rememberCredential = - dialogView.findViewById(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> = - 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> - - /** 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>().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 { - 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 { - when (authMode) { - AuthMode.SshKey -> - if (SshKey.exists) { - if (SshKey.mustAuthenticate) { - val result = - withContext(Dispatchers.Main) { - suspendCoroutine { 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> = - 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> = - 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? = 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(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) : - Factory.Named 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, isRetry: Boolean) - - final override fun reqPassword(resource: Resource<*>?): CharArray { - val password = - runBlocking(Dispatchers.Main) { - suspendCoroutine { 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 { - 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 { - 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() - 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 { - 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") - 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% - 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() - 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) : - 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) { - 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 { - 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, 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> { 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 { - return dir.listFiles(::shouldTake)?.asFlow() ?: emptyFlow() - } - - private fun listFilesRecursively(dir: File): Flow { - 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 - - data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?) - - private val navigationStack = Stack() - - 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() { - - 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( - private val layoutRes: Int, - private val viewHolderCreator: (view: View) -> T, - private val coroutineScope: CoroutineScope, - private val viewHolderBinder: suspend T.(item: PasswordItem) -> Unit, -) : ListAdapter(PasswordItemDiffCallback), PopupTextProvider { - - fun > 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() { - 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 { - check(onItemClickedListener == null) { - "Only a single listener can be registered for onItemClicked" - } - onItemClickedListener = listener - return this - } - - private var onSelectionChangedListener: ((selection: Selection) -> Unit)? = null - open fun onSelectionChanged( - listener: (selection: Selection) -> Unit - ): SearchableRepositoryAdapter { - check(onSelectionChangedListener == null) { - "Only a single listener can be registered for onSelectionChanged" - } - onSelectionChangedListener = listener - return this - } - - private val itemKeyProvider = - object : ItemKeyProvider(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? = 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()) - } -} -- cgit v1.2.3