diff options
Diffstat (limited to 'app/src/main/java/dev/msfjarvis/aps/util')
47 files changed, 3648 insertions, 3793 deletions
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt b/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt index eba61d77..c3655298 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt @@ -18,61 +18,69 @@ import dev.msfjarvis.aps.R object BiometricAuthenticator { - private const val TAG = "BiometricAuthenticator" - private const val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK + private const val TAG = "BiometricAuthenticator" + private const val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK - sealed class Result { - data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result() - data class Failure(val code: Int?, val message: CharSequence) : Result() - object HardwareUnavailableOrDisabled : Result() - object Cancelled : Result() - } - - fun canAuthenticate(activity: FragmentActivity): Boolean { - return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS - } + sealed class Result { + data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result() + data class Failure(val code: Int?, val message: CharSequence) : Result() + object HardwareUnavailableOrDisabled : Result() + object Cancelled : Result() + } - 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) - tag(TAG).d { "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 - } - else -> Result.Failure(errorCode, activity.getString(R.string.biometric_auth_error_reason, errString)) - }) - } + fun canAuthenticate(activity: FragmentActivity): Boolean { + return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS + } - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - callback(Result.Failure(null, activity.getString(R.string.biometric_auth_error))) + 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) + tag(TAG).d { "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 + } + else -> Result.Failure(errorCode, activity.getString(R.string.biometric_auth_error_reason, errString)) } + ) + } - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - callback(Result.Success(result.cryptoObject)) - } + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + callback(Result.Failure(null, activity.getString(R.string.biometric_auth_error))) } - val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true - if (canAuthenticate(activity) || deviceHasKeyguard) { - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(activity.getString(dialogTitleRes)) - .setAllowedAuthenticators(validAuthenticators) - .build() - BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback).authenticate(promptInfo) - } else { - callback(Result.HardwareUnavailableOrDisabled) + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + callback(Result.Success(result.cryptoObject)) } + } + val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true + if (canAuthenticate(activity) || deviceHasKeyguard) { + val promptInfo = + BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(dialogTitleRes)) + .setAllowedAuthenticators(validAuthenticators) + .build() + BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback) + .authenticate(promptInfo) + } else { + callback(Result.HardwareUnavailableOrDisabled) } + } } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt index fa2accdb..a39db31d 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt @@ -27,163 +27,166 @@ import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity import java.io.File -/** - * Implements [AutofillResponseBuilder]'s methods for API 30 and above - */ +/** Implements [AutofillResponseBuilder]'s methods for API 30 and above */ @RequiresApi(Build.VERSION_CODES.R) class Api30AutofillResponseBuilder(form: FillableForm) { - 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 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 = 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 makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null + val metadata = makeFillMatchMetadata(context, file) + val intentSender = 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 makePublisherChangedResponse( - context: Context, - inlineSuggestionsRequest: InlineSuggestionsRequest?, - publisherChangedException: AutofillPublisherChangedException - ): FillResponse { - val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull() - return FillResponse.Builder().run { - addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec)) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() + } + + private fun makeFillResponse( + context: Context, + inlineSuggestionsRequest: InlineSuggestionsRequest?, + matchedFiles: List<File> + ): FillResponse? { + var datasetCount = 0 + val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList() + return FillResponse.Builder().run { + for (file in matchedFiles) { + makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ + addDataset(it) } + } + makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ + addDataset(it) + } + makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ + addDataset(it) + } + makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ + addDataset(it) + } + if (datasetCount == 0) return null + setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)))) + makeSaveInfo()?.let { setSaveInfo(it) } + setClientState(clientState) + setIgnoredIds(*ignoredIds.toTypedArray()) + build() } - - private fun makeFillResponse(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, matchedFiles: List<File>): FillResponse? { - var datasetCount = 0 - val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList() - return FillResponse.Builder().run { - for (file in matchedFiles) { - makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let { - datasetCount++ - addDataset(it) - } - } - makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let { - datasetCount++ - addDataset(it) - } - makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let { - datasetCount++ - addDataset(it) - } - makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let { - datasetCount++ - addDataset(it) - } - if (datasetCount == 0) return null - setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)))) - makeSaveInfo()?.let { setSaveInfo(it) } - setClientState(clientState) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() - } + } + + // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE + // See: + // https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE + private fun makeSaveInfo(): SaveInfo? { + if (!canBeSaved) return null + check(saveFlags != null) + val idsToSave = scenario.fieldsToSave.toTypedArray() + if (idsToSave.isEmpty()) return null + var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD + if (scenario.hasUsername) { + saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME } - - // 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 -> - e(e) - callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e)) - } - ) + 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 -> + e(e) + 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 index 955aa047..418843f6 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt @@ -21,173 +21,165 @@ import java.io.File private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches" private val Context.autofillAppMatches - get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE) + 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) + 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 - } + 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") { + Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") { - init { - require(formOrigin is FormOrigin.App) - } + init { + require(formOrigin is FormOrigin.App) + } } -/** - * Manages "matches", i.e., associations between apps or websites and Password Store entries. - */ +/** 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) { - e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" } - true - } else { - false - } - } - } + 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) { + e { "$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. - } + private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) { + if (formOrigin is FormOrigin.App) { + val packageName = formOrigin.identifier + val certificatesHash = computeCertificatesHash(context, packageName) + context.autofillAppMatches.edit { putString(tokenKey(formOrigin), certificatesHash) } + } + // We don't need to store a hash for FormOrigin.Web since it can only originate from + // browsers we trust to verify the origin. + } - /** - * Get all Password Store entries that have already been associated with [formOrigin] by the - * user. - * - * If [formOrigin] represents an app and that app's certificates have changed since the - * first time the user associated an entry with it, an [AutofillPublisherChangedException] - * will be thrown. - */ - fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<List<File>, AutofillPublisherChangedException> { - if (hasFormOriginHashChanged(context, formOrigin)) { - return Err(AutofillPublisherChangedException(formOrigin)) - } - val matchPreferences = context.matchPreferences(formOrigin) - val matchedFiles = - matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) } - return Ok(matchedFiles.filter { it.exists() }.also { validFiles -> - matchPreferences.edit { - putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet()) - } - }) + /** + * Get all Password Store entries that have already been associated with [formOrigin] by the + * user. + * + * If [formOrigin] represents an app and that app's certificates have changed since the first + * time the user associated an entry with it, an [AutofillPublisherChangedException] will be + * thrown. + */ + fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<List<File>, AutofillPublisherChangedException> { + if (hasFormOriginHashChanged(context, formOrigin)) { + return Err(AutofillPublisherChangedException(formOrigin)) + } + val matchPreferences = context.matchPreferences(formOrigin) + val matchedFiles = matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) } + return Ok( + matchedFiles.filter { it.exists() }.also { validFiles -> + matchPreferences.edit { putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet()) } } + ) + } - fun clearMatchesFor(context: Context, formOrigin: FormOrigin) { - context.matchPreferences(formOrigin).edit { - remove(matchesKey(formOrigin)) - if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin)) - } - } + 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. - e { "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) - d { "Stored match for $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. + e { "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) + d { "Stored match for $formOrigin" } + } - /** - * Goes through all existing matches and updates their associated entries by using - * [moveFromTo] as a lookup table and deleting the matches for files in [delete]. - */ - fun updateMatches(context: Context, moveFromTo: Map<File, File> = emptyMap(), delete: Collection<File> = emptyList()) { - val deletePathList = delete.map { it.absolutePath } - val oldNewPathMap = moveFromTo.mapValues { it.value.absolutePath } - .mapKeys { it.key.absolutePath } - for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) { - for ((key, value) in prefs.all) { - if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue - // We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were - // created with `putStringSet`. - @Suppress("UNCHECKED_CAST") - val oldMatches = value as? Set<String> - if (oldMatches == null) { - w { "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 - d { "Updating match for $key: $match --> $newPath" } - newPath - }.toSet() - if (newMatches != oldMatches) - prefs.edit { putStringSet(key, newMatches) } - } - } + /** + * Goes through all existing matches and updates their associated entries by using [moveFromTo] + * as a lookup table and deleting the matches for files in [delete]. + */ + fun updateMatches( + context: Context, + moveFromTo: Map<File, File> = emptyMap(), + delete: Collection<File> = emptyList() + ) { + val deletePathList = delete.map { it.absolutePath } + val oldNewPathMap = moveFromTo.mapValues { it.value.absolutePath }.mapKeys { it.key.absolutePath } + for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) { + for ((key, value) in prefs.all) { + if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue + // We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were + // created with `putStringSet`. + @Suppress("UNCHECKED_CAST") val oldMatches = value as? Set<String> + if (oldMatches == null) { + w { "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 + d { "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 index b258e5df..6e1fe464 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt @@ -17,125 +17,128 @@ import java.io.File import java.nio.file.Paths enum class DirectoryStructure(val value: String) { - EncryptedUsername("encrypted_username"), - FileBased("file"), - DirectoryBased("directory"); + 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 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 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 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 + /** + * 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(Build.VERSION_CODES.O) - fun getSaveFolderName(sanitizedIdentifier: String, username: String?) = when (this) { - EncryptedUsername -> "/" - FileBased -> sanitizedIdentifier - DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString() + @RequiresApi(Build.VERSION_CODES.O) + 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" + fun getSaveFileName(username: String?, identifier: String) = + when (this) { + EncryptedUsername -> identifier + FileBased -> username + DirectoryBased -> "password" } - companion object { + companion object { - val DEFAULT = FileBased + val DEFAULT = FileBased - private val reverseMap = values().associateBy { it.value } - fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT - } + 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 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() - return Credentials(username, entry.password, entry.calculateTotpCode()) - } + 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() + return Credentials(username, entry.password, entry.calculateTotpCode()) + } } 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 index d5e16a26..d8126438 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt @@ -30,176 +30,178 @@ import java.io.File @RequiresApi(Build.VERSION_CODES.O) class AutofillResponseBuilder(form: FillableForm) { - 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 = 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 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 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 makeMatchDataset(context: Context, file: File): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null + val metadata = makeFillMatchMetadata(context, file) + val intentSender = 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() } - - 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) + } + + // 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 } - - 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) + return SaveInfo.Builder(saveDataTypes, idsToSave).run { + setFlags(saveFlags) + build() } - - private fun makePublisherChangedResponse( - context: Context, - publisherChangedException: AutofillPublisherChangedException - ): FillResponse { - return FillResponse.Builder().run { - addDataset(makePublisherChangedDataset(context, publisherChangedException)) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() + } + + private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? { + var datasetCount = 0 + return FillResponse.Builder().run { + for (file in matchedFiles) { + makeMatchDataset(context, file)?.let { + datasetCount++ + addDataset(it) } + } + makeGenerateDataset(context)?.let { + datasetCount++ + addDataset(it) + } + makeFillOtpFromSmsDataset(context)?.let { + datasetCount++ + addDataset(it) + } + makeSearchDataset(context)?.let { + datasetCount++ + addDataset(it) + } + if (datasetCount == 0) return null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + 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 + } + + /** 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 -> + e(e) + callback.onSuccess(makePublisherChangedResponse(context, e)) } - return SaveInfo.Builder(saveDataTypes, idsToSave).run { - setFlags(saveFlags) - build() - } - } + ) + } - private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? { - var datasetCount = 0 - return FillResponse.Builder().run { - for (file in matchedFiles) { - makeMatchDataset(context, file)?.let { - datasetCount++ - addDataset(it) - } - } - makeGenerateDataset(context)?.let { - datasetCount++ - addDataset(it) - } - makeFillOtpFromSmsDataset(context)?.let { - datasetCount++ - addDataset(it) - } - makeSearchDataset(context)?.let { - datasetCount++ - addDataset(it) - } - if (datasetCount == 0) return null - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)))) - } - makeSaveInfo()?.let { setSaveInfo(it) } - setClientState(clientState) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() - } - } + companion object { - /** - * 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 -> - e(e) - 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 >= Build.VERSION_CODES.P) { - Dataset.Builder() - } else { - Dataset.Builder(makeRemoteView(context, makeEmptyMetadata())) - } - return builder.run { - if (scenario != null) fillWith(scenario, action, credentials) - else e { "Failed to recover scenario from client state" } - build() - } + 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 >= Build.VERSION_CODES.P) { + Dataset.Builder() + } else { + Dataset.Builder(makeRemoteView(context, makeEmptyMetadata())) } + return builder.run { + if (scenario != null) fillWith(scenario, action, credentials) + else e { "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 index e460dd35..46f7d821 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt @@ -26,88 +26,74 @@ 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) - } + 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 < Build.VERSION_CODES.R) - return null +fun makeInlinePresentation( + context: Context, + imeSpec: InlinePresentationSpec, + metadata: DatasetMetadata +): InlinePresentation? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null - if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) - 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), 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 + val launchIntent = PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 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) + 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 - ) + 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 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( +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 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 makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher) -fun makeWarningMetadata(context: Context) = DatasetMetadata( +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 -) +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 index cb4bea07..cc759e9a 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt @@ -8,36 +8,33 @@ 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() + 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()) - } + 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()) - } + // 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) } - } + 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 index e200320e..94103118 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt @@ -34,146 +34,115 @@ import dev.msfjarvis.aps.data.repo.PasswordRepository import dev.msfjarvis.aps.util.git.operation.GitOperation /** - * Extension function for [AlertDialog] that requests focus for the - * view whose id is [id]. Solution based on a StackOverflow - * answer: https://stackoverflow.com/a/13056259/297261 + * Extension function for [AlertDialog] that requests focus for the view whose id is [id]. Solution + * based on a StackOverflow answer: https://stackoverflow.com/a/13056259/297261 */ fun <T : View> AlertDialog.requestInputFocusOnView(@IdRes id: Int) { - setOnShowListener { - findViewById<T>(id)?.apply { - setOnFocusChangeListener { v, _ -> - v.post { - context.getSystemService<InputMethodManager>() - ?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT) - } - } - requestFocus() - } + setOnShowListener { + findViewById<T>(id)?.apply { + setOnFocusChangeListener { v, _ -> + v.post { context.getSystemService<InputMethodManager>()?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT) } + } + requestFocus() } + } } -/** - * Get an instance of [AutofillManager]. Only - * available on Android Oreo and above - */ +/** Get an instance of [AutofillManager]. Only available on Android Oreo and above */ val Context.autofillManager: AutofillManager? - @RequiresApi(Build.VERSION_CODES.O) - get() = getSystemService() + @RequiresApi(Build.VERSION_CODES.O) get() = getSystemService() -/** - * Get an instance of [ClipboardManager] - */ +/** Get an instance of [ClipboardManager] */ val Context.clipboard - get() = getSystemService<ClipboardManager>() + get() = getSystemService<ClipboardManager>() -/** - * Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at - * each call site - */ +/** Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at each call site */ fun Context.getEncryptedGitPrefs() = getEncryptedPrefs("git_operation") -/** - * Wrapper for [getEncryptedPrefs] to get the encrypted preference set for the HTTP - * proxy. - */ +/** Wrapper for [getEncryptedPrefs] to get the encrypted preference set for the HTTP proxy. */ fun Context.getEncryptedProxyPrefs() = getEncryptedPrefs("http_proxy") -/** - * Get an instance of [EncryptedSharedPreferences] with the given [fileName] - */ +/** 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 - ) + 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] - */ +/** Get an instance of [KeyguardManager] */ val Context.keyguardManager: KeyguardManager - get() = getSystemService()!! + get() = getSystemService()!! -/** - * Get the default [SharedPreferences] instance - */ +/** Get the default [SharedPreferences] instance */ val Context.sharedPrefs: SharedPreferences - get() = PreferenceManager.getDefaultSharedPreferences(applicationContext) - + get() = PreferenceManager.getDefaultSharedPreferences(applicationContext) -/** - * Resolve [attr] from the [Context]'s theme - */ +/** 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 + 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] + * Commit changes to the store from a [FragmentActivity] using a custom implementation of + * [GitOperation] */ suspend fun FragmentActivity.commitChange( - message: String, + message: String, ): Result<Unit, Throwable> { - if (!PasswordRepository.isGitRepo()) { - return Ok(Unit) - } - return object : GitOperation(this@commitChange) { - override val commands = arrayOf( - // Stage all files - git.add().addFilepattern("."), - // Populate the changed files count - git.status(), - // Commit everything! If anything changed, that is. - git.commit().setAll(true).setMessage(message), + 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 { - d { "Committing with message: '$message'" } - return true - } - }.execute() + override fun preExecute(): Boolean { + d { "Committing with message: '$message'" } + return true + } + } + .execute() } -/** - * Check if [permission] has been granted to the app. - */ +/** Check if [permission] has been granted to the app. */ fun FragmentActivity.isPermissionGranted(permission: String): Boolean { - return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED + 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] + * 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, + 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 + val snackbar = Snackbar.make(view, message, length) + snackbar.anchorView = findViewById(R.id.fab) + snackbar.show() + return snackbar } -/** - * Simplifies the common `getString(key, null) ?: defaultValue` case slightly - */ +/** 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 - */ +/** Convert this [String] to its [Base64] representation */ fun String.base64(): String { - return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP) + 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 index 9b6d044c..a6d0066c 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt @@ -12,53 +12,40 @@ import java.util.Date import org.eclipse.jgit.lib.ObjectId import org.eclipse.jgit.revwalk.RevCommit -/** - * The default OpenPGP provider for the app - */ +/** The default OpenPGP provider for the app */ const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain" -/** - * Clears the given [flag] from the value of this [Int] - */ +/** Clears the given [flag] from the value of this [Int] */ fun Int.clearFlag(flag: Int): Int { - return this and flag.inv() + return this and flag.inv() } -/** - * Checks if this [Int] contains the given [flag] - */ +/** Checks if this [Int] contains the given [flag] */ infix fun Int.hasFlag(flag: Int): Boolean { - return this and flag == flag + return this and flag == flag } -/** - * Checks whether this [File] is a directory that contains [other]. - */ +/** 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 + 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 + // 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] + * 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) + return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath) } -/** - * Recursively lists the files in this [File], skipping any directories it encounters. - */ +/** Recursively lists the files in this [File], skipping any directories it encounters. */ fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList() /** @@ -67,7 +54,7 @@ fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toLis * @see RevCommit.getId */ val RevCommit.hash: String - get() = ObjectId.toString(id) + get() = ObjectId.toString(id) /** * Time this commit was made with second precision. @@ -75,16 +62,16 @@ val RevCommit.hash: String * @see RevCommit.commitTime */ val RevCommit.time: Date - get() { - val epochSeconds = commitTime.toLong() - val epochMilliseconds = epochSeconds * 1000 - return Date(epochMilliseconds) - } + 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. + * Splits this [String] into an [Array] of [String] s, split on the UNIX LF line ending and stripped + * of any empty lines. */ fun String.splitLines(): Array<String> { - return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() } 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 index 088b15f6..3a256ee7 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt @@ -11,31 +11,31 @@ 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. - */ +/** Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally. */ fun Fragment.isPermissionGranted(permission: String): Boolean { - return requireActivity().isPermissionGranted(permission) + return requireActivity().isPermissionGranted(permission) } -/** - * Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity] - */ +/** 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 + * 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) - } +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 index af2afe9e..5a1b554f 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt @@ -5,7 +5,6 @@ package dev.msfjarvis.aps.util.extensions - import android.view.LayoutInflater import android.view.View import androidx.appcompat.app.AppCompatActivity @@ -18,48 +17,49 @@ 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 + * Imported from + * https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c */ -class FragmentViewBindingDelegate<T : ViewBinding>( - val fragment: Fragment, - val viewBindingFactory: (View) -> T -) : ReadOnlyProperty<Fragment, T> { +class FragmentViewBindingDelegate<T : ViewBinding>(val fragment: Fragment, val viewBindingFactory: (View) -> T) : + ReadOnlyProperty<Fragment, T> { - private var binding: T? = null + 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 - } - }) + 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.") - } + override fun getValue(thisRef: Fragment, property: KProperty<*>): T { + val binding = binding + if (binding != null) { + return binding + } - return viewBindingFactory(thisRef.requireView()).also { this.binding = it } + val lifecycle = fragment.viewLifecycleOwner.lifecycle + if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { + throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.") } + + return viewBindingFactory(thisRef.requireView()).also { this.binding = it } + } } fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) = - FragmentViewBindingDelegate(this, viewBindingFactory) + FragmentViewBindingDelegate(this, viewBindingFactory) inline fun <T : ViewBinding> AppCompatActivity.viewBinding(crossinline bindingInflater: (LayoutInflater) -> T) = - lazy(LazyThreadSafetyMode.NONE) { - bindingInflater.invoke(layoutInflater) - } + lazy(LazyThreadSafetyMode.NONE) { bindingInflater.invoke(layoutInflater) } 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 index 44fd6d3f..04809fdd 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt @@ -12,57 +12,54 @@ import dev.msfjarvis.aps.R import java.net.UnknownHostException /** - * Supertype for all Git-related [Exception]s that can be thrown by [GitCommandExecutor.execute]. + * 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!! + override val message = super.message!! - companion object { + companion object { - private fun buildMessage(@StringRes res: Int, vararg fmt: String) = Application.instance.resources.getString(res, *fmt) - } + 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) { + /** 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) - } + 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) { + /** 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 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) - } + 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 + 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 index 87f1a8fd..4546aee1 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt @@ -26,96 +26,87 @@ import org.eclipse.jgit.lib.PersonIdent import org.eclipse.jgit.transport.RemoteRefUpdate class GitCommandExecutor( - private val activity: FragmentActivity, - private val operation: GitOperation, + private val activity: FragmentActivity, + private val operation: GitOperation, ) { - suspend fun execute(): Result<Unit, Throwable> { - 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 -> { - } - } - } - } + suspend fun execute(): Result<Unit, Throwable> { + 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) } - else -> { - withContext(Dispatchers.IO) { - command.call() - } + } + 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 -> {} } + } } - }.also { - snackbar.dismiss() + } + else -> { + withContext(Dispatchers.IO) { command.call() } + } } + } } + .also { snackbar.dismiss() } + } } 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 index f8f2af1c..1ba65086 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt @@ -15,41 +15,37 @@ import org.eclipse.jgit.api.Git import org.eclipse.jgit.revwalk.RevCommit private fun commits(): Iterable<RevCommit> { - val repo = PasswordRepository.getRepository(null) - if (repo == null) { - e { "Could not access git repository" } - return listOf() - } - return runCatching { - Git(repo).log().call() - }.getOrElse { e -> - e(e) { "Failed to obtain git commits" } - listOf() - } + val repo = PasswordRepository.getRepository(null) + if (repo == null) { + e { "Could not access git repository" } + return listOf() + } + return runCatching { Git(repo).log().call() }.getOrElse { e -> + e(e) { "Failed to obtain git commits" } + listOf() + } } /** - * Provides [GitCommit]s from a git-log of the password git repository. + * Provides [GitCommit] s from a git-log of the password git repository. * * All commits are acquired on the first request to this object. */ class GitLogModel { - // All commits are acquired here at once. Acquiring the commits in batches would not have been - // entirely sensible because the amount of computation required to obtain commit number n from - // the log includes the amount of computation required to obtain commit number n-1 from the log. - // This is because the commit graph is walked from HEAD to the last commit to obtain. - // Additionally, tests with 1000 commits in the log have not produced a significant delay in the - // user experience. - private val cache: MutableList<GitCommit> by lazy(LazyThreadSafetyMode.NONE) { - commits().map { - GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) - }.toMutableList() - } - val size = cache.size + // All commits are acquired here at once. Acquiring the commits in batches would not have been + // entirely sensible because the amount of computation required to obtain commit number n from + // the log includes the amount of computation required to obtain commit number n-1 from the log. + // This is because the commit graph is walked from HEAD to the last commit to obtain. + // Additionally, tests with 1000 commits in the log have not produced a significant delay in the + // user experience. + private val cache: MutableList<GitCommit> by lazy(LazyThreadSafetyMode.NONE) { + 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) e { "Cannot get git commit with index $index. There are only $size." } - return cache.getOrNull(index) - } + fun get(index: Int): GitCommit? { + if (index >= size) e { "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 index 4b3f0154..196d6d48 100644 --- 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 @@ -13,44 +13,45 @@ 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), + 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 lazy(LazyThreadSafetyMode.NONE) { - 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 val commands by lazy(LazyThreadSafetyMode.NONE) { + 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 + 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 + 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 index 3efc9088..a5a4f5d0 100644 --- 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 @@ -16,7 +16,8 @@ import org.eclipse.jgit.api.GitCommand */ class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) : GitOperation(callingActivity) { - override val commands: Array<GitCommand<out Any>> = arrayOf( - Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri), + override val commands: Array<GitCommand<out Any>> = + arrayOf( + Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri), ) } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt index 0833d935..40869cf2 100644 --- 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 @@ -24,80 +24,71 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys import kotlin.coroutines.Continuation import kotlin.coroutines.resume -class CredentialFinder( - val callingActivity: FragmentActivity, - val authMode: AuthMode -) : InteractivePasswordFinder() { +class CredentialFinder(val callingActivity: FragmentActivity, val authMode: AuthMode) : InteractivePasswordFinder() { - override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) { - val gitOperationPrefs = callingActivity.getEncryptedGitPrefs() - 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) + override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) { + val gitOperationPrefs = callingActivity.getEncryptedGitPrefs() + val credentialPref: String + @StringRes val messageRes: Int + @StringRes val hintRes: Int + @StringRes val rememberRes: Int + @StringRes val errorRes: Int + when (authMode) { + AuthMode.SshKey -> { + credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE + messageRes = R.string.passphrase_dialog_text + hintRes = R.string.ssh_keygen_passphrase + rememberRes = R.string.git_operation_remember_passphrase + errorRes = R.string.git_operation_wrong_passphrase + } + AuthMode.Password -> { + // Could be either an SSH or an HTTPS password + credentialPref = PreferenceKeys.HTTPS_PASSWORD + messageRes = R.string.password_dialog_text + hintRes = R.string.git_operation_hint_password + rememberRes = R.string.git_operation_remember_password + errorRes = R.string.git_operation_wrong_password + } + else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords") + } + if (isRetry) gitOperationPrefs.edit { remove(credentialPref) } + val storedCredential = gitOperationPrefs.getString(credentialPref, null) + if (storedCredential == null) { + val layoutInflater = LayoutInflater.from(callingActivity) - @SuppressLint("InflateParams") - val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null) - val credentialLayout = dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout) - val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential) - editCredential.setHint(hintRes) - val rememberCredential = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential) - rememberCredential.setText(rememberRes) - if (isRetry) { - credentialLayout.error = callingActivity.resources.getString(errorRes) - // Reset error when user starts entering a password - editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null } - } - MaterialAlertDialogBuilder(callingActivity).run { - setTitle(R.string.passphrase_dialog_title) - setMessage(messageRes) - setView(dialogView) - setPositiveButton(R.string.dialog_ok) { _, _ -> - val credential = editCredential.text.toString() - if (rememberCredential.isChecked) { - gitOperationPrefs.edit { - putString(credentialPref, credential) - } - } - cont.resume(credential) - } - setNegativeButton(R.string.dialog_cancel) { _, _ -> - cont.resume(null) - } - setOnCancelListener { - cont.resume(null) - } - create() - }.run { - requestInputFocusOnView<TextInputEditText>(R.id.git_auth_credential) - show() + @SuppressLint("InflateParams") val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null) + val credentialLayout = dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout) + val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential) + editCredential.setHint(hintRes) + val rememberCredential = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential) + rememberCredential.setText(rememberRes) + if (isRetry) { + credentialLayout.error = callingActivity.resources.getString(errorRes) + // Reset error when user starts entering a password + editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null } + } + MaterialAlertDialogBuilder(callingActivity) + .run { + setTitle(R.string.passphrase_dialog_title) + setMessage(messageRes) + setView(dialogView) + setPositiveButton(R.string.dialog_ok) { _, _ -> + val credential = editCredential.text.toString() + if (rememberCredential.isChecked) { + gitOperationPrefs.edit { putString(credentialPref, credential) } } - } else { - cont.resume(storedCredential) + cont.resume(credential) + } + setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(null) } + setOnCancelListener { cont.resume(null) } + create() + } + .run { + requestInputFocusOnView<TextInputEditText>(R.id.git_auth_credential) + show() } + } else { + cont.resume(storedCredential) } + } } 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 index 128ca578..0d0dac9c 100644 --- 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 @@ -50,170 +50,167 @@ import org.eclipse.jgit.transport.URIish */ abstract class GitOperation(protected val callingActivity: FragmentActivity) { - abstract val commands: Array<GitCommand<out Any>> - private val hostKeyFile = callingActivity.filesDir.resolve(".host_key") - private var sshSessionFactory: SshjSessionFactory? = null - - protected val repository = PasswordRepository.getRepository(null)!! - protected val git = Git(repository) - protected val remoteBranch = 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 + abstract val commands: Array<GitCommand<out Any>> + private val hostKeyFile = callingActivity.filesDir.resolve(".host_key") + private var sshSessionFactory: SshjSessionFactory? = null + + protected val repository = PasswordRepository.getRepository(null)!! + protected val git = Git(repository) + protected val remoteBranch = 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 } - 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 -> - e(e) - } - } + override fun supports(vararg items: CredentialItem) = + items.all { it is CredentialItem.Username || it is CredentialItem.Password } - private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) { - sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile) - commands.filterIsInstance<TransportCommand<*, *>>().forEach { command -> - command.setTransportConfigCallback { transport: Transport -> - (transport as? SshTransport)?.sshSessionFactory = sshSessionFactory - credentialsProvider?.let { transport.credentialsProvider = it } - } - command.setTimeout(CONNECT_TIMEOUT) - } + override fun reset(uri: URIish?) { + cachedPassword?.fill(0.toChar()) + cachedPassword = null } - - /** - * Executes the GitCommand in an async task. - */ - suspend fun execute(): Result<Unit, Throwable> { - if (!preExecute()) { - return Ok(Unit) + } + + private fun getSshKey(make: Boolean) { + runCatching { + val intent = + if (make) { + Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java) + } else { + Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java) } - val operationResult = GitCommandExecutor( - callingActivity, - this, - ).execute() - postExecute() - return operationResult + callingActivity.startActivity(intent) } - - 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() + .onFailure { e -> e(e) } + } + + private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) { + sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile) + commands.filterIsInstance<TransportCommand<*, *>>().forEach { command -> + command.setTransportConfigCallback { transport: Transport -> + (transport as? SshTransport)?.sshSessionFactory = sshSessionFactory + credentialsProvider?.let { transport.credentialsProvider = it } + } + command.setTimeout(CONNECT_TIMEOUT) } + } - suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> { - when (authMode) { - AuthMode.SshKey -> if (SshKey.exists) { - if (SshKey.mustAuthenticate) { - val result = withContext(Dispatchers.Main) { - suspendCoroutine<BiometricAuthenticator.Result> { cont -> - BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) { - if (it !is BiometricAuthenticator.Result.Failure) - cont.resume(it) - } - } - } - when (result) { - is BiometricAuthenticator.Result.Success -> { - registerAuthProviders(SshAuthMethod.SshKey(authActivity)) - } - is BiometricAuthenticator.Result.Cancelled -> { - return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) - } - is BiometricAuthenticator.Result.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)) + /** Executes the GitCommand in an async task. */ + suspend fun execute(): Result<Unit, Throwable> { + if (!preExecute()) { + return Ok(Unit) + } + val operationResult = + GitCommandExecutor( + callingActivity, + this, + ) + .execute() + postExecute() + return operationResult + } + + private fun onMissingSshKeyFile() { + MaterialAlertDialogBuilder(callingActivity) + .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text)) + .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title)) + .setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ -> + getSshKey(false) + } + .setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ -> + getSshKey(true) + } + .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> + // Finish the blank GitActivity so user doesn't have to press back + callingActivity.finish() + } + .show() + } + + suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> { + when (authMode) { + AuthMode.SshKey -> + if (SshKey.exists) { + if (SshKey.mustAuthenticate) { + val result = + withContext(Dispatchers.Main) { + suspendCoroutine<BiometricAuthenticator.Result> { cont -> + BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) { + if (it !is BiometricAuthenticator.Result.Failure) cont.resume(it) + } } - } else { - onMissingSshKeyFile() - // This would correctly cancel the operation but won't surface a user-visible - // error, allowing users to make the SSH key selection. + } + when (result) { + is BiometricAuthenticator.Result.Success -> { + registerAuthProviders(SshAuthMethod.SshKey(authActivity)) + } + is BiometricAuthenticator.Result.Cancelled -> { return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) + } + is BiometricAuthenticator.Result.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() + } } - AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity)) - AuthMode.Password -> { - val httpsCredentialProvider = HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password)) - registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider) - } - AuthMode.None -> { - } + } 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)) } - return execute() + 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 + /** Called before execution of the Git operation. Return false to cancel. */ + open fun preExecute() = true - private suspend fun postExecute() { - withContext(Dispatchers.IO) { - sshSessionFactory?.close() - } - } + private suspend fun postExecute() { + withContext(Dispatchers.IO) { sshSessionFactory?.close() } + } - companion object { + companion object { - /** - * Timeout in seconds before [TransportCommand] will abort a stalled IO operation. - */ - private const val CONNECT_TIMEOUT = 10 - } + /** Timeout in seconds before [TransportCommand] will abort a stalled IO operation. */ + private const val CONNECT_TIMEOUT = 10 + } } 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 index e517aac0..394b7cb4 100644 --- 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 @@ -8,27 +8,28 @@ import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity import org.eclipse.jgit.api.GitCommand class PullOperation( - callingActivity: ContinuationContainerActivity, - rebase: Boolean, + callingActivity: ContinuationContainerActivity, + rebase: Boolean, ) : GitOperation(callingActivity) { - /** - * The story of why the pull operation is committing files goes like this: Once upon a time when - * the world was burning and Blade Runner 2049 was real life (in the worst way), we were made - * aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing. - * So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation] - * and then a [PushOperation]. To make the behavior identical despite this suboptimal situation, - * we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly - * replicating [SyncOperation] but leaving the pushing part to [PushOperation]. - */ - override val commands: Array<GitCommand<out Any>> = arrayOf( - // Stage all files - git.add().addFilepattern("."), - // Populate the changed files count - git.status(), - // Commit everything! If needed, obviously. - git.commit().setAll(true).setMessage("[Android Password Store] Sync"), - // Pull and rebase on top of the remote branch - git.pull().setRebase(rebase).setRemote("origin"), + /** + * The story of why the pull operation is committing files goes like this: Once upon a time when + * the world was burning and Blade Runner 2049 was real life (in the worst way), we were made + * aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing. + * So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation] + * and then a [PushOperation]. To make the behavior identical despite this suboptimal situation, + * we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly + * replicating [SyncOperation] but leaving the pushing part to [PushOperation]. + */ + override val commands: Array<GitCommand<out Any>> = + arrayOf( + // Stage all files + git.add().addFilepattern("."), + // Populate the changed files count + git.status(), + // Commit everything! If needed, obviously. + git.commit().setAll(true).setMessage("[Android Password Store] Sync"), + // Pull and rebase on top of the remote branch + git.pull().setRebase(rebase).setRemote("origin"), ) } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt index de07087e..a9f168ad 100644 --- 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 @@ -9,7 +9,8 @@ import org.eclipse.jgit.api.GitCommand class PushOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { - override val commands: Array<GitCommand<out Any>> = arrayOf( - git.push().setPushAll().setRemote("origin"), + override val commands: Array<GitCommand<out Any>> = + arrayOf( + git.push().setPushAll().setRemote("origin"), ) } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt index 80848602..dccd69b0 100644 --- 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 @@ -9,15 +9,18 @@ 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), + 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 index fc63ce0c..589c6305 100644 --- 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 @@ -7,20 +7,21 @@ package dev.msfjarvis.aps.util.git.operation import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity class SyncOperation( - callingActivity: ContinuationContainerActivity, - rebase: Boolean, + 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"), + 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 index 4085c0c5..523ff5b6 100644 --- 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 @@ -14,24 +14,21 @@ import kotlin.coroutines.resumeWithException import net.schmizz.sshj.common.DisconnectReason import net.schmizz.sshj.userauth.UserAuthException -/** - * Workaround for https://msfjarvis.dev/aps/issue/1164 - */ +/** Workaround for https://msfjarvis.dev/aps/issue/1164 */ open class ContinuationContainerActivity : AppCompatActivity { - constructor() : super() - constructor(@LayoutRes layoutRes: Int) : super(layoutRes) + constructor() : super() + constructor(@LayoutRes layoutRes: Int) : super(layoutRes) - var stashedCont: Continuation<Intent>? = null + var stashedCont: Continuation<Intent>? = null - val continueAfterUserInteraction = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> - stashedCont?.let { cont -> - stashedCont = null - val data = result.data - if (data != null) - cont.resume(data) - else - cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER)) - } + 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 index 23f780c3..acb7d8d7 100644 --- 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 @@ -38,162 +38,170 @@ 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 { +class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) : + KeyProvider, Closeable { - companion object { + companion object { - suspend fun prepareAndUse(activity: ContinuationContainerActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) { - withContext(Dispatchers.Main) { - OpenKeychainKeyProvider(activity) - }.prepareAndUse(block) - } + 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 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 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) { + d { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" } + cont.resume(SshAuthenticationApi(context, sshAgent)) } - } - 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) { - d { "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) + override fun onError() { + throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable") } - 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 - } + if (keyId == null) { + selectKey() } - - private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse { - d { "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 { - d { "executeRequest($request): $it" } + 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 parseResult(request: Request, result: Intent): ApiResponse { - return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) { - SshAuthenticationApi.RESULT_CODE_SUCCESS -> { - ApiResponse.Success(when (request) { - is KeySelectionRequest -> KeySelectionResponse(result) - is SshPublicKeyRequest -> SshPublicKeyResponse(result) - is SigningRequest -> SigningResponse(result) - else -> throw IllegalArgumentException("Unsupported OpenKeychain request type") - }) - } - SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { - val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!! - val resultOfUserInteraction: Intent = withContext(Dispatchers.Main) { - suspendCoroutine { cont -> - activity.stashedCont = cont - activity.continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build()) - } - } - executeApiRequest(request, resultOfUserInteraction) - } - else -> { - val error = result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR) - val exception = UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}") - when (error?.error) { - SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception) - else -> ApiResponse.GeneralError(exception) - } - } - } - } - - private fun makePrivateKey() { - check(keyId != null && publicKey != null) - privateKey = object : OpenKeychainPrivateKey { - override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) = - when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) { - is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature - is ApiResponse.GeneralError -> throw signingResponse.exception - is ApiResponse.NoSuchKey -> throw signingResponse.exception - } - - override fun getAlgorithm() = publicKey!!.algorithm - override fun getParams() = (publicKey as? ECKey)?.params - } + 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 } - - override fun close() { - activity.lifecycleScope.launch { - withContext(Dispatchers.Main) { - activity.continueAfterUserInteraction.unregister() + } + + private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse { + d { "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 { d { "executeRequest($request): $it" } } + } + + private suspend fun parseResult(request: Request, result: Intent): ApiResponse { + return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) { + SshAuthenticationApi.RESULT_CODE_SUCCESS -> { + ApiResponse.Success( + when (request) { + is KeySelectionRequest -> KeySelectionResponse(result) + is SshPublicKeyRequest -> SshPublicKeyResponse(result) + is SigningRequest -> SigningResponse(result) + else -> throw IllegalArgumentException("Unsupported OpenKeychain request type") + } + ) + } + SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!! + val resultOfUserInteraction: Intent = + withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + activity.stashedCont = cont + activity.continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build()) } + } + executeApiRequest(request, resultOfUserInteraction) + } + else -> { + val error = result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR) + val exception = + UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}") + when (error?.error) { + SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY -> + ApiResponse.NoSuchKey(exception) + else -> ApiResponse.GeneralError(exception) } - sshServiceConnection.disconnect() + } + } + } + + 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 getPrivate() = privateKey - override fun getPublic() = publicKey + override fun getPublic() = publicKey - override fun getType(): KeyType = KeyType.fromKey(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 index e64f9909..0ab01ba5 100644 --- 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 @@ -8,8 +8,6 @@ import com.hierynomus.sshj.key.KeyAlgorithm import java.io.ByteArrayOutputStream import java.security.PrivateKey import java.security.interfaces.ECKey -import java.security.interfaces.ECPrivateKey -import java.security.spec.ECParameterSpec import kotlinx.coroutines.runBlocking import net.schmizz.sshj.common.Buffer import net.schmizz.sshj.common.Factory @@ -18,79 +16,83 @@ import org.openintents.ssh.authentication.SshAuthenticationApi interface OpenKeychainPrivateKey : PrivateKey, ECKey { - suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray + suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray - override fun getFormat() = null - override fun getEncoded() = null + override fun getFormat() = null + override fun getEncoded() = null } -class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) : Factory.Named<KeyAlgorithm> by factory { +class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) : + Factory.Named<KeyAlgorithm> by factory { - override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create()) + 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 + 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) + override fun newSignature() = OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm) } -class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) : Signature by wrappedSignature { +class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) : + Signature by wrappedSignature { - private val data = ByteArrayOutputStream() + private val data = ByteArrayOutputStream() - private var bridgedPrivateKey: OpenKeychainPrivateKey? = null + private var bridgedPrivateKey: OpenKeychainPrivateKey? = null - override fun initSign(prvkey: PrivateKey?) { - if (prvkey is OpenKeychainPrivateKey) { - bridgedPrivateKey = prvkey - } else { - wrappedSignature.initSign(prvkey) - } + 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?) { + 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 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) - } + override fun sign(): ByteArray? = + if (bridgedPrivateKey != null) { + runBlocking { bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm) } } else { - wrappedSignature.sign() + 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() - } + 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) + 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 index 793bbd28..37414707 100644 --- 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 @@ -51,286 +51,288 @@ private const val KEYSTORE_ALIAS = "sshkey" private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs" private val androidKeystore: KeyStore by lazy(LazyThreadSafetyMode.NONE) { - KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) } + KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) } } private val KeyStore.sshPrivateKey - get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey + get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey private val KeyStore.sshPublicKey - get() = getCertificate(KEYSTORE_ALIAS)?.publicKey + 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() + 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)}" + 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. - d(error) - false - } + 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") } - - 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) + } + .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. + d(error) + false } - - private val isStrongBoxSupported by lazy(LazyThreadSafetyMode.NONE) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) - 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"), - ; + private val context: Context + get() = Application.instance.applicationContext - companion object { + private val privateKeyFile + get() = File(context.filesDir, ".ssh_key") + private val publicKeyFile + get() = File(context.filesDir, ".ssh_key.pub") - 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 >= Build.VERSION_CODES.P) { - setIsStrongBoxBacked(isStrongBoxSupported) - } - }), - } + 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 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 - } + private val isStrongBoxSupported by lazy(LazyThreadSafetyMode.NONE) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) + else false + } - 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)) + private enum class Type(val value: String) { + Imported("imported"), + KeystoreNative("keystore_native"), + KeystoreWrappedEd25519("keystore_wrapped_ed25519"), - // 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")) + // Behaves like `Imported`, but allows to view the public key. + LegacyGenerated("legacy_generated"), + ; - type = Type.Imported - } + companion object { - @Deprecated("To be used only in Migrations.kt") - fun useLegacyKey(isGenerated: Boolean) { - type = if (isGenerated) Type.LegacyGenerated else Type.Imported + fun fromValue(value: String?): Type? = values().associateBy { it.value }[value] } - - @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() + } + + 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 >= Build.VERSION_CODES.P) { + 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() } - - @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() - } + if (publicKeyFile.isFile) { + publicKeyFile.delete() } - - @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 + 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() + } } - 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 >= Build.VERSION_CODES.R) { - 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() + @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() } - - // 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 { + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = + withContext(Dispatchers.IO) { + delete() - override fun getPublic(): PublicKey = runCatching { - androidKeystore.sshPublicKey!! - }.getOrElse { error -> - e(error) - throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error) - } + 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) } - override fun getPrivate(): PrivateKey = runCatching { - androidKeystore.sshPrivateKey!! - }.getOrElse { error -> - e(error) - throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error) - } + // Write public key in SSH format to .ssh_key.pub. + publicKeyFile.writeText(toSshPublicKey(keyPair.public)) - override fun getType(): KeyType = KeyType.fromKey(public) + type = Type.KeystoreWrappedEd25519 } - private object KeystoreWrappedEd25519KeyProvider : KeyProvider { - - override fun getPublic(): PublicKey = runCatching { - parseSshPublicKey(sshPublicKey!!)!! - }.getOrElse { error -> - e(error) - throw IOException("Failed to get the public key for wrapped ed25519 key", error) + 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 >= Build.VERSION_CODES.R) { + 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 + } - 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 -> - e(error) - throw IOException("Failed to unwrap wrapped ed25519 key", error) + private object KeystoreNativeKeyProvider : KeyProvider { + + override fun getPublic(): PublicKey = + runCatching { androidKeystore.sshPublicKey!! }.getOrElse { error -> + e(error) + throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error) + } + + override fun getPrivate(): PrivateKey = + runCatching { androidKeystore.sshPrivateKey!! }.getOrElse { error -> + e(error) + 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 -> + e(error) + 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 -> + e(error) + throw IOException("Failed to unwrap wrapped ed25519 key", error) } - override fun getType(): KeyType = KeyType.fromKey(public) - } + 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 index c3339676..e93787f4 100644 --- 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 @@ -33,250 +33,240 @@ 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) - } - d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" } - // Prevent sshj from forwarding all cryptographic operations to BC. - SecurityUtils.setRegisterBouncyCastle(false) - SecurityUtils.setSecurityProvider(null) + // 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) + } + d { "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) + 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 TimberLoggerFactory : LoggerFactory { - private class TimberLogger(name: String) : AbstractLogger(name) { - - // We defer the log level checks to Timber. - 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?) { - Timber.tag(name).v(t, message.fix(), *args) - } - - override fun d(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).d(t, message.fix(), *args) - } - - override fun i(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).i(t, message.fix(), *args) - } - - override fun w(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).w(t, message.fix(), *args) - } - - override fun e(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).e(t, message.fix(), *args) - } - } + private class TimberLogger(name: String) : AbstractLogger(name) { - override fun getLogger(name: String): Logger { - return TimberLogger(name) - } - - override fun getLogger(clazz: Class<*>): Logger { - return TimberLogger(clazz.name) - } + // We defer the log level checks to Timber. + 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") -class SshjConfig : ConfigImpl() { - - init { - loggerFactory = TimberLoggerFactory - keepAliveProvider = KeepAliveProvider.HEARTBEAT - version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1" - - initKeyExchangeFactories() - initKeyAlgorithms() - initRandomFactory() - initFileKeyProviderFactories() - initCipherFactories() - initCompressionFactories() - initMACFactories() + override fun t(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).v(t, message.fix(), *args) } - 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(), - ) + override fun d(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).d(t, message.fix(), *args) } - private fun initKeyAlgorithms() { - keyAlgorithms = listOf( - KeyAlgorithms.SSHRSACertV01(), - KeyAlgorithms.EdDSA25519(), - KeyAlgorithms.ECDSASHANistp521(), - KeyAlgorithms.ECDSASHANistp384(), - KeyAlgorithms.ECDSASHANistp256(), - KeyAlgorithms.RSASHA512(), - KeyAlgorithms.RSASHA256(), - KeyAlgorithms.SSHRSA(), - ).map { - OpenKeychainWrappedKeyAlgorithmFactory(it) - } + override fun i(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).i(t, message.fix(), *args) } - private fun initRandomFactory() { - randomFactory = SingletonRandomFactory(JCERandom.Factory()) + override fun w(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).w(t, message.fix(), *args) } - private fun initFileKeyProviderFactories() { - fileKeyProviderFactories = listOf( - OpenSSHKeyV1KeyFile.Factory(), - PKCS8KeyFile.Factory(), - PKCS5KeyFile.Factory(), - OpenSSHKeyFile.Factory(), - PuTTYKeyFile.Factory(), - ) + override fun e(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).e(t, message.fix(), *args) } + } + override fun getLogger(name: String): Logger { + return TimberLogger(name) + } - private fun initCipherFactories() { - cipherFactories = listOf( - GcmCiphers.AES128GCM(), - GcmCiphers.AES256GCM(), - BlockCiphers.AES256CTR(), - BlockCiphers.AES192CTR(), - BlockCiphers.AES128CTR(), - ) - } + override fun getLogger(clazz: Class<*>): Logger { + return TimberLogger(clazz.name) + } +} - private fun initMACFactories() { - macFactories = listOf( - Macs.HMACSHA2512Etm(), - Macs.HMACSHA2256Etm(), - Macs.HMACSHA2512(), - Macs.HMACSHA2256(), - ) - } +class SshjConfig : ConfigImpl() { - private fun initCompressionFactories() { - compressionFactories = listOf( - NoneCompression.Factory(), - ) - } + init { + loggerFactory = TimberLoggerFactory + 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 index 1062fb0e..95676d7f 100644 --- 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 @@ -40,158 +40,155 @@ 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) + 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 + private var isRetry = false - abstract fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) + abstract fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) - final override fun reqPassword(resource: Resource<*>?): CharArray { - val password = runBlocking(Dispatchers.Main) { - suspendCoroutine<String?> { cont -> - askForPassword(cont, isRetry) - } - } - isRetry = true - return password?.toCharArray() - ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER) - } + final override fun reqPassword(resource: Resource<*>?): CharArray { + val password = runBlocking(Dispatchers.Main) { suspendCoroutine<String?> { cont -> askForPassword(cont, isRetry) } } + isRetry = true + return password?.toCharArray() ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER) + } - final override fun shouldRetry(resource: Resource<*>?) = true + final override fun shouldRetry(resource: Resource<*>?) = true } class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) : SshSessionFactory() { - private var currentSession: SshjSession? = null + 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 { - d { "New SSH connection created" } - currentSession = it - } - } + override fun getSession(uri: URIish, credentialsProvider: CredentialsProvider?, fs: FS?, tms: Int): RemoteSession { + return currentSession + ?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also { + d { "New SSH connection created" } + currentSession = it + } + } - fun close() { - currentSession?.close() - } + fun close() { + currentSession?.close() + } } private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier { - if (!hostKeyFile.exists()) { - return HostKeyVerifier { _, _, key -> - 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)}" - d { "Trusting host key on first use: $hostKeyEntry" } - hostKeyFile.writeText(hostKeyEntry) - true - } - } else { - val hostKeyEntry = hostKeyFile.readText() - d { "Pinned host key: $hostKeyEntry" } - return FingerprintVerifier.getInstance(hostKeyEntry) + if (!hostKeyFile.exists()) { + return HostKeyVerifier { _, _, key -> + 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)}" + d { "Trusting host key on first use: $hostKeyEntry" } + hostKeyFile.writeText(hostKeyEntry) + true } + } else { + val hostKeyEntry = hostKeyFile.readText() + d { "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. - d { "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 { d { "After fixup: user=${it.user}, host=${it.host}" } } +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. + d { "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 { d { "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 + uri } - override fun exec(commandName: String?, timeout: Int): Process { - if (currentCommand != null) { - w { "Killing old command" } - disconnect() + 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) + } } - val session = ssh.startSession() - currentCommand = session - return SshjProcess(session.exec(commandName), timeout.toLong()) + } } + return this + } - /** - * 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() + override fun exec(commandName: String?, timeout: Int): Process { + if (currentCommand != null) { + w { "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 waitFor(): Int { + command.join(timeout, TimeUnit.SECONDS) + command.close() + return exitValue() + } - override fun destroy() = command.close() + override fun destroy() = command.close() - override fun getOutputStream(): OutputStream = command.outputStream + override fun getOutputStream(): OutputStream = command.outputStream - override fun getErrorStream(): InputStream = command.errorStream + override fun getErrorStream(): InputStream = command.errorStream - override fun exitValue(): Int = command.exitStatus + override fun exitValue(): Int = command.exitStatus - override fun getInputStream(): InputStream = command.inputStream + 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 index 592b75da..e6eb7e43 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt @@ -15,52 +15,52 @@ import java.net.ProxySelector import java.net.SocketAddress import java.net.URI -/** - * Utility class for [Proxy] handling. - */ +/** Utility class for [Proxy] handling. */ object ProxyUtils { - private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser" - private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword" + private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser" + private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword" - /** - * Set the default [Proxy] and [Authenticator] for the app based on user provided settings. - */ - fun setDefaultProxy() { - ProxySelector.setDefault(object : ProxySelector() { - override fun select(uri: URI?): MutableList<Proxy> { - val host = GitSettings.proxyHost - val port = GitSettings.proxyPort - return if (host == null || port == -1) { - mutableListOf(Proxy.NO_PROXY) - } else { - mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port))) - } - } + /** Set the default [Proxy] and [Authenticator] for the app based on user provided settings. */ + fun setDefaultProxy() { + ProxySelector.setDefault( + object : ProxySelector() { + override fun select(uri: URI?): MutableList<Proxy> { + val host = GitSettings.proxyHost + val port = GitSettings.proxyPort + return if (host == null || port == -1) { + mutableListOf(Proxy.NO_PROXY) + } else { + mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port))) + } + } - override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) { - if (uri == null || sa == null || ioe == null) { - throw IllegalArgumentException("Arguments can't be null.") - } - } - }) - val user = GitSettings.proxyUsername ?: "" - val password = GitSettings.proxyPassword ?: "" - if (user.isEmpty() || password.isEmpty()) { - System.clearProperty(HTTP_PROXY_USER_PROPERTY) - System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY) - } else { - System.setProperty(HTTP_PROXY_USER_PROPERTY, user) - System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password) + override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) { + if (uri == null || sa == null || ioe == null) { + throw IllegalArgumentException("Arguments can't be null.") + } } - Authenticator.setDefault(object : Authenticator() { - override fun getPasswordAuthentication(): PasswordAuthentication? { - return if (requestorType == RequestorType.PROXY) { - PasswordAuthentication(user, password.toCharArray()) - } else { - 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 + } + } + } + ) + } } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt index c9361e15..8199ebfc 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt @@ -12,128 +12,118 @@ import dev.msfjarvis.aps.util.extensions.hasFlag import dev.msfjarvis.aps.util.settings.PreferenceKeys enum class PasswordOption(val key: String) { - NoDigits("0"), - NoUppercaseLetters("A"), - NoAmbiguousCharacters("B"), - FullyRandom("s"), - AtLeastOneSymbol("y"), - NoLowercaseLetters("L") + NoDigits("0"), + NoUppercaseLetters("A"), + NoAmbiguousCharacters("B"), + FullyRandom("s"), + AtLeastOneSymbol("y"), + NoLowercaseLetters("L") } object PasswordGenerator { - const val DEFAULT_LENGTH = 16 + const val DEFAULT_LENGTH = 16 - const val DIGITS = 0x0001 - const val UPPERS = 0x0002 - const val SYMBOLS = 0x0004 - const val NO_AMBIGUOUS = 0x0008 - const val LOWERS = 0x0020 + const val DIGITS = 0x0001 + const val UPPERS = 0x0002 + const val SYMBOLS = 0x0004 + const val NO_AMBIGUOUS = 0x0008 + const val LOWERS = 0x0020 - const val DIGITS_STR = "0123456789" - const val UPPERS_STR = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - const val LOWERS_STR = "abcdefghijklmnopqrstuvwxyz" - const val SYMBOLS_STR = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" - const val AMBIGUOUS_STR = "B8G6I1l0OQDS5Z2" + const val DIGITS_STR = "0123456789" + const val UPPERS_STR = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + const val LOWERS_STR = "abcdefghijklmnopqrstuvwxyz" + const val SYMBOLS_STR = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + const val AMBIGUOUS_STR = "B8G6I1l0OQDS5Z2" - /** - * Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for - * generated passwords. - */ - fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean { - ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit { - for (possibleOption in PasswordOption.values()) - putBoolean(possibleOption.key, possibleOption in options) - putInt("length", targetLength) - } - return true - } - - fun isValidPassword(password: String, pwFlags: Int): Boolean { - if (pwFlags hasFlag DIGITS && password.none { it in DIGITS_STR }) - return false - if (pwFlags hasFlag UPPERS && password.none { it in UPPERS_STR }) - return false - if (pwFlags hasFlag LOWERS && password.none { it in LOWERS_STR }) - return false - if (pwFlags hasFlag SYMBOLS && password.none { it in SYMBOLS_STR }) - return false - if (pwFlags hasFlag NO_AMBIGUOUS && password.any { it in AMBIGUOUS_STR }) - return false - return true + /** + * Enables the [PasswordOption] s in [options] and sets [targetLength] as the length for generated + * passwords. + */ + fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean { + ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit { + for (possibleOption in PasswordOption.values()) putBoolean(possibleOption.key, possibleOption in options) + putInt("length", targetLength) } + return true + } - /** - * Generates a password using the preferences set by [setPrefs]. - */ - @Throws(PasswordGeneratorException::class) - fun generate(ctx: Context): String { - val prefs = ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) - var numCharacterCategories = 0 + fun isValidPassword(password: String, pwFlags: Int): Boolean { + if (pwFlags hasFlag DIGITS && password.none { it in DIGITS_STR }) return false + if (pwFlags hasFlag UPPERS && password.none { it in UPPERS_STR }) return false + if (pwFlags hasFlag LOWERS && password.none { it in LOWERS_STR }) return false + if (pwFlags hasFlag SYMBOLS && password.none { it in SYMBOLS_STR }) return false + if (pwFlags hasFlag NO_AMBIGUOUS && password.any { it in AMBIGUOUS_STR }) return false + return true + } - var phonemes = true - var pwgenFlags = DIGITS or UPPERS or LOWERS + /** Generates a password using the preferences set by [setPrefs]. */ + @Throws(PasswordGeneratorException::class) + fun generate(ctx: Context): String { + val prefs = ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) + var numCharacterCategories = 0 - for (option in PasswordOption.values()) { - if (prefs.getBoolean(option.key, false)) { - when (option) { - PasswordOption.NoDigits -> pwgenFlags = pwgenFlags.clearFlag(DIGITS) - PasswordOption.NoUppercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(UPPERS) - PasswordOption.NoLowercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(LOWERS) - PasswordOption.NoAmbiguousCharacters -> pwgenFlags = pwgenFlags or NO_AMBIGUOUS - PasswordOption.FullyRandom -> phonemes = false - PasswordOption.AtLeastOneSymbol -> { - numCharacterCategories++ - pwgenFlags = pwgenFlags or SYMBOLS - } - } - } else { - // The No* options are false, so the respective character category will be included. - when (option) { - PasswordOption.NoDigits, - PasswordOption.NoUppercaseLetters, - PasswordOption.NoLowercaseLetters -> { - numCharacterCategories++ - } - PasswordOption.NoAmbiguousCharacters, - PasswordOption.FullyRandom, - // Since AtLeastOneSymbol is not negated, it is counted in the if branch. - PasswordOption.AtLeastOneSymbol -> { - } - } - } - } + var phonemes = true + var pwgenFlags = DIGITS or UPPERS or LOWERS - val length = prefs.getInt(PreferenceKeys.LENGTH, DEFAULT_LENGTH) - if (pwgenFlags.clearFlag(NO_AMBIGUOUS) == 0) { - throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_no_chars_error)) - } - if (length < numCharacterCategories) { - throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_length_too_short_error)) - } - if (!(pwgenFlags hasFlag UPPERS) && !(pwgenFlags hasFlag LOWERS)) { - phonemes = false - pwgenFlags = pwgenFlags.clearFlag(NO_AMBIGUOUS) + for (option in PasswordOption.values()) { + if (prefs.getBoolean(option.key, false)) { + when (option) { + PasswordOption.NoDigits -> pwgenFlags = pwgenFlags.clearFlag(DIGITS) + PasswordOption.NoUppercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(UPPERS) + PasswordOption.NoLowercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(LOWERS) + PasswordOption.NoAmbiguousCharacters -> pwgenFlags = pwgenFlags or NO_AMBIGUOUS + PasswordOption.FullyRandom -> phonemes = false + PasswordOption.AtLeastOneSymbol -> { + numCharacterCategories++ + pwgenFlags = pwgenFlags or SYMBOLS + } } - // Experiments show that phonemes may require more than 1000 iterations to generate a valid - // password if the length is not at least 6. - if (length < 6) { - phonemes = false + } else { + // The No* options are false, so the respective character category will be included. + when (option) { + PasswordOption.NoDigits, PasswordOption.NoUppercaseLetters, PasswordOption.NoLowercaseLetters -> { + numCharacterCategories++ + } + PasswordOption.NoAmbiguousCharacters, + PasswordOption.FullyRandom, + // Since AtLeastOneSymbol is not negated, it is counted in the if branch. + PasswordOption.AtLeastOneSymbol -> {} } + } + } - var password: String? - var iterations = 0 - do { - if (iterations++ > 1000) - throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_max_iterations_exceeded)) - password = if (phonemes) { - RandomPhonemesGenerator.generate(length, pwgenFlags) - } else { - RandomPasswordGenerator.generate(length, pwgenFlags) - } - } while (password == null) - return password + val length = prefs.getInt(PreferenceKeys.LENGTH, DEFAULT_LENGTH) + if (pwgenFlags.clearFlag(NO_AMBIGUOUS) == 0) { + throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_no_chars_error)) + } + if (length < numCharacterCategories) { + throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_length_too_short_error)) + } + if (!(pwgenFlags hasFlag UPPERS) && !(pwgenFlags hasFlag LOWERS)) { + phonemes = false + pwgenFlags = pwgenFlags.clearFlag(NO_AMBIGUOUS) } + // Experiments show that phonemes may require more than 1000 iterations to generate a valid + // password if the length is not at least 6. + if (length < 6) { + phonemes = false + } + + var password: String? + var iterations = 0 + do { + if (iterations++ > 1000) + throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_max_iterations_exceeded)) + password = + if (phonemes) { + RandomPhonemesGenerator.generate(length, pwgenFlags) + } else { + RandomPasswordGenerator.generate(length, pwgenFlags) + } + } while (password == null) + return password + } - class PasswordGeneratorException(string: String) : Exception(string) + class PasswordGeneratorException(string: String) : Exception(string) } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt index 936f2cae..8ef490cc 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt @@ -8,26 +8,24 @@ import java.security.SecureRandom private val secureRandom = SecureRandom() -/** - * Returns a number between 0 (inclusive) and [exclusiveBound] (exclusive). - */ +/** Returns a number between 0 (inclusive) and [exclusiveBound](exclusive). */ fun secureRandomNumber(exclusiveBound: Int) = secureRandom.nextInt(exclusiveBound) -/** - * Returns `true` and `false` with probablity 50% each. - */ +/** Returns `true` and `false` with probablity 50% each. */ fun secureRandomBoolean() = secureRandom.nextBoolean() /** - * Returns `true` with probability [percentTrue]% and `false` with probability - * `(100 - [percentTrue])`%. + * Returns `true` with probability [percentTrue]% and `false` with probability `(100 - [percentTrue] + * )`%. */ fun secureRandomBiasedBoolean(percentTrue: Int): Boolean { - require(1 <= percentTrue) { "Probability for returning `true` must be at least 1%" } - require(percentTrue <= 99) { "Probability for returning `true` must be at most 99%" } - return secureRandomNumber(100) < percentTrue + require(1 <= percentTrue) { "Probability for returning `true` must be at least 1%" } + require(percentTrue <= 99) { "Probability for returning `true` must be at most 99%" } + return secureRandomNumber(100) < percentTrue } fun <T> Array<T>.secureRandomElement() = this[secureRandomNumber(size)] + fun <T> List<T>.secureRandomElement() = this[secureRandomNumber(size)] + fun String.secureRandomCharacter() = this[secureRandomNumber(length)] diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt index 33e58228..e92a753b 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt @@ -8,38 +8,39 @@ import dev.msfjarvis.aps.util.extensions.hasFlag object RandomPasswordGenerator { - /** - * Generates a random password of length [targetLength], taking the following flags in [pwFlags] - * into account, or fails to do so and returns null: - * - * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not - * set, the password will not contain any digits. - * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase - * letter; if not set, the password will not contain any uppercase letters. - * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase - * letter; if not set, the password will not contain any lowercase letters. - * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not - * set, the password will not contain any symbols. - * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous - * characters. - */ - fun generate(targetLength: Int, pwFlags: Int): String? { - val bank = listOfNotNull( - PasswordGenerator.DIGITS_STR.takeIf { pwFlags hasFlag PasswordGenerator.DIGITS }, - PasswordGenerator.UPPERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.UPPERS }, - PasswordGenerator.LOWERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.LOWERS }, - PasswordGenerator.SYMBOLS_STR.takeIf { pwFlags hasFlag PasswordGenerator.SYMBOLS }, - ).joinToString("") + /** + * Generates a random password of length [targetLength], taking the following flags in [pwFlags] + * into account, or fails to do so and returns null: + * + * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not set, + * the password will not contain any digits. + * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase letter; + * if not set, the password will not contain any uppercase letters. + * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase letter; + * if not set, the password will not contain any lowercase letters. + * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not + * set, the password will not contain any symbols. + * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous + * characters. + */ + fun generate(targetLength: Int, pwFlags: Int): String? { + val bank = + listOfNotNull( + PasswordGenerator.DIGITS_STR.takeIf { pwFlags hasFlag PasswordGenerator.DIGITS }, + PasswordGenerator.UPPERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.UPPERS }, + PasswordGenerator.LOWERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.LOWERS }, + PasswordGenerator.SYMBOLS_STR.takeIf { pwFlags hasFlag PasswordGenerator.SYMBOLS }, + ) + .joinToString("") - var password = "" - while (password.length < targetLength) { - val candidate = bank.secureRandomCharacter() - if (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && - candidate in PasswordGenerator.AMBIGUOUS_STR) { - continue - } - password += candidate - } - return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) } + var password = "" + while (password.length < targetLength) { + val candidate = bank.secureRandomCharacter() + if (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate in PasswordGenerator.AMBIGUOUS_STR) { + continue + } + password += candidate } + return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) } + } } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt index e0f7e387..1b2d0fb7 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt @@ -9,161 +9,161 @@ import java.util.Locale object RandomPhonemesGenerator { - private const val CONSONANT = 0x0001 - private const val VOWEL = 0x0002 - private const val DIPHTHONG = 0x0004 - private const val NOT_FIRST = 0x0008 - - private val elements = arrayOf( - Element("a", VOWEL), - Element("ae", VOWEL or DIPHTHONG), - Element("ah", VOWEL or DIPHTHONG), - Element("ai", VOWEL or DIPHTHONG), - Element("b", CONSONANT), - Element("c", CONSONANT), - Element("ch", CONSONANT or DIPHTHONG), - Element("d", CONSONANT), - Element("e", VOWEL), - Element("ee", VOWEL or DIPHTHONG), - Element("ei", VOWEL or DIPHTHONG), - Element("f", CONSONANT), - Element("g", CONSONANT), - Element("gh", CONSONANT or DIPHTHONG or NOT_FIRST), - Element("h", CONSONANT), - Element("i", VOWEL), - Element("ie", VOWEL or DIPHTHONG), - Element("j", CONSONANT), - Element("k", CONSONANT), - Element("l", CONSONANT), - Element("m", CONSONANT), - Element("n", CONSONANT), - Element("ng", CONSONANT or DIPHTHONG or NOT_FIRST), - Element("o", VOWEL), - Element("oh", VOWEL or DIPHTHONG), - Element("oo", VOWEL or DIPHTHONG), - Element("p", CONSONANT), - Element("ph", CONSONANT or DIPHTHONG), - Element("qu", CONSONANT or DIPHTHONG), - Element("r", CONSONANT), - Element("s", CONSONANT), - Element("sh", CONSONANT or DIPHTHONG), - Element("t", CONSONANT), - Element("th", CONSONANT or DIPHTHONG), - Element("u", VOWEL), - Element("v", CONSONANT), - Element("w", CONSONANT), - Element("x", CONSONANT), - Element("y", CONSONANT), - Element("z", CONSONANT) + private const val CONSONANT = 0x0001 + private const val VOWEL = 0x0002 + private const val DIPHTHONG = 0x0004 + private const val NOT_FIRST = 0x0008 + + private val elements = + arrayOf( + Element("a", VOWEL), + Element("ae", VOWEL or DIPHTHONG), + Element("ah", VOWEL or DIPHTHONG), + Element("ai", VOWEL or DIPHTHONG), + Element("b", CONSONANT), + Element("c", CONSONANT), + Element("ch", CONSONANT or DIPHTHONG), + Element("d", CONSONANT), + Element("e", VOWEL), + Element("ee", VOWEL or DIPHTHONG), + Element("ei", VOWEL or DIPHTHONG), + Element("f", CONSONANT), + Element("g", CONSONANT), + Element("gh", CONSONANT or DIPHTHONG or NOT_FIRST), + Element("h", CONSONANT), + Element("i", VOWEL), + Element("ie", VOWEL or DIPHTHONG), + Element("j", CONSONANT), + Element("k", CONSONANT), + Element("l", CONSONANT), + Element("m", CONSONANT), + Element("n", CONSONANT), + Element("ng", CONSONANT or DIPHTHONG or NOT_FIRST), + Element("o", VOWEL), + Element("oh", VOWEL or DIPHTHONG), + Element("oo", VOWEL or DIPHTHONG), + Element("p", CONSONANT), + Element("ph", CONSONANT or DIPHTHONG), + Element("qu", CONSONANT or DIPHTHONG), + Element("r", CONSONANT), + Element("s", CONSONANT), + Element("sh", CONSONANT or DIPHTHONG), + Element("t", CONSONANT), + Element("th", CONSONANT or DIPHTHONG), + Element("u", VOWEL), + Element("v", CONSONANT), + Element("w", CONSONANT), + Element("x", CONSONANT), + Element("y", CONSONANT), + Element("z", CONSONANT) ) - private class Element(str: String, val flags: Int) { - - val upperCase = str.toUpperCase(Locale.ROOT) - val lowerCase = str.toLowerCase(Locale.ROOT) - val length = str.length - val isAmbiguous = str.any { it in PasswordGenerator.AMBIGUOUS_STR } - } + private class Element(str: String, val flags: Int) { + + val upperCase = str.toUpperCase(Locale.ROOT) + val lowerCase = str.toLowerCase(Locale.ROOT) + val length = str.length + val isAmbiguous = str.any { it in PasswordGenerator.AMBIGUOUS_STR } + } + + /** + * Generates a random human-readable password of length [targetLength], taking the following flags + * in [pwFlags] into account, or fails to do so and returns null: + * + * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not set, + * the password will not contain any digits. + * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase letter; + * if not set, the password will not contain any uppercase letters. + * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase letter; + * if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any lowercase + * characters; if both are not set, an exception is thrown. + * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not + * set, the password will not contain any symbols. + * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous + * characters. + */ + fun generate(targetLength: Int, pwFlags: Int): String? { + require(pwFlags hasFlag PasswordGenerator.UPPERS || pwFlags hasFlag PasswordGenerator.LOWERS) + + var password = "" + + var isStartOfPart = true + var nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT + var previousFlags = 0 + + while (password.length < targetLength) { + // First part: Add a single letter or pronounceable pair of letters in varying case. + + val candidate = elements.secureRandomElement() + + // Reroll if the candidate does not fulfill the current requirements. + if (!candidate.flags.hasFlag(nextBasicType) || + (isStartOfPart && candidate.flags hasFlag NOT_FIRST) || + // Don't let a diphthong that starts with a vowel follow a vowel. + (previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) || + // Don't add multi-character candidates if we would go over the targetLength. + (password.length + candidate.length > targetLength) || + (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous) + ) { + continue + } + + // At this point the candidate could be appended to the password, but we still have + // to determine the case. If no upper case characters are required, we don't add + // any. + val useUpperIfBothCasesAllowed = + (isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20) + password += + if (pwFlags hasFlag PasswordGenerator.UPPERS && + (!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed) + ) { + candidate.upperCase + } else { + candidate.lowerCase + } - /** - * Generates a random human-readable password of length [targetLength], taking the following - * flags in [pwFlags] into account, or fails to do so and returns null: - * - * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not - * set, the password will not contain any digits. - * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase - * letter; if not set, the password will not contain any uppercase letters. - * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase - * letter; if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any - * lowercase characters; if both are not set, an exception is thrown. - * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not - * set, the password will not contain any symbols. - * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous - * characters. - */ - fun generate(targetLength: Int, pwFlags: Int): String? { - require(pwFlags hasFlag PasswordGenerator.UPPERS || pwFlags hasFlag PasswordGenerator.LOWERS) - - var password = "" - - var isStartOfPart = true - var nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT - var previousFlags = 0 - - while (password.length < targetLength) { - // First part: Add a single letter or pronounceable pair of letters in varying case. - - val candidate = elements.secureRandomElement() - - // Reroll if the candidate does not fulfill the current requirements. - if (!candidate.flags.hasFlag(nextBasicType) || - (isStartOfPart && candidate.flags hasFlag NOT_FIRST) || - // Don't let a diphthong that starts with a vowel follow a vowel. - (previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) || - // Don't add multi-character candidates if we would go over the targetLength. - (password.length + candidate.length > targetLength) || - (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)) { - continue - } - - // At this point the candidate could be appended to the password, but we still have - // to determine the case. If no upper case characters are required, we don't add - // any. - val useUpperIfBothCasesAllowed = - (isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20) - password += if (pwFlags hasFlag PasswordGenerator.UPPERS && - (!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed)) { - candidate.upperCase - } else { - candidate.lowerCase - } - - // We ensured above that we will not go above the target length. - check(password.length <= targetLength) - if (password.length == targetLength) - break - - // Second part: Add digits and symbols with a certain probability (if requested) if - // they would not directly follow the first character in a pronounceable part. - - if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.DIGITS && - secureRandomBiasedBoolean(30)) { - var randomDigit: Char - do { - randomDigit = secureRandomNumber(10).toString(10).first() - } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && - randomDigit in PasswordGenerator.AMBIGUOUS_STR) - - password += randomDigit - // Begin a new pronounceable part after every digit. - isStartOfPart = true - nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT - previousFlags = 0 - continue - } - - if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.SYMBOLS && - secureRandomBiasedBoolean(20)) { - var randomSymbol: Char - do { - randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter() - } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && - randomSymbol in PasswordGenerator.AMBIGUOUS_STR) - password += randomSymbol - // Continue the password generation as if nothing was added. - } - - // Third part: Determine the basic type of the next character depending on the letter - // we just added. - nextBasicType = when { - candidate.flags.hasFlag(CONSONANT) -> VOWEL - previousFlags.hasFlag(VOWEL) || candidate.flags.hasFlag(DIPHTHONG) || - secureRandomBiasedBoolean(60) -> CONSONANT - else -> VOWEL - } - previousFlags = candidate.flags - isStartOfPart = false + // We ensured above that we will not go above the target length. + check(password.length <= targetLength) + if (password.length == targetLength) break + + // Second part: Add digits and symbols with a certain probability (if requested) if + // they would not directly follow the first character in a pronounceable part. + + if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.DIGITS && secureRandomBiasedBoolean(30)) { + var randomDigit: Char + do { + randomDigit = secureRandomNumber(10).toString(10).first() + } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && randomDigit in PasswordGenerator.AMBIGUOUS_STR) + + password += randomDigit + // Begin a new pronounceable part after every digit. + isStartOfPart = true + nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT + previousFlags = 0 + continue + } + + if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.SYMBOLS && secureRandomBiasedBoolean(20)) { + var randomSymbol: Char + do { + randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter() + } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && randomSymbol in PasswordGenerator.AMBIGUOUS_STR) + password += randomSymbol + // Continue the password generation as if nothing was added. + } + + // Third part: Determine the basic type of the next character depending on the letter + // we just added. + nextBasicType = + when { + candidate.flags.hasFlag(CONSONANT) -> VOWEL + previousFlags.hasFlag(VOWEL) || candidate.flags.hasFlag(DIPHTHONG) || secureRandomBiasedBoolean(60) -> + CONSONANT + else -> VOWEL } - return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) } + previousFlags = candidate.flags + isStartOfPart = false } + return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) } + } } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt index 4b398f06..69a4692d 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt @@ -5,5 +5,9 @@ package dev.msfjarvis.aps.util.pwgenxkpwd enum class CapsType { - lowercase, UPPERCASE, TitleCase, Sentence, As_iS + lowercase, + UPPERCASE, + TitleCase, + Sentence, + As_iS } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt index 41860293..cc8257b4 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt @@ -16,127 +16,120 @@ import java.util.Locale class PasswordBuilder(ctx: Context) { - private var numSymbols = 0 - private var isAppendSymbolsSeparator = false - private var context = ctx - private var numWords = 3 - private var maxWordLength = 9 - private var minWordLength = 5 - private var separator = "." - private var capsType = CapsType.Sentence - private var prependDigits = 0 - private var numDigits = 0 - private var isPrependWithSeparator = false - private var isAppendNumberSeparator = false - - fun setNumberOfWords(amount: Int) = apply { - numWords = amount + private var numSymbols = 0 + private var isAppendSymbolsSeparator = false + private var context = ctx + private var numWords = 3 + private var maxWordLength = 9 + private var minWordLength = 5 + private var separator = "." + private var capsType = CapsType.Sentence + private var prependDigits = 0 + private var numDigits = 0 + private var isPrependWithSeparator = false + private var isAppendNumberSeparator = false + + fun setNumberOfWords(amount: Int) = apply { numWords = amount } + + fun setMinimumWordLength(min: Int) = apply { minWordLength = min } + + fun setMaximumWordLength(max: Int) = apply { maxWordLength = max } + + fun setSeparator(separator: String) = apply { this.separator = separator } + + fun setCapitalization(capitalizationScheme: CapsType) = apply { capsType = capitalizationScheme } + + @JvmOverloads + fun prependNumbers(numDigits: Int, addSeparator: Boolean = true) = apply { + prependDigits = numDigits + isPrependWithSeparator = addSeparator + } + + @JvmOverloads + fun appendNumbers(numDigits: Int, addSeparator: Boolean = false) = apply { + this.numDigits = numDigits + isAppendNumberSeparator = addSeparator + } + + @JvmOverloads + fun appendSymbols(numSymbols: Int, addSeparator: Boolean = false) = apply { + this.numSymbols = numSymbols + isAppendSymbolsSeparator = addSeparator + } + + private fun generateRandomNumberSequence(totalNumbers: Int): String { + val numbers = StringBuilder(totalNumbers) + for (i in 0 until totalNumbers) { + numbers.append(secureRandomNumber(10)) } + return numbers.toString() + } - fun setMinimumWordLength(min: Int) = apply { - minWordLength = min + private fun generateRandomSymbolSequence(numSymbols: Int): String { + val numbers = StringBuilder(numSymbols) + for (i in 0 until numSymbols) { + numbers.append(SYMBOLS.secureRandomCharacter()) } - - fun setMaximumWordLength(max: Int) = apply { - maxWordLength = max - } - - fun setSeparator(separator: String) = apply { - this.separator = separator - } - - fun setCapitalization(capitalizationScheme: CapsType) = apply { - capsType = capitalizationScheme + return numbers.toString() + } + + @OptIn(ExperimentalStdlibApi::class) + fun create(): Result<String, Throwable> { + val wordBank = mutableListOf<String>() + val password = StringBuilder() + + if (prependDigits != 0) { + password.append(generateRandomNumberSequence(prependDigits)) + if (isPrependWithSeparator) { + password.append(separator) + } } - - @JvmOverloads - fun prependNumbers(numDigits: Int, addSeparator: Boolean = true) = apply { - prependDigits = numDigits - isPrependWithSeparator = addSeparator - } - - @JvmOverloads - fun appendNumbers(numDigits: Int, addSeparator: Boolean = false) = apply { - this.numDigits = numDigits - isAppendNumberSeparator = addSeparator - } - - @JvmOverloads - fun appendSymbols(numSymbols: Int, addSeparator: Boolean = false) = apply { - this.numSymbols = numSymbols - isAppendSymbolsSeparator = addSeparator - } - - private fun generateRandomNumberSequence(totalNumbers: Int): String { - val numbers = StringBuilder(totalNumbers) - for (i in 0 until totalNumbers) { - numbers.append(secureRandomNumber(10)) + return runCatching { + val dictionary = XkpwdDictionary(context) + val words = dictionary.words + for (wordLength in minWordLength..maxWordLength) { + wordBank.addAll(words[wordLength] ?: emptyList()) + } + + if (wordBank.size == 0) { + throw PasswordGeneratorException( + context.getString(R.string.xkpwgen_builder_error, minWordLength, maxWordLength) + ) + } + + for (i in 0 until numWords) { + val candidate = wordBank.secureRandomElement() + val s = + when (capsType) { + CapsType.UPPERCASE -> candidate.toUpperCase(Locale.getDefault()) + CapsType.Sentence -> if (i == 0) candidate.capitalize(Locale.getDefault()) else candidate + CapsType.TitleCase -> candidate.capitalize(Locale.getDefault()) + CapsType.lowercase -> candidate.toLowerCase(Locale.getDefault()) + CapsType.As_iS -> candidate + } + password.append(s) + if (i + 1 < numWords) { + password.append(separator) } - return numbers.toString() - } - - private fun generateRandomSymbolSequence(numSymbols: Int): String { - val numbers = StringBuilder(numSymbols) - for (i in 0 until numSymbols) { - numbers.append(SYMBOLS.secureRandomCharacter()) + } + if (numDigits != 0) { + if (isAppendNumberSeparator) { + password.append(separator) } - return numbers.toString() - } - - @OptIn(ExperimentalStdlibApi::class) - fun create(): Result<String, Throwable> { - val wordBank = mutableListOf<String>() - val password = StringBuilder() - - if (prependDigits != 0) { - password.append(generateRandomNumberSequence(prependDigits)) - if (isPrependWithSeparator) { - password.append(separator) - } - } - return runCatching { - val dictionary = XkpwdDictionary(context) - val words = dictionary.words - for (wordLength in minWordLength..maxWordLength) { - wordBank.addAll(words[wordLength] ?: emptyList()) - } - - if (wordBank.size == 0) { - throw PasswordGeneratorException(context.getString(R.string.xkpwgen_builder_error, minWordLength, maxWordLength)) - } - - for (i in 0 until numWords) { - val candidate = wordBank.secureRandomElement() - val s = when (capsType) { - CapsType.UPPERCASE -> candidate.toUpperCase(Locale.getDefault()) - CapsType.Sentence -> if (i == 0) candidate.capitalize(Locale.getDefault()) else candidate - CapsType.TitleCase -> candidate.capitalize(Locale.getDefault()) - CapsType.lowercase -> candidate.toLowerCase(Locale.getDefault()) - CapsType.As_iS -> candidate - } - password.append(s) - if (i + 1 < numWords) { - password.append(separator) - } - } - if (numDigits != 0) { - if (isAppendNumberSeparator) { - password.append(separator) - } - password.append(generateRandomNumberSequence(numDigits)) - } - if (numSymbols != 0) { - if (isAppendSymbolsSeparator) { - password.append(separator) - } - password.append(generateRandomSymbolSequence(numSymbols)) - } - password.toString() + password.append(generateRandomNumberSequence(numDigits)) + } + if (numSymbols != 0) { + if (isAppendSymbolsSeparator) { + password.append(separator) } + password.append(generateRandomSymbolSequence(numSymbols)) + } + password.toString() } + } - companion object { + companion object { - private const val SYMBOLS = "!@\$%^&*-_+=:|~?/.;#" - } + private const val SYMBOLS = "!@\$%^&*-_+=:|~?/.;#" + } } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt index 1e44ab20..ab31892f 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt @@ -13,28 +13,28 @@ import java.io.File class XkpwdDictionary(context: Context) { - val words: Map<Int, List<String>> - - init { - val prefs = context.sharedPrefs - val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: "" - val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE) - - val lines = if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) && - uri.isNotEmpty() && customDictFile.canRead()) { - customDictFile.readLines() - } else { - context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines() - } - - words = lines.asSequence() - .map { it.trim() } - .filter { it.isNotEmpty() && !it.contains(' ') } - .groupBy { it.length } - } - - companion object { - - const val XKPWD_CUSTOM_DICT_FILE = "custom_dict.txt" - } + val words: Map<Int, List<String>> + + init { + val prefs = context.sharedPrefs + val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: "" + val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE) + + val lines = + if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) && + uri.isNotEmpty() && + customDictFile.canRead() + ) { + customDictFile.readLines() + } else { + context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines() + } + + words = lines.asSequence().map { it.trim() }.filter { it.isNotEmpty() && !it.contains(' ') }.groupBy { it.length } + } + + companion object { + + const val XKPWD_CUSTOM_DICT_FILE = "custom_dict.txt" + } } 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 index 1b6ba6c7..d25a110e 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt @@ -32,155 +32,150 @@ import kotlinx.coroutines.withContext 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 - } - } + 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) } - - 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 { - d { "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)) - } - } - } + 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() } - } else { - d { "Cannot get clipboard manager service" } - } - } - - private suspend fun startTimer(showTime: Int) { - var current = 0 - while (scope.isActive && current < showTime) { - // Block for 1s or until cancel is signalled - current++ - delay(1000) - } - } - - private fun 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 >= Build.VERSION_CODES.O) { - PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT) - } else { - PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT) + } + return START_NOT_STICKY } - val notification = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { - 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(Build.VERSION_CODES.N) - 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 >= Build.VERSION_CODES.O) { - val serviceChannel = NotificationChannel( - CHANNEL_ID, - getString(R.string.app_name), - NotificationManager.IMPORTANCE_LOW - ) - val manager = getSystemService<NotificationManager>() - if (manager != null) { - manager.createNotificationChannel(serviceChannel) - } else { - d { "Failed to create notification channel" } + 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 { + d { "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 { + d { "Cannot get clipboard manager service" } } - - 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 + } + + 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 >= Build.VERSION_CODES.O) { + PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT) + } else { + PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT) + } + val notification = + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { + 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(Build.VERSION_CODES.N) + 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 >= Build.VERSION_CODES.O) { + val serviceChannel = + NotificationChannel(CHANNEL_ID, getString(R.string.app_name), NotificationManager.IMPORTANCE_LOW) + val manager = getSystemService<NotificationManager>() + if (manager != null) { + manager.createNotificationChannel(serviceChannel) + } else { + d { "Failed to create notification channel" } + } } + } + + companion object { + + const val ACTION_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 index 35a2502e..ce55fe7b 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt @@ -38,108 +38,121 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys @RequiresApi(Build.VERSION_CODES.O) class OreoAutofillService : AutofillService() { - companion object { + 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", - ) + // 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 - } + private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L + } - override fun onCreate() { - super.onCreate() - cachePublicSuffixList(applicationContext) - } + 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 + 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 { - d { "Form cannot be filled" } - callback.onSuccess(null) - return - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Api30AutofillResponseBuilder(formToFill).fillCredentials(this, request.inlineSuggestionsRequest, callback) - } else { - AutofillResponseBuilder(formToFill).fillCredentials(this, callback) + 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 { + d { "Form cannot be filled" } + callback.onSuccess(null) + return } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Api30AutofillResponseBuilder(formToFill).fillCredentials(this, request.inlineSuggestionsRequest, callback) + } else { + AutofillResponseBuilder(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 + 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 { + e { "Received save request without client state" } + callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) + return } - val clientState = request.clientState ?: run { - e { "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 { + e { "Failed to recover client state or nodes from client state" } + callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) + return } - val scenario = AutofillScenario.fromClientState(clientState)?.recoverNodes(structure) - ?: run { - e { "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 { - e { "Failed to recover form origin from client state" } - callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) - return + val formOrigin = + FormOrigin.fromBundle(clientState) + ?: run { + e { "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 + 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 - ) - ) - } + callback.onSuccess( + AutofillSaveActivity.makeSaveIntentSender( + this, + credentials = Credentials(username, password, null), + formOrigin = formOrigin + ) + ) + } } fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) fun Context.getCustomSuffixes(): Sequence<String> { - return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) - ?.splitToSequence('\n') - ?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' } - ?: emptySequence() + 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 index 56b8e2e4..2ecd2287 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt @@ -25,134 +25,131 @@ import java.util.TimeZone class PasswordExportService : Service() { - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent != null) { - when (intent.action) { - ACTION_EXPORT_PASSWORD -> { - val uri = intent.getParcelableExtra<Uri>("uri") - if (uri != null) { - val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri) - - if (targetDirectory != null) { - createNotification() - exportPasswords(targetDirectory) - stopSelf() - return START_NOT_STICKY - } - } - } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent != null) { + when (intent.action) { + ACTION_EXPORT_PASSWORD -> { + val uri = intent.getParcelableExtra<Uri>("uri") + if (uri != null) { + val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri) + + if (targetDirectory != null) { + createNotification() + exportPasswords(targetDirectory) + stopSelf() + return START_NOT_STICKY } + } } - return super.onStartCommand(intent, flags, startId) + } } - - override fun onBind(intent: Intent?): IBinder? { - return null + 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) + + d { "Copying ${repositoryDirectory.path} to $targetDirectory" } + + val dateString = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME) + } else { + String.format("%tFT%<tRZ", Calendar.getInstance(TimeZone.getTimeZone("Z"))) + } + + val passDir = targetDirectory.createDirectory("password_store_$dateString") + + if (passDir != null) { + copyDirToDir(sourcePassDir, passDir) } - - /** - * 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) - - d { "Copying ${repositoryDirectory.path} to $targetDirectory" } - - val dateString = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - LocalDateTime - .now() - .format(DateTimeFormatter.ISO_DATE_TIME) - } else { - String.format("%tFT%<tRZ", Calendar.getInstance(TimeZone.getTimeZone("Z"))) - } - - val passDir = targetDirectory.createDirectory("password_store_$dateString") - - if (passDir != null) { - copyDirToDir(sourcePassDir, passDir) - } + } + + /** + * Copies a password file to a given directory. + * + * Note: this does not preserve last modified time. + * + * @param passwordFile password file to copy. + * @param targetDirectory target directory to copy password. + */ + private fun copyFileToDir(passwordFile: DocumentFile, targetDirectory: DocumentFile) { + val sourceInputStream = contentResolver.openInputStream(passwordFile.uri) + val name = passwordFile.name + val targetPasswordFile = targetDirectory.createFile("application/octet-stream", name!!) + if (targetPasswordFile?.exists() == true) { + val destOutputStream = contentResolver.openOutputStream(targetPasswordFile.uri) + + if (destOutputStream != null && sourceInputStream != null) { + sourceInputStream.copyTo(destOutputStream, 1024) + + sourceInputStream.close() + destOutputStream.close() + } } - - /** - * Copies a password file to a given directory. - * - * Note: this does not preserve last modified time. - * - * @param passwordFile password file to copy. - * @param targetDirectory target directory to copy password. - */ - private fun copyFileToDir(passwordFile: DocumentFile, targetDirectory: DocumentFile) { - val sourceInputStream = contentResolver.openInputStream(passwordFile.uri) - val name = passwordFile.name - val targetPasswordFile = targetDirectory.createFile("application/octet-stream", name!!) - if (targetPasswordFile?.exists() == true) { - val destOutputStream = contentResolver.openOutputStream(targetPasswordFile.uri) - - if (destOutputStream != null && sourceInputStream != null) { - sourceInputStream.copyTo(destOutputStream, 1024) - - sourceInputStream.close() - destOutputStream.close() - } - } + } + + /** + * Recursively copies a directory to a destination. + * + * @param sourceDirectory directory to copy from. + * @param targetDirectory directory to copy to. + */ + private fun copyDirToDir(sourceDirectory: DocumentFile, targetDirectory: DocumentFile) { + sourceDirectory.listFiles().forEach { file -> + if (file.isDirectory) { + // Create new directory and recurse + val newDir = targetDirectory.createDirectory(file.name!!) + copyDirToDir(file, newDir!!) + } else { + copyFileToDir(file, targetDirectory) + } } - - /** - * Recursively copies a directory to a destination. - * - * @param sourceDirectory directory to copy from. - * @param targetDirectory directory to copy to. - */ - private fun copyDirToDir(sourceDirectory: DocumentFile, targetDirectory: DocumentFile) { - sourceDirectory.listFiles().forEach { file -> - if (file.isDirectory) { - // Create new directory and recurse - val newDir = targetDirectory.createDirectory(file.name!!) - copyDirToDir(file, newDir!!) - } else { - copyFileToDir(file, targetDirectory) - } - } - } - - private fun createNotification() { - createNotificationChannel() - - val notification = NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.exporting_passwords)) - .setSmallIcon(R.drawable.ic_round_import_export) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build() - - startForeground(2, notification) + } + + private fun createNotification() { + createNotificationChannel() + + val notification = + NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.exporting_passwords)) + .setSmallIcon(R.drawable.ic_round_import_export) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + startForeground(2, notification) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = + NotificationChannel(CHANNEL_ID, getString(R.string.app_name), NotificationManager.IMPORTANCE_LOW) + val manager = getSystemService<NotificationManager>() + if (manager != null) { + manager.createNotificationChannel(serviceChannel) + } else { + d { "Failed to create notification channel" } + } } + } - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val serviceChannel = NotificationChannel( - CHANNEL_ID, - getString(R.string.app_name), - NotificationManager.IMPORTANCE_LOW - ) - val manager = getSystemService<NotificationManager>() - if (manager != null) { - manager.createNotificationChannel(serviceChannel) - } else { - d { "Failed to create notification channel" } - } - } - } + companion object { - companion object { - - const val ACTION_EXPORT_PASSWORD = "ACTION_EXPORT_PASSWORD" - private const val CHANNEL_ID = "NotificationService" - } + 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 index 79ee13d5..3a508e45 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt @@ -17,191 +17,168 @@ import java.io.File import org.eclipse.jgit.transport.URIish enum class Protocol(val pref: String) { - Ssh("ssh://"), - Https("https://"), - ; + Ssh("ssh://"), + Https("https://"), + ; - companion object { + 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") - } + 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") - } + 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") } + } } object GitSettings { - private const val DEFAULT_BRANCH = "master" - - private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs } - private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedGitPrefs() } - private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() } - private val hostKeyPath by lazy(LazyThreadSafetyMode.NONE) { "${Application.instance.filesDir}/.host_key" } - - var authMode - get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH)) - private set(value) { - settings.edit { - putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref) - } - } - - var url - get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL) - private set(value) { - require(value != null) - if (value == url) - return - settings.edit { - putString(PreferenceKeys.GIT_REMOTE_URL, value) - } - if (PasswordRepository.isInitialized) - PasswordRepository.addRemote("origin", value, true) - // When the server changes, remote password, multiplexing support and host key file - // should be deleted/reset. - useMultiplexing = true - encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) } - clearSavedHostKey() - } - - var authorName - get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: "" - set(value) { - settings.edit { - putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value) - } - } - - var authorEmail - get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: "" - set(value) { - settings.edit { - putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value) - } - } - - var branch - get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH - private set(value) { - settings.edit { - putString(PreferenceKeys.GIT_BRANCH_NAME, value) - } - } - - var useMultiplexing - get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true) - set(value) { - settings.edit { - putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value) - } - } - - var proxyHost - get() = proxySettings.getString(PreferenceKeys.PROXY_HOST) - set(value) { - proxySettings.edit { - putString(PreferenceKeys.PROXY_HOST, value) - } - } - - var proxyPort - get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1) - set(value) { - proxySettings.edit { - putInt(PreferenceKeys.PROXY_PORT, value) - } - } - - var proxyUsername - get() = settings.getString(PreferenceKeys.PROXY_USERNAME) - set(value) { - proxySettings.edit { - putString(PreferenceKeys.PROXY_USERNAME, value) - } - } - - var proxyPassword - get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD) - set(value) { - proxySettings.edit { - putString(PreferenceKeys.PROXY_PASSWORD, value) - } - } - - var rebaseOnPull - get() = settings.getBoolean(PreferenceKeys.REBASE_ON_PULL, true) - set(value) { - settings.edit { - putBoolean(PreferenceKeys.REBASE_ON_PULL, value) - } - } - - sealed class UpdateConnectionSettingsResult { - class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult() - class AuthModeMismatch(val newProtocol: Protocol, val validModes: List<AuthMode>) : UpdateConnectionSettingsResult() - object Valid : UpdateConnectionSettingsResult() - object FailedToParseUrl : UpdateConnectionSettingsResult() + private const val DEFAULT_BRANCH = "master" + + private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs } + private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) { + Application.instance.getEncryptedGitPrefs() + } + private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() } + private val hostKeyPath by lazy(LazyThreadSafetyMode.NONE) { "${Application.instance.filesDir}/.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) } } - 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 && 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 + var useMultiplexing + get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true) + set(value) { + settings.edit { putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value) } } - /** - * Deletes a previously saved SSH host key - */ - fun clearSavedHostKey() { - File(hostKeyPath).delete() + var proxyHost + get() = proxySettings.getString(PreferenceKeys.PROXY_HOST) + set(value) { + proxySettings.edit { putString(PreferenceKeys.PROXY_HOST, value) } } - /** - * Returns true if a host key was previously saved - */ - fun hasSavedHostKey(): Boolean = File(hostKeyPath).exists() + var proxyPort + get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1) + set(value) { + proxySettings.edit { putInt(PreferenceKeys.PROXY_PORT, value) } + } + + var proxyUsername + get() = settings.getString(PreferenceKeys.PROXY_USERNAME) + set(value) { + proxySettings.edit { putString(PreferenceKeys.PROXY_USERNAME, value) } + } + + var proxyPassword + get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD) + set(value) { + proxySettings.edit { putString(PreferenceKeys.PROXY_PASSWORD, value) } + } + + var rebaseOnPull + get() = settings.getBoolean(PreferenceKeys.REBASE_ON_PULL, true) + set(value) { + settings.edit { putBoolean(PreferenceKeys.REBASE_ON_PULL, value) } + } + + sealed class UpdateConnectionSettingsResult { + class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult() + class AuthModeMismatch(val newProtocol: Protocol, val validModes: List<AuthMode>) : + UpdateConnectionSettingsResult() + object Valid : UpdateConnectionSettingsResult() + object FailedToParseUrl : UpdateConnectionSettingsResult() + } + + fun updateConnectionSettingsIfValid( + newAuthMode: AuthMode, + newUrl: String, + newBranch: String + ): UpdateConnectionSettingsResult { + val parsedUrl = + runCatching { URIish(newUrl) }.getOrElse { + return UpdateConnectionSettingsResult.FailedToParseUrl + } + val newProtocol = + when (parsedUrl.scheme) { + in listOf("http", "https") -> Protocol.Https + in listOf("ssh", null) -> Protocol.Ssh + else -> return UpdateConnectionSettingsResult.FailedToParseUrl + } + if (newAuthMode != AuthMode.None && 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() } 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 index b804d748..a5612603 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt @@ -20,108 +20,100 @@ import java.io.File import java.net.URI fun runMigrations(context: Context) { - val sharedPrefs = context.sharedPrefs - migrateToGitUrlBasedConfig(sharedPrefs) - migrateToHideAll(sharedPrefs) - migrateToSshKey(context, sharedPrefs) - migrateToClipboardHistory(sharedPrefs) + val sharedPrefs = context.sharedPrefs + migrateToGitUrlBasedConfig(sharedPrefs) + migrateToHideAll(sharedPrefs) + migrateToSshKey(context, sharedPrefs) + migrateToClipboardHistory(sharedPrefs) } private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) { - val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER) - ?: return - i { "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)) + val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER) ?: return + i { "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() + // 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) { - e { "Failed to migrate to URL-based Git config, generated URL is invalid" } - } + 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 + ) { + e { "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) - } + 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(context: Context, sharedPrefs: SharedPreferences) { - val privateKeyFile = File(context.filesDir, ".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) - } - } + val privateKeyFile = File(context.filesDir, ".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) - } + 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) } + } } 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 index a858a355..ee678de2 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt @@ -13,37 +13,36 @@ import dev.msfjarvis.aps.util.extensions.base64 import dev.msfjarvis.aps.util.extensions.getString enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) { + FOLDER_FIRST( + Comparator { p1: PasswordItem, p2: PasswordItem -> + (p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true) + } + ), + INDEPENDENT(Comparator { p1: PasswordItem, p2: PasswordItem -> p1.name.compareTo(p2.name, ignoreCase = true) }), + RECENTLY_USED( + Comparator { p1: PasswordItem, p2: PasswordItem -> + val recentHistory = Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val timeP1 = recentHistory.getString(p1.file.absolutePath.base64()) + val timeP2 = recentHistory.getString(p2.file.absolutePath.base64()) + when { + timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1) + timeP1 != null && timeP2 == null -> return@Comparator -1 + timeP1 == null && timeP2 != null -> return@Comparator 1 + else -> p1.name.compareTo(p2.name, ignoreCase = true) + } + } + ), + FILE_FIRST( + Comparator { p1: PasswordItem, p2: PasswordItem -> + (p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true) + } + ); - 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 { + companion object { - @JvmStatic - fun getSortOrder(settings: SharedPreferences): PasswordSortOrder { - return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name) - } + @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 index 1cdb3d93..7e3166d8 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt @@ -7,85 +7,79 @@ package dev.msfjarvis.aps.util.settings object PreferenceKeys { - const val APP_THEME = "app_theme" - const val APP_VERSION = "app_version" - 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" - const val GIT_EXTERNAL = "git_external" - const val GIT_EXTERNAL_REPO = "git_external_repo" - const val GIT_REMOTE_AUTH = "git_remote_auth" - const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type" + const val APP_THEME = "app_theme" + const val APP_VERSION = "app_version" + 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" + const val GIT_EXTERNAL = "git_external" + const val GIT_EXTERNAL_REPO = "git_external_repo" + 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_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_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_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_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_CUSTOM_DICT = "pref_key_custom_dict" - const val PREF_KEY_IS_CUSTOM_DICT = "pref_key_is_custom_dict" - const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type" - const val PREF_SELECT_EXTERNAL = "pref_select_external" - const val REPOSITORY_INITIALIZED = "repository_initialized" - const val REPO_CHANGED = "repo_changed" - const val SEARCH_ON_START = "search_on_start" + @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_CUSTOM_DICT = "pref_key_custom_dict" + const val PREF_KEY_IS_CUSTOM_DICT = "pref_key_is_custom_dict" + const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type" + const val PREF_SELECT_EXTERNAL = "pref_select_external" + 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( + 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" + @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 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 REBASE_ON_PULL = "rebase_on_pull" } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt b/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt index c0fbb8c4..1ef155a5 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt @@ -16,58 +16,59 @@ import org.apache.commons.codec.binary.Base32 object Otp { - private val BASE_32 = Base32() - private val STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY".toCharArray() + private val BASE_32 = Base32() + private val STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY".toCharArray() - init { - check(STEAM_ALPHABET.size == 26) - } + init { + check(STEAM_ALPHABET.size == 26) + } - fun calculateCode(secret: String, counter: Long, algorithm: String, digits: String) = runCatching { - val algo = "Hmac${algorithm.toUpperCase(Locale.ROOT)}" - val decodedSecret = BASE_32.decode(secret) - val secretKey = SecretKeySpec(decodedSecret, algo) - val digest = Mac.getInstance(algo).run { - init(secretKey) - doFinal(ByteBuffer.allocate(8).putLong(counter).array()) + fun calculateCode(secret: String, counter: Long, algorithm: String, digits: String) = runCatching { + val algo = "Hmac${algorithm.toUpperCase(Locale.ROOT)}" + val decodedSecret = BASE_32.decode(secret) + val secretKey = SecretKeySpec(decodedSecret, algo) + val digest = + Mac.getInstance(algo).run { + init(secretKey) + doFinal(ByteBuffer.allocate(8).putLong(counter).array()) + } + // Least significant 4 bits are used as an offset into the digest. + val offset = (digest.last() and 0xf).toInt() + // Extract 32 bits at the offset and clear the most significant bit. + val code = digest.copyOfRange(offset, offset + 4) + code[0] = (0x7f and code[0].toInt()).toByte() + val codeInt = ByteBuffer.wrap(code).int + check(codeInt > 0) + if (digits == "s") { + // Steam + var remainingCodeInt = codeInt + buildString { + repeat(5) { + append(STEAM_ALPHABET[remainingCodeInt % 26]) + remainingCodeInt /= 26 + } + } + } else { + // Base 10, 6 to 10 digits + val numDigits = digits.toIntOrNull() + when { + numDigits == null -> { + return Err(IllegalArgumentException("Digits specifier has to be either 's' or numeric")) + } + numDigits < 6 -> { + return Err(IllegalArgumentException("TOTP codes have to be at least 6 digits long")) + } + numDigits > 10 -> { + return Err(IllegalArgumentException("TOTP codes can be at most 10 digits long")) } - // Least significant 4 bits are used as an offset into the digest. - val offset = (digest.last() and 0xf).toInt() - // Extract 32 bits at the offset and clear the most significant bit. - val code = digest.copyOfRange(offset, offset + 4) - code[0] = (0x7f and code[0].toInt()).toByte() - val codeInt = ByteBuffer.wrap(code).int - check(codeInt > 0) - if (digits == "s") { - // Steam - var remainingCodeInt = codeInt - buildString { - repeat(5) { - append(STEAM_ALPHABET[remainingCodeInt % 26]) - remainingCodeInt /= 26 - } - } - } else { - // Base 10, 6 to 10 digits - val numDigits = digits.toIntOrNull() - when { - numDigits == null -> { - return Err(IllegalArgumentException("Digits specifier has to be either 's' or numeric")) - } - numDigits < 6 -> { - return Err(IllegalArgumentException("TOTP codes have to be at least 6 digits long")) - } - numDigits > 10 -> { - return Err(IllegalArgumentException("TOTP codes can be at most 10 digits long")) - } - else -> { - // 2^31 = 2_147_483_648, so we can extract at most 10 digits with the first one - // always being 0, 1, or 2. Pad with leading zeroes. - val codeStringBase10 = codeInt.toString(10).padStart(10, '0') - check(codeStringBase10.length == 10) - codeStringBase10.takeLast(numDigits) - } - } + else -> { + // 2^31 = 2_147_483_648, so we can extract at most 10 digits with the first one + // always being 0, 1, or 2. Pad with leading zeroes. + val codeStringBase10 = codeInt.toString(10).padStart(10, '0') + check(codeStringBase10.length == 10) + codeStringBase10.takeLast(numDigits) } + } } + } } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt index 8144095d..e787fea5 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt @@ -5,28 +5,18 @@ package dev.msfjarvis.aps.util.totp -/** - * Defines a class that can extract relevant parts of a TOTP URL for use by the app. - */ +/** Defines a class that can extract relevant parts of a TOTP URL for use by the app. */ interface TotpFinder { - /** - * Get the TOTP secret from the given extra content. - */ - fun findSecret(content: String): String? + /** Get the TOTP secret from the given extra content. */ + fun findSecret(content: String): String? - /** - * Get the number of digits required in the final OTP. - */ - fun findDigits(content: String): String + /** Get the number of digits required in the final OTP. */ + fun findDigits(content: String): String - /** - * Get the TOTP timeout period. - */ - fun findPeriod(content: String): Long + /** Get the TOTP timeout period. */ + fun findPeriod(content: String): Long - /** - * Get the algorithm for the TOTP secret. - */ - fun findAlgorithm(content: String): String + /** Get the algorithm for the TOTP secret. */ + fun findAlgorithm(content: String): String } 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 index c205b94b..fa3ff28a 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt @@ -7,60 +7,51 @@ package dev.msfjarvis.aps.util.totp import android.net.Uri -/** - * [Uri] backed TOTP URL parser. - */ +/** [Uri] backed TOTP URL parser. */ class UriTotpFinder : TotpFinder { - override fun findSecret(content: String): String? { - content.split("\n".toRegex()).forEach { line -> - if (line.startsWith(TOTP_FIELDS[0])) { - return Uri.parse(line).getQueryParameter("secret") - } - if (line.startsWith(TOTP_FIELDS[1], ignoreCase = true)) { - return line.split(": *".toRegex(), 2).toTypedArray()[1] - } - } - return null + override fun findSecret(content: String): String? { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith(TOTP_FIELDS[0])) { + return Uri.parse(line).getQueryParameter("secret") + } + if (line.startsWith(TOTP_FIELDS[1], ignoreCase = true)) { + return line.split(": *".toRegex(), 2).toTypedArray()[1] + } } - - override fun findDigits(content: String): String { - content.split("\n".toRegex()).forEach { line -> - if (line.startsWith(TOTP_FIELDS[0]) && - Uri.parse(line).getQueryParameter("digits") != null) { - return Uri.parse(line).getQueryParameter("digits")!! - } - } - return "6" + return null + } + + override fun findDigits(content: String): String { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith(TOTP_FIELDS[0]) && Uri.parse(line).getQueryParameter("digits") != null) { + return Uri.parse(line).getQueryParameter("digits")!! + } } - - override fun findPeriod(content: String): Long { - content.split("\n".toRegex()).forEach { line -> - if (line.startsWith(TOTP_FIELDS[0]) && - Uri.parse(line).getQueryParameter("period") != null) { - val period = Uri.parse(line).getQueryParameter("period")!!.toLongOrNull() - if (period != null && period > 0) - return period - } - } - return 30 + return "6" + } + + override fun findPeriod(content: String): Long { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith(TOTP_FIELDS[0]) && Uri.parse(line).getQueryParameter("period") != null) { + val period = Uri.parse(line).getQueryParameter("period")!!.toLongOrNull() + if (period != null && period > 0) return period + } } - - override fun findAlgorithm(content: String): String { - content.split("\n".toRegex()).forEach { line -> - if (line.startsWith(TOTP_FIELDS[0]) && - Uri.parse(line).getQueryParameter("algorithm") != null) { - return Uri.parse(line).getQueryParameter("algorithm")!! - } - } - return "sha1" + return 30 + } + + override fun findAlgorithm(content: String): String { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith(TOTP_FIELDS[0]) && Uri.parse(line).getQueryParameter("algorithm") != null) { + return Uri.parse(line).getQueryParameter("algorithm")!! + } } + return "sha1" + } - companion object { + companion object { - val TOTP_FIELDS = arrayOf( - "otpauth://totp", - "totp:" - ) - } + val TOTP_FIELDS = arrayOf("otpauth://totp", "totp:") + } } 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 index 0734c1d6..c3998d2a 100644 --- a/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt @@ -50,425 +50,422 @@ import kotlinx.coroutines.flow.toList 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 File.toPasswordItem() = + if (isFile) PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory()) + else PasswordItem.newCategory(name, this, PasswordRepository.getRepositoryDirectory()) private fun PasswordItem.fuzzyMatch(filter: String): Int { - var i = 0 - var j = 0 - var score = 0 - var bonus = 0 - var bonusIncrement = 0 - - val toMatch = longName - - while (i < filter.length && j < toMatch.length) { - when { - filter[i].isWhitespace() -> i++ - filter[i].toLowerCase() == toMatch[j].toLowerCase() -> { - i++ - bonusIncrement += 1 - bonus += bonusIncrement - score += bonus - } - else -> { - bonus = 0 - bonusIncrement = 0 - } - } - j++ + var i = 0 + var j = 0 + var score = 0 + var bonus = 0 + var bonusIncrement = 0 + + val toMatch = longName + + while (i < filter.length && j < toMatch.length) { + when { + filter[i].isWhitespace() -> i++ + filter[i].toLowerCase() == toMatch[j].toLowerCase() -> { + i++ + bonusIncrement += 1 + bonus += bonusIncrement + score += bonus + } + else -> { + bonus = 0 + bonusIncrement = 0 + } } - return if (i == filter.length) score else 0 + j++ + } + return if (i == filter.length) score else 0 } -private val CaseInsensitiveComparator = Collator.getInstance().apply { - strength = Collator.PRIMARY -} +private val CaseInsensitiveComparator = Collator.getInstance().apply { strength = Collator.PRIMARY } private fun PasswordItem.Companion.makeComparator( - typeSortOrder: PasswordSortOrder, - directoryStructure: DirectoryStructure + typeSortOrder: PasswordSortOrder, + directoryStructure: DirectoryStructure ): Comparator<PasswordItem> { - return when (typeSortOrder) { - PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type } - // In order to let INDEPENDENT not distinguish between items based on their type, we simply - // declare them all equal at this stage. - PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 } - PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type } - PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator + 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) - }) + .then(compareBy(nullsLast(CaseInsensitiveComparator)) { directoryStructure.getIdentifierFor(it.file) }) + .then(compareBy(nullsLast(CaseInsensitiveComparator)) { directoryStructure.getUsernameFor(it.file) }) } val PasswordItem.stableId: String - get() = file.absolutePath + get() = file.absolutePath enum class FilterMode { - NoFilter, - StrictDomain, - Fuzzy + NoFilter, + StrictDomain, + Fuzzy } enum class SearchMode { - RecursivelyInSubdirectories, - InCurrentDirectoryOnly + RecursivelyInSubdirectories, + InCurrentDirectoryOnly } enum class ListMode { - FilesOnly, - DirectoriesOnly, - AllEntries + 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 lazy(LazyThreadSafetyMode.NONE) { 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 var _updateCounter = 0 + private val updateCounter: Int + get() = _updateCounter + + private fun forceUpdateOnNextSearchAction() { + _updateCounter++ + } + + private val root + get() = PasswordRepository.getRepositoryDirectory() + private val settings by lazy(LazyThreadSafetyMode.NONE) { 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 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 fun updateSearchAction(action: SearchAction) = action.copy(updateCounter = updateCounter) + + private val searchAction = + MutableLiveData( + makeSearchAction( + baseDirectory = root, + filter = "", + filterMode = FilterMode.NoFilter, + searchMode = SearchMode.InCurrentDirectoryOnly, + listMode = ListMode.AllEntries + ) ) - private val searchActionFlow = searchAction.asFlow().distinctUntilChanged() - - data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean) - - val searchResult = searchActionFlow - .mapLatest { searchAction -> - val listResultFlow = when (searchAction.searchMode) { - SearchMode.RecursivelyInSubdirectories -> listFilesRecursively(searchAction.baseDirectory) - SearchMode.InCurrentDirectoryOnly -> listFiles(searchAction.baseDirectory) + private val searchActionFlow = searchAction.asFlow().distinctUntilChanged() + + data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean) + + val searchResult = + searchActionFlow + .mapLatest { searchAction -> + val listResultFlow = + when (searchAction.searchMode) { + SearchMode.RecursivelyInSubdirectories -> listFilesRecursively(searchAction.baseDirectory) + SearchMode.InCurrentDirectoryOnly -> listFiles(searchAction.baseDirectory) + } + val prefilteredResultFlow = + when (searchAction.listMode) { + ListMode.FilesOnly -> listResultFlow.filter { it.isFile } + ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory } + ListMode.AllEntries -> listResultFlow + } + val filterModeToUse = if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode + val passwordList = + when (filterModeToUse) { + FilterMode.NoFilter -> { + prefilteredResultFlow.map { it.toPasswordItem() }.toList().sortedWith(itemComparator) } - val prefilteredResultFlow = when (searchAction.listMode) { - ListMode.FilesOnly -> listResultFlow.filter { it.isFile } - ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory } - ListMode.AllEntries -> listResultFlow + 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() + } } - val filterModeToUse = - if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode - val passwordList = when (filterModeToUse) { - FilterMode.NoFilter -> { - prefilteredResultFlow - .map { it.toPasswordItem() } - .toList() - .sortedWith(itemComparator) - } - FilterMode.StrictDomain -> { - check(searchAction.listMode == ListMode.FilesOnly) { "Searches with StrictDomain search mode can only list files" } - val regex = generateStrictDomainRegex(searchAction.filter) - if (regex != null) { - prefilteredResultFlow - .filter { absoluteFile -> - regex.containsMatchIn(absoluteFile.relativeTo(root).path) - } - .map { it.toPasswordItem() } - .toList() - .sortedWith(itemComparator) - } else { - emptyList() - } - } - FilterMode.Fuzzy -> { - prefilteredResultFlow - .map { - val item = it.toPasswordItem() - Pair(item.fuzzyMatch(searchAction.filter), item) - } - .filter { it.first > 0 } - .toList() - .sortedWith( - compareByDescending<Pair<Int, PasswordItem>> { it.first }.thenBy( - itemComparator - ) { it.second }) - .map { it.second } + FilterMode.Fuzzy -> { + prefilteredResultFlow + .map { + val item = it.toPasswordItem() + Pair(item.fuzzyMatch(searchAction.filter), item) } + .filter { it.first > 0 } + .toList() + .sortedWith( + compareByDescending<Pair<Int, PasswordItem>> { it.first }.thenBy(itemComparator) { it.second } + ) + .map { it.second } } - SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter) - }.asLiveData(Dispatchers.IO) - - private fun shouldTake(file: File) = with(file) { - if (showHiddenContents) return true - if (isDirectory) { - !isHidden - } else { - !isHidden && file.extension == "gpg" - } - } - - private fun listFiles(dir: File): Flow<File> { - return dir.listFiles { file -> shouldTake(file) }?.asFlow() ?: emptyFlow() + } + SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter) + } + .asLiveData(Dispatchers.IO) + + private fun shouldTake(file: File) = + with(file) { + if (showHiddenContents) return true + if (isDirectory) { + !isHidden + } else { + !isHidden && file.extension == "gpg" + } } - private fun listFilesRecursively(dir: File): Flow<File> { - return dir - // Take top directory even if it is hidden. - .walkTopDown().onEnter { file -> file == dir || shouldTake(file) } - .asFlow() - // Skip the root directory - .drop(1) - .map { - yield() - it - } - .filter { file -> shouldTake(file) } + private fun listFiles(dir: File): Flow<File> { + return dir.listFiles { file -> shouldTake(file) }?.asFlow() ?: emptyFlow() + } + + private fun listFilesRecursively(dir: File): Flow<File> { + return dir + // Take top directory even if it is hidden. + .walkTopDown() + .onEnter { file -> file == dir || shouldTake(file) } + .asFlow() + // Skip the root directory + .drop(1) + .map { + yield() + it + } + .filter { file -> shouldTake(file) } + } + + private val _currentDir = MutableLiveData(root) + val currentDir = _currentDir as LiveData<File> + + data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?) + + private val navigationStack = Stack<NavigationStackEntry>() + + fun navigateTo( + newDirectory: File = root, + listMode: ListMode = ListMode.AllEntries, + recyclerViewState: Parcelable? = null, + pushPreviousLocation: Boolean = true + ) { + if (!newDirectory.exists()) return + require(newDirectory.isDirectory) { "Can only navigate to a directory" } + if (pushPreviousLocation) { + navigationStack.push(NavigationStackEntry(_currentDir.value!!, recyclerViewState)) } - - private val _currentDir = MutableLiveData(root) - val currentDir = _currentDir as LiveData<File> - - data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?) - - private val navigationStack = Stack<NavigationStackEntry>() - - fun navigateTo( - newDirectory: File = root, - listMode: ListMode = ListMode.AllEntries, - recyclerViewState: Parcelable? = null, - pushPreviousLocation: Boolean = true - ) { - if (!newDirectory.exists()) return - require(newDirectory.isDirectory) { "Can only navigate to a directory" } - if (pushPreviousLocation) { - navigationStack.push(NavigationStackEntry(_currentDir.value!!, recyclerViewState)) - } - searchAction.postValue( - makeSearchAction( - filter = "", - baseDirectory = newDirectory, - filterMode = FilterMode.NoFilter, - searchMode = SearchMode.InCurrentDirectoryOnly, - listMode = listMode - ) - ) - _currentDir.postValue(newDirectory) - } - - val canNavigateBack - get() = navigationStack.isNotEmpty() - - /** - * Navigate back to the last location on the [navigationStack] and restore a cached scroll - * position if possible. - * - * Returns the old RecyclerView's LinearLayoutManager state as a [Parcelable] if it was cached. - */ - fun navigateBack(): Parcelable? { - if (!canNavigateBack) return null - val (oldDir, oldRecyclerViewState) = navigationStack.pop() - navigateTo(oldDir, pushPreviousLocation = false) - return oldRecyclerViewState - } - - fun reset() { - navigationStack.clear() - forceUpdateOnNextSearchAction() - navigateTo(pushPreviousLocation = false) - } - - fun search( - filter: String, - baseDirectory: File? = null, - filterMode: FilterMode = FilterMode.Fuzzy, - searchMode: SearchMode? = null, - listMode: ListMode = ListMode.AllEntries - ) { - require(baseDirectory?.isDirectory != false) { "Can only search in a directory" } - searchAction.postValue( - makeSearchAction( - filter = filter, - baseDirectory = baseDirectory ?: _currentDir.value!!, - filterMode = filterMode, - searchMode = searchMode ?: defaultSearchMode, - listMode = listMode - ) - ) - } - - fun forceRefresh() { - forceUpdateOnNextSearchAction() - searchAction.postValue(updateSearchAction(searchAction.value!!)) - } - - companion object { - - @VisibleForTesting - fun generateStrictDomainRegex(domain: String): Regex? { - // Valid domains do not contain path separators. - if (domain.contains('/')) - return null - // Matches the start of a path component, which is either the start of the - // string or a path separator. - val prefix = """(?:^|/)""" - val escapedFilter = Regex.escape(domain.replace("/", "")) - // Matches either the filter literally or a strict subdomain of the filter term. - // We allow a lot of freedom in what a subdomain is, as long as it is not an - // email address. - val subdomain = """(?:(?:[^/@]+\.)?$escapedFilter)""" - // Matches the end of a path component, which is either the literal ".gpg" or a - // path separator. - val suffix = """(?:\.gpg|/)""" - // Match any relative path with a component that is a subdomain of the filter. - return Regex(prefix + subdomain + suffix) - } + searchAction.postValue( + makeSearchAction( + filter = "", + baseDirectory = newDirectory, + filterMode = FilterMode.NoFilter, + searchMode = SearchMode.InCurrentDirectoryOnly, + listMode = listMode + ) + ) + _currentDir.postValue(newDirectory) + } + + val canNavigateBack + get() = navigationStack.isNotEmpty() + + /** + * Navigate back to the last location on the [navigationStack] and restore a cached scroll + * position if possible. + * + * Returns the old RecyclerView's LinearLayoutManager state as a [Parcelable] if it was cached. + */ + fun navigateBack(): Parcelable? { + if (!canNavigateBack) return null + val (oldDir, oldRecyclerViewState) = navigationStack.pop() + navigateTo(oldDir, pushPreviousLocation = false) + return oldRecyclerViewState + } + + fun reset() { + navigationStack.clear() + forceUpdateOnNextSearchAction() + navigateTo(pushPreviousLocation = false) + } + + fun search( + filter: String, + baseDirectory: File? = null, + filterMode: FilterMode = FilterMode.Fuzzy, + searchMode: SearchMode? = null, + listMode: ListMode = ListMode.AllEntries + ) { + require(baseDirectory?.isDirectory != false) { "Can only search in a directory" } + searchAction.postValue( + makeSearchAction( + filter = filter, + baseDirectory = baseDirectory ?: _currentDir.value!!, + filterMode = filterMode, + searchMode = searchMode ?: defaultSearchMode, + listMode = listMode + ) + ) + } + + fun forceRefresh() { + forceUpdateOnNextSearchAction() + searchAction.postValue(updateSearchAction(searchAction.value!!)) + } + + companion object { + + @VisibleForTesting + fun generateStrictDomainRegex(domain: String): Regex? { + // Valid domains do not contain path separators. + if (domain.contains('/')) return null + // Matches the start of a path component, which is either the start of the + // string or a path separator. + val prefix = """(?:^|/)""" + val escapedFilter = Regex.escape(domain.replace("/", "")) + // Matches either the filter literally or a strict subdomain of the filter term. + // We allow a lot of freedom in what a subdomain is, as long as it is not an + // email address. + val subdomain = """(?:(?:[^/@]+\.)?$escapedFilter)""" + // Matches the end of a path component, which is either the literal ".gpg" or a + // path separator. + val suffix = """(?:\.gpg|/)""" + // Match any relative path with a component that is a subdomain of the filter. + return Regex(prefix + subdomain + suffix) } + } } private object PasswordItemDiffCallback : DiffUtil.ItemCallback<PasswordItem>() { - override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = - oldItem.file.absolutePath == newItem.file.absolutePath + override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = + oldItem.file.absolutePath == newItem.file.absolutePath - override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = - oldItem == newItem + override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem } open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>( - private val layoutRes: Int, - private val viewHolderCreator: (view: View) -> T, - private val viewHolderBinder: T.(item: PasswordItem) -> Unit + private val layoutRes: Int, + private val viewHolderCreator: (view: View) -> T, + private val viewHolderBinder: T.(item: PasswordItem) -> Unit ) : ListAdapter<PasswordItem, T>(PasswordItemDiffCallback), PopupTextProvider { - fun <T : ItemDetailsLookup<String>> makeSelectable( - recyclerView: RecyclerView, - itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T - ) { - selectionTracker = SelectionTracker.Builder( - "SearchableRepositoryAdapter", - recyclerView, - itemKeyProvider, - itemDetailsLookupCreator(recyclerView), - StorageStrategy.createStringStorage() - ).withSelectionPredicate(SelectionPredicates.createSelectAnything()).build().apply { - addObserver(object : SelectionTracker.SelectionObserver<String>() { - override fun onSelectionChanged() { - this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke( - requireSelectionTracker().selection - ) - } - }) + fun <T : ItemDetailsLookup<String>> makeSelectable( + recyclerView: RecyclerView, + itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T + ) { + selectionTracker = + SelectionTracker.Builder( + "SearchableRepositoryAdapter", + recyclerView, + itemKeyProvider, + itemDetailsLookupCreator(recyclerView), + StorageStrategy.createStringStorage() + ) + .withSelectionPredicate(SelectionPredicates.createSelectAnything()) + .build() + .apply { + addObserver( + object : SelectionTracker.SelectionObserver<String>() { + override fun onSelectionChanged() { + this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke(requireSelectionTracker().selection) + } + } + ) } + } + + private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null + open fun onItemClicked(listener: (holder: T, item: PasswordItem) -> Unit): SearchableRepositoryAdapter<T> { + check(onItemClickedListener == null) { "Only a single listener can be registered for onItemClicked" } + onItemClickedListener = listener + return this + } + + private var onSelectionChangedListener: ((selection: Selection<String>) -> Unit)? = null + open fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): SearchableRepositoryAdapter<T> { + check(onSelectionChangedListener == null) { "Only a single listener can be registered for onSelectionChanged" } + onSelectionChangedListener = listener + return this + } + + private val itemKeyProvider = + object : ItemKeyProvider<String>(SCOPE_MAPPED) { + override fun getKey(position: Int) = getItem(position).stableId + + override fun getPosition(key: String) = + (0 until itemCount).firstOrNull { getItem(it).stableId == key } ?: RecyclerView.NO_POSITION } - private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null - open fun onItemClicked(listener: (holder: T, item: PasswordItem) -> Unit): SearchableRepositoryAdapter<T> { - check(onItemClickedListener == null) { "Only a single listener can be registered for onItemClicked" } - onItemClickedListener = listener - return this - } - - private var onSelectionChangedListener: ((selection: Selection<String>) -> Unit)? = null - open fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): SearchableRepositoryAdapter<T> { - check(onSelectionChangedListener == null) { "Only a single listener can be registered for onSelectionChanged" } - onSelectionChangedListener = listener - return this - } - - private val itemKeyProvider = object : ItemKeyProvider<String>(SCOPE_MAPPED) { - override fun getKey(position: Int) = getItem(position).stableId + private var selectionTracker: SelectionTracker<String>? = null + fun requireSelectionTracker() = selectionTracker!! - override fun getPosition(key: String) = - (0 until itemCount).firstOrNull { getItem(it).stableId == key } - ?: RecyclerView.NO_POSITION - } - - private var selectionTracker: SelectionTracker<String>? = null - fun requireSelectionTracker() = selectionTracker!! + private val selectedFiles + get() = requireSelectionTracker().selection.map { File(it) } - private val selectedFiles - get() = requireSelectionTracker().selection.map { File(it) } + fun getSelectedItems() = selectedFiles.map { it.toPasswordItem() } - fun getSelectedItems() = selectedFiles.map { it.toPasswordItem() } + fun getPositionForFile(file: File) = itemKeyProvider.getPosition(file.absolutePath) - 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 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 { + viewHolderBinder.invoke(this, 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 onBindViewHolder(holder: T, position: Int) { - val item = getItem(position) - holder.apply { - viewHolderBinder.invoke(this, 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().toUpperCase(Locale.getDefault()) - } + final override fun getPopupText(position: Int): String { + return getItem(position).name[0].toString().toUpperCase(Locale.getDefault()) + } } |
