diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 701d5de7..5e0d7c3a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,7 +11,7 @@ android:supportsRtl="true" android:theme="@style/Theme.LibrePass"> @@ -21,5 +21,15 @@ + + + + + + diff --git a/app/src/main/java/dev/medzik/librepass/android/activity/AutofillLauncherActivity.kt b/app/src/main/java/dev/medzik/librepass/android/activity/AutofillLauncherActivity.kt new file mode 100644 index 00000000..ad3f98bb --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/activity/AutofillLauncherActivity.kt @@ -0,0 +1,34 @@ +package dev.medzik.librepass.android.activity + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity + +@RequiresApi(api = Build.VERSION_CODES.O) +class AutofillLauncherActivity : AppCompatActivity() { + companion object { + private val TAG = AutofillLauncherActivity::class.java.name + + fun getPendingIntent(context: Context): PendingIntent? { + try { + return PendingIntent.getActivity( + context, + 0, + Intent(context, AutofillLauncherActivity::class.java), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT + } else { + PendingIntent.FLAG_CANCEL_CURRENT + } + ) + } catch (e: RuntimeException) { + Log.e(TAG, "Unable to create pending intent for selection", e) + return null + } + } + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/MainActivity.kt b/app/src/main/java/dev/medzik/librepass/android/activity/MainActivity.kt similarity index 96% rename from app/src/main/java/dev/medzik/librepass/android/MainActivity.kt rename to app/src/main/java/dev/medzik/librepass/android/activity/MainActivity.kt index 17ed814c..13257980 100644 --- a/app/src/main/java/dev/medzik/librepass/android/MainActivity.kt +++ b/app/src/main/java/dev/medzik/librepass/android/activity/MainActivity.kt @@ -1,4 +1,4 @@ -package dev.medzik.librepass.android +package dev.medzik.librepass.android.activity import android.os.Bundle import android.util.Log @@ -9,6 +9,7 @@ import androidx.fragment.app.FragmentActivity import androidx.navigation.NavController import dagger.hilt.android.AndroidEntryPoint import dev.medzik.android.components.navigate +import dev.medzik.librepass.android.Migrations import dev.medzik.librepass.android.data.Repository import dev.medzik.librepass.android.ui.LibrePassNavigation import dev.medzik.librepass.android.ui.Screen diff --git a/app/src/main/java/dev/medzik/librepass/android/autofill/AutofillHandler.kt b/app/src/main/java/dev/medzik/librepass/android/autofill/AutofillHandler.kt new file mode 100644 index 00000000..9eb4e9df --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/autofill/AutofillHandler.kt @@ -0,0 +1,110 @@ +package dev.medzik.librepass.android.autofill + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.os.CancellationSignal +import android.service.autofill.FillCallback +import android.service.autofill.FillRequest +import android.util.Log +import androidx.annotation.RequiresApi +import dev.medzik.librepass.android.autofill.Utils.getWindowNodes +import dev.medzik.librepass.android.utils.Vault + +@RequiresApi(Build.VERSION_CODES.O) +object AutofillHandler { + private val TAG = AutofillHandler::class.java.name + + fun handleAutofill( + vault: Vault, + context: Context, + request: FillRequest, + cancellationSignal: CancellationSignal, + callback: FillCallback + ) { + cancellationSignal.setOnCancelListener { + Log.w(TAG, "Cancelling autofill") + } + + val windowNode = getWindowNodes(request.fillContexts).lastOrNull() + if (windowNode?.rootViewNode == null) { + Log.i(TAG, "No window node found") + return callback.onSuccess(null) + } + + val structure = request.fillContexts.last().structure + StructureParser(structure).parse()?.let { + // TODO: maybe add blocklist support + + launchSelection(it, vault, context, callback) + } + +// val exceptionHandler = +// CoroutineExceptionHandler { _, exception -> +// Log.e(TAG, exception.toString()) +// callback.onSuccess(null) +// } +// val job = +// CoroutineScope(Dispatchers.IO) +// .launch(exceptionHandler) { +// // TODO: searchAndFill +// } +// +// cancellationSignal.setOnCancelListener { +// job.cancel() +// } + } + + @SuppressLint("RemoteViewLayout") + private fun launchSelection( + parsedStructure: StructureParser.AutofillResult, + vault: Vault, + context: Context, + callback: FillCallback + ) { +// val responseBuilder = FillResponse.Builder() + +// AutofillLauncherActivity.getPendingIntent(context)?.intentSender?.let { intentSender -> +// val remoteViewUnlock = RemoteViews( +// context.packageName, +// R.layout.autofill_unlock +// ) +// +// remoteViewUnlock.setTextViewText(R.id.text1, "Test") +// +// responseBuilder.setAuthentication( +// parsedStructure.getAutofillIDs(), +// intentSender, +// remoteViewUnlock +// ) +// } +// +// callback.onSuccess(responseBuilder.build()) + +// val usernamePresentation = RemoteViews(packageName, R.layout.autofill_unlock) +// usernamePresentation.setTextViewText(R.id.text1, "my_username") +// +// val fillResponse = FillResponse.Builder() +// .addDataset( +// Dataset.Builder() +// .setValue( +// parsedStructure.usernameId!!, +// AutofillValue.forText("test"), +// usernamePresentation +// ) +// .build()) +// .build() +// +// callback.onSuccess(fillResponse) + +// if (!vault.openedDatabase) { +// +// } +// +// parserResult.getAutofillIDs().let { autofillId -> +// if (autofillId.isNotEmpty()) { +// +// } +// } + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/autofill/AutofillService.kt b/app/src/main/java/dev/medzik/librepass/android/autofill/AutofillService.kt new file mode 100644 index 00000000..b4d47aa3 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/autofill/AutofillService.kt @@ -0,0 +1,40 @@ +package dev.medzik.librepass.android.autofill + +import android.os.Build +import android.os.CancellationSignal +import android.service.autofill.AutofillService +import android.service.autofill.FillCallback +import android.service.autofill.FillRequest +import android.service.autofill.SaveCallback +import android.service.autofill.SaveRequest +import androidx.annotation.RequiresApi +import dev.medzik.librepass.android.utils.Vault +import javax.inject.Inject + +@RequiresApi(Build.VERSION_CODES.O) +class LibrePassAutofillService : AutofillService() { + // TODO: does not init, why? + @Inject + lateinit var vault: Vault + + override fun onFillRequest( + request: FillRequest, + cancellationSignal: CancellationSignal, + callback: FillCallback + ) { + AutofillHandler.handleAutofill( + vault, + context = this, + request, + cancellationSignal, + callback + ) + } + + override fun onSaveRequest( + request: SaveRequest, + callback: SaveCallback + ) { + // TODO: implement + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/autofill/StructureParser.kt b/app/src/main/java/dev/medzik/librepass/android/autofill/StructureParser.kt new file mode 100644 index 00000000..3aa353a1 --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/autofill/StructureParser.kt @@ -0,0 +1,206 @@ +package dev.medzik.librepass.android.autofill + +import android.app.assist.AssistStructure +import android.os.Build +import android.util.Log +import android.view.View +import android.view.autofill.AutofillId +import android.view.autofill.AutofillValue +import androidx.annotation.RequiresApi +import java.util.Locale + +@RequiresApi(Build.VERSION_CODES.O) +class StructureParser(private val structure: AssistStructure) { + companion object { + private val TAG = StructureParser::class.java.name + } + + private var result = AutofillResult() + + fun parse(): AutofillResult? { + result = AutofillResult() + + mainLoop@ for (i in 0 until structure.windowNodeCount) { + val windowNode = structure.getWindowNodeAt(i) + val applicationId = windowNode.title.toString().split("/")[0] + Log.d(TAG, "Autofill applicationId: $applicationId") + + if (parseViewNode(windowNode.rootViewNode)) + break@mainLoop + } + + return if (result.usernameId != null) + result + else + null + } + + private fun parseViewNode(node: AssistStructure.ViewNode): Boolean { + var webDomain: String? = null +// var webScheme: String? = null + + var returnValue = false + + node.webDomain?.let { + webDomain = it + Log.d(TAG, "Autofill domain: $webDomain") + } +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { +// node.webDomain?.let { +// webScheme = it +// Log.d(TAG, "Autofill scheme: $webScheme") +// } +// } + + if (node.visibility == View.VISIBLE) { + if (node.autofillId != null) { + val hints = node.autofillId + if (hints != null) { + if (parseNodeByAutofillHint(node)) + returnValue = true + } else if (parseNodeByHtml(node)) { + returnValue = true + } + + // TODO: else if (parseNodeByAndroidInput) + } + + if (webDomain?.isNotEmpty() == true && returnValue) + return true + + // process each node + for (i in 0 until node.childCount) { + if (parseViewNode(node.getChildAt(i))) + returnValue = true + if (webDomain?.isNotEmpty() == true && returnValue) + return true + } + } + + return returnValue + } + + private fun parseNodeByAutofillHint(node: AssistStructure.ViewNode): Boolean { + node.autofillHints?.forEach { + when { + it.contains(View.AUTOFILL_HINT_USERNAME, true) || + it.contains(View.AUTOFILL_HINT_EMAIL_ADDRESS, true) || + it.contains("email", true) || + it.contains("e-mail", true) || + it.contains(View.AUTOFILL_HINT_PHONE, true) -> { + result.usernameId = node.autofillId + result.usernameValue = node.autofillValue + } + + it.contains(View.AUTOFILL_HINT_PASSWORD, true) -> { + result.passwordId = node.autofillId + result.passwordValue = node.autofillValue + + return true + } + + // ignore autocomplete="off" + // https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion + it.equals("off", true) -> { + return parseNodeByHtml(node) + } + + else -> Log.d(TAG, "Unsupported autofill hint: $it") + } + } + + return false + } + + private fun parseNodeByHtml(node: AssistStructure.ViewNode): Boolean { + when (node.htmlInfo?.tag?.lowercase(Locale.ENGLISH)) { + "input" -> { + node.htmlInfo?.attributes?.forEach { attributePair -> + when (attributePair.first.lowercase(Locale.ENGLISH)) { + "tel", "email" -> { + result.usernameId = node.autofillId + result.usernameValue = node.autofillValue + } + + // sometimes the email address field is not marked correctly, + // guess it is before password field + "text" -> { + if (result.usernameId == null && result.passwordId == null) { + result.usernameId = node.autofillId + result.usernameValue = node.autofillValue + } + } + + "password" -> { + result.passwordId = node.autofillId + result.passwordValue = node.autofillValue + + return true + } + } + } + } + } + + return false + } + +// private fun parseByAndroidInput(node: AssistStructure.ViewNode): Boolean { +// var usernameIdCandidate: AutofillId? = null +// var usernameValueCandidate: AutofillValue? = null +// +// when (node.inputType and InputType.TYPE_MASK_CLASS) { +// InputType.TYPE_CLASS_TEXT -> { +// when { +// androidInputIsVariationType( +// node.inputType, +// InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS, +// InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS +// ) -> { +// usernameIdCandidate = node.autofillId +// usernameValueCandidate = node.autofillValue +// } +// +// androidInputIsVariationType( +// node.inputType, +// InputType.TYPE_TEXT_VARIATION_NORMAL, +// InputType.TYPE_TEXT_VARIATION_PERSON_NAME, +// InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT +// ) -> { +// usernameIdCandidate = node.autofillId +// usernameValueCandidate = node.autofillValue +// } +// } +// } +// } +// } +// +// private fun androidInputIsVariationType( +// inputType: Int, +// vararg type: Int +// ): Boolean { +// type.forEach { +// if (inputType and InputType.TYPE_MASK_VARIATION == it) { +// return true +// } +// } +// +// return false +// } + + data class AutofillResult( + var usernameId: AutofillId? = null, + var usernameValue: AutofillValue? = null, + var passwordId: AutofillId? = null, + var passwordValue: AutofillValue? = null + ) { + fun getAutofillIDs(): Array { + val all = ArrayList() + + usernameId?.let { all.add(it) } + passwordId?.let { all.add(it) } + + return all.toTypedArray() + } + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/autofill/Utils.kt b/app/src/main/java/dev/medzik/librepass/android/autofill/Utils.kt new file mode 100644 index 00000000..e9ed1b2f --- /dev/null +++ b/app/src/main/java/dev/medzik/librepass/android/autofill/Utils.kt @@ -0,0 +1,24 @@ +package dev.medzik.librepass.android.autofill + +import android.app.assist.AssistStructure +import android.os.Build +import android.service.autofill.FillContext +import androidx.annotation.RequiresApi + +@RequiresApi(Build.VERSION_CODES.O) +object Utils { + fun getWindowNodes(fillContexts: List): List { + val fillContext = + fillContexts + .lastOrNull { !it.structure.activityComponent.className.contains("PopupWindow") } + ?: return emptyList() + + val structure = fillContext.structure + + return if (structure.windowNodeCount > 0) { + (0 until structure.windowNodeCount).map { structure.getWindowNodeAt(it) } + } else { + emptyList() + } + } +} diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/Navigation.kt b/app/src/main/java/dev/medzik/librepass/android/ui/Navigation.kt index b40bc6bd..4df6f0a0 100644 --- a/app/src/main/java/dev/medzik/librepass/android/ui/Navigation.kt +++ b/app/src/main/java/dev/medzik/librepass/android/ui/Navigation.kt @@ -38,8 +38,8 @@ import dev.medzik.android.components.NavScreen import dev.medzik.android.components.navigate import dev.medzik.android.components.rememberDialogState import dev.medzik.android.components.rememberMutableBoolean -import dev.medzik.librepass.android.MainActivity import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.activity.MainActivity import dev.medzik.librepass.android.ui.components.CipherTypeDialog import dev.medzik.librepass.android.ui.components.TopBar import dev.medzik.librepass.android.ui.components.TopBarBackIcon diff --git a/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/SettingsSecurity.kt b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/SettingsSecurity.kt index 471ea518..39fdf3d0 100644 --- a/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/SettingsSecurity.kt +++ b/app/src/main/java/dev/medzik/librepass/android/ui/screens/settings/SettingsSecurity.kt @@ -25,8 +25,8 @@ import dev.medzik.android.components.PropertyPreference import dev.medzik.android.components.SwitcherPreference import dev.medzik.android.components.rememberDialogState import dev.medzik.android.crypto.KeyStore -import dev.medzik.librepass.android.MainActivity import dev.medzik.librepass.android.R +import dev.medzik.librepass.android.activity.MainActivity import dev.medzik.librepass.android.ui.LibrePassViewModel import dev.medzik.librepass.android.utils.KeyAlias import dev.medzik.librepass.android.utils.SecretStore.readKey diff --git a/app/src/main/java/dev/medzik/librepass/android/utils/Vault.kt b/app/src/main/java/dev/medzik/librepass/android/utils/Vault.kt index c5ee15cc..7fcbb10a 100644 --- a/app/src/main/java/dev/medzik/librepass/android/utils/Vault.kt +++ b/app/src/main/java/dev/medzik/librepass/android/utils/Vault.kt @@ -136,7 +136,7 @@ class Vault( aesKey = byteArrayOf() runOnIOThread { - context.dataStore.deleteEncrypted(AES_KEY_STORE_KEY) + context.dataStore.deleteEncrypted(SecretStore.AES_KEY_STORE_KEY) } } }