aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main/java/dev/msfjarvis/aps/util
diff options
context:
space:
mode:
authorHarsh Shandilya <me@msfjarvis.dev>2021-03-09 14:53:11 +0530
committerHarsh Shandilya <me@msfjarvis.dev>2021-03-18 11:40:30 +0530
commit774fda83ac276a635e3402034b1eedbd10be916f (patch)
tree57783a953fcd193d32f1e3dc8df26e07f03df328 /app/src/main/java/dev/msfjarvis/aps/util
parentbe31ae37f443982b377d027cd613f04d0926568d (diff)
downloadHardenedPass-774fda83ac276a635e3402034b1eedbd10be916f.tar.gz
HardenedPass-774fda83ac276a635e3402034b1eedbd10be916f.zip
all: reformat with ktfmt
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Diffstat (limited to 'app/src/main/java/dev/msfjarvis/aps/util')
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt106
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt301
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt286
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt205
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt324
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt108
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt53
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt167
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt65
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt40
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt66
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt71
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt159
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt52
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt69
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt5
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt133
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt293
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt41
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt5
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt23
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt27
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt27
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt286
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt104
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt480
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt434
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt235
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt84
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt204
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt20
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt65
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt304
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt6
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt219
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt48
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt279
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt185
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt239
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt317
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt166
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt59
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt138
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt99
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt28
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt87
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt729
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())
+ }
}