Fill OTP fields with SMS codes (#900)

* Fill OTP fields with SMS codes

* Allow SMS OTP fill also for web origins

* Introduce free and nonFree build variants

* Fix up workflow

* Improve layout and feature detection

* Workflow changes

* Add Changelog entry

* github: update release workflow for nonFree/Free split

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

* Switch to lifecycleScope

* github: make snapshot deploy free variant

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
Fabian Henneke 2020-07-02 13:49:32 +02:00 committed by GitHub
parent c702d4aa9e
commit ca9c951a53
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 330 additions and 25 deletions

View file

@ -51,7 +51,7 @@ jobs:
run: ./gradlew dependencies
- name: Build release app
run: ./gradlew :app:assembleRelease
run: ./gradlew :app:assembleFreeRelease
env:
SNAPSHOT: "true"

View file

@ -7,7 +7,7 @@ jobs:
strategy:
matrix:
api-level: [23, 29]
variant: [Debug, Release]
variant: [freeDebug, freeRelease, nonFreeRelease]
steps:
- name: Check if relevant files have changed

View file

@ -50,20 +50,26 @@ jobs:
- name: Download gradle dependencies
run: ./gradlew dependencies
- name: Build release APK and bundle
run: ./gradlew :app:assembleRelease :app:bundleRelease
- name: Build release binaries
run: ./gradlew :app:assembleFreeRelease :app:assembleNonFreeRelease :app:bundleNonFreeRelease
- name: Upload release APK
- name: Upload non-free release APK
uses: actions/upload-artifact@master
with:
name: APS Release APK
path: app/build/outputs/apk/release/app-release.apk
name: APS Non-Free Release APK
path: app/build/outputs/apk/nonFree/release/app-release.apk
- name: Upload release Bundle
- name: Upload non-free release Bundle
uses: actions/upload-artifact@master
with:
name: APS Release Bundle
path: app/build/outputs/bundle/release/app-release.aab
name: APS Non-Free Release Bundle
path: app/build/outputs/bundle/nonFree/release/app-release.aab
- name: Upload free release APK
uses: actions/upload-artifact@master
with:
name: APS Free Release APK
path: app/build/outputs/apk/free/release/app-release.apk
- name: Clean secrets
if: always()
@ -77,17 +83,23 @@ jobs:
- name: Checkout
uses: actions/checkout@v1
- name: Get APK
- name: Get Non-Free Release APK
uses: actions/download-artifact@v1
with:
name: APS Release APK
path: artifacts
name: APS Non-Free Release APK
path: artifacts/nonFree
- name: Get Bundle
- name: Get Non-Free Bundle
uses: actions/download-artifact@v1
with:
name: APS Release Bundle
path: artifacts
name: APS Non-Free Release Bundle
path: artifacts/nonFree
- name: Get Free Release APK
uses: actions/download-artifact@v1
with:
name: APS Free Release APK
path: artifacts/free
- name: Get Changelog Entry
id: changelog_reader
@ -112,22 +124,32 @@ jobs:
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
- name: Upload Release Apk
- name: Upload Non-Free Release Apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./artifacts/app-release.apk
asset_name: APS_${{ steps.get_version.outputs.VERSION }}.apk
asset_path: ./artifacts/nonFree/app-release.apk
asset_name: APS-nonFree_${{ steps.get_version.outputs.VERSION }}.apk
asset_content_type: application/vnd.android.package-archive
- name: Upload Release Bundle
- name: Upload Non-Free Release Bundle
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./artifacts/app-release.aab
asset_name: APS_${{ steps.get_version.outputs.VERSION }}.aab
asset_path: ./artifacts/nonFree/app-release.aab
asset_name: APS-nonFree_${{ steps.get_version.outputs.VERSION }}.aab
asset_content_type: application/octet-stream
- name: Upload Free Release Apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./artifacts/free/app-release.apk
asset_name: APS-free_${{ steps.get_version.outputs.VERSION }}.apk
asset_content_type: application/vnd.android.package-archive

View file

@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file.
- TOTP support is reintroduced by popular demand. HOTP continues to be unsupported and heavily discouraged.
- Initial support for detecting and filling OTP fields with Autofill
- OTP codes can be automatically filled from SMS (requires Android P+ and Google Play Services)
- Importing TOTP secrets using QR codes
- Navigate into newly created folders and scroll to newly created passwords

View file

@ -68,6 +68,15 @@ android {
buildTypes.release.signingConfig = signingConfigs.release
buildTypes.debug.signingConfig = signingConfigs.release
}
flavorDimensions "free"
productFlavors {
free {
versionNameSuffix "-free"
}
nonFree {
}
}
}
dependencies {
@ -117,6 +126,8 @@ dependencies {
debugImplementation deps.third_party.whatthestack
}
nonFreeImplementation deps.non_free.google_play_auth_api_phone
// Testing-only dependencies
androidTestImplementation deps.testing.junit
androidTestImplementation deps.testing.kotlin_test_junit

View file

@ -0,0 +1,28 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo.ui
import android.content.Context
import android.content.IntentSender
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import com.zeapo.pwdstore.autofill.oreo.FormOrigin
@RequiresApi(Build.VERSION_CODES.O)
@Suppress("UNUSED_PARAMETER")
class AutofillSmsActivity : AppCompatActivity() {
companion object {
fun shouldOfferFillFromSms(context: Context): Boolean {
return false
}
fun makeFillOtpFromSmsIntentSender(context: Context): IntentSender {
throw NotImplementedError("Filling OTPs from SMS requires non-free dependencies")
}
}
}

View file

@ -138,6 +138,11 @@
<activity
android:name=".autofill.oreo.ui.AutofillSaveActivity"
android:theme="@style/NoBackgroundTheme" />
<activity
android:name=".autofill.oreo.ui.AutofillSmsActivity"
android:configChanges="orientation"
android:theme="@style/DialogLikeTheme"
android:windowSoftInputMode="adjustNothing" />
<activity
android:name=".autofill.oreo.ui.AutofillPublisherChangedActivity"
android:configChanges="orientation|keyboardHidden"

View file

@ -87,7 +87,7 @@ val AssistStructure.ViewNode.webOrigin: String?
"$scheme://$domain"
}
data class Credentials(val username: String?, val password: String, val otp: String?) {
data class Credentials(val username: String?, val password: String?, val otp: String?) {
companion object {
fun fromStoreEntry(
context: Context,
@ -141,6 +141,13 @@ fun makeGenerateAndFillRemoteView(context: Context, formOrigin: FormOrigin): Rem
return makeRemoteView(context, title, summary, iconRes)
}
fun makeFillOtpFromSmsRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews {
val title = formOrigin.getPrettyIdentifier(context, untrusted = true)
val summary = context.getString(R.string.oreo_autofill_fill_otp_from_sms)
val iconRes = R.drawable.ic_autofill_sms
return makeRemoteView(context, title, summary, iconRes)
}
fun makePlaceholderRemoteView(context: Context): RemoteViews {
return makeRemoteView(context, "PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher)
}

View file

@ -14,7 +14,7 @@ import androidx.annotation.RequiresApi
import com.github.ajalt.timberkt.e
enum class AutofillAction {
Match, Search, Generate
Match, Search, Generate, FillOtpFromSms
}
/**
@ -112,8 +112,13 @@ sealed class AutofillScenario<out T : Any> {
AutofillAction.Match -> passwordFieldsToFillOnMatch + listOfNotNull(otp)
AutofillAction.Search -> passwordFieldsToFillOnSearch + listOfNotNull(otp)
AutofillAction.Generate -> passwordFieldsToFillOnGenerate
AutofillAction.FillOtpFromSms -> listOfNotNull(otp)
}
return when {
action == AutofillAction.FillOtpFromSms -> {
// When filling from an SMS, we cannot get any data other than the OTP itself.
credentialFieldsToFill
}
credentialFieldsToFill.isNotEmpty() -> {
// If the current action would fill into any password field, we also fill into the
// username field if possible.

View file

@ -25,6 +25,7 @@ import com.zeapo.pwdstore.autofill.oreo.ui.AutofillDecryptActivity
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillFilterView
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillPublisherChangedActivity
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity
import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSmsActivity
import java.io.File
/**
@ -285,6 +286,14 @@ class FillableForm private constructor(
return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Generate)
}
private fun makeFillOtpFromSmsDataset(context: Context): Dataset? {
if (scenario.fieldsToFillOn(AutofillAction.FillOtpFromSms).isEmpty()) return null
if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
val remoteView = makeFillOtpFromSmsRemoteView(context, formOrigin)
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
return makePlaceholderDataset(remoteView, intentSender, AutofillAction.FillOtpFromSms)
}
private fun makePublisherChangedDataset(
context: Context,
publisherChangedException: AutofillPublisherChangedException
@ -341,6 +350,10 @@ class FillableForm private constructor(
hasDataset = true
addDataset(it)
}
makeFillOtpFromSmsDataset(context)?.let {
hasDataset = true
addDataset(it)
}
if (!hasDataset) return null
makeSaveInfo()?.let { setSaveInfo(it) }
setClientState(clientState)

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM9,11L7,11L7,9h2v2zM13,11h-2L11,9h2v2zM17,11h-2L15,9h2v2z" />
</vector>

View file

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
~ SPDX-License-Identifier: GPL-3.0-only
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="10dp"
tools:context="com.zeapo.pwdstore.autofill.oreo.ui.AutofillFilterView">
<ImageView
android:id="@+id/cover"
android:layout_width="0dp"
android:layout_height="50dp"
android:contentDescription="@string/app_name"
android:src="@drawable/ic_launcher_foreground"
app:layout_constraintBottom_toTopOf="@id/text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_margin="10dp"
app:layout_constraintVertical_bias="0.0" />
<TextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/oreo_autofill_waiting_for_sms"
android:layout_margin="10dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/cover" />
<ProgressBar
android:id="@+id/progress"
style="@style/Widget.MaterialComponents.ProgressIndicator.Circular.Indeterminate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="10dp"
app:layout_constraintBottom_toTopOf="@id/cancelButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text" />
<Button
android:id="@+id/cancelButton"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_margin="10dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dialog_cancel"
android:textColor="?attr/colorSecondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progress" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -253,6 +253,8 @@
<string name="oreo_autofill_save_app_not_supported">This app is currently not supported</string>
<string name="oreo_autofill_save_passwords_dont_match">Passwords don\'t match</string>
<string name="oreo_autofill_generate_password">Generate password…</string>
<string name="oreo_autofill_fill_otp_from_sms">Extract code from SMS…</string>
<string name="oreo_autofill_waiting_for_sms">Waiting for SMS…</string>
<string name="oreo_autofill_max_matches_reached">Maximum number of matches (%1$d) reached; clear matches before adding new ones.</string>
<string name="oreo_autofill_warning_publisher_header">This app\'s publisher has changed since you first associated a Password Store entry with it:</string>
<string name="oreo_autofill_warning_publisher_footer"><b>The currently installed app may be trying to steal your credentials by pretending to be a trusted app.</b>\n\nTry to uninstall and reinstall the app from a trusted source, such as the Play Store, Amazon Appstore, F-Droid, or your phone manufacturer\'s store.</string>

View file

@ -0,0 +1,136 @@
/*
* Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved.
* SPDX-License-Identifier: GPL-3.0-only
*/
package com.zeapo.pwdstore.autofill.oreo.ui
import android.app.Activity
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.IntentSender
import android.os.Build
import android.os.Bundle
import android.view.autofill.AutofillManager
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.github.ajalt.timberkt.e
import com.github.ajalt.timberkt.w
import com.google.android.gms.auth.api.phone.SmsCodeRetriever
import com.google.android.gms.auth.api.phone.SmsRetriever
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.api.ResolvableApiException
import com.google.android.gms.tasks.Tasks
import com.zeapo.pwdstore.autofill.oreo.AutofillAction
import com.zeapo.pwdstore.autofill.oreo.Credentials
import com.zeapo.pwdstore.autofill.oreo.FillableForm
import com.zeapo.pwdstore.databinding.ActivityOreoAutofillSmsBinding
import com.zeapo.pwdstore.utils.viewBinding
import kotlinx.coroutines.launch
@RequiresApi(Build.VERSION_CODES.O)
class AutofillSmsActivity : AppCompatActivity() {
companion object {
private var fillOtpFromSmsRequestCode = 1
fun shouldOfferFillFromSms(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P)
return false
val googleApiAvailabilityInstance = GoogleApiAvailability.getInstance()
val googleApiStatus = googleApiAvailabilityInstance.isGooglePlayServicesAvailable(context)
if (googleApiStatus != ConnectionResult.SUCCESS) {
w { "Google Play Services unavailable or not updated: ${googleApiAvailabilityInstance.getErrorString(googleApiStatus)}" }
return false
}
// https://developer.android.com/guide/topics/text/autofill-services#sms-autofill
if (googleApiAvailabilityInstance.getApkVersion(context) < 190056000) {
w { "Google Play Service 19.0.56 or higher required for SMS OTP Autofill" }
return false
}
return true
}
fun makeFillOtpFromSmsIntentSender(context: Context): IntentSender {
val intent = Intent(context, AutofillSmsActivity::class.java)
return PendingIntent.getActivity(
context,
fillOtpFromSmsRequestCode++,
intent,
PendingIntent.FLAG_CANCEL_CURRENT
).intentSender
}
}
private val binding by viewBinding(ActivityOreoAutofillSmsBinding::inflate)
private lateinit var clientState: Bundle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setResult(RESULT_CANCELED)
binding.cancelButton.setOnClickListener {
finish()
}
}
override fun onStart() {
super.onStart()
clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run {
e { "AutofillSmsActivity started without EXTRA_CLIENT_STATE" }
finish()
return
}
registerReceiver(smsCodeRetrievedReceiver, IntentFilter(SmsCodeRetriever.SMS_CODE_RETRIEVED_ACTION), SmsRetriever.SEND_PERMISSION, null)
lifecycleScope.launch {
waitForSms()
}
}
// Retry starting the SMS code retriever after a permission request.
@Suppress("DEPRECATION")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode != Activity.RESULT_OK)
return
lifecycleScope.launch {
waitForSms()
}
}
private fun waitForSms() {
val smsClient = SmsCodeRetriever.getAutofillClient(this@AutofillSmsActivity)
try {
Tasks.await(smsClient.startSmsCodeRetriever())
} catch (e: ResolvableApiException) {
e.startResolutionForResult(this, 1)
} catch (e: Exception) {
e(e)
finish()
}
}
private val smsCodeRetrievedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val smsCode = intent.getStringExtra(SmsCodeRetriever.EXTRA_SMS_CODE)
val fillInDataset =
FillableForm.makeFillInDataset(
this@AutofillSmsActivity,
Credentials(null, null, smsCode),
clientState,
AutofillAction.FillOtpFromSms
)
setResult(RESULT_OK, Intent().apply {
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
})
finish()
}
}
}

View file

@ -64,6 +64,10 @@ ext.deps = [
whatthestack: 'com.github.haroldadmin:WhatTheStack:0.0.3',
],
non_free: [
google_play_auth_api_phone: 'com.google.android.gms:play-services-auth-api-phone:17.4.0',
],
testing: [
junit: 'junit:junit:4.13',
kotlin_test_junit: 'org.jetbrains.kotlin:kotlin-test-junit:1.3.72',

View file

@ -5,7 +5,7 @@ mkdir -p "$SSHDIR"
echo "$ACTIONS_DEPLOY_KEY" > "$SSHDIR/key"
chmod 600 "$SSHDIR/key"
export SERVER_DEPLOY_STRING="$SSH_USERNAME@$SERVER_ADDRESS:$SERVER_DESTINATION"
cd "$GITHUB_WORKSPACE/app/build/outputs/apk/release"
cd "$GITHUB_WORKSPACE/app/build/outputs/apk/free/release"
rm output.json
rsync -ahvcr --omit-dir-times --progress --delete --no-o --no-g -e "ssh -i $SSHDIR/key -o StrictHostKeyChecking=no -p $SSH_PORT" . "$SERVER_DEPLOY_STRING" || exit 1
exit 0