diff --git a/.github/workflows/deploy_snapshot.yml b/.github/workflows/deploy_snapshot.yml
index e34e41bf5..5b3b1cc45 100644
--- a/.github/workflows/deploy_snapshot.yml
+++ b/.github/workflows/deploy_snapshot.yml
@@ -51,7 +51,7 @@ jobs:
run: ./gradlew dependencies
- name: Build release app
- run: ./gradlew :app:assembleRelease
+ run: ./gradlew :app:assembleFreeRelease
env:
SNAPSHOT: "true"
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index ccd77e8bf..d4326bb0a 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6b652347e..5f03c5882 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2bc258b61..06b60282d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/app/build.gradle b/app/build.gradle
index 4489c0baa..399402cba 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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
diff --git a/app/src/free/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSmsActivity.kt b/app/src/free/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSmsActivity.kt
new file mode 100644
index 000000000..f86e5d4cc
--- /dev/null
+++ b/app/src/free/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSmsActivity.kt
@@ -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")
+ }
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2098abc98..40bcb4819 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -138,6 +138,11 @@
+
{
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.
diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt
index 210fefaba..e4ae1f754 100644
--- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt
+++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Form.kt
@@ -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)
diff --git a/app/src/main/res/drawable/ic_autofill_sms.xml b/app/src/main/res/drawable/ic_autofill_sms.xml
new file mode 100644
index 000000000..e58c33c40
--- /dev/null
+++ b/app/src/main/res/drawable/ic_autofill_sms.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_oreo_autofill_sms.xml b/app/src/main/res/layout/activity_oreo_autofill_sms.xml
new file mode 100644
index 000000000..608727d05
--- /dev/null
+++ b/app/src/main/res/layout/activity_oreo_autofill_sms.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3023d9957..6d06a7a4a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -253,6 +253,8 @@
This app is currently not supported
Passwords don\'t match
Generate password…
+ Extract code from SMS…
+ Waiting for SMS…
Maximum number of matches (%1$d) reached; clear matches before adding new ones.
This app\'s publisher has changed since you first associated a Password Store entry with it:
The currently installed app may be trying to steal your credentials by pretending to be a trusted app.\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.
diff --git a/app/src/nonFree/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSmsActivity.kt b/app/src/nonFree/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSmsActivity.kt
new file mode 100644
index 000000000..02394867e
--- /dev/null
+++ b/app/src/nonFree/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSmsActivity.kt
@@ -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()
+ }
+ }
+}
diff --git a/dependencies.gradle b/dependencies.gradle
index f5c7c965e..d05e0e6da 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -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',
diff --git a/release/deploy-snapshot.sh b/release/deploy-snapshot.sh
index 9139614bc..3687ea468 100755
--- a/release/deploy-snapshot.sh
+++ b/release/deploy-snapshot.sh
@@ -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