diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b22aa60..3344d5f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -67,4 +67,43 @@ jobs: if: always() with: name: reports - path: guardian/build/reports/reports.html \ No newline at end of file + path: guardian/build/reports/reports.html + + sample: + name: Build sample app + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # pin@v7.0.1 + + - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # pin@v6.1.0 + with: + distribution: temurin + java-version-file: '.java-version' + + - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # pin@v6.4.0 + + - uses: ruby/setup-ruby@7f797815d8adceb1c412e7abefdd1ab4abc47a5f # pin@v1.335.0 + with: + bundler: 'Gemfile.lock' + + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # pin@v6.1.0 + with: + path: ~/vendor/bundle + key: bundle-v1-${{ hashFiles('fastlane/Gemfile.lock') }} + restore-keys: bundle-v1- + + - name: Install Ruby dependencies + run: bundle check || bundle install --jobs 4 --retry 3 + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/fastlane/Gemfile + BUNDLE_PATH: ~/vendor/bundle + + # Runs in parallel with the test job and reports its own result, so a lint or + # coverage failure can't hide a broken sample (and vice versa). Fails the PR if + # the sample stops compiling against the SDK sources under review. + - name: Build sample app + run: bundle exec fastlane android build_sample + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/fastlane/Gemfile + BUNDLE_PATH: ~/vendor/bundle diff --git a/README.md b/README.md index c8f6cea..1f11ba4 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,12 @@ Auth0 helps you to: ## Development +### Sample app + +The `app` module is a sample Guardian authenticator built against the local SDK. It is +compiled on every PR to `master` (the `sample` job in `pr.yml`). See [app/README.md](app/README.md) for how +to point it at your tenant and Firebase project. + ### CI / Toolchain maintenance Runtime versions are pinned in `.java-version` and `.ruby-version`. The diff --git a/app/.gitignore b/app/.gitignore index 796b96d..1498f92 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1,3 @@ /build +# Real Firebase config for a test project; overrides the placeholder app/google-services.json +/src/debug/google-services.json diff --git a/app/README.md b/app/README.md index bababba..88c2712 100644 --- a/app/README.md +++ b/app/README.md @@ -1,10 +1,138 @@ # GuardianSDK sample app -## Running +A minimal Guardian authenticator built against the local `:guardian` module. It enrolls the device +by scanning a Guardian QR code, receives push notifications through FCM, and lets you allow or reject +login requests. It is also the host app for the SDK's integration tests. -Before running the sample app please configure your Firebase app and Guardian URL properly. +The sample is compiled on every PR to `master` and in the merge queue (the `sample` job in +`pr.yml`), so it can't silently break. It is never +published. -You should place the `google-services.json` file (required for FCM push notifications) inside your Android app module root. Typically at` at `MyApplication/app/google-services.json`. +## Prerequisites -The Guardian URL can be changed at `src/main/res/values/guardian.xml`. It should look like -`https://.guardian.auth0.com`. \ No newline at end of file +- JDK 17 or newer (see `.java-version`) +- Android SDK platforms 36 (the app) and 33 (the `:guardian` module). Gradle downloads them if you + accept the licences, otherwise install them with the SDK Manager. Point Gradle at the SDK with + `ANDROID_HOME` or a `local.properties` file (`sdk.dir=…`). +- A device or emulator running Android 7.0 (API 24) or newer **with Google Play services**. On an + emulator pick a "Google APIs" or "Google Play" system image, because FCM doesn't work without it. + +## Configuration + +### Guardian tenant + +The tenant URL is compiled into `BuildConfig.TENANT_URL`. It defaults to +`https://guardian-demo.us.auth0.com`. Override it with any of these, listed by precedence: + +1. A Gradle property on the command line: + `./gradlew :app:installDebug -PguardianTenantUrl=https://` +2. The same property in `~/.gradle/gradle.properties`, so it stays out of the repo: + `guardianTenantUrl=https://` +3. The `GUARDIAN_TENANT_URL` environment variable, which is handy in CI. Android Studio started from + the macOS Dock doesn't inherit shell variables; use the Gradle property there. + +Any other way of setting the Gradle project property (for example the +`ORG_GRADLE_PROJECT_guardianTenantUrl` environment variable) also works and beats +`GUARDIAN_TENANT_URL`, which is only the fallback. + +The value must be an `https://` URL. Because it is compiled in, **rebuild and reinstall after +changing it**. It must be the domain the enrollment link is issued from (your tenant's canonical or +custom domain), otherwise enrollment fails. + +The tenant must have the **Push Notification using Auth0 Guardian** MFA factor enabled, with push +credentials for the Firebase project described below. In the Auth0 Dashboard open +**Security → Multi-factor Auth → Push Notification using Auth0 Guardian** and, under **Custom App**, +configure the Android push provider: either direct FCM, which needs a service-account key JSON +from the Firebase project (**Project settings → Service accounts**), or Amazon SNS. Labels may differ +slightly; see Auth0's "Configure Push Notifications for MFA" documentation. + +### Firebase (`google-services.json`) + +The committed `app/google-services.json` is a **placeholder with dummy data**. It stays in the repo +on purpose, so a clean clone and CI can build the app. Never commit a real config over it. + +With the placeholder the app builds, installs and launches, but it can't obtain an FCM token. It +typically shows "Could not obtain FCM token" and closes when you dismiss the dialog, so the +**Enroll** button on the main screen is never reachable. Enrollment without a Firebase project is +only possible through the test hook below. + +To receive real push notifications: + +1. In the [Firebase console](https://console.firebase.google.com), create a test project, or use an + existing one, and add an Android app with package name **`com.auth0.guardian.sample`** exactly + (the build fails if the file has no client for that package). +2. Download its `google-services.json` and save it as **`app/src/debug/google-services.json`**. + The Google Services plugin prefers this file over the placeholder for debug builds, and the path is + git-ignored so the real config is never committed. Release builds still use the placeholder. +3. Give the tenant this project's FCM credentials (see above). + +## Build and run + +```sh +./gradlew :app:assembleDebug # build only +./gradlew :app:installDebug -PguardianTenantUrl=https:// +``` + +or open the project in Android Studio and run the `app` configuration. + +### Manual test flow + +1. **Set up a tenant user that must enroll in push MFA.** Create a test user, then in + **Security → Multi-factor Auth** enable Push and set the policy to require MFA ("Always"). +2. Launch the app. On Android 13 and newer, grant the notification permission. The FCM token is shown + while no account is enrolled. +3. Log in as the test user (for example with the database connection's **Try** button in the + Dashboard). Universal Login shows an enrollment QR code. Alternatively create an enrollment + ticket (`POST /api/v2/guardian/enrollments/ticket`, or "Send enrollment invitation" on the user's + page) and open the ticket URL. +4. In the app tap **Enroll**, grant the camera permission, and scan the QR code. If scanning isn't + practical, tap **Enter enrollment link**, paste the content of the QR code (an `otpauth://…` + link) and tap **Enroll** in the dialog. The button is also on the camera permission screen, so + you can enroll after denying the camera. On a device without a camera the dialog opens + automatically; emulators usually have a virtual camera, so tap the button there. Pasted links + take the same path as scanned codes. +5. Log in again as the same user to trigger an MFA push. + - With the app open (visible), the authentication request opens directly. + - With the app in the background or closed, a notification is shown: tap it. This needs the + notification permission; without it background pushes are dropped. +6. Tap **Allow** or **Deny**, then check the login result in the browser. +7. Tap **Unenroll** to delete the enrollment. + +## Integration test hooks + +- **Tenant URL**: also passed to instrumentation as the runner argument `tenantUrl` + (`InstrumentationRegistry.getArguments().getString("tenantUrl")`), and available as + `BuildConfig.TENANT_URL`. The module has no `androidTest` setup yet; the integration tests will + need to add `testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"` and the + `androidx.test` dependencies. Gradle only injects the argument for `connectedAndroidTest`; with a + raw `adb shell am instrument` pass `-e tenantUrl `. +- **Skip QR scanning**: start `EnrollActivity` with the extra + `com.auth0.guardian.sample.EnrollActivity.ENROLLMENT_DATA` set to the enrollment URI, which is the + content of the QR code. Enrollment then starts immediately without the camera, the successful + enrollment is saved (so the next `MainActivity` launch is enrolled and handles pushes), and the + activity finishes with `RESULT_OK` and the enrollment as a result extra. On failure it shows the + error and finishes with `RESULT_CANCELED`. The device name and FCM token extras are still + required. Use `EnrollActivity.getStartIntent(context, deviceName, fcmToken, enrollmentData)` from + tests in the same package (the extra keys other than `ENROLLMENT_DATA` are private). For manual + testing use **Enter enrollment link** instead. `EnrollActivity` is **not exported**, so + `adb shell am start` can't launch it as a normal user; this hook is for instrumentation only. +- **Views**: each control has an ID, and the interactive ones also a content description. Prefer the + IDs as the locator; the descriptions are for tests that locate views by accessibility label. Each + description contains the visible label of its button (for example "Deny authentication request" + for **Deny**), so they also work with Voice Access. + + | Screen | Control | ID | Content description | + | --- | --- | --- | --- | + | Main | Enroll button | `enrollButton` | Enroll | + | Main | Unenroll button | `unenrollButton` | Unenroll | + | Main | FCM token | `fcmTokenText` | (none, uses its text) | + | Main | TOTP code | `otpView` | One-time code | + | Enroll | QR scanner | `scanner` | Guardian QR code scanner | + | Enroll | Scan hint (also the no-camera message) | `scanHintText` | (none, uses its text) | + | Enroll | Enter enrollment link button | `enterLinkButton` | Enter enrollment link | + | Enroll | Same button on the camera permission screen | `enterLinkPermissionButton` | Enter enrollment link | + | Enroll | Enrollment link input (dialog) | `enrollmentLinkInput` | Enrollment link | + | Enroll | Camera permission explanation | `permissionMessageText` | (none, uses its text) | + | Enroll | Request camera permission | `requestPermissionButton` | Request permission to use the camera | + | Notification | Allow button (label "Allow") | `allowButton` | Allow authentication request | + | Notification | Reject button (label "Deny") | `rejectButton` | Deny authentication request | diff --git a/app/build.gradle b/app/build.gradle index 6f2d0e4..856c721 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,15 +1,36 @@ apply plugin: 'com.android.application' +apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'com.google.gms.google-services' +// Guardian tenant the sample talks to. Override per machine or per CI run with +// -PguardianTenantUrl=https://..auth0.com, an entry in +// ~/.gradle/gradle.properties, or the GUARDIAN_TENANT_URL environment variable. +def guardianTenantUrl = project.findProperty('guardianTenantUrl') ?: + System.getenv('GUARDIAN_TENANT_URL') ?: + 'https://guardian-demo.us.auth0.com' +// It is compiled into a Java string literal, so reject anything that would break it. +if (!(guardianTenantUrl ==~ /^https:\/\/[^"\\\s]+$/)) { + throw new GradleException("guardianTenantUrl must be an https URL, got: '${guardianTenantUrl}'") +} + android { - compileSdkVersion 33 + compileSdk 36 + + buildFeatures { + // Explicit: gradle.properties' android.defaults.buildfeatures.buildconfig is deprecated + buildConfig true + } defaultConfig { applicationId "com.auth0.guardian.sample" - minSdkVersion 22 - targetSdkVersion 33 + minSdkVersion 24 + targetSdkVersion 36 versionCode 1 versionName "1.0" + + buildConfigField "String", "TENANT_URL", "\"${guardianTenantUrl}\"" + // Exposed to instrumentation tests via InstrumentationRegistry.getArguments(). + testInstrumentationRunnerArguments tenantUrl: guardianTenantUrl } buildTypes { release { @@ -18,28 +39,36 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } namespace 'com.auth0.guardian.sample' } +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') implementation project(':guardian') - implementation platform('com.google.firebase:firebase-bom:22.1.0') + implementation platform('com.google.firebase:firebase-bom:35.1.0') // FCM - implementation 'com.google.firebase:firebase-core' implementation 'com.google.firebase:firebase-messaging' - implementation 'com.google.android.material:material:1.6.1' + implementation 'androidx.appcompat:appcompat:1.8.0' + implementation 'androidx.activity:activity:1.13.0' + implementation 'androidx.core:core:1.18.0' + implementation 'com.google.android.material:material:1.14.0' // GSON - implementation 'com.google.code.gson:gson:2.9.1' + implementation 'com.google.code.gson:gson:2.15.0' // EventBus implementation 'org.greenrobot:eventbus:3.3.1' // ZXing QR decoder deps - implementation 'com.google.zxing:core:3.5.0' + implementation 'com.google.zxing:core:3.5.4' implementation 'org.bouncycastle:bcprov-jdk18on:1.85' } diff --git a/app/google-services.json b/app/google-services.json index 6157a7f..fa3d3b4 100644 --- a/app/google-services.json +++ b/app/google-services.json @@ -1,9 +1,9 @@ /* ** THIS IS A SAMPLE CONFIGURATION FILE WITH DUMMY DATA ** -** Replace this file with a valid google-services.json -** downloaded from the Firebase Console with your project details. -** See: https://console.firebase.google.com +** Kept on purpose so a clean clone and CI can build the app. Do not replace or +** commit a real config here: put yours in app/src/debug/google-services.json +** (git-ignored). See app/README.md. */ { diff --git a/app/src/debug/res/values/.gitignore b/app/src/debug/res/values/.gitignore deleted file mode 100644 index b8a9401..0000000 --- a/app/src/debug/res/values/.gitignore +++ /dev/null @@ -1 +0,0 @@ -test_config.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7b10f9f..c5fda34 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,8 +9,12 @@ + + * Guardian calls back on a background thread, possibly after the Activity was rotated or + * closed. The dialogs are therefore only touched while the Activity is alive: the progress + * dialog is dismissed when the Activity is destroyed (no leaked window) and the error dialog is + * skipped for a dead Activity. The wrapped callback is always invoked. + */ public class DialogCallback implements Callback { private static final String TAG = DialogCallback.class.getName(); - private final Context context; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final WeakReference contextRef; private final Callback callback; private final AlertDialog progressDialog; @@ -47,14 +63,14 @@ public class DialogCallback implements Callback { @StringRes int titleResId, @StringRes int messageResId, Callback callback) { - this.context = context; + this.contextRef = new WeakReference<>(context); this.callback = callback; - ProgressBar progressBar = new ProgressBar(this.context); + ProgressBar progressBar = new ProgressBar(context); progressBar.setIndeterminate(true); progressBar.setId(R.id.progress_bar); // need an id to align "right of" this - RelativeLayout layout = new RelativeLayout(this.context); + RelativeLayout layout = new RelativeLayout(context); RelativeLayout.LayoutParams progressBarLayoutParams = new RelativeLayout.LayoutParams( RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); @@ -63,7 +79,7 @@ public class DialogCallback implements Callback { progressBarLayoutParams.setMargins(margin, margin, margin, margin); layout.addView(progressBar, progressBarLayoutParams); - TextView textView = new TextView(this.context); + TextView textView = new TextView(context); textView.setText(messageResId); RelativeLayout.LayoutParams textViewLayoutParams = new RelativeLayout.LayoutParams( RelativeLayout.LayoutParams.WRAP_CONTENT, @@ -72,46 +88,92 @@ public class DialogCallback implements Callback { textViewLayoutParams.addRule(RelativeLayout.CENTER_VERTICAL, RelativeLayout.TRUE); layout.addView(textView, textViewLayoutParams); - this.progressDialog = new AlertDialog.Builder(this.context) + this.progressDialog = new AlertDialog.Builder(context) .setTitle(titleResId) .setView(layout) .setCancelable(false) .create(); this.progressDialog.show(); + + if (context instanceof LifecycleOwner) { + // Dismiss before the window is torn down, otherwise it is reported as leaked and a + // later dismiss() can throw. + ((LifecycleOwner) context).getLifecycle().addObserver(new DefaultLifecycleObserver() { + @Override + public void onDestroy(@NonNull LifecycleOwner owner) { + owner.getLifecycle().removeObserver(this); + dismissProgressNow(); + } + }); + } } @Override public void onSuccess(T response) { - progressDialog.dismiss(); + dismissProgress(); callback.onSuccess(response); } @Override public void onFailure(final Throwable exception) { Log.e(TAG, "Guardian error", exception); - progressDialog.dismiss(); - new Handler(context.getMainLooper()) - .post(new Runnable() { - @Override - public void run() { - new AlertDialog.Builder(context) - .setTitle(R.string.alert_title_error) - .setMessage(exception.toString()) - .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - dialog.dismiss(); - } - }) - .setOnDismissListener(new DialogInterface.OnDismissListener() { - @Override - public void onDismiss(DialogInterface dialog) { - callback.onFailure(exception); - } - }) - .create() - .show(); - } - }); + dismissProgress(); + mainHandler.post(new Runnable() { + @Override + public void run() { + Context context = contextRef.get(); + if (!isAlive(context)) { + callback.onFailure(exception); + return; + } + new AlertDialog.Builder(context) + .setTitle(R.string.alert_title_error) + .setMessage(exception.toString()) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }) + .setOnDismissListener(new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(DialogInterface dialog) { + callback.onFailure(exception); + } + }) + .create() + .show(); + } + }); + } + + private static boolean isAlive(Context context) { + if (context == null) { + return false; + } + if (context instanceof Activity) { + Activity activity = (Activity) context; + return !activity.isFinishing() && !activity.isDestroyed(); + } + return true; + } + + private void dismissProgress() { + if (Looper.myLooper() == Looper.getMainLooper()) { + dismissProgressNow(); + } else { + mainHandler.post(this::dismissProgressNow); + } + } + + private void dismissProgressNow() { + if (progressDialog.isShowing()) { + try { + progressDialog.dismiss(); + } catch (IllegalArgumentException e) { + // the window is already gone + Log.w(TAG, "Progress dialog was already detached", e); + } + } } } diff --git a/app/src/main/java/com/auth0/guardian/sample/EnrollActivity.java b/app/src/main/java/com/auth0/guardian/sample/EnrollActivity.java index e2169e8..eabc4b0 100644 --- a/app/src/main/java/com/auth0/guardian/sample/EnrollActivity.java +++ b/app/src/main/java/com/auth0/guardian/sample/EnrollActivity.java @@ -26,15 +26,22 @@ import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageManager; -import android.net.Uri; import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.core.app.ActivityCompat; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; +import android.text.InputType; import android.util.Log; import android.view.View; import android.widget.Button; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; import com.auth0.android.guardian.sdk.CurrentDevice; import com.auth0.android.guardian.sdk.Enrollment; @@ -52,16 +59,40 @@ public class EnrollActivity extends AppCompatActivity implements CaptureView.Lis private static final String DEVICE_NAME = "com.auth0.guardian.sample.EnrollActivity.DEVICE_NAME"; private static final String FCM_TOKEN = "com.auth0.guardian.sample.EnrollActivity.FCM_TOKEN"; - - private static final int REQUEST_CAMERA = 55; + /** + * Optional enrollment URI (the content of the Guardian QR code). When present the + * camera is skipped and enrollment starts immediately, so integration tests and + * emulators can enroll without scanning a QR code. + */ + static final String ENROLLMENT_DATA = "com.auth0.guardian.sample.EnrollActivity.ENROLLMENT_DATA"; private Guardian guardian; private String deviceName; private String fcmToken; + private String enrollmentData; + // False for the ENROLLMENT_DATA test hook and on devices without a camera (e.g. emulators + // and some tablets): the enrollment link is entered by hand instead of scanned. + private boolean useCamera; + + private final ActivityResultLauncher cameraPermissionLauncher = registerForActivityResult( + new ActivityResultContracts.RequestPermission(), + granted -> { + if (granted) { + Log.i(TAG, "CAMERA permission has now been granted. Showing preview."); + showScanView(); + } else { + Log.i(TAG, "CAMERA permission was NOT granted."); + } + }); + + private static final String STATE_LINK_DIALOG_OPEN = "linkDialogOpen"; + private static final String STATE_LINK_TEXT = "linkText"; private View permissionLayout; private View scannerLayout; private CaptureView scanner; + private AlertDialog linkDialog; + private EditText linkInput; static Intent getStartIntent(@NonNull Context context, @NonNull String deviceName, @@ -72,20 +103,73 @@ static Intent getStartIntent(@NonNull Context context, return intent; } + static Intent getStartIntent(@NonNull Context context, + @NonNull String deviceName, + @NonNull String fcmToken, + @Nullable String enrollmentData) { + Intent intent = getStartIntent(context, deviceName, fcmToken); + intent.putExtra(ENROLLMENT_DATA, enrollmentData); + return intent; + } + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_enroll); + SystemBars.applyEdgeToEdge(this); setupGuardian(); setupUI(); - checkCameraPermission(); + useCamera = enrollmentData == null && hasCamera(); + if (enrollmentData == null && !useCamera) { + showManualEntryOnly(); + if (savedInstanceState == null) { + showEnrollmentLinkDialog(null); + } + } else if (enrollmentData != null) { + // Test hook: no camera, no scanner UI. An enrollment already in flight can't be + // resumed after a recreation (it belongs to the destroyed instance), so give up. + scannerLayout.setVisibility(View.GONE); + permissionLayout.setVisibility(View.GONE); + if (savedInstanceState == null) { + onCodeScanned(enrollmentData); + } else { + abortEnrollment(); + } + } else { + checkCameraPermission(); + } + + // The paste dialog is rebuilt after a rotation, keeping what was typed. + if (savedInstanceState != null && savedInstanceState.getBoolean(STATE_LINK_DIALOG_OPEN)) { + showEnrollmentLinkDialog(savedInstanceState.getString(STATE_LINK_TEXT)); + } + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + if (linkDialog != null && linkDialog.isShowing()) { + outState.putBoolean(STATE_LINK_DIALOG_OPEN, true); + outState.putString(STATE_LINK_TEXT, linkInput.getText().toString()); + } + } + + @Override + protected void onDestroy() { + if (linkDialog != null) { + linkDialog.dismiss(); + linkDialog = null; + } + super.onDestroy(); } @Override protected void onStart() { super.onStart(); - scanner.start(this); + if (useCamera) { + scanner.start(this); + } } @Override @@ -97,43 +181,25 @@ protected void onResume() { @Override protected void onPause() { super.onPause(); - scanner.pause(); + if (useCamera) { + scanner.pause(); + } } @Override protected void onStop() { super.onStop(); - scanner.stop(); - } - - @Override - public void onRequestPermissionsResult(int requestCode, - @NonNull String[] permissions, - @NonNull int[] grantResults) { - if (requestCode == REQUEST_CAMERA) { - // Received permission result for camera permission. - Log.i(TAG, "Received response for Camera permission request."); - - // Check if the only required permission has been granted - if (grantResults.length == 1 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - // Camera permission has been granted, preview can be displayed - Log.i(TAG, "CAMERA permission has now been granted. Showing preview."); - // Set up QR code scanning - showScanView(); - } else { - Log.i(TAG, "CAMERA permission was NOT granted."); - } - } else { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (useCamera) { + scanner.stop(); } } @Override - public void onCodeScanned(String enrollmentData) { + public void onCodeScanned(String code) { try { KeyPair keyPair = generateKeyPair(); CurrentDevice device = new CurrentDevice(this, fcmToken, deviceName); - guardian.enroll(enrollmentData, device, keyPair) + guardian.enroll(code, device, keyPair) .start(new DialogCallback<>(this, R.string.progress_title_please_wait, R.string.progress_message_enroll, @@ -146,11 +212,12 @@ public void onSuccess(Enrollment enrollment) { @Override public void onFailure(Throwable exception) { - // TODO might not need here - // It looks like the toast resumes scanning as well - // This resume happens as the error is popped up, - // wait to dismiss instead - // resumeScanning(); + // DialogCallback has already shown the error and the user + // dismissed it. Scanning resumes on its own (onResume); with + // the test hook there is nothing to retry. + if (enrollmentData != null) { + abortEnrollment(); + } } })); } catch (IllegalArgumentException exception) { @@ -163,15 +230,13 @@ private void setupGuardian() { Intent intent = getIntent(); deviceName = intent.getStringExtra(DEVICE_NAME); fcmToken = intent.getStringExtra(FCM_TOKEN); + enrollmentData = intent.getStringExtra(ENROLLMENT_DATA); if (deviceName == null || fcmToken == null) { throw new IllegalStateException("Missing deviceName or fcmToken"); } - guardian = new Guardian.Builder() - .url(Uri.parse(getString(R.string.tenant_url))) - .enableLogging() - .build(); + guardian = SampleGuardian.create(); } private void setupUI() { @@ -179,6 +244,13 @@ private void setupUI() { scannerLayout = findViewById(R.id.scannerLayout); scanner = (CaptureView) findViewById(R.id.scanner); + // Reachable from both the scanner and the camera permission screen, so a user who + // denies the camera can still enroll with the link. + findViewById(R.id.enterLinkButton) + .setOnClickListener(v -> showEnrollmentLinkDialog(null)); + findViewById(R.id.enterLinkPermissionButton) + .setOnClickListener(v -> showEnrollmentLinkDialog(null)); + Button requestPermissionButton = (Button) findViewById(R.id.requestPermissionButton); assert requestPermissionButton != null; requestPermissionButton.setOnClickListener(new View.OnClickListener() { @@ -189,6 +261,56 @@ public void onClick(View v) { }); } + private boolean hasCamera() { + return getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY); + } + + private void showManualEntryOnly() { + scanner.setVisibility(View.GONE); + permissionLayout.setVisibility(View.GONE); + scannerLayout.setVisibility(View.VISIBLE); + ((TextView) findViewById(R.id.scanHintText)).setText(R.string.no_camera_hint); + } + + /** + * Lets the user paste the enrollment link (the content of the Guardian QR code) instead of + * scanning it. It takes the same path as a scanned code. + */ + private void showEnrollmentLinkDialog(@Nullable String prefill) { + if (linkDialog != null && linkDialog.isShowing()) { + return; + } + EditText input = new EditText(this); + linkInput = input; + if (prefill != null) { + input.setText(prefill); + } + input.setId(R.id.enrollmentLinkInput); + input.setContentDescription(getString(R.string.cd_enrollment_link_input)); + input.setHint(R.string.enter_link_hint); + input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI + | InputType.TYPE_TEXT_FLAG_MULTI_LINE); + input.setMinLines(3); + + int padding = getResources().getDimensionPixelSize(R.dimen.activity_horizontal_margin); + FrameLayout container = new FrameLayout(this); + container.setPadding(padding, 0, padding, 0); + container.addView(input); + + linkDialog = new AlertDialog.Builder(this) + .setTitle(R.string.enter_link_title) + .setMessage(R.string.enter_link_message) + .setView(container) + .setPositiveButton(R.string.enroll_action, (dialog, which) -> { + String link = input.getText().toString().trim(); + if (!link.isEmpty()) { + onCodeScanned(link); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + private void checkCameraPermission() { if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { @@ -216,9 +338,7 @@ private void tryRequestCameraPermissionDirectly() { } private void requestCameraPermission() { - ActivityCompat.requestPermissions(this, - new String[]{android.Manifest.permission.CAMERA}, - REQUEST_CAMERA); + cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA); } private void showCameraPermissionUnavailable() { @@ -232,12 +352,22 @@ private void showScanView() { } private void resumeScanning() { - scanner.resume(); + if (useCamera) { + scanner.resume(); + } + } + + private void abortEnrollment() { + setResult(RESULT_CANCELED); + finish(); } private void onEnrollSuccess(Enrollment enrollment) { Intent data = new Intent(); ParcelableEnrollment parcelableEnrollment = new ParcelableEnrollment(enrollment); + // Persist here too: when this activity is started on its own (test hook) there is no + // MainActivity result callback to do it, and pushes would be ignored afterwards. + EnrollmentStorage.save(this, parcelableEnrollment); data.putExtra(Constants.ENROLLMENT, parcelableEnrollment); setResult(RESULT_OK, data); finish(); @@ -261,7 +391,11 @@ public void onClick(DialogInterface dialog, int which) { .setOnDismissListener(new DialogInterface.OnDismissListener() { @Override public void onDismiss(DialogInterface dialog) { - resumeScanning(); + if (enrollmentData != null) { + abortEnrollment(); + } else { + resumeScanning(); + } } }) .create() diff --git a/app/src/main/java/com/auth0/guardian/sample/EnrollmentStorage.kt b/app/src/main/java/com/auth0/guardian/sample/EnrollmentStorage.kt new file mode 100644 index 0000000..1f6e0f9 --- /dev/null +++ b/app/src/main/java/com/auth0/guardian/sample/EnrollmentStorage.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Auth0 (http://auth0.com) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.auth0.guardian.sample + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit + +/** + * Persists the sample's single enrollment. Shared by [MainActivity] and [EnrollActivity], so an + * enrollment started on its own (e.g. through the `ENROLLMENT_DATA` test hook) is still known to + * the app afterwards. + */ +object EnrollmentStorage { + + // Same file android.preference.PreferenceManager used, so existing enrollments survive. + private fun preferences(context: Context): SharedPreferences = + context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE) + + @JvmStatic + fun load(context: Context): ParcelableEnrollment? = + preferences(context).getString(Constants.ENROLLMENT, null) + ?.let { ParcelableEnrollment.fromJSON(it) } + + @JvmStatic + fun save(context: Context, enrollment: ParcelableEnrollment?) { + preferences(context).edit { + putString(Constants.ENROLLMENT, enrollment?.toJSON()) + } + } +} diff --git a/app/src/main/java/com/auth0/guardian/sample/MainActivity.java b/app/src/main/java/com/auth0/guardian/sample/MainActivity.java index bb29e43..f12bdbb 100644 --- a/app/src/main/java/com/auth0/guardian/sample/MainActivity.java +++ b/app/src/main/java/com/auth0/guardian/sample/MainActivity.java @@ -22,22 +22,26 @@ package com.auth0.guardian.sample; +import android.Manifest; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; -import android.content.SharedPreferences; -import android.net.Uri; +import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; -import android.preference.PreferenceManager; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; import android.util.Log; import android.view.View; import android.widget.Button; import android.widget.TextView; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; +import androidx.core.content.IntentCompat; + import com.auth0.android.guardian.sdk.Guardian; import com.auth0.android.guardian.sdk.GuardianException; import com.auth0.android.guardian.sdk.ParcelableNotification; @@ -58,8 +62,6 @@ public class MainActivity extends AppCompatActivity implements FcmUtils.FcmToken private static final String TAG = MainActivity.class.getName(); - private static final int ENROLL_REQUEST = 123; - private View loadingView; private View enrollView; private View accountView; @@ -73,6 +75,22 @@ public class MainActivity extends AppCompatActivity implements FcmUtils.FcmToken private ParcelableEnrollment enrollment; private String fcmToken; + private final ActivityResultLauncher enrollLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == RESULT_OK && result.getData() != null) { + ParcelableEnrollment enrollment = IntentCompat.getParcelableExtra( + result.getData(), Constants.ENROLLMENT, ParcelableEnrollment.class); + updateEnrollment(enrollment); + } + }); + + // Denying is not fatal: notifications that arrive while the app is visible are + // delivered through EventBus and don't need the permission. + private final ActivityResultLauncher notificationPermissionLauncher = + registerForActivityResult(new ActivityResultContracts.RequestPermission(), + granted -> Log.i(TAG, "POST_NOTIFICATIONS granted: " + granted)); + public static Intent getStartIntent(@NonNull Context context, @NonNull ParcelableNotification notification) { Intent intent = new Intent(context, MainActivity.class); @@ -84,16 +102,14 @@ public static Intent getStartIntent(@NonNull Context context, protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); + SystemBars.applyEdgeToEdge(this); setupUI(); + requestNotificationPermission(); eventBus = EventBus.getDefault(); - eventBus.register(this); - guardian = new Guardian.Builder() - .url(Uri.parse(getString(R.string.tenant_url))) - .enableLogging() - .build(); + guardian = SampleGuardian.create(); /* * The following fetch token call is NOT required in a production app @@ -105,34 +121,45 @@ protected void onCreate(Bundle savedInstanceState) { FcmUtils fcmUtils = new FcmUtils(); fcmUtils.fetchFcmToken(this); - SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); - String enrollmentJSON = sharedPreferences.getString(Constants.ENROLLMENT, null); - if (enrollmentJSON != null) { - enrollment = ParcelableEnrollment.fromJSON(enrollmentJSON); + enrollment = EnrollmentStorage.load(this); + if (enrollment != null) { updateUI(); - ParcelableNotification notification = getIntent().getParcelableExtra(Constants.NOTIFICATION); - if (notification != null) { + // Only on the first creation: after a rotation the intent still carries the + // notification and the request screen would open a second time. + ParcelableNotification notification = IntentCompat.getParcelableExtra( + getIntent(), Constants.NOTIFICATION, ParcelableNotification.class); + if (notification != null && savedInstanceState == null) { onPushNotificationReceived(notification); } } } + // Subscribed only while visible. FcmListenerService posts to EventBus whenever there is a + // subscriber and shows a system notification otherwise. Staying subscribed while in the + // background would swallow the push, because Android 10+ blocks starting + // NotificationActivity from the background. @Override - protected void onDestroy() { - super.onDestroy(); - eventBus.unregister(this); + protected void onStart() { + super.onStart(); + // Pick up an enrollment saved while this activity was stopped (EnrollActivity + // persists it itself, and a rotation during enrollment loses the activity result). + enrollment = EnrollmentStorage.load(this); + updateUI(); + eventBus.register(this); } @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == ENROLL_REQUEST) { - if (resultCode == RESULT_OK) { - ParcelableEnrollment enrollment = data.getParcelableExtra(Constants.ENROLLMENT); - updateEnrollment(enrollment); - } - } else { - super.onActivityResult(requestCode, resultCode, data); + protected void onStop() { + eventBus.unregister(this); + super.onStop(); + } + + private void requestNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS); } } @@ -188,7 +215,7 @@ public void run() { private void onEnrollRequested() { Intent enrollIntent = EnrollActivity .getStartIntent(this, deviceNameText.getText().toString(), fcmToken); - startActivityForResult(enrollIntent, ENROLL_REQUEST); + enrollLauncher.launch(enrollIntent); } private void onUnEnrollRequested() { @@ -216,10 +243,7 @@ public void onFailure(Throwable exception) { } private void updateEnrollment(ParcelableEnrollment enrollment) { - SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); - SharedPreferences.Editor editor = sharedPreferences.edit(); - editor.putString(Constants.ENROLLMENT, enrollment != null ? enrollment.toJSON() : null); - editor.apply(); + EnrollmentStorage.save(this, enrollment); this.enrollment = enrollment; @@ -227,7 +251,16 @@ private void updateEnrollment(ParcelableEnrollment enrollment) { } private void onPushNotificationReceived(ParcelableNotification notification) { - startActivity(NotificationActivity.getStartIntent(this, notification, enrollment)); + if (enrollment == null) { + Log.w(TAG, "Received a notification but the device is not enrolled, ignoring it"); + return; + } + try { + startActivity(NotificationActivity.getStartIntent(this, notification, enrollment)); + } catch (IllegalArgumentException e) { + // e.g. a stale notification for an enrollment that has since been replaced + Log.w(TAG, "Ignoring a notification that doesn't match the current enrollment", e); + } } @Override diff --git a/app/src/main/java/com/auth0/guardian/sample/NotificationActivity.java b/app/src/main/java/com/auth0/guardian/sample/NotificationActivity.java index 82c297b..ca78ee8 100644 --- a/app/src/main/java/com/auth0/guardian/sample/NotificationActivity.java +++ b/app/src/main/java/com/auth0/guardian/sample/NotificationActivity.java @@ -24,7 +24,6 @@ import android.content.Context; import android.content.Intent; -import android.net.Uri; import android.os.Bundle; import android.util.Log; import android.view.View; @@ -32,6 +31,7 @@ import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.IntentCompat; import androidx.fragment.app.Fragment; import com.auth0.android.guardian.sdk.Guardian; @@ -78,38 +78,39 @@ static Intent getStartIntent(@NonNull Context context, protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_notification); + SystemBars.applyEdgeToEdge(this); - guardian = new Guardian.Builder() - .url(Uri.parse(getString(R.string.tenant_url))) - .enableLogging() - .build(); + guardian = SampleGuardian.create(); Intent intent = getIntent(); - enrollment = intent.getParcelableExtra(Constants.ENROLLMENT); - notification = intent.getParcelableExtra(Constants.NOTIFICATION); + enrollment = IntentCompat.getParcelableExtra( + intent, Constants.ENROLLMENT, ParcelableEnrollment.class); + notification = IntentCompat.getParcelableExtra( + intent, Constants.NOTIFICATION, ParcelableNotification.class); setupUI(); if (notification.getTransactionLinkingId() != null) { try { guardian.fetchConsent(notification, enrollment).start(new Callback() { + // Guardian calls back on a background thread @Override public void onSuccess(RichConsent response) { richConsent = response; - updateUI(); + runOnUiThread(NotificationActivity.this::updateUI); } @Override public void onFailure(Throwable exception) { - if (exception instanceof GuardianException) { - GuardianException guardianException = (GuardianException) exception; - if (guardianException.isResourceNotFound()) { - // Render regular authentication request details - updateUI(); - } + if (exception instanceof GuardianException + && ((GuardianException) exception).isResourceNotFound()) { + // Render regular authentication request details + runOnUiThread(NotificationActivity.this::updateUI); } else { + // Don't crash the sample: fall back to the regular request details, + // which can still be allowed or rejected. Log.e(TAG, "Error requesting consent details", exception); - throw new RuntimeException(exception); + runOnUiThread(NotificationActivity.this::updateUI); } } }); @@ -161,9 +162,12 @@ private void updateUI() { } + if (isFinishing() || isDestroyed()) { + return; + } getSupportFragmentManager().beginTransaction() .replace(R.id.authenticationDetailsFragmentContainer, fragment) - .commit(); + .commitAllowingStateLoss(); } diff --git a/app/src/main/java/com/auth0/guardian/sample/SampleGuardian.kt b/app/src/main/java/com/auth0/guardian/sample/SampleGuardian.kt new file mode 100644 index 0000000..b1bf602 --- /dev/null +++ b/app/src/main/java/com/auth0/guardian/sample/SampleGuardian.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Auth0 (http://auth0.com) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.auth0.guardian.sample + +import androidx.core.net.toUri +import com.auth0.android.guardian.sdk.Guardian + +object SampleGuardian { + + /** + * Builds the Guardian client for the configured tenant. HTTP logging is debug-only: it + * logs request and response bodies, which carry the bearer token, the TOTP secret and + * transaction tokens, and those must not end up in the logs of a release build. + */ + @JvmStatic + fun create(): Guardian { + val builder = Guardian.Builder().url(BuildConfig.TENANT_URL.toUri()) + if (BuildConfig.DEBUG) { + builder.enableLogging() + } + return builder.build() + } +} diff --git a/app/src/main/java/com/auth0/guardian/sample/SystemBars.kt b/app/src/main/java/com/auth0/guardian/sample/SystemBars.kt new file mode 100644 index 0000000..9cb1381 --- /dev/null +++ b/app/src/main/java/com/auth0/guardian/sample/SystemBars.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 Auth0 (http://auth0.com) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.auth0.guardian.sample + +import android.graphics.Color +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat + +object SystemBars { + + /** + * Apps targeting API 35+ are always drawn edge-to-edge. Pad the content view by the + * system bar insets so the toolbar and the bottom action buttons stay visible and tappable. + */ + @JvmStatic + fun applyEdgeToEdge(activity: ComponentActivity) { + // The theme is dark (Theme.AppCompat): force light bar icons, otherwise EdgeToEdge + // picks dark icons whenever the system itself is in light mode. + activity.enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT) + ) + val content = activity.findViewById(android.R.id.content) + ViewCompat.setOnApplyWindowInsetsListener(content) { view, windowInsets -> + val bars = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + view.setPadding(bars.left, bars.top, bars.right, bars.bottom) + WindowInsetsCompat.CONSUMED + } + } +} diff --git a/app/src/main/java/com/auth0/guardian/sample/fcm/FcmListenerService.java b/app/src/main/java/com/auth0/guardian/sample/fcm/FcmListenerService.java index 0d5140d..822faa4 100644 --- a/app/src/main/java/com/auth0/guardian/sample/fcm/FcmListenerService.java +++ b/app/src/main/java/com/auth0/guardian/sample/fcm/FcmListenerService.java @@ -22,12 +22,16 @@ package com.auth0.guardian.sample.fcm; +import android.annotation.SuppressLint; import android.app.PendingIntent; import android.content.Intent; import android.graphics.Color; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationChannelCompat; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; -import android.util.Log; import com.auth0.android.guardian.sdk.Guardian; import com.auth0.android.guardian.sdk.ParcelableNotification; @@ -44,13 +48,15 @@ public class FcmListenerService extends FirebaseMessagingService { private static final String TAG = FcmListenerService.class.getName(); + private static final String CHANNEL_ID = "guardian_notifications"; + /** * Called when message is received. * * @param message The message instance */ @Override - public void onMessageReceived(RemoteMessage message) { + public void onMessageReceived(@NonNull RemoteMessage message) { if (BuildConfig.DEBUG) { Log.d(TAG, String.format("Received FCM message from: %s with data: %s", message.getFrom(), message.getData())); } @@ -61,31 +67,50 @@ public void onMessageReceived(RemoteMessage message) { if (eventBus.hasSubscriberForEvent(GuardianNotificationReceivedEvent.class)) { eventBus.post(new GuardianNotificationReceivedEvent(notification)); } else { - Intent intent = MainActivity.getStartIntent(this, notification); - PendingIntent pendingIntent = PendingIntent - .getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); - NotificationCompat.Builder builder = new NotificationCompat.Builder(this) - .setSmallIcon(R.mipmap.ic_launcher) - .setColor(Color.BLACK) - .setContentTitle(getString(R.string.guardian_notification_title)) - .setContentText(getString(R.string.guardian_notification_text)) - .setContentIntent(pendingIntent) - .setAutoCancel(true); - NotificationManagerCompat.from(this) - .notify(notification.getTransactionToken().hashCode(), builder.build()); + showNotification(notification); } } catch (IllegalArgumentException e) { Log.e(TAG, "Received a notification that is not a Guardian Notification", e); } } + // areNotificationsEnabled() covers the POST_NOTIFICATIONS runtime permission on API 33+ + @SuppressLint("MissingPermission") + private void showNotification(ParcelableNotification notification) { + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this); + if (!notificationManager.areNotificationsEnabled()) { + Log.w(TAG, "Notifications are disabled, dropping Guardian notification"); + return; + } + notificationManager.createNotificationChannel( + new NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH) + .setName(getString(R.string.notification_channel_name)) + .build()); + + Intent intent = MainActivity.getStartIntent(this, notification); + // One PendingIntent per transaction: with a shared request code the extras of the + // latest notification would replace those of every older one still on screen. + int notificationId = notification.getTransactionToken().hashCode(); + PendingIntent pendingIntent = PendingIntent.getActivity(this, notificationId, intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setColor(Color.BLACK) + .setContentTitle(getString(R.string.guardian_notification_title)) + .setContentText(getString(R.string.guardian_notification_text)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(pendingIntent) + .setAutoCancel(true); + notificationManager.notify(notificationId, builder.build()); + } + /** - * Called if InstanceID token is updated. This may occur if the security of - * the previous token had been compromised. This call is initiated by the - * InstanceID provider. + * Called when the FCM registration token is updated. This may occur if the + * security of the previous token had been compromised. */ + @SuppressWarnings("deprecation") // see FcmUtils#fetchFcmToken @Override - public void onNewToken(String token) { + public void onNewToken(@NonNull String token) { // Use updated token and notify our app's server of any changes (if applicable). Log.w(TAG, "Should refresh token!"); } diff --git a/app/src/main/java/com/auth0/guardian/sample/fcm/FcmUtils.java b/app/src/main/java/com/auth0/guardian/sample/fcm/FcmUtils.java index a75b7bf..b20b076 100644 --- a/app/src/main/java/com/auth0/guardian/sample/fcm/FcmUtils.java +++ b/app/src/main/java/com/auth0/guardian/sample/fcm/FcmUtils.java @@ -25,26 +25,25 @@ import androidx.annotation.NonNull; import com.google.android.gms.tasks.OnCompleteListener; -import com.google.android.gms.tasks.OnFailureListener; import com.google.android.gms.tasks.Task; -import com.google.firebase.iid.FirebaseInstanceId; -import com.google.firebase.iid.InstanceIdResult; +import com.google.firebase.messaging.FirebaseMessaging; public class FcmUtils { + // getToken() is deprecated since firebase-messaging 25.1.0 in favor of FID-based + // register(). Guardian enrolls the device with an FCM registration token, so the + // token API is kept until the Guardian backend supports FID registration. + @SuppressWarnings("deprecation") public void fetchFcmToken(FcmTokenListener listener) { - FirebaseInstanceId.getInstance().getInstanceId() - .addOnCompleteListener(new OnCompleteListener() { + FirebaseMessaging.getInstance().getToken() + .addOnCompleteListener(new OnCompleteListener() { @Override - public void onComplete(@NonNull Task task) { - String token = task.getResult().getToken(); - listener.onFcmTokenObtained(token); - } - }) - .addOnFailureListener(new OnFailureListener() { - @Override - public void onFailure(@NonNull Exception e) { - listener.onFcmFailure(e); + public void onComplete(@NonNull Task task) { + if (task.isSuccessful()) { + listener.onFcmTokenObtained(task.getResult()); + } else { + listener.onFcmFailure(task.getException()); + } } }); } diff --git a/app/src/main/java/com/auth0/guardian/sample/scanner/CaptureView.java b/app/src/main/java/com/auth0/guardian/sample/scanner/CaptureView.java index 9efadaf..0f87d0b 100644 --- a/app/src/main/java/com/auth0/guardian/sample/scanner/CaptureView.java +++ b/app/src/main/java/com/auth0/guardian/sample/scanner/CaptureView.java @@ -173,7 +173,8 @@ private void startCameraSource() throws SecurityException { if (cameraSource != null) { try { preview.start(cameraSource, graphicOverlay); - } catch (IOException e) { + } catch (IOException | RuntimeException e) { + // RuntimeException: e.g. no back camera (emulators, some tablets) Log.e(TAG, "Unable to start camera source.", e); cameraSource.release(); cameraSource = null; diff --git a/app/src/main/java/com/auth0/guardian/sample/scanner/camera/CameraSourceCropPreview.java b/app/src/main/java/com/auth0/guardian/sample/scanner/camera/CameraSourceCropPreview.java index cd409ba..96fa596 100644 --- a/app/src/main/java/com/auth0/guardian/sample/scanner/camera/CameraSourceCropPreview.java +++ b/app/src/main/java/com/auth0/guardian/sample/scanner/camera/CameraSourceCropPreview.java @@ -121,7 +121,8 @@ public void surfaceCreated(SurfaceHolder surface) { startIfReady(); } catch (SecurityException se) { Log.e(TAG,"Do not have permission to start the camera", se); - } catch (IOException e) { + } catch (IOException | RuntimeException e) { + // RuntimeException: e.g. no back camera (emulators, some tablets) Log.e(TAG, "Could not start camera source.", e); } } diff --git a/app/src/main/res/layout/activity_enroll.xml b/app/src/main/res/layout/activity_enroll.xml index 5e7b2bb..3a707e9 100644 --- a/app/src/main/res/layout/activity_enroll.xml +++ b/app/src/main/res/layout/activity_enroll.xml @@ -36,11 +36,13 @@ +